@agentprojectcontext/apx 1.70.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.70.0",
3
+ "version": "1.71.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,5 +1,6 @@
1
1
  import { callEngine } from "#core/engines/index.js";
2
2
  import { readAgents } from "#core/apc/parser.js";
3
+ import { agentScopedMemoryBlock } from "#core/memory/index.js";
3
4
  import { buildAgentSystem, resolveProject } from "../helpers.js";
4
5
 
5
6
  export default {
@@ -26,14 +27,19 @@ export default {
26
27
  if (!agent) throw new Error(`agent ${slug} not found`);
27
28
  if (!agent.fields.Model) throw new Error(`agent ${slug} has no model`);
28
29
 
30
+ const config = p.config || globalConfig;
31
+ // Scoped RAG recall for this agent + its project, grounded in the prompt.
32
+ const scopedMemory = await agentScopedMemoryBlock(prompt, { project: p, agent, config });
33
+
29
34
  const result = await callEngine({
30
35
  modelId: agent.fields.Model,
31
36
  system: buildAgentSystem(p, agent, {
32
37
  invocation: "engine",
33
38
  caller: "super_agent_tool",
39
+ extraParts: scopedMemory ? [scopedMemory] : [],
34
40
  }),
35
41
  messages: [{ role: "user", content: prompt }],
36
- config: p.config || globalConfig,
42
+ config,
37
43
  });
38
44
  p.logMessage({
39
45
  agent_slug: slug,
@@ -9,7 +9,7 @@ export default {
9
9
  function: {
10
10
  name: "obsidian_write_note",
11
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.",
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. Write Obsidian-native markdown: link related notes with [[wikilinks]] and classify with #tags so the vault's graph and backlinks stay connected (e.g. a note about billing might link [[Stripe]] and tag #area/payments).",
13
13
  parameters: {
14
14
  type: "object",
15
15
  properties: {
@@ -14,7 +14,7 @@
14
14
  import { callEngine } from "#core/engines/index.js";
15
15
  import { isSuperAgentEnabled } from "#core/agent/super-agent.js";
16
16
  import { getRecentTelegramTurnsFromFs, appendGlobalMessage } from "#core/stores/messages.js";
17
- import { compactChannelIfNeeded } from "#core/memory/index.js";
17
+ import { compactChannelIfNeeded, agentScopedMemoryBlock } from "#core/memory/index.js";
18
18
  import { readAgents } from "#core/apc/parser.js";
19
19
  import { buildAgentSystem } from "#core/agent/build-agent-system.js";
20
20
  import { resolveAgentName, SUPERAGENT_ACTOR_ID } from "#core/identity/index.js";
@@ -237,11 +237,12 @@ export async function handleUpdate(self, u) {
237
237
  const agent = readAgents(target.path).find((a) => a.slug === routeSlug);
238
238
  if (agent && agent.fields.Model) {
239
239
  try {
240
+ const scopedMemory = await agentScopedMemoryBlock(text, { project: target, agent, config: projectCfg });
240
241
  const system = buildAgentSystem(target, agent, {
241
242
  invocation: "telegram",
242
243
  channel: self.channel.name,
243
244
  caller: author,
244
- extraParts: [relationshipBlock],
245
+ extraParts: [relationshipBlock, scopedMemory].filter(Boolean),
245
246
  });
246
247
  const result = await callEngine({
247
248
  modelId: agent.fields.Model,
@@ -76,33 +76,104 @@ export function collectMemorySources(ctx = {}) {
76
76
  return sources;
77
77
  }
78
78
 
79
+ // The wikilink map (MOC) that connects every mirrored note so Obsidian's graph
80
+ // + backlinks light up. Lives alongside the notes inside <folder>/.
81
+ const INDEX_REL = "APX Memory Index.md";
82
+
83
+ const noteLink = (rel) => rel.replace(/\.md$/i, "");
84
+
85
+ // Friendly display for a note in the index: "projects/App/memory" → "App".
86
+ function noteDisplay(rel) {
87
+ const parts = noteLink(rel).split("/");
88
+ const leaf = parts[parts.length - 1];
89
+ return parts.length >= 2 && leaf === "memory" ? parts[parts.length - 2] : leaf;
90
+ }
91
+
92
+ function sourceTags(src) {
93
+ const tags = ["#apx/memory"];
94
+ if (src.id === "global" || /Global/i.test(src.rel)) tags.push("#apx/global");
95
+ else if (String(src.id).startsWith("project:") || String(src.rel).startsWith("projects/")) tags.push("#apx/project");
96
+ return tags.join(" ");
97
+ }
98
+
99
+ // One mirrored note: managed markers + tags + a backlink to the index (so the
100
+ // graph connects) + the body. Deterministic → idempotent.
101
+ function sourceContent(src) {
102
+ return [
103
+ MANAGED_MARK,
104
+ `<!-- source: ${src.from} -->`,
105
+ "",
106
+ sourceTags(src),
107
+ "",
108
+ `> Part of [[${noteLink(INDEX_REL)}]]`,
109
+ "",
110
+ String(src.body).trimEnd(),
111
+ "",
112
+ ].join("\n");
113
+ }
114
+
115
+ function indexContent(sources) {
116
+ const links = sources.map((s) => {
117
+ const link = noteLink(s.rel);
118
+ const disp = noteDisplay(s.rel);
119
+ return link === disp ? `- [[${link}]]` : `- [[${link}|${disp}]]`;
120
+ });
121
+ return [
122
+ MANAGED_MARK,
123
+ "",
124
+ "#apx/memory #apx/index",
125
+ "",
126
+ "# APX Memory Index",
127
+ "",
128
+ "Map of content for APX-managed memory mirrored into this vault.",
129
+ "",
130
+ ...links,
131
+ "",
132
+ ].join("\n");
133
+ }
134
+
79
135
  // Mirror the collected sources into <vault>/<folder>/. Idempotent: identical
80
136
  // input → byte-identical output; each source overwrites its own single note.
137
+ // Also writes an index MOC linking every note (not counted in count/changed —
138
+ // it's derived meta, not a source).
81
139
  export function syncMemoryToVault({ vaultPath, folder = "APX", sources = [] }) {
82
140
  assertVaultDir(vaultPath);
83
141
  const root = path.resolve(vaultPath);
84
142
  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`;
143
+ const inBase = (abs) => abs === base || abs.startsWith(base + path.sep);
144
+ const writeIdempotent = (abs, content) => {
91
145
  fs.mkdirSync(path.dirname(abs), { recursive: true });
92
146
  const prev = readIfExists(abs);
93
147
  const changed = prev !== content;
94
148
  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
- });
149
+ return changed;
150
+ };
151
+
152
+ const written = [];
153
+ const okSources = [];
154
+ for (const src of sources) {
155
+ const abs = path.resolve(base, src.rel.split("/").join(path.sep));
156
+ if (!inBase(abs)) continue; // never escape the target folder
157
+ const changed = writeIdempotent(abs, sourceContent(src));
158
+ written.push({ id: src.id, note: path.relative(root, abs).split(path.sep).join("/"), changed });
159
+ okSources.push(src);
100
160
  }
161
+
162
+ let index = null;
163
+ if (okSources.length) {
164
+ const idxAbs = path.resolve(base, INDEX_REL.split("/").join(path.sep));
165
+ index = {
166
+ note: path.relative(root, idxAbs).split(path.sep).join("/"),
167
+ changed: writeIdempotent(idxAbs, indexContent(okSources)),
168
+ };
169
+ }
170
+
101
171
  return {
102
172
  ok: true,
103
173
  folder,
104
174
  count: written.length,
105
175
  changed: written.filter((w) => w.changed).length,
106
176
  notes: written,
177
+ index,
107
178
  };
108
179
  }
@@ -53,6 +53,46 @@ export function assertVaultDir(vaultPath) {
53
53
  return true;
54
54
  }
55
55
 
56
+ // Append `entry` to <root>/.gitignore unless already present. Idempotent.
57
+ function ensureGitignore(root, entry) {
58
+ const gi = path.join(root, ".gitignore");
59
+ let text = "";
60
+ try {
61
+ text = fs.readFileSync(gi, "utf8");
62
+ } catch {
63
+ /* no .gitignore yet */
64
+ }
65
+ const bare = entry.replace(/\/$/, "");
66
+ const present = text.split("\n").some((l) => {
67
+ const t = l.trim();
68
+ return t === entry || t === bare;
69
+ });
70
+ if (present) return false;
71
+ const prefix = text && !text.endsWith("\n") ? `${text}\n` : text;
72
+ fs.writeFileSync(gi, `${prefix}${entry}\n`);
73
+ return true;
74
+ }
75
+
76
+ // Turn a project folder into an Obsidian vault so "the project IS the vault":
77
+ // create a minimal `.obsidian/` config (makes the app recognize it) and
78
+ // gitignore that config so per-machine vault settings never land in the repo.
79
+ // Idempotent — safe to call on every activation. Returns { created, gitignored }.
80
+ // NOTE: building block for auto-vault-on-activate — not yet wired into the
81
+ // plugin lifecycle (that changes the configure contract + writes into repos;
82
+ // pending an explicit product call on global-vs-per-project).
83
+ export function ensureProjectVault(projectRoot) {
84
+ const root = path.resolve(projectRoot);
85
+ assertVaultDir(root);
86
+ const dot = path.join(root, ".obsidian");
87
+ let created = false;
88
+ if (!fs.existsSync(dot)) {
89
+ fs.mkdirSync(dot, { recursive: true });
90
+ fs.writeFileSync(path.join(dot, "app.json"), "{}\n");
91
+ created = true;
92
+ }
93
+ return { created, gitignored: ensureGitignore(root, ".obsidian/") };
94
+ }
95
+
56
96
  // Normalize a user-supplied note reference to an absolute `.md` path inside the
57
97
  // vault, refusing anything that escapes the vault root (path-traversal guard).
58
98
  function noteAbs(vaultPath, note) {
@@ -88,12 +88,17 @@ export async function buildMemoryBlock(message, opts = {}) {
88
88
  const topK = opts.topK || DEFAULT_TOP_K;
89
89
  const store = opts.store || null;
90
90
  // Scope isolation: the super-agent recalls only global rows ("global"), a
91
- // project/agent turn recalls only its own ("project:<id>" / "agent:…").
91
+ // project/agent turn recalls only its own a single channel or an array of
92
+ // channels (["agent:…","project:…"]).
92
93
  const scope = opts.scope || "global";
94
+ // The flat notebook slice is always included for the super-agent, but a
95
+ // project-agent turn already gets its own memory.md injected elsewhere, so it
96
+ // opts out (includeFlat:false) to keep this block RAG-only.
97
+ const includeFlat = opts.includeFlat !== false;
93
98
  const query = clean(message);
94
99
 
95
100
  // memory.md entries are read synchronously and always make the deadline.
96
- const memEntries = lastMemoryEntries(memoryPath, 10);
101
+ const memEntries = includeFlat ? lastMemoryEntries(memoryPath, 10) : [];
97
102
 
98
103
  // RAG retrieval is the slow part — race it against the budget.
99
104
  let hits = [];
@@ -135,7 +140,7 @@ export async function buildMemoryBlock(message, opts = {}) {
135
140
  if (bullets.length === 0) return "";
136
141
 
137
142
  return [
138
- "# Relevant memory (cross-channel)",
143
+ `# ${opts.heading || "Relevant memory (cross-channel)"}`,
139
144
  "Context recovered from your notebook and from the message log across channels.",
140
145
  "Treat these as known facts. If a fresh session opens and something here is still",
141
146
  "open, bring it up naturally in the user's language (e.g. \"yesterday we were on X — shall we continue?\") without being asked.",
@@ -14,6 +14,7 @@
14
14
  import path from "node:path";
15
15
  import { APX_HOME } from "../config/index.js";
16
16
  import { ensureSelfMemoryFile } from "../agent/self-memory.js";
17
+ import { agentRuntimeDir } from "../agent/memory.js";
17
18
  import fs from "node:fs";
18
19
  import { openMemoryStore } from "./store.js";
19
20
  import { indexNewMessages, CURSOR_PATH } from "./indexer.js";
@@ -166,9 +167,9 @@ export async function memoryBlockFor(message, { config, channel, budgetMs } = {}
166
167
  }
167
168
 
168
169
  // Scoped recall for a specific project or agent turn. `scope` is the row channel
169
- // key: "project:<id>" or "agent:<projdir>:<slug>". `memoryPath` (optional) is
170
- // that scope's own notebook, read as the always-included flat slice. Returns ""
171
- // on any failure so the prompt builder can drop the block.
170
+ // key: "project:<id>" or "agent:<projdir>:<slug>" (or an array of them).
171
+ // `memoryPath` (optional) is that scope's own notebook, read as the flat slice.
172
+ // Returns "" on any failure so the prompt builder can drop the block.
172
173
  export async function scopedMemoryBlockFor(message, { scope, memoryPath, config, budgetMs } = {}) {
173
174
  try {
174
175
  if (!scope || !memoryEnabled(config)) return "";
@@ -186,3 +187,35 @@ export async function scopedMemoryBlockFor(message, { scope, memoryPath, config,
186
187
  return "";
187
188
  }
188
189
  }
190
+
191
+ // Consumer for a project-agent turn (Pieza 5). Retrieves the agent's OWN memory
192
+ // plus its project's memory in one scoped query, isolated from every other
193
+ // agent/project and from the super-agent's global recall. RAG-only (the agent's
194
+ // flat memory.md is already injected by buildAgentSystem). Returns a `# Relevant
195
+ // memory (this agent & project)` block, or "" on disabled/empty/error — so the
196
+ // caller can just append it to `extraParts`.
197
+ export async function agentScopedMemoryBlock(message, { project, agent, config, budgetMs } = {}) {
198
+ try {
199
+ if (!message || !agent?.slug || !memoryEnabled(config)) return "";
200
+ const store = await getMemoryStore();
201
+ if (!store) return "";
202
+ // The storage root is <APX_HOME>/projects/<apxId>; agent rows are indexed
203
+ // under channel "agent:<apxId>:<slug>" — derive apxId the same way the
204
+ // indexer's filesystem walk names it, straight from agentRuntimeDir.
205
+ const apxId = path.basename(path.dirname(path.dirname(agentRuntimeDir(project, agent.slug))));
206
+ const scope = [`agent:${apxId}:${agent.slug}`];
207
+ if (project?.id != null && String(project.id) !== "0") scope.push(`project:${project.id}`);
208
+ return await buildMemoryBlock(message, {
209
+ store,
210
+ config,
211
+ scope,
212
+ includeFlat: false,
213
+ heading: "Relevant memory (this agent & project)",
214
+ budgetMs: budgetMs || config?.memory?.broker_budget_ms || 800,
215
+ topK: config?.memory?.rag_top_k || 5,
216
+ embed: embedOptsFromConfig(config),
217
+ });
218
+ } catch {
219
+ return "";
220
+ }
221
+ }
@@ -100,12 +100,17 @@ export class JsonStore {
100
100
  }
101
101
 
102
102
  search(vector, { embedder, k = 5, channel, scope } = {}) {
103
+ const scopeArr = Array.isArray(scope) ? scope : null;
103
104
  const scored = [];
104
105
  for (const row of this.rows.values()) {
105
106
  if (embedder && row.embedder !== embedder) continue;
106
107
  if (channel && row.channel !== channel) continue;
107
108
  if (scope === "global" && isScopedChannel(row.channel)) continue;
108
- if (scope && scope !== "global" && row.channel !== scope) continue;
109
+ if (scopeArr) {
110
+ if (!scopeArr.includes(row.channel)) continue;
111
+ } else if (scope && scope !== "global" && row.channel !== scope) {
112
+ continue;
113
+ }
109
114
  if (!Array.isArray(row.vector) || row.vector.length !== vector.length) continue;
110
115
  scored.push({ ...row, score: cosineSim(vector, row.vector) });
111
116
  }
@@ -195,6 +200,13 @@ class SqliteVecStore {
195
200
  }
196
201
  if (scope === "global") {
197
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
+ }
198
210
  } else if (scope) {
199
211
  where.push("channel = ?");
200
212
  params.push(scope);