codex-dev-mcp-suite 1.6.0 → 1.8.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/CHANGELOG.md +39 -0
- package/context-pack/server.js +173 -1
- package/devjournal/server.js +38 -0
- package/docs/ROADMAP.md +74 -0
- package/docs/clients/antigravity.md +52 -0
- package/docs/clients/hermes.md +48 -0
- package/docs/providers.md +13 -0
- package/docs/superpowers/plans/2026-06-15-hardening-v1-2-implementation.md +60 -0
- package/docs/superpowers/plans/2026-06-15-provider-chain-v1-1-implementation.md +65 -0
- package/docs/superpowers/plans/2026-06-15-provider-client-agnostic-config.md +41 -0
- package/docs/superpowers/plans/2026-06-15-trust-release-implementation.md +62 -0
- package/docs/superpowers/plans/2026-06-18-v1-5-0-knowledge-graph.md +792 -0
- package/docs/superpowers/specs/2026-06-15-provider-chain-v1-1-design.md +89 -0
- package/docs/superpowers/specs/2026-06-15-trust-release-design.md +164 -0
- package/docs/superpowers/specs/2026-06-18-dev-mcp-suite-v1-5-0-knowledge-graph-design.md +386 -0
- package/package.json +5 -18
- package/project-memory/embedding.js +23 -8
- package/project-memory/graph.js +8 -1
- package/project-memory/local-embed.js +92 -0
- package/project-memory/server.js +162 -4
- package/run-tests.mjs +54 -0
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
# v1.5.0 Knowledge Graph Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add note linking, cross-project recall, and safe duplicate detection to `project-memory` without making embeddings mandatory and without breaking existing vaults.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Extend `project-memory` with a small graph/index layer rather than a separate subsystem. Keep canonical note content in Markdown and per-project `index.json`, derive link/global lookup state from existing notes, and expose three new MCP tools that degrade cleanly to keyword/deterministic behavior.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js 18+, ESM, `@modelcontextprotocol/sdk`, local JSON indexes, existing embedding/rerank helpers, existing `project-memory/test.mjs` harness.
|
|
10
|
+
|
|
11
|
+
## Global Constraints
|
|
12
|
+
|
|
13
|
+
- Keep release scope limited to `memory_link`, `memory_global_recall`, and `memory_dedup`.
|
|
14
|
+
- Do not auto-merge or auto-delete notes.
|
|
15
|
+
- Keep embeddings optional; keyword/deterministic fallback must remain usable.
|
|
16
|
+
- Preserve same-project bias for resolution and ranking.
|
|
17
|
+
- Derived graph state must be rebuildable from canonical note content and metadata.
|
|
18
|
+
- Existing vaults must continue to work without a mandatory blocking migration.
|
|
19
|
+
- Keep MCP tool surface separate rather than creating one monolithic graph tool.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## File Structure
|
|
24
|
+
|
|
25
|
+
### Existing files to modify
|
|
26
|
+
|
|
27
|
+
- Modify: `project-memory/server.js`
|
|
28
|
+
- Register three new MCP tools.
|
|
29
|
+
- Route tool calls to new graph helper methods.
|
|
30
|
+
- Reuse existing path/index/note-loading patterns.
|
|
31
|
+
- Modify: `project-memory/test.mjs`
|
|
32
|
+
- Add end-to-end MCP tests for link resolution, global recall, dedup, and lazy backfill.
|
|
33
|
+
- Modify: `README.md`
|
|
34
|
+
- Document the three new tools and the link syntax.
|
|
35
|
+
- Modify: `CHANGELOG.md`
|
|
36
|
+
- Add `v1.5.0` entry.
|
|
37
|
+
- Modify: `package.json`
|
|
38
|
+
- Bump version to `1.5.0` once implementation is complete.
|
|
39
|
+
|
|
40
|
+
### New files to create
|
|
41
|
+
|
|
42
|
+
- Create: `project-memory/graph.js`
|
|
43
|
+
- Parse wiki-style links from note bodies.
|
|
44
|
+
- Resolve links by same-project bias, explicit project override, and global fallback.
|
|
45
|
+
- Build backlink maps and graph-derived metadata.
|
|
46
|
+
- Create: `project-memory/global-index.js`
|
|
47
|
+
- Enumerate projects in the vault.
|
|
48
|
+
- Load per-project note metadata into a global candidate set.
|
|
49
|
+
- Provide shared helpers for cross-project recall and dedup.
|
|
50
|
+
- Create: `project-memory/dedup.js`
|
|
51
|
+
- Compute duplicate-candidate scores using title/content/link/semantic signals.
|
|
52
|
+
- Produce non-destructive review output.
|
|
53
|
+
|
|
54
|
+
### Unit boundaries
|
|
55
|
+
|
|
56
|
+
- `server.js` stays as the MCP boundary and orchestration layer.
|
|
57
|
+
- `graph.js` owns link parsing, graph state derivation, and backlink logic.
|
|
58
|
+
- `global-index.js` owns cross-project enumeration and candidate gathering.
|
|
59
|
+
- `dedup.js` owns duplicate scoring only.
|
|
60
|
+
- Tests remain end-to-end in `project-memory/test.mjs` to match current repo style.
|
|
61
|
+
|
|
62
|
+
### Task 1: Build Graph Parsing and Derived State Helpers
|
|
63
|
+
|
|
64
|
+
**Files:**
|
|
65
|
+
- Create: `project-memory/graph.js`
|
|
66
|
+
- Modify: `project-memory/server.js`
|
|
67
|
+
- Test: `project-memory/test.mjs`
|
|
68
|
+
|
|
69
|
+
**Interfaces:**
|
|
70
|
+
- Consumes: `projectSlug(dir)`, `parseFrontmatter(raw)`, `loadIndex(p)`, `saveIndex(p, index)` patterns from `project-memory/server.js`
|
|
71
|
+
- Produces:
|
|
72
|
+
- `extractWikiLinks(body: string): Array<{ raw: string, ref: string, project: string|null, kind: "id"|"title" }>`
|
|
73
|
+
- `loadNoteBody(projectDir: string, file: string): Promise<string>`
|
|
74
|
+
- `ensureGraphState(args: { vaultRoot: string, projectDir: string, slug: string, index: object, noteLoader: Function }): Promise<{ index: object, changed: boolean }>`
|
|
75
|
+
- `resolveLink(args: { vaultRoot: string, currentSlug: string, ref: string, project: string|null, kind: "id"|"title" }): Promise<{ status: "resolved"|"ambiguous"|"missing", match?: object, candidates?: object[] }>`
|
|
76
|
+
|
|
77
|
+
- [ ] **Step 1: Write the failing graph helper tests**
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
it("parses wiki links from note bodies", async () => {
|
|
81
|
+
const mod = await import("./graph.js");
|
|
82
|
+
const links = mod.extractWikiLinks("See [[abc123]] and [[proj:Design Note]] and [[JWT Flow]]");
|
|
83
|
+
assertEqual(links, [
|
|
84
|
+
{ raw: "[[abc123]]", ref: "abc123", project: null, kind: "id" },
|
|
85
|
+
{ raw: "[[proj:Design Note]]", ref: "Design Note", project: "proj", kind: "title" },
|
|
86
|
+
{ raw: "[[JWT Flow]]", ref: "JWT Flow", project: null, kind: "title" },
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
92
|
+
|
|
93
|
+
Run: `node project-memory/test.mjs`
|
|
94
|
+
Expected: FAIL with missing `./graph.js` import or missing `extractWikiLinks`
|
|
95
|
+
|
|
96
|
+
- [ ] **Step 3: Write minimal graph helper implementation**
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
// project-memory/graph.js
|
|
100
|
+
export function extractWikiLinks(body) {
|
|
101
|
+
const out = [];
|
|
102
|
+
const re = /\[\[([^\]]+)\]\]/g;
|
|
103
|
+
let m;
|
|
104
|
+
while ((m = re.exec(String(body || "")))) {
|
|
105
|
+
const raw = m[0];
|
|
106
|
+
const inner = m[1].trim();
|
|
107
|
+
const colon = inner.indexOf(":");
|
|
108
|
+
const project = colon > 0 ? inner.slice(0, colon).trim() : null;
|
|
109
|
+
const ref = colon > 0 ? inner.slice(colon + 1).trim() : inner;
|
|
110
|
+
const kind = /^[0-9T-]{10,}/.test(ref) ? "id" : "title";
|
|
111
|
+
out.push({ raw, ref, project, kind });
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- [ ] **Step 4: Extend helper to derive per-note graph metadata**
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
export async function ensureGraphState({ index, projectDir, noteLoader }) {
|
|
121
|
+
let changed = false;
|
|
122
|
+
const backlinks = new Map();
|
|
123
|
+
for (const note of Object.values(index.notes || {})) {
|
|
124
|
+
const body = await noteLoader(projectDir, note.file);
|
|
125
|
+
const links = extractWikiLinks(body).map((x) => ({
|
|
126
|
+
raw: x.raw,
|
|
127
|
+
ref: x.ref,
|
|
128
|
+
project: x.project,
|
|
129
|
+
kind: x.kind,
|
|
130
|
+
}));
|
|
131
|
+
const next = JSON.stringify(links);
|
|
132
|
+
const prev = JSON.stringify(note.links || []);
|
|
133
|
+
if (next !== prev) {
|
|
134
|
+
note.links = links;
|
|
135
|
+
changed = true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const note of Object.values(index.notes || {})) {
|
|
139
|
+
for (const link of note.links || []) {
|
|
140
|
+
if (link.project) continue;
|
|
141
|
+
if (link.kind !== "id") continue;
|
|
142
|
+
const arr = backlinks.get(link.ref) || [];
|
|
143
|
+
arr.push({ id: note.id, title: note.title });
|
|
144
|
+
backlinks.set(link.ref, arr);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const note of Object.values(index.notes || {})) {
|
|
148
|
+
const next = backlinks.get(note.id) || [];
|
|
149
|
+
if (JSON.stringify(next) !== JSON.stringify(note.backlinks || [])) {
|
|
150
|
+
note.backlinks = next;
|
|
151
|
+
changed = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { index, changed };
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- [ ] **Step 5: Run targeted tests to verify helper passes**
|
|
159
|
+
|
|
160
|
+
Run: `node project-memory/test.mjs`
|
|
161
|
+
Expected: PASS for new graph helper tests; other unrelated tests still passing
|
|
162
|
+
|
|
163
|
+
### Task 2: Add `memory_link` MCP Tool
|
|
164
|
+
|
|
165
|
+
**Files:**
|
|
166
|
+
- Modify: `project-memory/server.js`
|
|
167
|
+
- Modify: `project-memory/test.mjs`
|
|
168
|
+
- Consumes: `extractWikiLinks`, `ensureGraphState`, `resolveLink` from `project-memory/graph.js`
|
|
169
|
+
- Produces:
|
|
170
|
+
- MCP tool `memory_link`
|
|
171
|
+
- Handler signature: `memory_link({ id?: string, dir?: string, include_unresolved?: boolean }): Promise<ToolResult>`
|
|
172
|
+
|
|
173
|
+
**Interfaces:**
|
|
174
|
+
- Consumes: `paths(dir)`, `loadIndex(p)`, `saveIndex(p, index)`
|
|
175
|
+
- Produces: `async link(args)` method on `ProjectMemoryServer`
|
|
176
|
+
|
|
177
|
+
- [ ] **Step 1: Write the failing MCP test for `memory_link`**
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
it("memory_link resolves note links and backlinks", async () => {
|
|
181
|
+
const saveA = await client.callTool("memory_save", {
|
|
182
|
+
dir: DEMO,
|
|
183
|
+
title: "Auth Hub",
|
|
184
|
+
content: "See [[JWT Details]]",
|
|
185
|
+
tags: ["auth"],
|
|
186
|
+
});
|
|
187
|
+
const saveB = await client.callTool("memory_save", {
|
|
188
|
+
dir: DEMO,
|
|
189
|
+
title: "JWT Details",
|
|
190
|
+
content: "Token notes",
|
|
191
|
+
tags: ["jwt"],
|
|
192
|
+
});
|
|
193
|
+
const idA = (saveA.text.match(/Saved note (\S+)/) || [])[1];
|
|
194
|
+
const r = await client.callTool("memory_link", { dir: DEMO, id: idA });
|
|
195
|
+
assertIncludes(r.text, "Auth Hub");
|
|
196
|
+
assertIncludes(r.text, "JWT Details");
|
|
197
|
+
assertIncludes(r.text, "resolved");
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
202
|
+
|
|
203
|
+
Run: `node project-memory/test.mjs`
|
|
204
|
+
Expected: FAIL with `Unknown tool: memory_link`
|
|
205
|
+
|
|
206
|
+
- [ ] **Step 3: Register the new tool in `ListToolsRequestSchema`**
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
{
|
|
210
|
+
name: "memory_link",
|
|
211
|
+
description: "Resolve wiki-style note links and show backlinks for a note.",
|
|
212
|
+
inputSchema: {
|
|
213
|
+
type: "object",
|
|
214
|
+
properties: {
|
|
215
|
+
id: { type: "string", description: "Note id to inspect" },
|
|
216
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
217
|
+
include_unresolved: { type: "boolean", default: true }
|
|
218
|
+
},
|
|
219
|
+
required: ["id"]
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
- [ ] **Step 4: Add call routing and minimal handler**
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
case "memory_link": return await this.link(args || {});
|
|
228
|
+
|
|
229
|
+
async link({ id, dir, include_unresolved = true }) {
|
|
230
|
+
id = limit(id, "id", 100);
|
|
231
|
+
const p = this.paths(dir);
|
|
232
|
+
const index = await this.loadIndex(p);
|
|
233
|
+
const note = index.notes?.[id];
|
|
234
|
+
if (!note) throw new Error(`Note ${id} not found in ${p.slug}`);
|
|
235
|
+
const { ensureGraphState, resolveLink, loadNoteBody } = await import("./graph.js");
|
|
236
|
+
const ensured = await ensureGraphState({
|
|
237
|
+
vaultRoot: VAULT_ROOT,
|
|
238
|
+
projectDir: p.projectDir,
|
|
239
|
+
slug: p.slug,
|
|
240
|
+
index,
|
|
241
|
+
noteLoader: loadNoteBody,
|
|
242
|
+
});
|
|
243
|
+
if (ensured.changed) await this.saveIndex(p, ensured.index);
|
|
244
|
+
const links = [];
|
|
245
|
+
for (const link of ensured.index.notes[id].links || []) {
|
|
246
|
+
const resolved = await resolveLink({
|
|
247
|
+
vaultRoot: VAULT_ROOT,
|
|
248
|
+
currentSlug: p.slug,
|
|
249
|
+
ref: link.ref,
|
|
250
|
+
project: link.project,
|
|
251
|
+
kind: link.kind,
|
|
252
|
+
});
|
|
253
|
+
if (resolved.status !== "missing" || include_unresolved) links.push({ link, resolved });
|
|
254
|
+
}
|
|
255
|
+
return { content: [{ type: "text", text: JSON.stringify({ note: ensured.index.notes[id], links }, null, 2) }] };
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
- [ ] **Step 5: Improve output formatting for human-readable backlinks**
|
|
260
|
+
|
|
261
|
+
```js
|
|
262
|
+
const lines = [
|
|
263
|
+
`Links for ${note.title} (${note.id})`,
|
|
264
|
+
"",
|
|
265
|
+
"Outgoing:",
|
|
266
|
+
...links.map(({ link, resolved }) => `- ${link.raw} -> ${resolved.status}${resolved.match ? ` (${resolved.match.title})` : ""}`),
|
|
267
|
+
"",
|
|
268
|
+
"Backlinks:",
|
|
269
|
+
...(note.backlinks || []).map((b) => `- ${b.title} (${b.id})`),
|
|
270
|
+
];
|
|
271
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
- [ ] **Step 6: Run targeted tests to verify `memory_link` passes**
|
|
275
|
+
|
|
276
|
+
Run: `node project-memory/test.mjs`
|
|
277
|
+
Expected: PASS for `memory_link` tests and no regressions in existing `memory_save` / `memory_recall`
|
|
278
|
+
|
|
279
|
+
### Task 3: Build Global Project Enumeration Helpers
|
|
280
|
+
|
|
281
|
+
**Files:**
|
|
282
|
+
- Create: `project-memory/global-index.js`
|
|
283
|
+
- Modify: `project-memory/test.mjs`
|
|
284
|
+
|
|
285
|
+
**Interfaces:**
|
|
286
|
+
- Consumes: `VAULT_ROOT` directory layout and per-project `index.json`
|
|
287
|
+
- Produces:
|
|
288
|
+
- `listProjects(vaultRoot: string): Promise<Array<{ slug: string, projectDir: string, indexFile: string }>>`
|
|
289
|
+
- `loadGlobalNotes(vaultRoot: string): Promise<Array<{ slug: string, note: object }>>`
|
|
290
|
+
- `findGlobalCandidates(args: { vaultRoot: string, currentSlug: string, query: string }): Promise<Array<{ slug: string, note: object }>>`
|
|
291
|
+
|
|
292
|
+
- [ ] **Step 1: Write the failing global-index test**
|
|
293
|
+
|
|
294
|
+
```js
|
|
295
|
+
it("loads notes across projects for global operations", async () => {
|
|
296
|
+
const mod = await import("./global-index.js");
|
|
297
|
+
const rows = await mod.loadGlobalNotes(process.env.MEMORY_VAULT_DIR);
|
|
298
|
+
assert(rows.length >= 1, "expected at least one global note row");
|
|
299
|
+
assert("slug" in rows[0]);
|
|
300
|
+
assert("note" in rows[0]);
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
305
|
+
|
|
306
|
+
Run: `node project-memory/test.mjs`
|
|
307
|
+
Expected: FAIL with missing `./global-index.js`
|
|
308
|
+
|
|
309
|
+
- [ ] **Step 3: Write minimal global-index implementation**
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
// project-memory/global-index.js
|
|
313
|
+
import fs from "fs/promises";
|
|
314
|
+
import path from "path";
|
|
315
|
+
|
|
316
|
+
export async function listProjects(vaultRoot) {
|
|
317
|
+
let slugs = [];
|
|
318
|
+
try { slugs = await fs.readdir(vaultRoot); } catch { slugs = []; }
|
|
319
|
+
return slugs.map((slug) => ({
|
|
320
|
+
slug,
|
|
321
|
+
projectDir: path.join(vaultRoot, slug),
|
|
322
|
+
indexFile: path.join(vaultRoot, slug, "index.json"),
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function loadGlobalNotes(vaultRoot) {
|
|
327
|
+
const projects = await listProjects(vaultRoot);
|
|
328
|
+
const rows = [];
|
|
329
|
+
for (const p of projects) {
|
|
330
|
+
try {
|
|
331
|
+
const raw = await fs.readFile(p.indexFile, "utf8");
|
|
332
|
+
const index = JSON.parse(raw);
|
|
333
|
+
for (const note of Object.values(index.notes || {})) rows.push({ slug: p.slug, note });
|
|
334
|
+
} catch {}
|
|
335
|
+
}
|
|
336
|
+
return rows;
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
- [ ] **Step 4: Add same-project-first candidate ordering helper**
|
|
341
|
+
|
|
342
|
+
```js
|
|
343
|
+
export async function findGlobalCandidates({ vaultRoot, currentSlug, query }) {
|
|
344
|
+
const q = String(query || "").toLowerCase();
|
|
345
|
+
const rows = await loadGlobalNotes(vaultRoot);
|
|
346
|
+
return rows
|
|
347
|
+
.filter(({ note }) => note.title?.toLowerCase().includes(q) || (note.keywords || []).some((k) => k.includes(q)))
|
|
348
|
+
.sort((a, b) => {
|
|
349
|
+
const ap = a.slug === currentSlug ? 0 : 1;
|
|
350
|
+
const bp = b.slug === currentSlug ? 0 : 1;
|
|
351
|
+
return ap - bp || (a.note.created < b.note.created ? 1 : -1);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
- [ ] **Step 5: Run targeted tests to verify helpers pass**
|
|
357
|
+
|
|
358
|
+
Run: `node project-memory/test.mjs`
|
|
359
|
+
Expected: PASS for global helper tests
|
|
360
|
+
|
|
361
|
+
### Task 4: Add `memory_global_recall` MCP Tool
|
|
362
|
+
|
|
363
|
+
**Files:**
|
|
364
|
+
- Modify: `project-memory/server.js`
|
|
365
|
+
- Modify: `project-memory/test.mjs`
|
|
366
|
+
- Consumes: `loadGlobalNotes`, `findGlobalCandidates` from `project-memory/global-index.js`; `embedOne`, `cosine`, `rerank`, `rerankConfig`
|
|
367
|
+
- Produces:
|
|
368
|
+
- MCP tool `memory_global_recall`
|
|
369
|
+
- Handler signature: `memory_global_recall({ query, dir?, limit?, full? }): Promise<ToolResult>`
|
|
370
|
+
|
|
371
|
+
**Interfaces:**
|
|
372
|
+
- Consumes: existing keyword/semantic scoring logic from `recall()`
|
|
373
|
+
- Produces: `async globalRecall(args)` method on `ProjectMemoryServer`
|
|
374
|
+
|
|
375
|
+
- [ ] **Step 1: Write the failing MCP test for same-project bias**
|
|
376
|
+
|
|
377
|
+
```js
|
|
378
|
+
it("memory_global_recall prefers same-project notes before global fallback", async () => {
|
|
379
|
+
await client.callTool("memory_save", { dir: DEMO, title: "API Auth Local", content: "same project winner" });
|
|
380
|
+
await client.callTool("memory_save", { dir: "/tmp/pm-other-project", title: "API Auth Remote", content: "other project candidate" });
|
|
381
|
+
const r = await client.callTool("memory_global_recall", { dir: DEMO, query: "API Auth", limit: 5 });
|
|
382
|
+
const localPos = r.text.indexOf("API Auth Local");
|
|
383
|
+
const remotePos = r.text.indexOf("API Auth Remote");
|
|
384
|
+
assert(localPos !== -1 && remotePos !== -1);
|
|
385
|
+
assert(localPos < remotePos, "expected same-project note first");
|
|
386
|
+
});
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
390
|
+
|
|
391
|
+
Run: `node project-memory/test.mjs`
|
|
392
|
+
Expected: FAIL with `Unknown tool: memory_global_recall`
|
|
393
|
+
|
|
394
|
+
- [ ] **Step 3: Register the new tool and route calls**
|
|
395
|
+
|
|
396
|
+
```js
|
|
397
|
+
{
|
|
398
|
+
name: "memory_global_recall",
|
|
399
|
+
description: "Recall relevant notes across projects with same-project bias and graceful keyword fallback.",
|
|
400
|
+
inputSchema: {
|
|
401
|
+
type: "object",
|
|
402
|
+
properties: {
|
|
403
|
+
query: { type: "string" },
|
|
404
|
+
dir: { type: "string" },
|
|
405
|
+
limit: { type: "number", default: 5 },
|
|
406
|
+
full: { type: "boolean", default: false }
|
|
407
|
+
},
|
|
408
|
+
required: ["query"]
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
case "memory_global_recall": return await this.globalRecall(args || {});
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
- [ ] **Step 4: Implement minimal global recall reusing current ranking style**
|
|
416
|
+
|
|
417
|
+
```js
|
|
418
|
+
async globalRecall({ query, dir, limit: lim = 5, full = false }) {
|
|
419
|
+
query = limit(query, "query", 2000);
|
|
420
|
+
const p = this.paths(dir);
|
|
421
|
+
const { loadGlobalNotes } = await import("./global-index.js");
|
|
422
|
+
const rows = await loadGlobalNotes(VAULT_ROOT);
|
|
423
|
+
const qTokens = new Set(tokenize(query));
|
|
424
|
+
const qVec = deterministicEnabled() ? null : await embedOne(query);
|
|
425
|
+
const scored = rows.map(({ slug, note }) => {
|
|
426
|
+
let kwScore = 0;
|
|
427
|
+
for (const k of note.keywords || []) if (qTokens.has(k)) kwScore += 1;
|
|
428
|
+
for (const w of tokenize(note.title)) if (qTokens.has(w)) kwScore += 2;
|
|
429
|
+
const sameProjectBoost = slug === p.slug ? 0.25 : 0;
|
|
430
|
+
const sim = qVec && Array.isArray(note.embedding) ? cosine(qVec, note.embedding) : 0;
|
|
431
|
+
const score = (sim ? 0.8 * sim : 0) + (0.2 * kwScore) + sameProjectBoost;
|
|
432
|
+
return { slug, note, sim, score };
|
|
433
|
+
}).filter((x) => x.score > 0)
|
|
434
|
+
.sort((a, b) => b.score - a.score || (a.note.created < b.note.created ? 1 : -1))
|
|
435
|
+
.slice(0, Math.max(1, Math.min(20, lim)));
|
|
436
|
+
return { content: [{ type: "text", text: scored.map((x) => `- [${x.slug}] ${x.note.title}`).join("\n") }] };
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
- [ ] **Step 5: Add provenance labels and keyword fallback path**
|
|
441
|
+
|
|
442
|
+
```js
|
|
443
|
+
const mode = qVec && rows.some((x) => Array.isArray(x.note.embedding)) ? "semantic+graph" : "keyword+graph";
|
|
444
|
+
const blocks = await Promise.all(scored.map(async ({ slug, note, sim, score }) => {
|
|
445
|
+
const raw = await fs.readFile(path.join(VAULT_ROOT, slug, note.file), "utf8").catch(() => "");
|
|
446
|
+
const { body } = parseFrontmatter(raw);
|
|
447
|
+
const text = full ? body.trim() : body.trim().split("\n").slice(0, 12).join("\n");
|
|
448
|
+
return `### ${note.title} ([${slug}], ${sim ? `sim:${sim.toFixed(3)}` : `score:${score.toFixed(2)}`})\n${text}`;
|
|
449
|
+
}));
|
|
450
|
+
return { content: [{ type: "text", text: `Global recall for "${query}" [${mode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
- [ ] **Step 6: Run targeted tests to verify same-project bias and fallback behavior**
|
|
454
|
+
|
|
455
|
+
Run: `node project-memory/test.mjs`
|
|
456
|
+
Expected: PASS for global recall tests; same-project result ordered before remote candidates
|
|
457
|
+
|
|
458
|
+
### Task 5: Build Duplicate Scoring Helpers
|
|
459
|
+
|
|
460
|
+
**Files:**
|
|
461
|
+
- Create: `project-memory/dedup.js`
|
|
462
|
+
- Modify: `project-memory/test.mjs`
|
|
463
|
+
|
|
464
|
+
**Interfaces:**
|
|
465
|
+
- Consumes: `tokenize`-compatible keyword data, note bodies, optional embeddings
|
|
466
|
+
- Produces:
|
|
467
|
+
- `scoreDuplicatePair(args: { a: object, b: object, aBody: string, bBody: string }): { score: number, reasons: string[] }`
|
|
468
|
+
- `findDuplicateCandidates(args: { rows: Array<{ slug: string, note: object, body: string }>, threshold: number }): Array<{ left: object, right: object, score: number, reasons: string[] }>`
|
|
469
|
+
|
|
470
|
+
- [ ] **Step 1: Write the failing duplicate-score test**
|
|
471
|
+
|
|
472
|
+
```js
|
|
473
|
+
it("scores duplicate candidates with explanatory reasons", async () => {
|
|
474
|
+
const mod = await import("./dedup.js");
|
|
475
|
+
const result = mod.scoreDuplicatePair({
|
|
476
|
+
a: { title: "JWT Plan", links: [] },
|
|
477
|
+
b: { title: "JWT Plan", links: [] },
|
|
478
|
+
aBody: "refresh token cookie flow",
|
|
479
|
+
bBody: "refresh token cookie flow",
|
|
480
|
+
});
|
|
481
|
+
assert(result.score >= 0.9);
|
|
482
|
+
assert(result.reasons.some((x) => x.includes("title")));
|
|
483
|
+
});
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
487
|
+
|
|
488
|
+
Run: `node project-memory/test.mjs`
|
|
489
|
+
Expected: FAIL with missing `./dedup.js`
|
|
490
|
+
|
|
491
|
+
- [ ] **Step 3: Write minimal duplicate-score implementation**
|
|
492
|
+
|
|
493
|
+
```js
|
|
494
|
+
// project-memory/dedup.js
|
|
495
|
+
function jaccard(a, b) {
|
|
496
|
+
const sa = new Set(a);
|
|
497
|
+
const sb = new Set(b);
|
|
498
|
+
const inter = [...sa].filter((x) => sb.has(x)).length;
|
|
499
|
+
const union = new Set([...sa, ...sb]).size || 1;
|
|
500
|
+
return inter / union;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function scoreDuplicatePair({ a, b, aBody, bBody }) {
|
|
504
|
+
const reasons = [];
|
|
505
|
+
let score = 0;
|
|
506
|
+
if ((a.title || "").trim().toLowerCase() === (b.title || "").trim().toLowerCase()) {
|
|
507
|
+
score += 0.5;
|
|
508
|
+
reasons.push("exact normalized title match");
|
|
509
|
+
}
|
|
510
|
+
const bodyScore = jaccard(String(aBody).toLowerCase().split(/\W+/), String(bBody).toLowerCase().split(/\W+/));
|
|
511
|
+
score += bodyScore * 0.4;
|
|
512
|
+
if (bodyScore >= 0.75) reasons.push("strong content overlap");
|
|
513
|
+
return { score: Math.min(1, score), reasons };
|
|
514
|
+
}
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
- [ ] **Step 4: Add candidate-pair generation with threshold filtering**
|
|
518
|
+
|
|
519
|
+
```js
|
|
520
|
+
export function findDuplicateCandidates({ rows, threshold }) {
|
|
521
|
+
const out = [];
|
|
522
|
+
for (let i = 0; i < rows.length; i++) {
|
|
523
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
524
|
+
const result = scoreDuplicatePair({
|
|
525
|
+
a: rows[i].note,
|
|
526
|
+
b: rows[j].note,
|
|
527
|
+
aBody: rows[i].body,
|
|
528
|
+
bBody: rows[j].body,
|
|
529
|
+
});
|
|
530
|
+
if (result.score >= threshold) {
|
|
531
|
+
out.push({ left: rows[i], right: rows[j], score: result.score, reasons: result.reasons });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return out.sort((a, b) => b.score - a.score);
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
- [ ] **Step 5: Run targeted tests to verify duplicate scoring passes**
|
|
540
|
+
|
|
541
|
+
Run: `node project-memory/test.mjs`
|
|
542
|
+
Expected: PASS for duplicate-score tests
|
|
543
|
+
|
|
544
|
+
### Task 6: Add `memory_dedup` MCP Tool
|
|
545
|
+
|
|
546
|
+
**Files:**
|
|
547
|
+
- Modify: `project-memory/server.js`
|
|
548
|
+
- Modify: `project-memory/test.mjs`
|
|
549
|
+
- Consumes: `loadGlobalNotes` from `project-memory/global-index.js`, `findDuplicateCandidates` from `project-memory/dedup.js`
|
|
550
|
+
- Produces:
|
|
551
|
+
- MCP tool `memory_dedup`
|
|
552
|
+
- Handler signature: `memory_dedup({ dir?, threshold?, scope? }): Promise<ToolResult>`
|
|
553
|
+
|
|
554
|
+
**Interfaces:**
|
|
555
|
+
- Consumes: global note enumeration and per-note body loading
|
|
556
|
+
- Produces: `async dedup(args)` method on `ProjectMemoryServer`
|
|
557
|
+
|
|
558
|
+
- [ ] **Step 1: Write the failing MCP dedup test**
|
|
559
|
+
|
|
560
|
+
```js
|
|
561
|
+
it("memory_dedup suggests merges without deleting notes", async () => {
|
|
562
|
+
await client.callTool("memory_save", { dir: DEMO, title: "Duplicate JWT", content: "same body same body" });
|
|
563
|
+
await client.callTool("memory_save", { dir: DEMO, title: "Duplicate JWT", content: "same body same body" });
|
|
564
|
+
const r = await client.callTool("memory_dedup", { dir: DEMO, threshold: 0.9 });
|
|
565
|
+
assertIncludes(r.text, "Duplicate JWT");
|
|
566
|
+
assertIncludes(r.text, "suggested merge");
|
|
567
|
+
assert(!/Deleted note/.test(r.text));
|
|
568
|
+
});
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
572
|
+
|
|
573
|
+
Run: `node project-memory/test.mjs`
|
|
574
|
+
Expected: FAIL with `Unknown tool: memory_dedup`
|
|
575
|
+
|
|
576
|
+
- [ ] **Step 3: Register the new tool and route calls**
|
|
577
|
+
|
|
578
|
+
```js
|
|
579
|
+
{
|
|
580
|
+
name: "memory_dedup",
|
|
581
|
+
description: "Find likely duplicate notes and suggest non-destructive merges.",
|
|
582
|
+
inputSchema: {
|
|
583
|
+
type: "object",
|
|
584
|
+
properties: {
|
|
585
|
+
dir: { type: "string" },
|
|
586
|
+
threshold: { type: "number", default: 0.9 },
|
|
587
|
+
scope: { type: "string", enum: ["project", "global"], default: "project" }
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
case "memory_dedup": return await this.dedup(args || {});
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
- [ ] **Step 4: Implement minimal dedup handler**
|
|
596
|
+
|
|
597
|
+
```js
|
|
598
|
+
async dedup({ dir, threshold = 0.9, scope = "project" }) {
|
|
599
|
+
const p = this.paths(dir);
|
|
600
|
+
const { loadGlobalNotes } = await import("./global-index.js");
|
|
601
|
+
const { findDuplicateCandidates } = await import("./dedup.js");
|
|
602
|
+
const rows = await loadGlobalNotes(VAULT_ROOT);
|
|
603
|
+
const filtered = scope === "project" ? rows.filter((x) => x.slug === p.slug) : rows;
|
|
604
|
+
const withBodies = await Promise.all(filtered.map(async ({ slug, note }) => {
|
|
605
|
+
const raw = await fs.readFile(path.join(VAULT_ROOT, slug, note.file), "utf8").catch(() => "");
|
|
606
|
+
const { body } = parseFrontmatter(raw);
|
|
607
|
+
return { slug, note, body };
|
|
608
|
+
}));
|
|
609
|
+
const pairs = findDuplicateCandidates({ rows: withBodies, threshold });
|
|
610
|
+
if (!pairs.length) return { content: [{ type: "text", text: `No duplicate suggestions above ${threshold} in ${scope} scope.` }] };
|
|
611
|
+
const lines = [`Duplicate suggestions [threshold=${threshold}, scope=${scope}]:`, ""];
|
|
612
|
+
for (const pair of pairs) {
|
|
613
|
+
lines.push(`- suggested merge: [${pair.left.slug}] ${pair.left.note.title} <-> [${pair.right.slug}] ${pair.right.note.title} (score=${pair.score.toFixed(3)})`);
|
|
614
|
+
lines.push(` reasons: ${pair.reasons.join(", ")}`);
|
|
615
|
+
}
|
|
616
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
617
|
+
}
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
- [ ] **Step 5: Run targeted tests to verify non-destructive dedup passes**
|
|
621
|
+
|
|
622
|
+
Run: `node project-memory/test.mjs`
|
|
623
|
+
Expected: PASS for dedup tests; notes remain retrievable via `memory_list`
|
|
624
|
+
|
|
625
|
+
### Task 7: Add Lazy Backfill and Graph-Aware Boost to Existing Recall Paths
|
|
626
|
+
|
|
627
|
+
**Files:**
|
|
628
|
+
- Modify: `project-memory/server.js`
|
|
629
|
+
- Modify: `project-memory/graph.js`
|
|
630
|
+
- Modify: `project-memory/test.mjs`
|
|
631
|
+
|
|
632
|
+
**Interfaces:**
|
|
633
|
+
- Consumes: `ensureGraphState`, existing `recall()` ranking pipeline
|
|
634
|
+
- Produces:
|
|
635
|
+
- lazy graph-state refresh inside graph-aware tools
|
|
636
|
+
- optional graph boost helper `graphBoost(note, queryTokens): number`
|
|
637
|
+
|
|
638
|
+
- [ ] **Step 1: Write the failing lazy-backfill test**
|
|
639
|
+
|
|
640
|
+
```js
|
|
641
|
+
it("lazy backfill populates links for old notes on first graph tool use", async () => {
|
|
642
|
+
const save = await client.callTool("memory_save", {
|
|
643
|
+
dir: DEMO,
|
|
644
|
+
title: "Legacy Link Note",
|
|
645
|
+
content: "points to [[Legacy Target]]",
|
|
646
|
+
});
|
|
647
|
+
await client.callTool("memory_save", {
|
|
648
|
+
dir: DEMO,
|
|
649
|
+
title: "Legacy Target",
|
|
650
|
+
content: "target body",
|
|
651
|
+
});
|
|
652
|
+
const id = (save.text.match(/Saved note (\S+)/) || [])[1];
|
|
653
|
+
const p = serverPathsForTest(DEMO); // helper inside test file
|
|
654
|
+
const index = JSON.parse(fs.readFileSync(p.indexFile, "utf8"));
|
|
655
|
+
delete index.notes[id].links;
|
|
656
|
+
fs.writeFileSync(p.indexFile, JSON.stringify(index, null, 2));
|
|
657
|
+
const r = await client.callTool("memory_link", { dir: DEMO, id });
|
|
658
|
+
assertIncludes(r.text, "Legacy Target");
|
|
659
|
+
});
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
663
|
+
|
|
664
|
+
Run: `node project-memory/test.mjs`
|
|
665
|
+
Expected: FAIL because graph state is not rebuilt automatically
|
|
666
|
+
|
|
667
|
+
- [ ] **Step 3: Add graph-aware boost helper and lazy refresh**
|
|
668
|
+
|
|
669
|
+
```js
|
|
670
|
+
function graphBoost(note, queryTokens) {
|
|
671
|
+
let score = 0;
|
|
672
|
+
for (const link of note.links || []) {
|
|
673
|
+
for (const token of queryTokens) {
|
|
674
|
+
if (String(link.ref || "").toLowerCase().includes(token)) score += 0.05;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return Math.min(0.15, score);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// inside recall/globalRecall after keyword/semantic score
|
|
681
|
+
const graph = graphBoost(note, [...qTokens]);
|
|
682
|
+
const total = baseScore + graph;
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
- [ ] **Step 4: Ensure graph-aware tools call `ensureGraphState` before scoring**
|
|
686
|
+
|
|
687
|
+
```js
|
|
688
|
+
const ensured = await ensureGraphState({
|
|
689
|
+
vaultRoot: VAULT_ROOT,
|
|
690
|
+
projectDir: p.projectDir,
|
|
691
|
+
slug: p.slug,
|
|
692
|
+
index,
|
|
693
|
+
noteLoader: loadNoteBody,
|
|
694
|
+
});
|
|
695
|
+
if (ensured.changed) await this.saveIndex(p, ensured.index);
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
- [ ] **Step 5: Run targeted tests to verify lazy backfill passes**
|
|
699
|
+
|
|
700
|
+
Run: `node project-memory/test.mjs`
|
|
701
|
+
Expected: PASS for lazy-backfill test and no regression in old `memory_recall` tests
|
|
702
|
+
|
|
703
|
+
### Task 8: Update Docs, Version, and Full Verification
|
|
704
|
+
|
|
705
|
+
**Files:**
|
|
706
|
+
- Modify: `README.md`
|
|
707
|
+
- Modify: `CHANGELOG.md`
|
|
708
|
+
- Modify: `package.json`
|
|
709
|
+
- Test: `project-memory/test.mjs`
|
|
710
|
+
- Test: `run-tests.mjs`
|
|
711
|
+
|
|
712
|
+
**Interfaces:**
|
|
713
|
+
- Consumes: completed implementations from Tasks 1–7
|
|
714
|
+
- Produces: release-ready docs and version metadata for `1.5.0`
|
|
715
|
+
|
|
716
|
+
- [ ] **Step 1: Add README tool documentation**
|
|
717
|
+
|
|
718
|
+
```md
|
|
719
|
+
### New in v1.5.0
|
|
720
|
+
|
|
721
|
+
- `memory_link` resolves wiki-style links like `[[id]]`, `[[title]]`, and `[[project:title]]`
|
|
722
|
+
- `memory_global_recall` searches across projects with same-project bias
|
|
723
|
+
- `memory_dedup` suggests duplicate-note merges without deleting anything
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
- [ ] **Step 2: Add changelog entry**
|
|
727
|
+
|
|
728
|
+
```md
|
|
729
|
+
## 1.5.0
|
|
730
|
+
|
|
731
|
+
- add `memory_link` with backlinks and wiki-link resolution
|
|
732
|
+
- add `memory_global_recall` with same-project bias and graceful fallback
|
|
733
|
+
- add `memory_dedup` with non-destructive duplicate suggestions
|
|
734
|
+
- add hybrid lazy/explicit graph backfill behavior
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
- [ ] **Step 3: Bump package version**
|
|
738
|
+
|
|
739
|
+
```json
|
|
740
|
+
{
|
|
741
|
+
"version": "1.5.0"
|
|
742
|
+
}
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
- [ ] **Step 4: Run focused project-memory tests**
|
|
746
|
+
|
|
747
|
+
Run: `node project-memory/test.mjs`
|
|
748
|
+
Expected: PASS with all new graph/global/dedup tests green
|
|
749
|
+
|
|
750
|
+
- [ ] **Step 5: Run full suite**
|
|
751
|
+
|
|
752
|
+
Run: `npm test`
|
|
753
|
+
Expected: all server tests and top-level tests pass
|
|
754
|
+
|
|
755
|
+
- [ ] **Step 6: Review git diff for release scope sanity**
|
|
756
|
+
|
|
757
|
+
Run: `git diff -- project-memory README.md CHANGELOG.md package.json`
|
|
758
|
+
Expected: only graph/global/dedup/docs/version changes
|
|
759
|
+
|
|
760
|
+
## Self-Review
|
|
761
|
+
|
|
762
|
+
### Spec coverage
|
|
763
|
+
|
|
764
|
+
- `memory_link`: covered by Tasks 1–2 and Task 7
|
|
765
|
+
- `memory_global_recall`: covered by Tasks 3–4 and Task 7
|
|
766
|
+
- `memory_dedup`: covered by Tasks 5–6
|
|
767
|
+
- hybrid backfill: covered by Task 7
|
|
768
|
+
- safe non-destructive behavior: covered by Tasks 5–6
|
|
769
|
+
- docs/version/release hygiene: covered by Task 8
|
|
770
|
+
|
|
771
|
+
No spec gaps identified.
|
|
772
|
+
|
|
773
|
+
### Placeholder scan
|
|
774
|
+
|
|
775
|
+
- No `TODO` or `TBD` placeholders remain.
|
|
776
|
+
- Every task includes file paths, concrete code, and concrete commands.
|
|
777
|
+
|
|
778
|
+
### Type consistency
|
|
779
|
+
|
|
780
|
+
- `graph.js` exports are referenced consistently across Tasks 1, 2, and 7.
|
|
781
|
+
- `global-index.js` exports are referenced consistently across Tasks 3, 4, and 6.
|
|
782
|
+
- `dedup.js` exports are referenced consistently across Tasks 5 and 6.
|
|
783
|
+
|
|
784
|
+
## Execution Handoff
|
|
785
|
+
|
|
786
|
+
Plan complete and saved to `docs/superpowers/plans/2026-06-18-v1-5-0-knowledge-graph.md`. Two execution options:
|
|
787
|
+
|
|
788
|
+
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
|
|
789
|
+
|
|
790
|
+
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
|
791
|
+
|
|
792
|
+
Which approach?
|