codex-dev-mcp-suite 1.4.0 → 1.5.1
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 +183 -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.1",
|
|
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
|
@@ -20,15 +20,20 @@ import {
|
|
|
20
20
|
CallToolRequestSchema,
|
|
21
21
|
ListToolsRequestSchema,
|
|
22
22
|
ListResourcesRequestSchema,
|
|
23
|
+
ListResourceTemplatesRequestSchema,
|
|
23
24
|
ReadResourceRequestSchema,
|
|
24
25
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
25
26
|
import fs from "fs/promises";
|
|
26
27
|
import path from "path";
|
|
27
28
|
import os from "os";
|
|
28
29
|
import crypto from "crypto";
|
|
30
|
+
import { fileURLToPath } from "url";
|
|
29
31
|
import { embed, embedOne, cosine, embeddingConfig } from "./embedding.js";
|
|
30
32
|
import { rerank, rerankConfig } from "./rerank.js";
|
|
31
33
|
import { deterministicEnabled } from "./env.js";
|
|
34
|
+
import { ensureGraphState, resolveLink, loadNoteBody } from "./graph.js";
|
|
35
|
+
import { loadGlobalNotes } from "./global-index.js";
|
|
36
|
+
import { findDuplicateCandidates } from "./dedup.js";
|
|
32
37
|
import { computeStats, formatText, formatJson } from "../lib/stats.js";
|
|
33
38
|
|
|
34
39
|
const VAULT_ROOT =
|
|
@@ -44,7 +49,7 @@ const STOPWORDS = new Set([
|
|
|
44
49
|
"not", "no", "do", "did", "does", "can", "will", "so", "my", "our", "your",
|
|
45
50
|
]);
|
|
46
51
|
|
|
47
|
-
function projectSlug(dir) {
|
|
52
|
+
export function projectSlug(dir) {
|
|
48
53
|
const resolved = path.resolve(dir || process.cwd());
|
|
49
54
|
const base = path.basename(resolved) || "root";
|
|
50
55
|
const hash = crypto.createHash("sha1").update(resolved).digest("hex").slice(0, 8);
|
|
@@ -76,7 +81,7 @@ function limit(value, name, max, required = true) {
|
|
|
76
81
|
return text;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
function parseFrontmatter(raw) {
|
|
84
|
+
export function parseFrontmatter(raw) {
|
|
80
85
|
const m = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
81
86
|
if (!m) return { meta: {}, body: raw };
|
|
82
87
|
const meta = {};
|
|
@@ -105,6 +110,16 @@ function buildFrontmatter(meta) {
|
|
|
105
110
|
return lines.join("\n");
|
|
106
111
|
}
|
|
107
112
|
|
|
113
|
+
function graphBoost(note, queryTokens) {
|
|
114
|
+
let score = 0;
|
|
115
|
+
for (const link of note.links || []) {
|
|
116
|
+
for (const token of queryTokens) {
|
|
117
|
+
if (String(link.ref || "").toLowerCase().includes(token)) score += 0.05;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return Math.min(0.15, score);
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
class ProjectMemoryServer {
|
|
109
124
|
constructor() {
|
|
110
125
|
this.server = new Server(
|
|
@@ -222,6 +237,45 @@ class ProjectMemoryServer {
|
|
|
222
237
|
},
|
|
223
238
|
},
|
|
224
239
|
},
|
|
240
|
+
{
|
|
241
|
+
name: "memory_link",
|
|
242
|
+
description: "Resolve wiki-style note links and show backlinks for a note.",
|
|
243
|
+
inputSchema: {
|
|
244
|
+
type: "object",
|
|
245
|
+
properties: {
|
|
246
|
+
id: { type: "string", description: "Note id to inspect" },
|
|
247
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
248
|
+
include_unresolved: { type: "boolean", description: "Include unresolved links in output", default: true },
|
|
249
|
+
},
|
|
250
|
+
required: ["id"],
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: "memory_global_recall",
|
|
255
|
+
description: "Recall relevant notes across projects with same-project bias and graceful keyword fallback.",
|
|
256
|
+
inputSchema: {
|
|
257
|
+
type: "object",
|
|
258
|
+
properties: {
|
|
259
|
+
query: { type: "string", description: "What you need context about" },
|
|
260
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
261
|
+
limit: { type: "number", description: "Max notes to return", default: 5 },
|
|
262
|
+
full: { type: "boolean", description: "Return full bodies instead of excerpts", default: false },
|
|
263
|
+
},
|
|
264
|
+
required: ["query"],
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: "memory_dedup",
|
|
269
|
+
description: "Find likely duplicate notes and suggest non-destructive merges.",
|
|
270
|
+
inputSchema: {
|
|
271
|
+
type: "object",
|
|
272
|
+
properties: {
|
|
273
|
+
dir: { type: "string", description: "Project directory (defaults to CWD)" },
|
|
274
|
+
threshold: { type: "number", description: "Duplicate threshold", default: 0.9 },
|
|
275
|
+
scope: { type: "string", enum: ["project", "global"], description: "Dedup scope", default: "project" },
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
225
279
|
{
|
|
226
280
|
name: "memory_stats",
|
|
227
281
|
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 +301,9 @@ class ProjectMemoryServer {
|
|
|
247
301
|
case "memory_get": return await this.get(args || {});
|
|
248
302
|
case "memory_delete": return await this.del(args || {});
|
|
249
303
|
case "memory_reindex": return await this.reindex(args || {});
|
|
304
|
+
case "memory_link": return await this.link(args || {});
|
|
305
|
+
case "memory_global_recall": return await this.globalRecall(args || {});
|
|
306
|
+
case "memory_dedup": return await this.dedup(args || {});
|
|
250
307
|
case "memory_stats": return await this.stats(args || {});
|
|
251
308
|
default: throw new Error(`Unknown tool: ${name}`);
|
|
252
309
|
}
|
|
@@ -276,6 +333,8 @@ class ProjectMemoryServer {
|
|
|
276
333
|
return { resources };
|
|
277
334
|
});
|
|
278
335
|
|
|
336
|
+
this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
|
|
337
|
+
|
|
279
338
|
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
280
339
|
const uri = request.params.uri || "";
|
|
281
340
|
const m = uri.match(/^memory:\/\/([^/]+)\/(.+)$/);
|
|
@@ -321,7 +380,15 @@ class ProjectMemoryServer {
|
|
|
321
380
|
query = limit(query, "query", 2000);
|
|
322
381
|
const p = this.paths(dir);
|
|
323
382
|
const index = await this.loadIndex(p);
|
|
324
|
-
const
|
|
383
|
+
const ensured = await ensureGraphState({
|
|
384
|
+
vaultRoot: VAULT_ROOT,
|
|
385
|
+
projectDir: p.projectDir,
|
|
386
|
+
slug: p.slug,
|
|
387
|
+
index,
|
|
388
|
+
noteLoader: loadNoteBody,
|
|
389
|
+
});
|
|
390
|
+
if (ensured.changed) await this.saveIndex(p, ensured.index);
|
|
391
|
+
const notes = Object.values(ensured.index.notes || {});
|
|
325
392
|
if (notes.length === 0) {
|
|
326
393
|
return { content: [{ type: "text", text: `No memories for ${p.slug} yet. Use memory_save first.` }] };
|
|
327
394
|
}
|
|
@@ -352,14 +419,14 @@ class ProjectMemoryServer {
|
|
|
352
419
|
scored = notes.map((n) => {
|
|
353
420
|
const sim = Array.isArray(n.embedding) ? cosine(qVec, n.embedding) : 0;
|
|
354
421
|
const kwNorm = kw(n) / maxKw;
|
|
355
|
-
const score = 0.8 * sim + 0.2 * kwNorm;
|
|
422
|
+
const score = 0.8 * sim + 0.2 * kwNorm + graphBoost(n, [...qTokens]);
|
|
356
423
|
return { n, score, sim };
|
|
357
424
|
}).filter((x) => x.score > 0.05)
|
|
358
425
|
.sort((a, b) => b.score - a.score || (a.n.created < b.n.created ? 1 : -1))
|
|
359
426
|
.slice(0, Math.max(1, Math.min(20, lim)));
|
|
360
427
|
} else {
|
|
361
428
|
const want = Math.max(1, Math.min(20, lim));
|
|
362
|
-
const kwRanked = notes.map((n) => ({ n, score: kw(n) }))
|
|
429
|
+
const kwRanked = notes.map((n) => ({ n, score: kw(n) + graphBoost(n, [...qTokens]) }))
|
|
363
430
|
.filter((x) => x.score > 0)
|
|
364
431
|
.sort((a, b) => b.score - a.score || (a.n.created < b.n.created ? 1 : -1));
|
|
365
432
|
|
|
@@ -411,6 +478,70 @@ class ProjectMemoryServer {
|
|
|
411
478
|
return { content: [{ type: "text", text: `Recall for "${query}" in ${p.slug} [${displayMode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
|
|
412
479
|
}
|
|
413
480
|
|
|
481
|
+
async dedup({ dir, threshold = 0.9, scope = "project" }) {
|
|
482
|
+
const p = this.paths(dir);
|
|
483
|
+
const rows = await loadGlobalNotes(VAULT_ROOT);
|
|
484
|
+
const filtered = scope === "project" ? rows.filter((row) => row.slug === p.slug) : rows;
|
|
485
|
+
const withBodies = await Promise.all(filtered.map(async ({ slug, note }) => {
|
|
486
|
+
const raw = await fs.readFile(path.join(VAULT_ROOT, slug, note.file), "utf8").catch(() => "");
|
|
487
|
+
const { body } = parseFrontmatter(raw);
|
|
488
|
+
return { slug, note, body };
|
|
489
|
+
}));
|
|
490
|
+
const pairs = findDuplicateCandidates({ rows: withBodies, threshold });
|
|
491
|
+
if (!pairs.length) {
|
|
492
|
+
return { content: [{ type: "text", text: `No duplicate suggestions above ${threshold} in ${scope} scope.` }] };
|
|
493
|
+
}
|
|
494
|
+
const lines = [`Duplicate suggestions [threshold=${threshold}, scope=${scope}]:`, ""];
|
|
495
|
+
for (const pair of pairs) {
|
|
496
|
+
lines.push(`- suggested merge: [${pair.left.slug}] ${pair.left.note.title} <-> [${pair.right.slug}] ${pair.right.note.title} (score=${pair.score.toFixed(3)})`);
|
|
497
|
+
lines.push(` reasons: ${pair.reasons.join(", ")}`);
|
|
498
|
+
}
|
|
499
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async globalRecall({ query, dir, limit: lim = 5, full = false }) {
|
|
503
|
+
query = limit(query, "query", 2000);
|
|
504
|
+
const p = this.paths(dir);
|
|
505
|
+
const currentIndex = await this.loadIndex(p);
|
|
506
|
+
const ensured = await ensureGraphState({
|
|
507
|
+
vaultRoot: VAULT_ROOT,
|
|
508
|
+
projectDir: p.projectDir,
|
|
509
|
+
slug: p.slug,
|
|
510
|
+
index: currentIndex,
|
|
511
|
+
noteLoader: loadNoteBody,
|
|
512
|
+
});
|
|
513
|
+
if (ensured.changed) await this.saveIndex(p, ensured.index);
|
|
514
|
+
const rows = await loadGlobalNotes(VAULT_ROOT);
|
|
515
|
+
const qTokens = new Set(tokenize(query));
|
|
516
|
+
const qVec = deterministicEnabled() ? null : await embedOne(query);
|
|
517
|
+
const haveEmbeddings = !!(qVec && rows.some((row) => Array.isArray(row.note.embedding)));
|
|
518
|
+
|
|
519
|
+
const scored = rows.map(({ slug, note }) => {
|
|
520
|
+
let kwScore = 0;
|
|
521
|
+
for (const keyword of note.keywords || []) if (qTokens.has(keyword)) kwScore += 1;
|
|
522
|
+
for (const word of tokenize(note.title)) if (qTokens.has(word)) kwScore += 2;
|
|
523
|
+
const sameProjectBoost = slug === p.slug ? 0.25 : 0;
|
|
524
|
+
const sim = haveEmbeddings && Array.isArray(note.embedding) ? cosine(qVec, note.embedding) : 0;
|
|
525
|
+
const score = (haveEmbeddings ? (0.8 * sim) : 0) + (0.2 * kwScore) + sameProjectBoost + graphBoost(note, [...qTokens]);
|
|
526
|
+
return { slug, note, sim, score };
|
|
527
|
+
}).filter((entry) => entry.score > 0)
|
|
528
|
+
.sort((a, b) => b.score - a.score || (a.note.created < b.note.created ? 1 : -1))
|
|
529
|
+
.slice(0, Math.max(1, Math.min(20, lim)));
|
|
530
|
+
|
|
531
|
+
if (scored.length === 0) {
|
|
532
|
+
return { content: [{ type: "text", text: `No relevant global memories for "${query}". Try memory_list or memory_recall.` }] };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const mode = haveEmbeddings ? "semantic+graph" : "keyword+graph";
|
|
536
|
+
const blocks = await Promise.all(scored.map(async ({ slug, note, sim, score }) => {
|
|
537
|
+
const raw = await fs.readFile(path.join(VAULT_ROOT, slug, note.file), "utf8").catch(() => "");
|
|
538
|
+
const { body } = parseFrontmatter(raw);
|
|
539
|
+
const text = full ? body.trim() : body.trim().split("\n").slice(0, 12).join("\n");
|
|
540
|
+
return `### ${note.title} ([${slug}], ${haveEmbeddings ? `sim:${sim.toFixed(3)}` : `score:${score.toFixed(2)}`})\n${text}`;
|
|
541
|
+
}));
|
|
542
|
+
return { content: [{ type: "text", text: `Global recall for "${query}" [${mode}]:\n\n${blocks.join("\n\n---\n\n")}` }] };
|
|
543
|
+
}
|
|
544
|
+
|
|
414
545
|
async list({ dir, limit: lim = 20 }) {
|
|
415
546
|
const p = this.paths(dir);
|
|
416
547
|
const index = await this.loadIndex(p);
|
|
@@ -444,6 +575,47 @@ class ProjectMemoryServer {
|
|
|
444
575
|
return { content: [{ type: "text", text: `Deleted note ${id} from ${p.slug}` }] };
|
|
445
576
|
}
|
|
446
577
|
|
|
578
|
+
async link({ id, dir, include_unresolved = true }) {
|
|
579
|
+
id = limit(id, "id", 100);
|
|
580
|
+
const p = this.paths(dir);
|
|
581
|
+
const index = await this.loadIndex(p);
|
|
582
|
+
const note = index.notes?.[id];
|
|
583
|
+
if (!note) throw new Error(`Note ${id} not found in ${p.slug}`);
|
|
584
|
+
|
|
585
|
+
const ensured = await ensureGraphState({
|
|
586
|
+
vaultRoot: VAULT_ROOT,
|
|
587
|
+
projectDir: p.projectDir,
|
|
588
|
+
slug: p.slug,
|
|
589
|
+
index,
|
|
590
|
+
noteLoader: loadNoteBody,
|
|
591
|
+
});
|
|
592
|
+
if (ensured.changed) await this.saveIndex(p, ensured.index);
|
|
593
|
+
|
|
594
|
+
const current = ensured.index.notes[id];
|
|
595
|
+
const links = [];
|
|
596
|
+
for (const link of current.links || []) {
|
|
597
|
+
const resolved = await resolveLink({
|
|
598
|
+
vaultRoot: VAULT_ROOT,
|
|
599
|
+
currentSlug: p.slug,
|
|
600
|
+
ref: link.ref,
|
|
601
|
+
project: link.project,
|
|
602
|
+
kind: link.kind,
|
|
603
|
+
});
|
|
604
|
+
if (resolved.status !== "missing" || include_unresolved) links.push({ link, resolved });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const lines = [
|
|
608
|
+
`Links for ${current.title} (${current.id})`,
|
|
609
|
+
"",
|
|
610
|
+
"Outgoing:",
|
|
611
|
+
...links.map(({ link, resolved }) => `- ${link.raw} -> ${resolved.status}${resolved.match ? ` (${resolved.match.title})` : ""}`),
|
|
612
|
+
"",
|
|
613
|
+
"Backlinks:",
|
|
614
|
+
...((current.backlinks || []).length ? current.backlinks.map((b) => `- ${b.title} (${b.id})`) : ["- none"]),
|
|
615
|
+
];
|
|
616
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
617
|
+
}
|
|
618
|
+
|
|
447
619
|
async reindex({ dir, force = false }) {
|
|
448
620
|
const p = this.paths(dir);
|
|
449
621
|
if (deterministicEnabled()) return { content: [{ type: "text", text: `Reindex ${p.slug}: skipped (deterministic no-network mode; embeddings disabled).` }] };
|
|
@@ -488,5 +660,9 @@ class ProjectMemoryServer {
|
|
|
488
660
|
}
|
|
489
661
|
}
|
|
490
662
|
|
|
491
|
-
|
|
492
|
-
server
|
|
663
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
664
|
+
const server = new ProjectMemoryServer();
|
|
665
|
+
server.run().catch(console.error);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export { ProjectMemoryServer };
|