codex-dev-mcp-suite 1.4.0 → 1.5.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 +9 -0
- package/README.md +11 -1
- package/package.json +1 -1
- package/project-memory/dedup.js +49 -0
- package/project-memory/global-index.js +46 -0
- package/project-memory/graph.js +86 -0
- package/project-memory/server.js +180 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 1.5.0
|
|
2
|
+
|
|
3
|
+
- add `memory_link` with wiki-link resolution for `[[id]]`, `[[title]]`, `[[project:id]]`, and `[[project:title]]`, plus backlink inspection
|
|
4
|
+
- add `memory_global_recall` with same-project bias and graceful keyword/semantic fallback across project vaults
|
|
5
|
+
- add `memory_dedup` with non-destructive duplicate suggestions for project or global scope
|
|
6
|
+
- add hybrid lazy graph backfill so link metadata is derived on first graph-aware use and remains rebuildable from canonical notes
|
|
7
|
+
- add graph-aware soft boost in recall paths without making embeddings mandatory
|
|
8
|
+
- 36 project-memory tests pass after graph/global/dedup coverage expansion
|
|
9
|
+
|
|
1
10
|
# Changelog
|
|
2
11
|
|
|
3
12
|
## 1.4.0 - 2026-06-17
|
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ Other clients (Claude Code, Cursor, Cline, ...): see
|
|
|
66
66
|
|
|
67
67
|
| Server | What it does | Key tools |
|
|
68
68
|
|---|---|---|
|
|
69
|
-
| **project-memory** | Searchable Markdown knowledge vault (Obsidian-style notes + on-demand recall). Notes are also exposed as MCP resources. | `memory_save`, `memory_recall`, `memory_list`, `memory_get`, `memory_delete`, `memory_reindex` |
|
|
69
|
+
| **project-memory** | Searchable Markdown knowledge vault (Obsidian-style notes + on-demand recall). Notes are also exposed as MCP resources. | `memory_save`, `memory_recall`, `memory_list`, `memory_get`, `memory_delete`, `memory_reindex`, `memory_link`, `memory_global_recall`, `memory_dedup` |
|
|
70
70
|
| **devjournal** | Per-project session timeline + handoff/resume (anti-compaction). | `journal_log`, `journal_handoff`, `journal_resume`, `journal_timeline`, `journal_search`, `journal_clear_handoff` |
|
|
71
71
|
| **checkpoint** | Git-independent file snapshots for safe experimentation. | `checkpoint_create`, `checkpoint_list`, `checkpoint_diff`, `checkpoint_restore`, `checkpoint_delete` |
|
|
72
72
|
| **context-pack** | Token-efficient project briefing (stack, tree, symbols, search). | `pack_overview`, `pack_tree`, `pack_outline`, `pack_search` |
|
|
@@ -192,6 +192,16 @@ With `MCP_DETERMINISTIC_FALLBACK=true`, the mode is always `[deterministic]`.
|
|
|
192
192
|
This means you can run codex-dev-mcp-suite with **zero API keys configured** —
|
|
193
193
|
`memory_recall` still works via keyword scoring, just no semantic similarity.
|
|
194
194
|
|
|
195
|
+
### New in v1.5.0
|
|
196
|
+
|
|
197
|
+
`project-memory` now adds a lightweight knowledge-graph layer:
|
|
198
|
+
|
|
199
|
+
- `memory_link` resolves wiki-style links like `[[id]]`, `[[title]]`, and `[[project:title]]`, and shows backlinks
|
|
200
|
+
- `memory_global_recall` searches across projects with same-project bias and graceful keyword fallback
|
|
201
|
+
- `memory_dedup` suggests duplicate-note merges without deleting anything
|
|
202
|
+
|
|
203
|
+
Link resolution always prefers the active project first, then falls back globally when appropriate.
|
|
204
|
+
|
|
195
205
|
## Daily workflow
|
|
196
206
|
|
|
197
207
|
- Session start: `pack_overview` + `journal_resume` + `memory_recall "<topic>"`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-dev-mcp-suite",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Four local, file-based MCP servers for solo devs/vibecoders: persistent project memory, session handoff/resume, git-independent file checkpoints, and token-efficient project briefings. Works with any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
function jaccard(left, right) {
|
|
2
|
+
const leftSet = new Set(left.filter(Boolean));
|
|
3
|
+
const rightSet = new Set(right.filter(Boolean));
|
|
4
|
+
const intersection = [...leftSet].filter((item) => rightSet.has(item)).length;
|
|
5
|
+
const union = new Set([...leftSet, ...rightSet]).size || 1;
|
|
6
|
+
return intersection / union;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function tokenizeBody(text) {
|
|
10
|
+
return String(text || "").toLowerCase().split(/\W+/).filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function scoreDuplicatePair({ a, b, aBody, bBody }) {
|
|
14
|
+
const reasons = [];
|
|
15
|
+
let score = 0;
|
|
16
|
+
|
|
17
|
+
if (String(a.title || "").trim().toLowerCase() === String(b.title || "").trim().toLowerCase()) {
|
|
18
|
+
score += 0.5;
|
|
19
|
+
reasons.push("exact normalized title match");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const bodyScore = jaccard(tokenizeBody(aBody), tokenizeBody(bBody));
|
|
23
|
+
score += bodyScore * 0.4;
|
|
24
|
+
if (bodyScore >= 0.75) reasons.push("strong content overlap");
|
|
25
|
+
|
|
26
|
+
const linkScore = jaccard((a.links || []).map((link) => `${link.project || ""}:${link.ref || ""}`), (b.links || []).map((link) => `${link.project || ""}:${link.ref || ""}`));
|
|
27
|
+
score += linkScore * 0.1;
|
|
28
|
+
if (linkScore >= 0.75 && (a.links || []).length && (b.links || []).length) reasons.push("similar link neighborhood");
|
|
29
|
+
|
|
30
|
+
return { score: Math.min(1, score), reasons };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function findDuplicateCandidates({ rows, threshold }) {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
36
|
+
for (let j = i + 1; j < rows.length; j += 1) {
|
|
37
|
+
const result = scoreDuplicatePair({
|
|
38
|
+
a: rows[i].note,
|
|
39
|
+
b: rows[j].note,
|
|
40
|
+
aBody: rows[i].body,
|
|
41
|
+
bBody: rows[j].body,
|
|
42
|
+
});
|
|
43
|
+
if (result.score >= threshold) {
|
|
44
|
+
out.push({ left: rows[i], right: rows[j], score: result.score, reasons: result.reasons });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out.sort((a, b) => b.score - a.score);
|
|
49
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export async function listProjects(vaultRoot) {
|
|
5
|
+
let slugs = [];
|
|
6
|
+
try {
|
|
7
|
+
slugs = await fs.readdir(vaultRoot);
|
|
8
|
+
} catch {
|
|
9
|
+
slugs = [];
|
|
10
|
+
}
|
|
11
|
+
return slugs.map((slug) => ({
|
|
12
|
+
slug,
|
|
13
|
+
projectDir: path.join(vaultRoot, slug),
|
|
14
|
+
indexFile: path.join(vaultRoot, slug, "index.json"),
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function loadGlobalNotes(vaultRoot) {
|
|
19
|
+
const projects = await listProjects(vaultRoot);
|
|
20
|
+
const rows = [];
|
|
21
|
+
for (const project of projects) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = await fs.readFile(project.indexFile, "utf8");
|
|
24
|
+
const index = JSON.parse(raw);
|
|
25
|
+
for (const note of Object.values(index.notes || {})) rows.push({ slug: project.slug, note });
|
|
26
|
+
} catch {
|
|
27
|
+
// ignore invalid or incomplete project index
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return rows;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function findGlobalCandidates({ vaultRoot, currentSlug, query }) {
|
|
34
|
+
const q = String(query || "").toLowerCase();
|
|
35
|
+
const rows = await loadGlobalNotes(vaultRoot);
|
|
36
|
+
return rows
|
|
37
|
+
.filter(({ note }) =>
|
|
38
|
+
String(note.title || "").toLowerCase().includes(q) ||
|
|
39
|
+
(note.keywords || []).some((keyword) => String(keyword).toLowerCase().includes(q))
|
|
40
|
+
)
|
|
41
|
+
.sort((a, b) => {
|
|
42
|
+
const ap = a.slug === currentSlug ? 0 : 1;
|
|
43
|
+
const bp = b.slug === currentSlug ? 0 : 1;
|
|
44
|
+
return ap - bp || (a.note.created < b.note.created ? 1 : -1);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { parseFrontmatter } from "./server.js";
|
|
4
|
+
|
|
5
|
+
export function extractWikiLinks(body) {
|
|
6
|
+
const out = [];
|
|
7
|
+
const re = /\[\[([^\]]+)\]\]/g;
|
|
8
|
+
let match;
|
|
9
|
+
while ((match = re.exec(String(body || "")))) {
|
|
10
|
+
const raw = match[0];
|
|
11
|
+
const inner = match[1].trim();
|
|
12
|
+
const colon = inner.indexOf(":");
|
|
13
|
+
const project = colon > 0 ? inner.slice(0, colon).trim() : null;
|
|
14
|
+
const ref = colon > 0 ? inner.slice(colon + 1).trim() : inner;
|
|
15
|
+
const kind = /^[0-9A-Za-z_-]{6,}$/.test(ref) && !/\s/.test(ref) ? "id" : "title";
|
|
16
|
+
out.push({ raw, ref, project, kind });
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function loadNoteBody(projectDir, file) {
|
|
22
|
+
const raw = await fs.readFile(path.join(projectDir, file), "utf8");
|
|
23
|
+
return parseFrontmatter(raw).body;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function ensureGraphState({ vaultRoot, projectDir, slug, index, noteLoader }) {
|
|
27
|
+
void vaultRoot;
|
|
28
|
+
void slug;
|
|
29
|
+
let changed = false;
|
|
30
|
+
const backlinks = new Map();
|
|
31
|
+
for (const note of Object.values(index.notes || {})) {
|
|
32
|
+
const body = await noteLoader(projectDir, note.file);
|
|
33
|
+
const links = extractWikiLinks(body).map((link) => ({
|
|
34
|
+
raw: link.raw,
|
|
35
|
+
ref: link.ref,
|
|
36
|
+
project: link.project,
|
|
37
|
+
kind: link.kind,
|
|
38
|
+
}));
|
|
39
|
+
const next = JSON.stringify(links);
|
|
40
|
+
const prev = JSON.stringify(note.links || []);
|
|
41
|
+
if (next !== prev) {
|
|
42
|
+
note.links = links;
|
|
43
|
+
changed = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const note of Object.values(index.notes || {})) {
|
|
47
|
+
for (const link of note.links || []) {
|
|
48
|
+
if (link.project || link.kind !== "id") continue;
|
|
49
|
+
const arr = backlinks.get(link.ref) || [];
|
|
50
|
+
arr.push({ id: note.id, title: note.title });
|
|
51
|
+
backlinks.set(link.ref, arr);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const note of Object.values(index.notes || {})) {
|
|
55
|
+
const next = backlinks.get(note.id) || [];
|
|
56
|
+
if (JSON.stringify(next) !== JSON.stringify(note.backlinks || [])) {
|
|
57
|
+
note.backlinks = next;
|
|
58
|
+
changed = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { index, changed };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function resolveLink({ vaultRoot, currentSlug, ref, project, kind }) {
|
|
65
|
+
const targetSlug = project || currentSlug;
|
|
66
|
+
const indexFile = path.join(vaultRoot, targetSlug, "index.json");
|
|
67
|
+
let index;
|
|
68
|
+
try {
|
|
69
|
+
index = JSON.parse(await fs.readFile(indexFile, "utf8"));
|
|
70
|
+
} catch {
|
|
71
|
+
return { status: "missing" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const notes = Object.values(index.notes || {});
|
|
75
|
+
let candidates;
|
|
76
|
+
if (kind === "id") {
|
|
77
|
+
candidates = notes.filter((note) => note.id === ref);
|
|
78
|
+
} else {
|
|
79
|
+
const target = String(ref || "").trim().toLowerCase();
|
|
80
|
+
candidates = notes.filter((note) => String(note.title || "").trim().toLowerCase() === target);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (candidates.length === 0) return { status: "missing" };
|
|
84
|
+
if (candidates.length === 1) return { status: "resolved", match: candidates[0] };
|
|
85
|
+
return { status: "ambiguous", candidates };
|
|
86
|
+
}
|
package/project-memory/server.js
CHANGED
|
@@ -26,9 +26,13 @@ import fs from "fs/promises";
|
|
|
26
26
|
import path from "path";
|
|
27
27
|
import os from "os";
|
|
28
28
|
import crypto from "crypto";
|
|
29
|
+
import { fileURLToPath } from "url";
|
|
29
30
|
import { embed, embedOne, cosine, embeddingConfig } from "./embedding.js";
|
|
30
31
|
import { rerank, rerankConfig } from "./rerank.js";
|
|
31
32
|
import { deterministicEnabled } from "./env.js";
|
|
33
|
+
import { ensureGraphState, resolveLink, loadNoteBody } from "./graph.js";
|
|
34
|
+
import { loadGlobalNotes } from "./global-index.js";
|
|
35
|
+
import { findDuplicateCandidates } from "./dedup.js";
|
|
32
36
|
import { computeStats, formatText, formatJson } from "../lib/stats.js";
|
|
33
37
|
|
|
34
38
|
const VAULT_ROOT =
|
|
@@ -44,7 +48,7 @@ const STOPWORDS = new Set([
|
|
|
44
48
|
"not", "no", "do", "did", "does", "can", "will", "so", "my", "our", "your",
|
|
45
49
|
]);
|
|
46
50
|
|
|
47
|
-
function projectSlug(dir) {
|
|
51
|
+
export function projectSlug(dir) {
|
|
48
52
|
const resolved = path.resolve(dir || process.cwd());
|
|
49
53
|
const base = path.basename(resolved) || "root";
|
|
50
54
|
const hash = crypto.createHash("sha1").update(resolved).digest("hex").slice(0, 8);
|
|
@@ -76,7 +80,7 @@ function limit(value, name, max, required = true) {
|
|
|
76
80
|
return text;
|
|
77
81
|
}
|
|
78
82
|
|
|
79
|
-
function parseFrontmatter(raw) {
|
|
83
|
+
export function parseFrontmatter(raw) {
|
|
80
84
|
const m = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
81
85
|
if (!m) return { meta: {}, body: raw };
|
|
82
86
|
const meta = {};
|
|
@@ -105,6 +109,16 @@ function buildFrontmatter(meta) {
|
|
|
105
109
|
return lines.join("\n");
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
function graphBoost(note, queryTokens) {
|
|
113
|
+
let score = 0;
|
|
114
|
+
for (const link of note.links || []) {
|
|
115
|
+
for (const token of queryTokens) {
|
|
116
|
+
if (String(link.ref || "").toLowerCase().includes(token)) score += 0.05;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return Math.min(0.15, score);
|
|
120
|
+
}
|
|
121
|
+
|
|
108
122
|
class ProjectMemoryServer {
|
|
109
123
|
constructor() {
|
|
110
124
|
this.server = new Server(
|
|
@@ -222,6 +236,45 @@ class ProjectMemoryServer {
|
|
|
222
236
|
},
|
|
223
237
|
},
|
|
224
238
|
},
|
|
239
|
+
{
|
|
240
|
+
name: "memory_link",
|
|
241
|
+
description: "Resolve wiki-style note links and show backlinks for a note.",
|
|
242
|
+
inputSchema: {
|
|
243
|
+
type: "object",
|
|
244
|
+
properties: {
|
|
245
|
+
id: { type: "string", description: "Note id to inspect" },
|
|
246
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
247
|
+
include_unresolved: { type: "boolean", description: "Include unresolved links in output", default: true },
|
|
248
|
+
},
|
|
249
|
+
required: ["id"],
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: "memory_global_recall",
|
|
254
|
+
description: "Recall relevant notes across projects with same-project bias and graceful keyword fallback.",
|
|
255
|
+
inputSchema: {
|
|
256
|
+
type: "object",
|
|
257
|
+
properties: {
|
|
258
|
+
query: { type: "string", description: "What you need context about" },
|
|
259
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
260
|
+
limit: { type: "number", description: "Max notes to return", default: 5 },
|
|
261
|
+
full: { type: "boolean", description: "Return full bodies instead of excerpts", default: false },
|
|
262
|
+
},
|
|
263
|
+
required: ["query"],
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: "memory_dedup",
|
|
268
|
+
description: "Find likely duplicate notes and suggest non-destructive merges.",
|
|
269
|
+
inputSchema: {
|
|
270
|
+
type: "object",
|
|
271
|
+
properties: {
|
|
272
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
273
|
+
threshold: { type: "number", description: "Duplicate threshold", default: 0.9 },
|
|
274
|
+
scope: { type: "string", enum: ["project", "global"], description: "Dedup scope", default: "project" },
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
},
|
|
225
278
|
{
|
|
226
279
|
name: "memory_stats",
|
|
227
280
|
description: "Summarize local memory storage across vault / journal / checkpoints: totals, top projects, recent activity, and temp-slug cleanup candidates. Returns the same text as the `stats` CLI.",
|
|
@@ -247,6 +300,9 @@ class ProjectMemoryServer {
|
|
|
247
300
|
case "memory_get": return await this.get(args || {});
|
|
248
301
|
case "memory_delete": return await this.del(args || {});
|
|
249
302
|
case "memory_reindex": return await this.reindex(args || {});
|
|
303
|
+
case "memory_link": return await this.link(args || {});
|
|
304
|
+
case "memory_global_recall": return await this.globalRecall(args || {});
|
|
305
|
+
case "memory_dedup": return await this.dedup(args || {});
|
|
250
306
|
case "memory_stats": return await this.stats(args || {});
|
|
251
307
|
default: throw new Error(`Unknown tool: ${name}`);
|
|
252
308
|
}
|
|
@@ -321,7 +377,15 @@ class ProjectMemoryServer {
|
|
|
321
377
|
query = limit(query, "query", 2000);
|
|
322
378
|
const p = this.paths(dir);
|
|
323
379
|
const index = await this.loadIndex(p);
|
|
324
|
-
const
|
|
380
|
+
const ensured = await ensureGraphState({
|
|
381
|
+
vaultRoot: VAULT_ROOT,
|
|
382
|
+
projectDir: p.projectDir,
|
|
383
|
+
slug: p.slug,
|
|
384
|
+
index,
|
|
385
|
+
noteLoader: loadNoteBody,
|
|
386
|
+
});
|
|
387
|
+
if (ensured.changed) await this.saveIndex(p, ensured.index);
|
|
388
|
+
const notes = Object.values(ensured.index.notes || {});
|
|
325
389
|
if (notes.length === 0) {
|
|
326
390
|
return { content: [{ type: "text", text: `No memories for ${p.slug} yet. Use memory_save first.` }] };
|
|
327
391
|
}
|
|
@@ -352,14 +416,14 @@ class ProjectMemoryServer {
|
|
|
352
416
|
scored = notes.map((n) => {
|
|
353
417
|
const sim = Array.isArray(n.embedding) ? cosine(qVec, n.embedding) : 0;
|
|
354
418
|
const kwNorm = kw(n) / maxKw;
|
|
355
|
-
const score = 0.8 * sim + 0.2 * kwNorm;
|
|
419
|
+
const score = 0.8 * sim + 0.2 * kwNorm + graphBoost(n, [...qTokens]);
|
|
356
420
|
return { n, score, sim };
|
|
357
421
|
}).filter((x) => x.score > 0.05)
|
|
358
422
|
.sort((a, b) => b.score - a.score || (a.n.created < b.n.created ? 1 : -1))
|
|
359
423
|
.slice(0, Math.max(1, Math.min(20, lim)));
|
|
360
424
|
} else {
|
|
361
425
|
const want = Math.max(1, Math.min(20, lim));
|
|
362
|
-
const kwRanked = notes.map((n) => ({ n, score: kw(n) }))
|
|
426
|
+
const kwRanked = notes.map((n) => ({ n, score: kw(n) + graphBoost(n, [...qTokens]) }))
|
|
363
427
|
.filter((x) => x.score > 0)
|
|
364
428
|
.sort((a, b) => b.score - a.score || (a.n.created < b.n.created ? 1 : -1));
|
|
365
429
|
|
|
@@ -411,6 +475,70 @@ class ProjectMemoryServer {
|
|
|
411
475
|
return { content: [{ type: "text", text: `Recall for "${query}" in ${p.slug} [${displayMode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
|
|
412
476
|
}
|
|
413
477
|
|
|
478
|
+
async dedup({ dir, threshold = 0.9, scope = "project" }) {
|
|
479
|
+
const p = this.paths(dir);
|
|
480
|
+
const rows = await loadGlobalNotes(VAULT_ROOT);
|
|
481
|
+
const filtered = scope === "project" ? rows.filter((row) => row.slug === p.slug) : rows;
|
|
482
|
+
const withBodies = await Promise.all(filtered.map(async ({ slug, note }) => {
|
|
483
|
+
const raw = await fs.readFile(path.join(VAULT_ROOT, slug, note.file), "utf8").catch(() => "");
|
|
484
|
+
const { body } = parseFrontmatter(raw);
|
|
485
|
+
return { slug, note, body };
|
|
486
|
+
}));
|
|
487
|
+
const pairs = findDuplicateCandidates({ rows: withBodies, threshold });
|
|
488
|
+
if (!pairs.length) {
|
|
489
|
+
return { content: [{ type: "text", text: `No duplicate suggestions above ${threshold} in ${scope} scope.` }] };
|
|
490
|
+
}
|
|
491
|
+
const lines = [`Duplicate suggestions [threshold=${threshold}, scope=${scope}]:`, ""];
|
|
492
|
+
for (const pair of pairs) {
|
|
493
|
+
lines.push(`- suggested merge: [${pair.left.slug}] ${pair.left.note.title} <-> [${pair.right.slug}] ${pair.right.note.title} (score=${pair.score.toFixed(3)})`);
|
|
494
|
+
lines.push(` reasons: ${pair.reasons.join(", ")}`);
|
|
495
|
+
}
|
|
496
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async globalRecall({ query, dir, limit: lim = 5, full = false }) {
|
|
500
|
+
query = limit(query, "query", 2000);
|
|
501
|
+
const p = this.paths(dir);
|
|
502
|
+
const currentIndex = await this.loadIndex(p);
|
|
503
|
+
const ensured = await ensureGraphState({
|
|
504
|
+
vaultRoot: VAULT_ROOT,
|
|
505
|
+
projectDir: p.projectDir,
|
|
506
|
+
slug: p.slug,
|
|
507
|
+
index: currentIndex,
|
|
508
|
+
noteLoader: loadNoteBody,
|
|
509
|
+
});
|
|
510
|
+
if (ensured.changed) await this.saveIndex(p, ensured.index);
|
|
511
|
+
const rows = await loadGlobalNotes(VAULT_ROOT);
|
|
512
|
+
const qTokens = new Set(tokenize(query));
|
|
513
|
+
const qVec = deterministicEnabled() ? null : await embedOne(query);
|
|
514
|
+
const haveEmbeddings = !!(qVec && rows.some((row) => Array.isArray(row.note.embedding)));
|
|
515
|
+
|
|
516
|
+
const scored = rows.map(({ slug, note }) => {
|
|
517
|
+
let kwScore = 0;
|
|
518
|
+
for (const keyword of note.keywords || []) if (qTokens.has(keyword)) kwScore += 1;
|
|
519
|
+
for (const word of tokenize(note.title)) if (qTokens.has(word)) kwScore += 2;
|
|
520
|
+
const sameProjectBoost = slug === p.slug ? 0.25 : 0;
|
|
521
|
+
const sim = haveEmbeddings && Array.isArray(note.embedding) ? cosine(qVec, note.embedding) : 0;
|
|
522
|
+
const score = (haveEmbeddings ? (0.8 * sim) : 0) + (0.2 * kwScore) + sameProjectBoost + graphBoost(note, [...qTokens]);
|
|
523
|
+
return { slug, note, sim, score };
|
|
524
|
+
}).filter((entry) => entry.score > 0)
|
|
525
|
+
.sort((a, b) => b.score - a.score || (a.note.created < b.note.created ? 1 : -1))
|
|
526
|
+
.slice(0, Math.max(1, Math.min(20, lim)));
|
|
527
|
+
|
|
528
|
+
if (scored.length === 0) {
|
|
529
|
+
return { content: [{ type: "text", text: `No relevant global memories for "${query}". Try memory_list or memory_recall.` }] };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const mode = haveEmbeddings ? "semantic+graph" : "keyword+graph";
|
|
533
|
+
const blocks = await Promise.all(scored.map(async ({ slug, note, sim, score }) => {
|
|
534
|
+
const raw = await fs.readFile(path.join(VAULT_ROOT, slug, note.file), "utf8").catch(() => "");
|
|
535
|
+
const { body } = parseFrontmatter(raw);
|
|
536
|
+
const text = full ? body.trim() : body.trim().split("\n").slice(0, 12).join("\n");
|
|
537
|
+
return `### ${note.title} ([${slug}], ${haveEmbeddings ? `sim:${sim.toFixed(3)}` : `score:${score.toFixed(2)}`})\n${text}`;
|
|
538
|
+
}));
|
|
539
|
+
return { content: [{ type: "text", text: `Global recall for "${query}" [${mode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
|
|
540
|
+
}
|
|
541
|
+
|
|
414
542
|
async list({ dir, limit: lim = 20 }) {
|
|
415
543
|
const p = this.paths(dir);
|
|
416
544
|
const index = await this.loadIndex(p);
|
|
@@ -444,6 +572,47 @@ class ProjectMemoryServer {
|
|
|
444
572
|
return { content: [{ type: "text", text: `Deleted note ${id} from ${p.slug}` }] };
|
|
445
573
|
}
|
|
446
574
|
|
|
575
|
+
async link({ id, dir, include_unresolved = true }) {
|
|
576
|
+
id = limit(id, "id", 100);
|
|
577
|
+
const p = this.paths(dir);
|
|
578
|
+
const index = await this.loadIndex(p);
|
|
579
|
+
const note = index.notes?.[id];
|
|
580
|
+
if (!note) throw new Error(`Note ${id} not found in ${p.slug}`);
|
|
581
|
+
|
|
582
|
+
const ensured = await ensureGraphState({
|
|
583
|
+
vaultRoot: VAULT_ROOT,
|
|
584
|
+
projectDir: p.projectDir,
|
|
585
|
+
slug: p.slug,
|
|
586
|
+
index,
|
|
587
|
+
noteLoader: loadNoteBody,
|
|
588
|
+
});
|
|
589
|
+
if (ensured.changed) await this.saveIndex(p, ensured.index);
|
|
590
|
+
|
|
591
|
+
const current = ensured.index.notes[id];
|
|
592
|
+
const links = [];
|
|
593
|
+
for (const link of current.links || []) {
|
|
594
|
+
const resolved = await resolveLink({
|
|
595
|
+
vaultRoot: VAULT_ROOT,
|
|
596
|
+
currentSlug: p.slug,
|
|
597
|
+
ref: link.ref,
|
|
598
|
+
project: link.project,
|
|
599
|
+
kind: link.kind,
|
|
600
|
+
});
|
|
601
|
+
if (resolved.status !== "missing" || include_unresolved) links.push({ link, resolved });
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const lines = [
|
|
605
|
+
`Links for ${current.title} (${current.id})`,
|
|
606
|
+
"",
|
|
607
|
+
"Outgoing:",
|
|
608
|
+
...links.map(({ link, resolved }) => `- ${link.raw} -> ${resolved.status}${resolved.match ? ` (${resolved.match.title})` : ""}`),
|
|
609
|
+
"",
|
|
610
|
+
"Backlinks:",
|
|
611
|
+
...((current.backlinks || []).length ? current.backlinks.map((b) => `- ${b.title} (${b.id})`) : ["- none"]),
|
|
612
|
+
];
|
|
613
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
614
|
+
}
|
|
615
|
+
|
|
447
616
|
async reindex({ dir, force = false }) {
|
|
448
617
|
const p = this.paths(dir);
|
|
449
618
|
if (deterministicEnabled()) return { content: [{ type: "text", text: `Reindex ${p.slug}: skipped (deterministic no-network mode; embeddings disabled).` }] };
|
|
@@ -488,5 +657,9 @@ class ProjectMemoryServer {
|
|
|
488
657
|
}
|
|
489
658
|
}
|
|
490
659
|
|
|
491
|
-
|
|
492
|
-
server
|
|
660
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
661
|
+
const server = new ProjectMemoryServer();
|
|
662
|
+
server.run().catch(console.error);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export { ProjectMemoryServer };
|