@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 +1 -1
- package/src/core/agent/tools/handlers/call-agent.js +7 -1
- package/src/core/agent/tools/handlers/obsidian-write-note.js +1 -1
- package/src/core/channels/telegram/dispatch.js +3 -2
- package/src/core/integrations/obsidian-memory.js +82 -11
- package/src/core/integrations/plugins/obsidian.js +40 -0
- package/src/core/memory/broker.js +8 -3
- package/src/core/memory/index.js +36 -3
- package/src/core/memory/store.js +13 -1
- package/src/interfaces/web/dist/assets/{index-YFsZFhM6.js → index-CX15mZXM.js} +99 -99
- package/src/interfaces/web/dist/assets/index-CX15mZXM.js.map +1 -0
- package/src/interfaces/web/dist/index.html +1 -1
- package/src/interfaces/web/src/components/common/TabNav.tsx +10 -3
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +12 -2
- package/src/interfaces/web/dist/assets/index-YFsZFhM6.js.map +0 -1
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
"
|
|
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.",
|
package/src/core/memory/index.js
CHANGED
|
@@ -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>"
|
|
170
|
-
// that scope's own notebook, read as the
|
|
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
|
+
}
|
package/src/core/memory/store.js
CHANGED
|
@@ -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 (
|
|
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);
|