@agentprojectcontext/apx 1.69.0 → 1.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/tools/handlers/_obsidian.js +28 -0
  3. package/src/core/agent/tools/handlers/call-agent.js +7 -1
  4. package/src/core/agent/tools/handlers/obsidian-list-notes.js +28 -0
  5. package/src/core/agent/tools/handlers/obsidian-read-note.js +31 -0
  6. package/src/core/agent/tools/handlers/obsidian-search-notes.js +30 -0
  7. package/src/core/agent/tools/handlers/obsidian-write-note.js +38 -0
  8. package/src/core/agent/tools/names.js +10 -0
  9. package/src/core/agent/tools/registry.js +8 -0
  10. package/src/core/channels/telegram/dispatch.js +3 -2
  11. package/src/core/integrations/catalog.js +6 -3
  12. package/src/core/integrations/index.js +2 -0
  13. package/src/core/integrations/mcp-sync.js +71 -0
  14. package/src/core/integrations/obsidian-memory.js +179 -0
  15. package/src/core/integrations/plugins/obsidian.js +339 -0
  16. package/src/core/mcp/runner.js +54 -20
  17. package/src/core/memory/broker.js +11 -3
  18. package/src/core/memory/index.js +62 -2
  19. package/src/core/memory/indexer.js +88 -1
  20. package/src/core/memory/store.js +35 -3
  21. package/src/host/daemon/api/integrations.js +27 -2
  22. package/src/host/daemon/api/mcps.js +2 -2
  23. package/src/host/daemon/index.js +7 -1
  24. package/src/interfaces/cli/commands/obsidian.js +79 -0
  25. package/src/interfaces/cli/index.js +48 -1
  26. package/src/interfaces/web/dist/assets/index-CDz9OwCP.css +1 -0
  27. package/src/interfaces/web/dist/assets/index-CX15mZXM.js +798 -0
  28. package/src/interfaces/web/dist/assets/index-CX15mZXM.js.map +1 -0
  29. package/src/interfaces/web/dist/index.html +2 -2
  30. package/src/interfaces/web/src/components/common/TabNav.tsx +10 -3
  31. package/src/interfaces/web/src/components/integrations/BrandLogos.tsx +29 -0
  32. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +5 -4
  33. package/src/interfaces/web/src/components/integrations/FolderInput.tsx +137 -0
  34. package/src/interfaces/web/src/components/integrations/PluginConnect.tsx +129 -10
  35. package/src/interfaces/web/src/i18n/en.ts +21 -0
  36. package/src/interfaces/web/src/i18n/es.ts +21 -0
  37. package/src/interfaces/web/src/lib/api/integrations.ts +10 -1
  38. package/src/interfaces/web/src/screens/ProjectScreen.tsx +12 -2
  39. package/src/interfaces/web/dist/assets/index-_2zKBH4O.js +0 -803
  40. package/src/interfaces/web/dist/assets/index-_2zKBH4O.js.map +0 -1
  41. package/src/interfaces/web/dist/assets/index-xQYf6_ab.css +0 -1
@@ -21,12 +21,14 @@ import fs from "node:fs";
21
21
  import path from "node:path";
22
22
  import { GLOBAL_MESSAGES_DIR, APX_HOME } from "../config/index.js";
23
23
  import { SELF_MEMORY_PATH, parseSelfMemoryEntries } from "../agent/self-memory.js";
24
+ import { apcMemoryFile } from "../apc/paths.js";
24
25
  import { embedBatch, embedOne } from "./embeddings.js";
25
26
 
26
27
  export const CURSOR_PATH = path.join(APX_HOME, "memory-cursor.json");
27
28
 
28
29
  const BODY_CAP = 1200; // chars kept per user/agent chunk
29
30
  const TOOL_CAP = 400; // chars kept per tool-result chunk
31
+ const SCOPED_CAP = 800; // chars kept per project/agent memory block
30
32
 
31
33
  function fnv1aHex(str) {
32
34
  let h = 0x811c9dc5;
@@ -37,6 +39,14 @@ function fnv1aHex(str) {
37
39
  return (h >>> 0).toString(16);
38
40
  }
39
41
 
42
+ function readIfExists(p) {
43
+ try {
44
+ return fs.readFileSync(p, "utf8");
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
40
50
  function readCursor(cursorPath) {
41
51
  try {
42
52
  return JSON.parse(fs.readFileSync(cursorPath, "utf8"));
@@ -177,6 +187,78 @@ function collectMemoryChunks(store, memoryPath) {
177
187
  return fresh;
178
188
  }
179
189
 
190
+ // Split free-form markdown (project/agent memory — NOT the dated-notebook
191
+ // format) into meaningful blocks: blank-line separated, whitespace-collapsed,
192
+ // capped, and filtered so empty template sections ("## Identity\n- ") never
193
+ // pollute the store. Each block becomes one scoped chunk.
194
+ function chunkFreeMarkdown(text) {
195
+ return String(text || "")
196
+ .split(/\n\s*\n/)
197
+ .map((block) => {
198
+ // Drop pure-heading blocks (a "# Title" with no body under it) — they add
199
+ // noise to retrieval. Keep blocks that have at least one non-heading line.
200
+ const lines = block.split("\n").map((l) => l.trim()).filter(Boolean);
201
+ if (!lines.some((l) => !l.startsWith("#"))) return "";
202
+ return block.replace(/\s+/g, " ").trim();
203
+ })
204
+ .filter((b) => meaningfulBody(b))
205
+ .map((b) => b.slice(0, SCOPED_CAP));
206
+ }
207
+
208
+ // Per-agent memory lives at ~/.apx/projects/<projdir>/agents/<slug>/memory.md.
209
+ // Walkable straight off the filesystem — no registry needed. Scoped by channel
210
+ // "agent:<projdir>:<slug>" so retrieval never leaks between agents.
211
+ function collectAgentMemoryChunks(store, apxHome) {
212
+ const fresh = [];
213
+ const projectsRoot = path.join(apxHome, "projects");
214
+ let projDirs;
215
+ try {
216
+ projDirs = fs.readdirSync(projectsRoot);
217
+ } catch {
218
+ return fresh;
219
+ }
220
+ for (const projDir of projDirs) {
221
+ const agentsDir = path.join(projectsRoot, projDir, "agents");
222
+ let slugs;
223
+ try {
224
+ slugs = fs.readdirSync(agentsDir);
225
+ } catch {
226
+ continue;
227
+ }
228
+ for (const slug of slugs) {
229
+ const body = readIfExists(path.join(agentsDir, slug, "memory.md"));
230
+ if (body == null) continue;
231
+ const channel = `agent:${projDir}:${slug}`;
232
+ for (const block of chunkFreeMarkdown(body)) {
233
+ const id = `agentmem:${projDir}:${slug}:${fnv1aHex(block)}`;
234
+ if (store.hasId(id)) continue;
235
+ fresh.push({ id, source: "agent-memory", channel, ts: "", tag: "agent-memory", text: block });
236
+ }
237
+ }
238
+ }
239
+ return fresh;
240
+ }
241
+
242
+ // Project memory (.apc/memory.md) for every registered project. Needs the
243
+ // registry to map id → repo path. Scoped by channel "project:<id>".
244
+ function collectProjectMemoryChunks(store, projects) {
245
+ const fresh = [];
246
+ const list = typeof projects?.list === "function" ? projects.list() : Array.isArray(projects) ? projects : [];
247
+ for (const entry of list) {
248
+ const root = entry?.path;
249
+ if (!root) continue; // the default project (id 0) has no repo root
250
+ const body = readIfExists(apcMemoryFile(root));
251
+ if (body == null) continue;
252
+ const channel = `project:${entry.id}`;
253
+ for (const block of chunkFreeMarkdown(body)) {
254
+ const id = `projmem:${entry.id}:${fnv1aHex(block)}`;
255
+ if (store.hasId(id)) continue;
256
+ fresh.push({ id, source: "project-memory", channel, ts: "", tag: "project-memory", text: block });
257
+ }
258
+ }
259
+ return fresh;
260
+ }
261
+
180
262
  // Run one incremental indexing pass. Returns { indexed, backend }.
181
263
  // `opts.embed` overrides embedding options (baseUrl/model/timeoutMs) for tests.
182
264
  export async function indexNewMessages(store, opts = {}) {
@@ -220,7 +302,12 @@ export async function indexNewMessages(store, opts = {}) {
220
302
 
221
303
  const { fresh: msgChunks, maxTsByChannel } = collectMessageChunks(store, cursor, messagesDir);
222
304
  const memChunks = collectMemoryChunks(store, memoryPath);
223
- let chunks = [...msgChunks, ...memChunks];
305
+ // Scoped memory (Pieza 5): per-agent + per-project notebooks, tagged with a
306
+ // scoped channel so retrieval can be isolated. Idempotent by content hash, so
307
+ // re-derived every pass like memory.md (cheap, few blocks).
308
+ const agentChunks = collectAgentMemoryChunks(store, opts.apxHome || APX_HOME);
309
+ const projChunks = collectProjectMemoryChunks(store, opts.projects);
310
+ let chunks = [...msgChunks, ...memChunks, ...agentChunks, ...projChunks];
224
311
  if (chunks.length === 0) return { indexed: 0, backend: store.backend };
225
312
 
226
313
  // Cap per-run work so a huge first index doesn't block; the rest is picked
@@ -8,18 +8,30 @@
8
8
  //
9
9
  // Both expose the same interface:
10
10
  // upsert(rows) rows: {id, source, channel, ts, tag, text, embedder, dim, vector}
11
- // search(vector, {embedder, k, channel}) -> [{...row, score}]
11
+ // search(vector, {embedder, k, channel, scope}) -> [{...row, score}]
12
12
  // hasId(id) / count()
13
13
  // close()
14
14
  //
15
15
  // Cosine is only meaningful within one embedder space, so search() filters to
16
16
  // rows whose `embedder` matches the query's embedder. Everything here is
17
17
  // best-effort: open() never throws — on any failure it returns a JsonStore.
18
+ //
19
+ // Scope isolation (memory belongs to whoever wrote it):
20
+ // - project/agent memory rows carry a scoped `channel` ("project:<id>" or
21
+ // "agent:<projdir>:<slug>"). Global conversational rows use the channel name
22
+ // (telegram/web/…) or "memory".
23
+ // - search({scope}) keeps retrieval from leaking across scopes:
24
+ // scope === "global" → EXCLUDE project:/agent: rows (super-agent recall)
25
+ // scope === "<value>" → ONLY rows whose channel equals that value
26
+ // scope omitted → no scope filter (back-compat)
18
27
 
19
28
  import fs from "node:fs";
20
29
  import path from "node:path";
21
30
  import { cosineSim } from "./embeddings.js";
22
31
 
32
+ export const SCOPE_PREFIXES = ["project:", "agent:"];
33
+ const isScopedChannel = (c) => SCOPE_PREFIXES.some((p) => String(c || "").startsWith(p));
34
+
23
35
  function vecToBlob(vec) {
24
36
  const f = new Float32Array(vec);
25
37
  return Buffer.from(f.buffer, f.byteOffset, f.byteLength);
@@ -87,11 +99,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
- res.json(await fn.call(svc.actions, record));
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.shutdown();
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.shutdown();
153
+ registries.evictName(req.params.name);
154
154
  projects.rebuild(p.id);
155
155
  res.status(204).end();
156
156
  });
@@ -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));