@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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/tools/handlers/_obsidian.js +28 -0
  3. package/src/core/agent/tools/handlers/obsidian-list-notes.js +28 -0
  4. package/src/core/agent/tools/handlers/obsidian-read-note.js +31 -0
  5. package/src/core/agent/tools/handlers/obsidian-search-notes.js +30 -0
  6. package/src/core/agent/tools/handlers/obsidian-write-note.js +38 -0
  7. package/src/core/agent/tools/names.js +10 -0
  8. package/src/core/agent/tools/registry.js +8 -0
  9. package/src/core/integrations/catalog.js +6 -3
  10. package/src/core/integrations/index.js +2 -0
  11. package/src/core/integrations/mcp-sync.js +71 -0
  12. package/src/core/integrations/obsidian-memory.js +108 -0
  13. package/src/core/integrations/plugins/obsidian.js +299 -0
  14. package/src/core/mcp/runner.js +54 -20
  15. package/src/core/memory/broker.js +4 -1
  16. package/src/core/memory/index.js +29 -2
  17. package/src/core/memory/indexer.js +88 -1
  18. package/src/core/memory/store.js +23 -3
  19. package/src/host/daemon/api/agents.js +37 -1
  20. package/src/host/daemon/api/integrations.js +27 -2
  21. package/src/host/daemon/api/mcps.js +2 -2
  22. package/src/host/daemon/index.js +7 -1
  23. package/src/interfaces/cli/commands/obsidian.js +79 -0
  24. package/src/interfaces/cli/index.js +48 -1
  25. package/src/interfaces/web/dist/assets/index-CDz9OwCP.css +1 -0
  26. package/src/interfaces/web/dist/assets/index-YFsZFhM6.js +798 -0
  27. package/src/interfaces/web/dist/assets/index-YFsZFhM6.js.map +1 -0
  28. package/src/interfaces/web/dist/index.html +2 -2
  29. package/src/interfaces/web/src/components/integrations/BrandLogos.tsx +29 -0
  30. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +5 -4
  31. package/src/interfaces/web/src/components/integrations/FolderInput.tsx +137 -0
  32. package/src/interfaces/web/src/components/integrations/PluginConnect.tsx +129 -10
  33. package/src/interfaces/web/src/i18n/en.ts +25 -0
  34. package/src/interfaces/web/src/i18n/es.ts +25 -0
  35. package/src/interfaces/web/src/lib/api/agents.ts +2 -1
  36. package/src/interfaces/web/src/lib/api/integrations.ts +10 -1
  37. package/src/interfaces/web/src/screens/project/AgentBrainGraph.tsx +169 -47
  38. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +108 -34
  39. package/src/interfaces/web/src/screens/project/AgentsTab.tsx +72 -16
  40. package/src/interfaces/web/src/screens/project/Overview.tsx +90 -1
  41. package/src/interfaces/web/src/types/daemon.ts +10 -0
  42. package/src/interfaces/web/dist/assets/index-D4BmWoDM.css +0 -1
  43. package/src/interfaces/web/dist/assets/index-vwd6yQVw.js +0 -803
  44. package/src/interfaces/web/dist/assets/index-vwd6yQVw.js.map +0 -1
@@ -25,6 +25,40 @@ import {
25
25
  import { agentToResponse } from "./shared.js";
26
26
  import { normalizeVaultPatch } from "#core/apc/agents-vault.js";
27
27
  import { PERMISSION_MODES } from "#core/constants/permissions.js";
28
+ import { listConversations } from "#core/stores/conversations.js";
29
+ import { listTasks } from "#core/stores/tasks.js";
30
+ import { listRoutines } from "#core/stores/routines.js";
31
+ import { readProjectMessages } from "#core/stores/messages.js";
32
+
33
+ // Attach a per-agent activity summary ({ threads, records, tasks, heartbeats })
34
+ // to a list of agent responses. Reads each store once and tallies by agent, so
35
+ // the whole list costs O(stores) rather than O(agents × stores). Gated behind
36
+ // `?stats=1` on the list endpoint since it touches the message ledger.
37
+ function attachAgentStats(p, agents) {
38
+ const store = p.storagePath || p.path;
39
+ const tally = (rows, key) => {
40
+ const m = Object.create(null);
41
+ for (const r of rows) {
42
+ const a = typeof key === "function" ? key(r) : r?.[key];
43
+ if (a) m[a] = (m[a] || 0) + 1;
44
+ }
45
+ return m;
46
+ };
47
+ let tasksByAgent = {}, hbByAgent = {}, recByAgent = {};
48
+ try { tasksByAgent = tally(listTasks(store, { state: "all" }), "agent"); } catch { /* no task store */ }
49
+ try { hbByAgent = tally(listRoutines(store), (r) => r?.spec?.agent); } catch { /* no routines */ }
50
+ try { recByAgent = tally(readProjectMessages(store, { limit: 1000 }), "agent_slug"); } catch { /* no ledger */ }
51
+ for (const a of agents) {
52
+ let threads = 0;
53
+ try { threads = listConversations(store, a.slug).length; } catch { /* none */ }
54
+ a.stats = {
55
+ threads,
56
+ records: recByAgent[a.slug] || 0,
57
+ tasks: tasksByAgent[a.slug] || 0,
58
+ heartbeats: hbByAgent[a.slug] || 0,
59
+ };
60
+ }
61
+ }
28
62
 
29
63
  // Autonomy mirrors the super-agent permission modes (total/automatico/permiso).
30
64
  // An invalid value is dropped rather than persisted so a typo can't silently
@@ -123,7 +157,9 @@ export function register(app, { projects, project }) {
123
157
  app.get("/projects/:pid/agents", (req, res) => {
124
158
  const p = project(req, res);
125
159
  if (!p) return;
126
- res.json(readAgents(p.path).map(agentToResponse));
160
+ const agents = readAgents(p.path).map(agentToResponse);
161
+ if (req.query.stats === "1") attachAgentStats(p, agents);
162
+ res.json(agents);
127
163
  });
128
164
 
129
165
  app.get("/projects/:pid/agents/:slug", (req, res) => {
@@ -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));