@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
package/package.json
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Shared helper for the Obsidian agent tools (obsidian-*.js). Underscore file —
|
|
2
|
+
// like _asana.js / _git.js — so it declares no tool `name:` of its own. Each
|
|
3
|
+
// tool stays a thin adapter: resolve the project's effective Obsidian vault (its
|
|
4
|
+
// own record wins over the default project's — see resolveIntegration), then
|
|
5
|
+
// call the pure filesystem client in core/integrations/plugins/obsidian.js.
|
|
6
|
+
import { resolveProject } from "../helpers.js";
|
|
7
|
+
import { resolveIntegration } from "#core/integrations/index.js";
|
|
8
|
+
import { resolveVaultPath } from "#core/integrations/plugins/obsidian.js";
|
|
9
|
+
|
|
10
|
+
// Resolve the active Obsidian vault for a project, or throw a message the model
|
|
11
|
+
// can act on (tell the user to connect a vault).
|
|
12
|
+
export function resolveObsidian(projects, project) {
|
|
13
|
+
const p = resolveProject(projects, project);
|
|
14
|
+
const resolved = resolveIntegration({ projectStorage: p.storagePath, slug: "obsidian" });
|
|
15
|
+
if (!resolved) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
"Obsidian is not connected for this project. Ask the user to connect a Vault in the web panel → Integrations → Plugins → Obsidian, or run `apx obsidian set <path>`.",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
const config = resolved.record.config || {};
|
|
21
|
+
if (!config.vault_path) throw new Error("Obsidian integration has no vault path configured");
|
|
22
|
+
return { vaultPath: resolveVaultPath(config.vault_path), config, scope: resolved.scope };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The optional `project` arg every Obsidian tool accepts (defaults to current).
|
|
26
|
+
export const PROJECT_ARG = {
|
|
27
|
+
project: { type: "string", description: "APX project id/name (optional; defaults to current)" },
|
|
28
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as obsidian from "#core/integrations/plugins/obsidian.js";
|
|
2
|
+
import { resolveObsidian, PROJECT_ARG } from "./_obsidian.js";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: "obsidian_list_notes",
|
|
6
|
+
category: "integrations",
|
|
7
|
+
schema: {
|
|
8
|
+
type: "function",
|
|
9
|
+
function: {
|
|
10
|
+
name: "obsidian_list_notes",
|
|
11
|
+
description: "List note paths in the active Obsidian vault (vault-relative, sorted).",
|
|
12
|
+
parameters: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
limit: { type: "number", description: "Max notes to return (default 500)" },
|
|
16
|
+
...PROJECT_ARG,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
makeHandler:
|
|
22
|
+
({ projects }) =>
|
|
23
|
+
async ({ project, limit = 500 } = {}) => {
|
|
24
|
+
const { vaultPath } = resolveObsidian(projects, project);
|
|
25
|
+
const notes = obsidian.listNotes(vaultPath, { limit });
|
|
26
|
+
return { notes, count: notes.length };
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as obsidian from "#core/integrations/plugins/obsidian.js";
|
|
2
|
+
import { resolveObsidian, PROJECT_ARG } from "./_obsidian.js";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: "obsidian_read_note",
|
|
6
|
+
category: "integrations",
|
|
7
|
+
schema: {
|
|
8
|
+
type: "function",
|
|
9
|
+
function: {
|
|
10
|
+
name: "obsidian_read_note",
|
|
11
|
+
description: "Read the full contents of a note in the active Obsidian vault.",
|
|
12
|
+
parameters: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
note: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "Vault-relative note path, e.g. 'Folder/Note.md' (the .md is optional)",
|
|
18
|
+
},
|
|
19
|
+
...PROJECT_ARG,
|
|
20
|
+
},
|
|
21
|
+
required: ["note"],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
makeHandler:
|
|
26
|
+
({ projects }) =>
|
|
27
|
+
async ({ project, note } = {}) => {
|
|
28
|
+
const { vaultPath } = resolveObsidian(projects, project);
|
|
29
|
+
return { note, content: obsidian.readNote(vaultPath, note) };
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as obsidian from "#core/integrations/plugins/obsidian.js";
|
|
2
|
+
import { resolveObsidian, PROJECT_ARG } from "./_obsidian.js";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: "obsidian_search_notes",
|
|
6
|
+
category: "integrations",
|
|
7
|
+
schema: {
|
|
8
|
+
type: "function",
|
|
9
|
+
function: {
|
|
10
|
+
name: "obsidian_search_notes",
|
|
11
|
+
description:
|
|
12
|
+
"Search notes in the active Obsidian vault by text. Matches note bodies and filenames; returns a short snippet per hit.",
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
query: { type: "string", description: "Text to search for" },
|
|
17
|
+
limit: { type: "number", description: "Max results (default 50)" },
|
|
18
|
+
...PROJECT_ARG,
|
|
19
|
+
},
|
|
20
|
+
required: ["query"],
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
makeHandler:
|
|
25
|
+
({ projects }) =>
|
|
26
|
+
async ({ project, query, limit = 50 } = {}) => {
|
|
27
|
+
const { vaultPath } = resolveObsidian(projects, project);
|
|
28
|
+
return { results: obsidian.searchNotes(vaultPath, query, { limit }) };
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as obsidian from "#core/integrations/plugins/obsidian.js";
|
|
2
|
+
import { resolveObsidian, PROJECT_ARG } from "./_obsidian.js";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: "obsidian_write_note",
|
|
6
|
+
category: "integrations",
|
|
7
|
+
schema: {
|
|
8
|
+
type: "function",
|
|
9
|
+
function: {
|
|
10
|
+
name: "obsidian_write_note",
|
|
11
|
+
description:
|
|
12
|
+
"Create or update a note in the active Obsidian vault. Use mode 'append' to add to an existing note, otherwise the note is overwritten.",
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
note: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "Vault-relative note path, e.g. 'Inbox/Idea.md' (the .md is optional; parent folders are created)",
|
|
19
|
+
},
|
|
20
|
+
content: { type: "string", description: "Markdown content to write" },
|
|
21
|
+
mode: {
|
|
22
|
+
type: "string",
|
|
23
|
+
enum: ["overwrite", "append"],
|
|
24
|
+
description: "Write mode (default 'overwrite')",
|
|
25
|
+
},
|
|
26
|
+
...PROJECT_ARG,
|
|
27
|
+
},
|
|
28
|
+
required: ["note", "content"],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
makeHandler:
|
|
33
|
+
({ projects }) =>
|
|
34
|
+
async ({ project, note, content, mode = "overwrite" } = {}) => {
|
|
35
|
+
const { vaultPath } = resolveObsidian(projects, project);
|
|
36
|
+
return obsidian.writeNote(vaultPath, note, content, { mode });
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -61,6 +61,12 @@ export const TOOLS = Object.freeze({
|
|
|
61
61
|
GITHUB_LIST_REPOS: "github_list_repos",
|
|
62
62
|
GITHUB_CREATE_ISSUE: "github_create_issue",
|
|
63
63
|
|
|
64
|
+
// Integrations — Obsidian plugin (see core/integrations/plugins/obsidian.js)
|
|
65
|
+
OBSIDIAN_SEARCH_NOTES: "obsidian_search_notes",
|
|
66
|
+
OBSIDIAN_READ_NOTE: "obsidian_read_note",
|
|
67
|
+
OBSIDIAN_WRITE_NOTE: "obsidian_write_note",
|
|
68
|
+
OBSIDIAN_LIST_NOTES: "obsidian_list_notes",
|
|
69
|
+
|
|
64
70
|
// Side-effects
|
|
65
71
|
SEND_TELEGRAM: "send_telegram",
|
|
66
72
|
SET_IDENTITY: "set_identity",
|
|
@@ -112,6 +118,10 @@ export const NATIVE_TOOL_NAMES = new Set([
|
|
|
112
118
|
TOOLS.ASANA_UPDATE_TASK,
|
|
113
119
|
TOOLS.GITHUB_LIST_REPOS,
|
|
114
120
|
TOOLS.GITHUB_CREATE_ISSUE,
|
|
121
|
+
TOOLS.OBSIDIAN_SEARCH_NOTES,
|
|
122
|
+
TOOLS.OBSIDIAN_READ_NOTE,
|
|
123
|
+
TOOLS.OBSIDIAN_WRITE_NOTE,
|
|
124
|
+
TOOLS.OBSIDIAN_LIST_NOTES,
|
|
115
125
|
TOOLS.SEND_TELEGRAM,
|
|
116
126
|
TOOLS.SET_IDENTITY,
|
|
117
127
|
TOOLS.SET_PERMISSION_MODE,
|
|
@@ -40,6 +40,10 @@ import asanaCreateTask from "./handlers/asana-create-task.js";
|
|
|
40
40
|
import asanaUpdateTask from "./handlers/asana-update-task.js";
|
|
41
41
|
import githubListRepos from "./handlers/github-list-repos.js";
|
|
42
42
|
import githubCreateIssue from "./handlers/github-create-issue.js";
|
|
43
|
+
import obsidianSearchNotes from "./handlers/obsidian-search-notes.js";
|
|
44
|
+
import obsidianReadNote from "./handlers/obsidian-read-note.js";
|
|
45
|
+
import obsidianWriteNote from "./handlers/obsidian-write-note.js";
|
|
46
|
+
import obsidianListNotes from "./handlers/obsidian-list-notes.js";
|
|
43
47
|
import { createPermissionGuard } from "./helpers.js";
|
|
44
48
|
import { buildBridgedTools, DEFAULT_CATEGORIES } from "./registry-bridge.js";
|
|
45
49
|
import { TOOLS, CODE_CHANNEL_TOOLS } from "./names.js";
|
|
@@ -91,6 +95,10 @@ const NATIVE_TOOLS = [
|
|
|
91
95
|
asanaUpdateTask,
|
|
92
96
|
githubListRepos,
|
|
93
97
|
githubCreateIssue,
|
|
98
|
+
obsidianSearchNotes,
|
|
99
|
+
obsidianReadNote,
|
|
100
|
+
obsidianWriteNote,
|
|
101
|
+
obsidianListNotes,
|
|
94
102
|
];
|
|
95
103
|
|
|
96
104
|
// Registry-backed bridges. Categories can be overridden per-process via env
|
|
@@ -7,16 +7,19 @@
|
|
|
7
7
|
// rest carry `coming_soon: true`. Adding a plugin = drop a module in ./plugins/
|
|
8
8
|
// + register it in PLUGIN_SERVICES (open/closed) — no API/route changes.
|
|
9
9
|
//
|
|
10
|
-
// Scope of this catalog (per product decision): Asana, GitHub,
|
|
11
|
-
// Telegram is intentionally absent — it's a channel, configured under
|
|
12
|
-
// surface, not a service plugin. Transcription lives with the desktop
|
|
10
|
+
// Scope of this catalog (per product decision): Asana, GitHub, Obsidian,
|
|
11
|
+
// WhatsApp. Telegram is intentionally absent — it's a channel, configured under
|
|
12
|
+
// its own surface, not a service plugin. Transcription lives with the desktop
|
|
13
|
+
// STT stack. Obsidian is path-based (a local Vault) rather than token-based.
|
|
13
14
|
import { asanaPlugin } from "./plugins/asana.js";
|
|
14
15
|
import { githubPlugin } from "./plugins/github.js";
|
|
16
|
+
import { obsidianPlugin } from "./plugins/obsidian.js";
|
|
15
17
|
|
|
16
18
|
// slug -> live plugin service (must implement the lifecycle contract).
|
|
17
19
|
export const PLUGIN_SERVICES = Object.freeze({
|
|
18
20
|
asana: asanaPlugin,
|
|
19
21
|
github: githubPlugin,
|
|
22
|
+
obsidian: obsidianPlugin,
|
|
20
23
|
});
|
|
21
24
|
|
|
22
25
|
// Static descriptors for plugins that are declared but not yet connectable.
|
|
@@ -8,3 +8,5 @@ export {
|
|
|
8
8
|
} from "./sources.js";
|
|
9
9
|
export { IntegrationStore, resolveIntegration, redactRecord } from "./store.js";
|
|
10
10
|
export { listCatalog, getPluginService, PLUGIN_SERVICES } from "./catalog.js";
|
|
11
|
+
export { reconcilePluginMcp } from "./mcp-sync.js";
|
|
12
|
+
export { collectMemorySources, syncMemoryToVault } from "./obsidian-memory.js";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Generic plugin → MCP reconcile. A plugin may expose an optional
|
|
2
|
+
// `mcpServer(record)` hook returning `{ name, def }` (def:null → should not
|
|
3
|
+
// exist). The daemon calls reconcilePluginMcp after every lifecycle change so
|
|
4
|
+
// an integration can keep an auto-registered MCP server in lockstep with its
|
|
5
|
+
// state. Today only the Obsidian plugin uses it (opt-in `auto_mcp`).
|
|
6
|
+
import {
|
|
7
|
+
readRuntimeMcps,
|
|
8
|
+
writeRuntimeMcps,
|
|
9
|
+
readGlobalMcps,
|
|
10
|
+
writeGlobalMcps,
|
|
11
|
+
} from "#core/mcp/sources.js";
|
|
12
|
+
|
|
13
|
+
// Map an integration scope to the MCP scope its auto-registered servers live in.
|
|
14
|
+
// Auto-registered servers point at machine-local paths, so project→runtime
|
|
15
|
+
// (per-project, never committed, chmod 0600) and global→global (machine-wide).
|
|
16
|
+
// We deliberately avoid the committed `.apc/mcps.json` (shared) scope.
|
|
17
|
+
function mcpScopeFor(integrationScope) {
|
|
18
|
+
return integrationScope === "global" ? "global" : "runtime";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readMcpScope(scope, project) {
|
|
22
|
+
return scope === "global" ? readGlobalMcps() : readRuntimeMcps(project.storagePath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeMcpScope(scope, project, json) {
|
|
26
|
+
return scope === "global" ? writeGlobalMcps(json) : writeRuntimeMcps(project.storagePath, json);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Reconcile a plugin's desired MCP server against the store, then evict live
|
|
30
|
+
// registries so agents pick up the change (mirrors host/daemon/api/mcps.js).
|
|
31
|
+
// `desired` = { name, def } from svc.mcpServer(record); def:null removes it.
|
|
32
|
+
export function reconcilePluginMcp({ desired, integrationScope, project, projects, registries }) {
|
|
33
|
+
if (!desired || !desired.name) return { changed: false, reason: "no-hook" };
|
|
34
|
+
const scope = mcpScopeFor(integrationScope);
|
|
35
|
+
if (scope === "runtime" && !project?.storagePath) {
|
|
36
|
+
return { changed: false, reason: "no-storage" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const json = readMcpScope(scope, project) || {};
|
|
40
|
+
json.mcpServers = json.mcpServers || {};
|
|
41
|
+
const existing = json.mcpServers[desired.name];
|
|
42
|
+
|
|
43
|
+
let action = "none";
|
|
44
|
+
if (desired.def === null) {
|
|
45
|
+
if (existing) {
|
|
46
|
+
delete json.mcpServers[desired.name];
|
|
47
|
+
action = "removed";
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
const next = { ...(existing || {}), ...desired.def };
|
|
51
|
+
if (JSON.stringify(existing ?? null) !== JSON.stringify(next)) {
|
|
52
|
+
action = existing ? "updated" : "added";
|
|
53
|
+
}
|
|
54
|
+
json.mcpServers[desired.name] = next;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (action === "none") return { changed: false, scope, name: desired.name };
|
|
58
|
+
|
|
59
|
+
writeMcpScope(scope, project, json);
|
|
60
|
+
try {
|
|
61
|
+
registries?.shutdown?.();
|
|
62
|
+
} catch {
|
|
63
|
+
/* best-effort — a rebuild below still refreshes the registry */
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
projects?.rebuild?.(project?.id);
|
|
67
|
+
} catch {
|
|
68
|
+
/* best-effort */
|
|
69
|
+
}
|
|
70
|
+
return { changed: true, scope, name: desired.name, action };
|
|
71
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Memory ↔ Obsidian sync. Mirrors APX's memory.md files into a folder of the
|
|
2
|
+
// active vault so notes are browsable/backup-able in Obsidian — cleanly and
|
|
3
|
+
// WITHOUT duplicates: every source file maps to exactly ONE deterministic vault
|
|
4
|
+
// note, overwritten in place on each sync (idempotent). We never delete vault
|
|
5
|
+
// notes here, so removing a project won't nuke its backup.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { assertVaultDir } from "./plugins/obsidian.js";
|
|
9
|
+
import { SELF_MEMORY_PATH } from "#core/agent/self-memory.js";
|
|
10
|
+
import { apcMemoryFile } from "#core/apc/paths.js";
|
|
11
|
+
|
|
12
|
+
const MANAGED_MARK = "<!-- APX-managed mirror — edits are overwritten on next sync -->";
|
|
13
|
+
|
|
14
|
+
function readIfExists(p) {
|
|
15
|
+
try {
|
|
16
|
+
return fs.readFileSync(p, "utf8");
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Turn a project name into a safe single vault-folder segment.
|
|
23
|
+
function safeSeg(name) {
|
|
24
|
+
const s = String(name || "project")
|
|
25
|
+
.trim()
|
|
26
|
+
.replace(/[/\\:*?"<>|]+/g, "-")
|
|
27
|
+
.replace(/\s+/g, " ")
|
|
28
|
+
.slice(0, 80);
|
|
29
|
+
return s || "project";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function projectLabel(entry) {
|
|
33
|
+
return (
|
|
34
|
+
entry?.config?.name ||
|
|
35
|
+
entry?.name ||
|
|
36
|
+
(entry?.path ? path.basename(entry.path) : null) ||
|
|
37
|
+
`project-${entry?.id ?? "?"}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Gather the APX memory files to mirror: the global notebook (~/.apx/memory.md)
|
|
42
|
+
// plus every registered project's committed .apc/memory.md. Each source carries
|
|
43
|
+
// a stable `rel` (its 1:1 destination inside the sync folder).
|
|
44
|
+
export function collectMemorySources(ctx = {}) {
|
|
45
|
+
const sources = [];
|
|
46
|
+
|
|
47
|
+
const globalBody = readIfExists(SELF_MEMORY_PATH);
|
|
48
|
+
if (globalBody != null) {
|
|
49
|
+
sources.push({
|
|
50
|
+
id: "global",
|
|
51
|
+
label: "APX Global Memory",
|
|
52
|
+
rel: "APX Global Memory.md",
|
|
53
|
+
from: SELF_MEMORY_PATH,
|
|
54
|
+
body: globalBody,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const projects = ctx.projects;
|
|
59
|
+
const list = typeof projects?.list === "function" ? projects.list() : [];
|
|
60
|
+
for (const entry of list) {
|
|
61
|
+
const root = entry?.path;
|
|
62
|
+
if (!root) continue; // the default project (id 0) has no repo root
|
|
63
|
+
const memFile = apcMemoryFile(root);
|
|
64
|
+
const body = readIfExists(memFile);
|
|
65
|
+
if (body == null) continue;
|
|
66
|
+
const seg = safeSeg(projectLabel(entry));
|
|
67
|
+
sources.push({
|
|
68
|
+
id: `project:${entry.id}`,
|
|
69
|
+
label: `${projectLabel(entry)} — memory`,
|
|
70
|
+
rel: path.posix.join("projects", seg, "memory.md"),
|
|
71
|
+
from: memFile,
|
|
72
|
+
body,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return sources;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Mirror the collected sources into <vault>/<folder>/. Idempotent: identical
|
|
80
|
+
// input → byte-identical output; each source overwrites its own single note.
|
|
81
|
+
export function syncMemoryToVault({ vaultPath, folder = "APX", sources = [] }) {
|
|
82
|
+
assertVaultDir(vaultPath);
|
|
83
|
+
const root = path.resolve(vaultPath);
|
|
84
|
+
const base = path.resolve(root, folder);
|
|
85
|
+
const written = [];
|
|
86
|
+
for (const src of sources) {
|
|
87
|
+
const abs = path.resolve(base, src.rel.split("/").join(path.sep));
|
|
88
|
+
// Guard: never escape the target folder.
|
|
89
|
+
if (abs !== base && !abs.startsWith(base + path.sep)) continue;
|
|
90
|
+
const content = `${MANAGED_MARK}\n<!-- source: ${src.from} -->\n\n${String(src.body).trimEnd()}\n`;
|
|
91
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
92
|
+
const prev = readIfExists(abs);
|
|
93
|
+
const changed = prev !== content;
|
|
94
|
+
if (changed) fs.writeFileSync(abs, content);
|
|
95
|
+
written.push({
|
|
96
|
+
id: src.id,
|
|
97
|
+
note: path.relative(root, abs).split(path.sep).join("/"),
|
|
98
|
+
changed,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
folder,
|
|
104
|
+
count: written.length,
|
|
105
|
+
changed: written.filter((w) => w.changed).length,
|
|
106
|
+
notes: written,
|
|
107
|
+
};
|
|
108
|
+
}
|