@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
|
@@ -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,18 @@ 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 } = {}) {
|
|
103
|
+
const scopeArr = Array.isArray(scope) ? scope : null;
|
|
91
104
|
const scored = [];
|
|
92
105
|
for (const row of this.rows.values()) {
|
|
93
106
|
if (embedder && row.embedder !== embedder) continue;
|
|
94
107
|
if (channel && row.channel !== channel) continue;
|
|
108
|
+
if (scope === "global" && isScopedChannel(row.channel)) continue;
|
|
109
|
+
if (scopeArr) {
|
|
110
|
+
if (!scopeArr.includes(row.channel)) continue;
|
|
111
|
+
} else if (scope && scope !== "global" && row.channel !== scope) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
95
114
|
if (!Array.isArray(row.vector) || row.vector.length !== vector.length) continue;
|
|
96
115
|
scored.push({ ...row, score: cosineSim(vector, row.vector) });
|
|
97
116
|
}
|
|
@@ -171,7 +190,7 @@ class SqliteVecStore {
|
|
|
171
190
|
this.db.prepare("DELETE FROM chunks").run();
|
|
172
191
|
}
|
|
173
192
|
|
|
174
|
-
search(vector, { embedder, k = 5, channel } = {}) {
|
|
193
|
+
search(vector, { embedder, k = 5, channel, scope } = {}) {
|
|
175
194
|
const blob = vecToBlob(vector);
|
|
176
195
|
const where = ["embedder = ?", "dim = ?"];
|
|
177
196
|
const params = [embedder, vector.length];
|
|
@@ -179,6 +198,19 @@ class SqliteVecStore {
|
|
|
179
198
|
where.push("channel = ?");
|
|
180
199
|
params.push(channel);
|
|
181
200
|
}
|
|
201
|
+
if (scope === "global") {
|
|
202
|
+
where.push("(channel NOT LIKE 'project:%' AND channel NOT LIKE 'agent:%')");
|
|
203
|
+
} else if (Array.isArray(scope)) {
|
|
204
|
+
if (scope.length === 0) {
|
|
205
|
+
where.push("0"); // no scopes → match nothing
|
|
206
|
+
} else {
|
|
207
|
+
where.push(`channel IN (${scope.map(() => "?").join(",")})`);
|
|
208
|
+
params.push(...scope);
|
|
209
|
+
}
|
|
210
|
+
} else if (scope) {
|
|
211
|
+
where.push("channel = ?");
|
|
212
|
+
params.push(scope);
|
|
213
|
+
}
|
|
182
214
|
const rows = this.db
|
|
183
215
|
.prepare(
|
|
184
216
|
`SELECT id, source, channel, ts, tag, text, embedder, dim,
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
defaultIntegrationsStorage,
|
|
24
24
|
listCatalog,
|
|
25
25
|
getPluginService,
|
|
26
|
+
reconcilePluginMcp,
|
|
26
27
|
} from "#core/integrations/index.js";
|
|
27
28
|
|
|
28
29
|
function normalizeScope(raw) {
|
|
@@ -43,7 +44,23 @@ function storagePathForScope(scope, p, projects) {
|
|
|
43
44
|
return p.storagePath || null;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
export function register(app, { projects, project }) {
|
|
47
|
+
export function register(app, { projects, project, registries }) {
|
|
48
|
+
// Keep a plugin's optional auto-registered MCP server (svc.mcpServer hook) in
|
|
49
|
+
// lockstep with its stored state. Best-effort: a failure here must not break
|
|
50
|
+
// the configure/validate/deactivate/delete response. `storagePath` is the one
|
|
51
|
+
// the handler wrote to (so global-scope records resolve from the default
|
|
52
|
+
// store), `scope` is the integration scope ("project" | "global").
|
|
53
|
+
function reconcileMcp(svc, storagePath, scope, p) {
|
|
54
|
+
if (typeof svc?.mcpServer !== "function") return;
|
|
55
|
+
try {
|
|
56
|
+
const record = storagePath ? new IntegrationStore(storagePath).get(svc.slug) : null;
|
|
57
|
+
const desired = svc.mcpServer(record);
|
|
58
|
+
reconcilePluginMcp({ desired, integrationScope: scope, project: p, projects, registries });
|
|
59
|
+
} catch {
|
|
60
|
+
/* best-effort MCP reconcile — ignore */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
47
64
|
// List stored integrations in the chosen scope (secrets redacted).
|
|
48
65
|
app.get("/projects/:pid/integrations", (req, res) => {
|
|
49
66
|
const p = project(req, res);
|
|
@@ -107,6 +124,7 @@ export function register(app, { projects, project }) {
|
|
|
107
124
|
try {
|
|
108
125
|
const { patch } = svc.configure(store.get(req.params.slug), req.body || {});
|
|
109
126
|
const record = store.upsert(req.params.slug, patch);
|
|
127
|
+
reconcileMcp(svc, storagePath, scope, p);
|
|
110
128
|
res.status(201).json(redactRecord(record));
|
|
111
129
|
} catch (e) {
|
|
112
130
|
res.status(400).json({ error: e.message });
|
|
@@ -129,6 +147,7 @@ export function register(app, { projects, project }) {
|
|
|
129
147
|
try {
|
|
130
148
|
const { patch, result } = await svc.validate(record);
|
|
131
149
|
store.upsert(req.params.slug, patch);
|
|
150
|
+
reconcileMcp(svc, storagePath, scope, p);
|
|
132
151
|
if (result && result.ok === false) return res.status(400).json(result);
|
|
133
152
|
res.json(result);
|
|
134
153
|
} catch (e) {
|
|
@@ -150,6 +169,7 @@ export function register(app, { projects, project }) {
|
|
|
150
169
|
if (!store.get(req.params.slug)) return res.status(404).json({ error: "integration not configured" });
|
|
151
170
|
const { patch } = svc.deactivate(store.get(req.params.slug));
|
|
152
171
|
const record = store.upsert(req.params.slug, patch);
|
|
172
|
+
reconcileMcp(svc, storagePath, scope, p);
|
|
153
173
|
res.json(svc.status(record));
|
|
154
174
|
});
|
|
155
175
|
|
|
@@ -170,7 +190,10 @@ export function register(app, { projects, project }) {
|
|
|
170
190
|
const record = new IntegrationStore(storagePath).get(req.params.slug);
|
|
171
191
|
if (!record) return res.status(404).json({ error: "integration not configured" });
|
|
172
192
|
try {
|
|
173
|
-
|
|
193
|
+
// Actions get a 2nd ctx arg (existing plugins ignore it). Obsidian's
|
|
194
|
+
// sync_memory uses it to reach every project's memory.md.
|
|
195
|
+
const actionCtx = { storagePath, scope, project: p, projects, registries };
|
|
196
|
+
res.json(await fn.call(svc.actions, record, actionCtx));
|
|
174
197
|
} catch (e) {
|
|
175
198
|
res.status(400).json({ error: e.message });
|
|
176
199
|
}
|
|
@@ -186,6 +209,8 @@ export function register(app, { projects, project }) {
|
|
|
186
209
|
if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
|
|
187
210
|
const removed = new IntegrationStore(storagePath).remove(req.params.slug);
|
|
188
211
|
if (!removed) return res.status(404).end();
|
|
212
|
+
// Record is gone → svc.mcpServer(null) yields def:null → drop any auto MCP.
|
|
213
|
+
reconcileMcp(getPluginService(req.params.slug), storagePath, scope, p);
|
|
189
214
|
res.status(204).end();
|
|
190
215
|
});
|
|
191
216
|
}
|
|
@@ -96,7 +96,7 @@ export function register(app, { projects, registries, project }) {
|
|
|
96
96
|
} catch (e) {
|
|
97
97
|
return res.status(400).json({ error: e.message });
|
|
98
98
|
}
|
|
99
|
-
registries.
|
|
99
|
+
registries.evictName(name);
|
|
100
100
|
projects.rebuild(p.id);
|
|
101
101
|
const entry = registries.for(p).getByName(name);
|
|
102
102
|
res.status(201).json(entry);
|
|
@@ -150,7 +150,7 @@ export function register(app, { projects, registries, project }) {
|
|
|
150
150
|
} catch (e) {
|
|
151
151
|
return res.status(400).json({ error: e.message });
|
|
152
152
|
}
|
|
153
|
-
registries.
|
|
153
|
+
registries.evictName(req.params.name);
|
|
154
154
|
projects.rebuild(p.id);
|
|
155
155
|
res.status(204).end();
|
|
156
156
|
});
|
package/src/host/daemon/index.js
CHANGED
|
@@ -147,6 +147,12 @@ class RegistryCache {
|
|
|
147
147
|
for(projectEntry) {
|
|
148
148
|
return this.ensure(projectEntry);
|
|
149
149
|
}
|
|
150
|
+
// Evict one MCP by name from every cached registry so its live process
|
|
151
|
+
// respawns with fresh config on next use. Used after add/remove instead of
|
|
152
|
+
// shutting down (and cold-restarting) every MCP across every project.
|
|
153
|
+
evictName(name) {
|
|
154
|
+
for (const r of this.byProjectId.values()) r.evict(name);
|
|
155
|
+
}
|
|
150
156
|
shutdown() {
|
|
151
157
|
for (const r of this.byProjectId.values()) r.shutdown();
|
|
152
158
|
this.byProjectId.clear();
|
|
@@ -238,7 +244,7 @@ async function main() {
|
|
|
238
244
|
// Cross-channel memory: ensure ~/.apx/memory.md exists, open the vector
|
|
239
245
|
// store, and start the incremental RAG indexer. Best-effort — never blocks
|
|
240
246
|
// boot and never throws into the daemon.
|
|
241
|
-
initMemory({ config: cfg, log }).catch((e) => log(`memory: init failed: ${e?.message || e}`));
|
|
247
|
+
initMemory({ config: cfg, log, projects }).catch((e) => log(`memory: init failed: ${e?.message || e}`));
|
|
242
248
|
// Skill Inspector: if enabled, refresh its vector index in the background so
|
|
243
249
|
// any SKILL.md added/edited while the daemon was down is picked up without a
|
|
244
250
|
// manual `apx skills index`. Best-effort; never blocks boot.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// `apx obsidian` — configure and drive the Obsidian integration from the CLI.
|
|
2
|
+
// Thin client over the generic integrations daemon API (the same routes the web
|
|
3
|
+
// panel uses), so scoping stays consistent: `--global` (or `--scope global`)
|
|
4
|
+
// targets the default space; otherwise the current/`--project` project. A vault
|
|
5
|
+
// is a local directory of Markdown notes; connecting it lets APX agents read,
|
|
6
|
+
// search and write notes, optionally auto-registers an Obsidian MCP, and lets
|
|
7
|
+
// you mirror APX memory into the vault.
|
|
8
|
+
import { http } from "../http.js";
|
|
9
|
+
import { resolveProjectId } from "./project.js";
|
|
10
|
+
|
|
11
|
+
const SLUG = "obsidian";
|
|
12
|
+
|
|
13
|
+
function resolveScope(flags = {}) {
|
|
14
|
+
if (flags.global) return "global";
|
|
15
|
+
const s = flags.scope ? String(flags.scope).toLowerCase() : "project";
|
|
16
|
+
if (s === "default") return "global";
|
|
17
|
+
if (s !== "project" && s !== "global") {
|
|
18
|
+
throw new Error(`unknown --scope "${flags.scope}" (use project|global)`);
|
|
19
|
+
}
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function scopeQuery(scope) {
|
|
24
|
+
return `?scope=${encodeURIComponent(scope)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function cmdObsidianSet(args) {
|
|
28
|
+
const vaultPath = args._[0];
|
|
29
|
+
if (!vaultPath) throw new Error("apx obsidian set: missing <vault-path>");
|
|
30
|
+
const scope = resolveScope(args.flags);
|
|
31
|
+
const pid = await resolveProjectId(args?.flags?.project);
|
|
32
|
+
const body = { vault_path: vaultPath };
|
|
33
|
+
if (args.flags.mcp) body.auto_mcp = true;
|
|
34
|
+
if (args.flags.memory || args.flags["memory-sync"]) body.memory_sync = true;
|
|
35
|
+
|
|
36
|
+
const q = scopeQuery(scope);
|
|
37
|
+
await http.post(`/projects/${pid}/integrations/${SLUG}/configure${q}`, body);
|
|
38
|
+
try {
|
|
39
|
+
const r = await http.post(`/projects/${pid}/integrations/${SLUG}/validate${q}`, {});
|
|
40
|
+
const badge = r.is_vault ? "Obsidian vault" : "folder (no .obsidian)";
|
|
41
|
+
console.log(`✓ Obsidian connected (${scope}) — ${r.vault_name} · ${r.note_count} notes · ${badge}`);
|
|
42
|
+
console.log(` ${r.vault_path}`);
|
|
43
|
+
if (body.auto_mcp) console.log(" auto-MCP: on — an 'obsidian' MCP server was registered for this scope");
|
|
44
|
+
if (body.memory_sync) console.log(" memory-sync: on — run `apx obsidian sync` to mirror APX memory into the vault");
|
|
45
|
+
} catch (e) {
|
|
46
|
+
throw new Error(`Vault path saved but validation failed: ${e.message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function cmdObsidianStatus(args) {
|
|
51
|
+
const scope = resolveScope(args.flags);
|
|
52
|
+
const pid = await resolveProjectId(args?.flags?.project);
|
|
53
|
+
const s = await http.get(`/projects/${pid}/integrations/${SLUG}${scopeQuery(scope)}`);
|
|
54
|
+
if (!s || s.status === "disconnected") {
|
|
55
|
+
console.log(`(Obsidian not configured in scope "${scope}")`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
console.log(`Obsidian — ${s.status}${s.is_enabled ? " (enabled)" : ""}`);
|
|
59
|
+
if (s.vault_path) console.log(` vault: ${s.vault_path}`);
|
|
60
|
+
if (s.vault_name) console.log(` name: ${s.vault_name}`);
|
|
61
|
+
if (s.note_count != null) console.log(` notes: ${s.note_count}`);
|
|
62
|
+
console.log(` auto-MCP: ${s.auto_mcp ? "on" : "off"}`);
|
|
63
|
+
console.log(` memory-sync: ${s.memory_sync ? "on" : "off"}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function cmdObsidianSync(args) {
|
|
67
|
+
const scope = resolveScope(args.flags);
|
|
68
|
+
const pid = await resolveProjectId(args?.flags?.project);
|
|
69
|
+
const r = await http.post(`/projects/${pid}/integrations/${SLUG}/action/sync_memory${scopeQuery(scope)}`, {});
|
|
70
|
+
console.log(`✓ Synced ${r.count} memory file(s) → vault folder "${r.folder}" (${r.changed} changed)`);
|
|
71
|
+
for (const n of r.notes || []) console.log(` ${n.changed ? "↑" : "="} ${n.note}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function cmdObsidianRemove(args) {
|
|
75
|
+
const scope = resolveScope(args.flags);
|
|
76
|
+
const pid = await resolveProjectId(args?.flags?.project);
|
|
77
|
+
await http.delete(`/projects/${pid}/integrations/${SLUG}${scopeQuery(scope)}`);
|
|
78
|
+
console.log(`Removed Obsidian integration (scope: ${scope})`);
|
|
79
|
+
}
|
|
@@ -101,6 +101,12 @@ import {
|
|
|
101
101
|
cmdPermission,
|
|
102
102
|
} from "./commands/config.js";
|
|
103
103
|
import { cmdPluginsList, cmdPluginStatus } from "./commands/plugins.js";
|
|
104
|
+
import {
|
|
105
|
+
cmdObsidianSet,
|
|
106
|
+
cmdObsidianStatus,
|
|
107
|
+
cmdObsidianSync,
|
|
108
|
+
cmdObsidianRemove,
|
|
109
|
+
} from "./commands/obsidian.js";
|
|
104
110
|
import { cmdDesktopStart, cmdDesktopStop, cmdDesktopRestart, cmdDesktopStatus, cmdDesktopInstall, cmdDesktopUninstall, desktopRunning } from "./commands/desktop.js";
|
|
105
111
|
import { cmdVoiceSay, cmdVoiceListen, cmdVoiceProviders } from "./commands/voice.js";
|
|
106
112
|
import { cmdSkillsAdd, cmdSkillsList, cmdSkillsStatus, cmdSkillsSync, cmdSkillsIndex, cmdSkillsInspect, cmdSkillsInspector } from "./commands/skills.js";
|
|
@@ -715,6 +721,30 @@ const HELP_TOPICS = new Map(Object.entries({
|
|
|
715
721
|
"apx sessions list --engine codex --dir /Volumes/work/iacrmar",
|
|
716
722
|
],
|
|
717
723
|
}),
|
|
724
|
+
obsidian: topic({
|
|
725
|
+
title: "apx obsidian",
|
|
726
|
+
summary: "Connect an Obsidian vault so agents can read, search and write notes; mirror APX memory into it.",
|
|
727
|
+
usage: ["apx obsidian <subcommand> [--global | --project <name|id|path>]"],
|
|
728
|
+
commands: [
|
|
729
|
+
["set <vault-path>", "Connect a vault (validates the path). Add --mcp / --memory to enable extras."],
|
|
730
|
+
["status", "Show the configured vault, note count and toggles for the scope."],
|
|
731
|
+
["sync", "Mirror APX memory (global + projects) into the vault, without duplicates."],
|
|
732
|
+
["remove | rm", "Disconnect the vault (also removes any auto-registered MCP)."],
|
|
733
|
+
],
|
|
734
|
+
options: [
|
|
735
|
+
["--global", "Target the default space (shared by all projects) instead of the current project."],
|
|
736
|
+
["--scope <project|global>", "Explicit scope (alias of --global)."],
|
|
737
|
+
["--mcp", "On `set`: auto-register an 'obsidian' MCP server pointing at the vault."],
|
|
738
|
+
["--memory", "On `set`: enable memory sync (then run `apx obsidian sync`)."],
|
|
739
|
+
["--project <name|id|path>", "Pin command to a specific project."],
|
|
740
|
+
],
|
|
741
|
+
examples: [
|
|
742
|
+
"apx obsidian set ~/Obsidian/Work --project appsi",
|
|
743
|
+
"apx obsidian set ~/Obsidian/Personal --global --mcp --memory",
|
|
744
|
+
"apx obsidian sync --global",
|
|
745
|
+
"apx obsidian status",
|
|
746
|
+
],
|
|
747
|
+
}),
|
|
718
748
|
mcp: topic({
|
|
719
749
|
title: "apx mcp",
|
|
720
750
|
summary: "Manage and call MCP servers merged from APC and supported IDE configs.",
|
|
@@ -2135,6 +2165,12 @@ function buildHelp(version) {
|
|
|
2135
2165
|
hCmd("apx mcp logs <name>", 36, "spawn/init log + stderr tail"),
|
|
2136
2166
|
hCmd("apx mcp check", 36, "audit multi-source merge"),
|
|
2137
2167
|
|
|
2168
|
+
hSec("Obsidian"),
|
|
2169
|
+
hCmd("apx obsidian set <path>", 36, "connect a vault --global --project P --mcp --memory"),
|
|
2170
|
+
hCmd("apx obsidian status", 36, "show configured vault + toggles"),
|
|
2171
|
+
hCmd("apx obsidian sync", 36, "mirror APX memory into the vault (no duplicates)"),
|
|
2172
|
+
hCmd("apx obsidian remove", 36, "disconnect the vault"),
|
|
2173
|
+
|
|
2138
2174
|
hSec("Daemon Service"),
|
|
2139
2175
|
hCmd("apx daemon start", 36, ""),
|
|
2140
2176
|
hCmd("apx daemon reload", 36, "reload ~/.apx/config.json without restart"),
|
|
@@ -2313,7 +2349,7 @@ function findHelpTopic(argv) {
|
|
|
2313
2349
|
// Flags that never take a value. Without this the parser would greedily
|
|
2314
2350
|
// swallow the following positional (e.g. `apx exec --code "hi"` would set
|
|
2315
2351
|
// flags.code = "hi" and drop the prompt). Boolean flags always resolve to true.
|
|
2316
|
-
const BOOLEAN_FLAGS = new Set(["code", "verbose"]);
|
|
2352
|
+
const BOOLEAN_FLAGS = new Set(["code", "verbose", "global", "mcp", "memory"]);
|
|
2317
2353
|
|
|
2318
2354
|
function parseArgs(argv) {
|
|
2319
2355
|
const args = { _: [], flags: {} };
|
|
@@ -2501,6 +2537,17 @@ async function dispatch(cmd, rest) {
|
|
|
2501
2537
|
break;
|
|
2502
2538
|
}
|
|
2503
2539
|
|
|
2540
|
+
case "obsidian": {
|
|
2541
|
+
const sub = rest[0];
|
|
2542
|
+
const a = parseArgs(rest.slice(1));
|
|
2543
|
+
if (!sub || sub === "status" || sub === "show") await cmdObsidianStatus(a);
|
|
2544
|
+
else if (sub === "set" || sub === "connect" || sub === "add") await cmdObsidianSet(a);
|
|
2545
|
+
else if (sub === "sync") await cmdObsidianSync(a);
|
|
2546
|
+
else if (sub === "remove" || sub === "rm" || sub === "disconnect") await cmdObsidianRemove(a);
|
|
2547
|
+
else die(`unknown obsidian subcommand: ${sub}\nUsage: apx obsidian <set|status|sync|remove> [--global|--project <p>]`);
|
|
2548
|
+
break;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2504
2551
|
case "daemon": {
|
|
2505
2552
|
const sub = rest[0];
|
|
2506
2553
|
const a = parseArgs(rest.slice(1));
|