@danielmarbach/mnemonic-mcp 0.15.0 → 0.17.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 +23 -0
- package/README.md +3 -2
- package/build/cache.d.ts +66 -0
- package/build/cache.d.ts.map +1 -0
- package/build/cache.js +149 -0
- package/build/cache.js.map +1 -0
- package/build/index.js +320 -66
- package/build/index.js.map +1 -1
- package/build/relationships.d.ts +47 -0
- package/build/relationships.d.ts.map +1 -0
- package/build/relationships.js +153 -0
- package/build/relationships.js.map +1 -0
- package/build/structured-content.d.ts +241 -6
- package/build/structured-content.d.ts.map +1 -1
- package/build/structured-content.js +27 -1
- package/build/structured-content.js.map +1 -1
- package/package.json +2 -2
package/build/index.js
CHANGED
|
@@ -9,8 +9,11 @@ import { NOTE_LIFECYCLES } from "./storage.js";
|
|
|
9
9
|
import { embed, cosineSimilarity, embedModel } from "./embeddings.js";
|
|
10
10
|
import { buildTemporalHistoryEntry, computeConfidence, getNoteProvenance } from "./provenance.js";
|
|
11
11
|
import { getOrBuildProjection } from "./projections.js";
|
|
12
|
+
import { invalidateActiveProjectCache, getOrBuildVaultEmbeddings, getOrBuildVaultNoteList, getSessionCachedNote, } from "./cache.js";
|
|
13
|
+
import { performance } from "perf_hooks";
|
|
12
14
|
import { filterRelationships, mergeRelationshipsFromNotes, normalizeMergePlanSourceIds, resolveEffectiveConsolidationMode, } from "./consolidate.js";
|
|
13
15
|
import { selectRecallResults } from "./recall.js";
|
|
16
|
+
import { getRelationshipPreview } from "./relationships.js";
|
|
14
17
|
import { cleanMarkdown } from "./markdown.js";
|
|
15
18
|
import { MnemonicConfigStore, readVaultSchemaVersion } from "./config.js";
|
|
16
19
|
import { CONSOLIDATION_MODES, PROTECTED_BRANCH_BEHAVIORS, PROJECT_POLICY_SCOPES, WRITE_SCOPES, isProtectedBranch, resolveProtectedBranchBehavior, resolveProtectedBranchPatterns, resolveConsolidationMode, resolveWriteScope, } from "./project-memory-policy.js";
|
|
@@ -359,6 +362,8 @@ async function ensureBranchSynced(cwd) {
|
|
|
359
362
|
}
|
|
360
363
|
const mainBackfill = await backfillEmbeddingsAfterSync(vaultManager.main.storage, "main vault", [], true);
|
|
361
364
|
console.error(`[branch] Main vault embedded ${mainBackfill.embedded} notes`);
|
|
365
|
+
// Vault contents changed — discard session cache so next access rebuilds from fresh state
|
|
366
|
+
invalidateActiveProjectCache();
|
|
362
367
|
return true;
|
|
363
368
|
}
|
|
364
369
|
function formatProjectIdentityText(identity) {
|
|
@@ -382,10 +387,10 @@ function formatProjectIdentityText(identity) {
|
|
|
382
387
|
function describeLifecycle(lifecycle) {
|
|
383
388
|
return `lifecycle: ${lifecycle}`;
|
|
384
389
|
}
|
|
385
|
-
function formatNote(note, score) {
|
|
390
|
+
function formatNote(note, score, showRawRelated = true) {
|
|
386
391
|
const scoreStr = score !== undefined ? ` | similarity: ${score.toFixed(3)}` : "";
|
|
387
392
|
const projectStr = note.project ? ` | project: ${note.projectName ?? note.project}` : " | global";
|
|
388
|
-
const relStr = note.relatedTo && note.relatedTo.length > 0
|
|
393
|
+
const relStr = showRawRelated && note.relatedTo && note.relatedTo.length > 0
|
|
389
394
|
? `\n**related:** ${note.relatedTo.map((r) => `\`${r.id}\` (${r.type})`).join(", ")}`
|
|
390
395
|
: "";
|
|
391
396
|
return (`## ${note.title}\n` +
|
|
@@ -404,6 +409,15 @@ function formatTemporalHistory(history) {
|
|
|
404
409
|
}
|
|
405
410
|
return lines.join("\n");
|
|
406
411
|
}
|
|
412
|
+
function formatRelationshipPreview(preview) {
|
|
413
|
+
const shown = preview.shown
|
|
414
|
+
.map(r => `${r.title} (\`${r.id}\`) [${r.relationType ?? "related-to"}]`)
|
|
415
|
+
.join(", ");
|
|
416
|
+
const more = preview.truncated
|
|
417
|
+
? ` [+${preview.totalDirectRelations - preview.shown.length} more]`
|
|
418
|
+
: "";
|
|
419
|
+
return `**related (${preview.totalDirectRelations}):** ${shown}${more}`;
|
|
420
|
+
}
|
|
407
421
|
// ── Git commit message helpers ────────────────────────────────────────────────
|
|
408
422
|
/**
|
|
409
423
|
* Extract a short human-readable summary from note content.
|
|
@@ -772,7 +786,7 @@ function vaultMatchesStorageScope(vault, storedIn) {
|
|
|
772
786
|
// "project-vault" covers the primary project vault and all submodule vaults.
|
|
773
787
|
return vault.isProject;
|
|
774
788
|
}
|
|
775
|
-
async function collectVisibleNotes(cwd, scope = "all", tags, storedIn = "any") {
|
|
789
|
+
async function collectVisibleNotes(cwd, scope = "all", tags, storedIn = "any", sessionProjectId) {
|
|
776
790
|
const project = await resolveProject(cwd);
|
|
777
791
|
const vaults = await vaultManager.searchOrder(cwd);
|
|
778
792
|
let filterProject = undefined;
|
|
@@ -783,8 +797,23 @@ async function collectVisibleNotes(cwd, scope = "all", tags, storedIn = "any") {
|
|
|
783
797
|
const seen = new Set();
|
|
784
798
|
const entries = [];
|
|
785
799
|
for (const vault of vaults) {
|
|
786
|
-
|
|
787
|
-
|
|
800
|
+
let rawNotes;
|
|
801
|
+
if (sessionProjectId) {
|
|
802
|
+
const cached = await getOrBuildVaultNoteList(sessionProjectId, vault);
|
|
803
|
+
if (cached !== undefined) {
|
|
804
|
+
// Apply project filter on the full cached list
|
|
805
|
+
rawNotes = filterProject !== undefined
|
|
806
|
+
? cached.filter((n) => filterProject === null ? !n.project : n.project === filterProject)
|
|
807
|
+
: cached;
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
rawNotes = await vault.storage.listNotes(filterProject !== undefined ? { project: filterProject } : undefined);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
rawNotes = await vault.storage.listNotes(filterProject !== undefined ? { project: filterProject } : undefined);
|
|
815
|
+
}
|
|
816
|
+
for (const note of rawNotes) {
|
|
788
817
|
if (seen.has(note.id)) {
|
|
789
818
|
continue;
|
|
790
819
|
}
|
|
@@ -1432,6 +1461,7 @@ server.registerTool("remember", {
|
|
|
1432
1461
|
timestamp: now,
|
|
1433
1462
|
persistence,
|
|
1434
1463
|
};
|
|
1464
|
+
invalidateActiveProjectCache();
|
|
1435
1465
|
return {
|
|
1436
1466
|
content: [{ type: "text", text: textContent }],
|
|
1437
1467
|
structuredContent,
|
|
@@ -1636,6 +1666,7 @@ server.registerTool("recall", {
|
|
|
1636
1666
|
"- You just want to browse by tags or scope; use `list`\n\n" +
|
|
1637
1667
|
"Returns:\n" +
|
|
1638
1668
|
"- Ranked memory matches with scores, vault label, tags, lifecycle, and updated time\n" +
|
|
1669
|
+
"- Bounded 1-hop relationship previews automatically attached to top results\n" +
|
|
1639
1670
|
"- In temporal mode, optional compact history entries for top matches\n\n" +
|
|
1640
1671
|
"Read-only.\n\n" +
|
|
1641
1672
|
"Typical next step:\n" +
|
|
@@ -1663,6 +1694,7 @@ server.registerTool("recall", {
|
|
|
1663
1694
|
}),
|
|
1664
1695
|
outputSchema: RecallResultSchema,
|
|
1665
1696
|
}, async ({ query, cwd, limit, minSimilarity, mode, verbose, tags, scope }) => {
|
|
1697
|
+
const t0Recall = performance.now();
|
|
1666
1698
|
await ensureBranchSynced(cwd);
|
|
1667
1699
|
const project = await resolveProject(cwd);
|
|
1668
1700
|
const queryVec = await embed(query);
|
|
@@ -1670,6 +1702,12 @@ server.registerTool("recall", {
|
|
|
1670
1702
|
const noteCache = new Map();
|
|
1671
1703
|
const noteCacheKey = (vault, id) => `${vault.storage.vaultPath}::${id}`;
|
|
1672
1704
|
const readCachedNote = async (vault, id) => {
|
|
1705
|
+
// Check session cache first (populated when getOrBuildVaultEmbeddings was called)
|
|
1706
|
+
if (project) {
|
|
1707
|
+
const sessionNote = getSessionCachedNote(project.id, vault.storage.vaultPath, id);
|
|
1708
|
+
if (sessionNote !== undefined)
|
|
1709
|
+
return sessionNote;
|
|
1710
|
+
}
|
|
1673
1711
|
const key = noteCacheKey(vault, id);
|
|
1674
1712
|
const cached = noteCache.get(key);
|
|
1675
1713
|
if (cached) {
|
|
@@ -1686,7 +1724,9 @@ server.registerTool("recall", {
|
|
|
1686
1724
|
}
|
|
1687
1725
|
const scored = [];
|
|
1688
1726
|
for (const vault of vaults) {
|
|
1689
|
-
const embeddings =
|
|
1727
|
+
const embeddings = project
|
|
1728
|
+
? (await getOrBuildVaultEmbeddings(project.id, vault)) ?? await vault.storage.listEmbeddings()
|
|
1729
|
+
: await vault.storage.listEmbeddings();
|
|
1690
1730
|
for (const rec of embeddings) {
|
|
1691
1731
|
const rawScore = cosineSimilarity(queryVec, rec.embedding);
|
|
1692
1732
|
if (rawScore < minSimilarity)
|
|
@@ -1723,6 +1763,9 @@ server.registerTool("recall", {
|
|
|
1723
1763
|
: `Recall results (global):`;
|
|
1724
1764
|
const sections = [];
|
|
1725
1765
|
const structuredResults = [];
|
|
1766
|
+
// Determine how many top results get relationship expansion
|
|
1767
|
+
// Top 1 by default, top 3 if result count is small
|
|
1768
|
+
const recallRelationshipLimit = top.length <= 3 ? 3 : 1;
|
|
1726
1769
|
for (const [index, { id, score, vault, boosted }] of top.entries()) {
|
|
1727
1770
|
const note = await readCachedNote(vault, id);
|
|
1728
1771
|
if (note) {
|
|
@@ -1740,10 +1783,19 @@ server.registerTool("recall", {
|
|
|
1740
1783
|
}));
|
|
1741
1784
|
}
|
|
1742
1785
|
}
|
|
1786
|
+
// Add relationship preview for top N results (fail-soft)
|
|
1787
|
+
let relationships;
|
|
1788
|
+
if (index < recallRelationshipLimit) {
|
|
1789
|
+
relationships = await getRelationshipPreview(note, vaultManager.allKnownVaults(), { activeProjectId: project?.id, limit: 3 });
|
|
1790
|
+
}
|
|
1743
1791
|
const formattedHistory = mode === "temporal" && history !== undefined
|
|
1744
1792
|
? `\n\n${formatTemporalHistory(history)}`
|
|
1745
1793
|
: "";
|
|
1746
|
-
|
|
1794
|
+
const formattedRelationships = relationships !== undefined
|
|
1795
|
+
? `\n\n${formatRelationshipPreview(relationships)}`
|
|
1796
|
+
: "";
|
|
1797
|
+
// Suppress raw related IDs when enriched preview is shown to avoid duplication
|
|
1798
|
+
sections.push(`${formatNote(note, score, relationships === undefined)}${formattedHistory}${formattedRelationships}`);
|
|
1747
1799
|
structuredResults.push({
|
|
1748
1800
|
id,
|
|
1749
1801
|
title: note.title,
|
|
@@ -1758,6 +1810,7 @@ server.registerTool("recall", {
|
|
|
1758
1810
|
provenance,
|
|
1759
1811
|
confidence,
|
|
1760
1812
|
history,
|
|
1813
|
+
relationships,
|
|
1761
1814
|
});
|
|
1762
1815
|
}
|
|
1763
1816
|
}
|
|
@@ -1768,6 +1821,7 @@ server.registerTool("recall", {
|
|
|
1768
1821
|
scope: scope || "all",
|
|
1769
1822
|
results: structuredResults,
|
|
1770
1823
|
};
|
|
1824
|
+
console.error(`[recall:timing] ${(performance.now() - t0Recall).toFixed(1)}ms`);
|
|
1771
1825
|
return {
|
|
1772
1826
|
content: [{ type: "text", text: textContent }],
|
|
1773
1827
|
structuredContent,
|
|
@@ -1918,6 +1972,7 @@ server.registerTool("update", {
|
|
|
1918
1972
|
lifecycle: updated.lifecycle,
|
|
1919
1973
|
persistence,
|
|
1920
1974
|
};
|
|
1975
|
+
invalidateActiveProjectCache();
|
|
1921
1976
|
return { content: [{ type: "text", text: `Updated memory '${id}'\n${formatPersistenceSummary(persistence)}` }], structuredContent };
|
|
1922
1977
|
});
|
|
1923
1978
|
// ── forget ────────────────────────────────────────────────────────────────────
|
|
@@ -2024,6 +2079,7 @@ server.registerTool("forget", {
|
|
|
2024
2079
|
retry,
|
|
2025
2080
|
};
|
|
2026
2081
|
const retrySummary = formatRetrySummary(retry);
|
|
2082
|
+
invalidateActiveProjectCache();
|
|
2027
2083
|
return {
|
|
2028
2084
|
content: [{
|
|
2029
2085
|
type: "text",
|
|
@@ -2044,7 +2100,8 @@ server.registerTool("get", {
|
|
|
2044
2100
|
"- You are still searching by topic; use `recall`\n" +
|
|
2045
2101
|
"- You want to browse many notes; use `list`\n\n" +
|
|
2046
2102
|
"Returns:\n" +
|
|
2047
|
-
"- Full note content and metadata for the requested ids, including storage label\n
|
|
2103
|
+
"- Full note content and metadata for the requested ids, including storage label\n" +
|
|
2104
|
+
"- Bounded 1-hop relationship previews when `includeRelationships` is true (max 3 shown)\n\n" +
|
|
2048
2105
|
"Read-only.\n\n" +
|
|
2049
2106
|
"Typical next step:\n" +
|
|
2050
2107
|
"- Use `update`, `forget`, `move_memory`, or `relate` after inspection.",
|
|
@@ -2056,19 +2113,39 @@ server.registerTool("get", {
|
|
|
2056
2113
|
inputSchema: z.object({
|
|
2057
2114
|
ids: z.array(z.string()).min(1).describe("One or more memory ids to fetch"),
|
|
2058
2115
|
cwd: projectParam,
|
|
2116
|
+
includeRelationships: z.boolean().optional().default(false).describe("Include bounded direct relationship previews (1-hop expansion, max 3 shown)"),
|
|
2059
2117
|
}),
|
|
2060
2118
|
outputSchema: GetResultSchema,
|
|
2061
|
-
}, async ({ ids, cwd }) => {
|
|
2119
|
+
}, async ({ ids, cwd, includeRelationships }) => {
|
|
2120
|
+
const t0Get = performance.now();
|
|
2062
2121
|
await ensureBranchSynced(cwd);
|
|
2122
|
+
const project = await resolveProject(cwd);
|
|
2063
2123
|
const found = [];
|
|
2064
2124
|
const notFound = [];
|
|
2065
2125
|
for (const id of ids) {
|
|
2066
|
-
|
|
2126
|
+
// Check session cache before hitting storage
|
|
2127
|
+
let result = null;
|
|
2128
|
+
if (project) {
|
|
2129
|
+
for (const vault of vaultManager.allKnownVaults()) {
|
|
2130
|
+
const cached = getSessionCachedNote(project.id, vault.storage.vaultPath, id);
|
|
2131
|
+
if (cached !== undefined) {
|
|
2132
|
+
result = { note: cached, vault };
|
|
2133
|
+
break;
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
if (!result) {
|
|
2138
|
+
result = await vaultManager.findNote(id, cwd);
|
|
2139
|
+
}
|
|
2067
2140
|
if (!result) {
|
|
2068
2141
|
notFound.push(id);
|
|
2069
2142
|
continue;
|
|
2070
2143
|
}
|
|
2071
2144
|
const { note, vault } = result;
|
|
2145
|
+
let relationships;
|
|
2146
|
+
if (includeRelationships) {
|
|
2147
|
+
relationships = await getRelationshipPreview(note, vaultManager.allKnownVaults(), { activeProjectId: project?.id, limit: 3 });
|
|
2148
|
+
}
|
|
2072
2149
|
found.push({
|
|
2073
2150
|
id: note.id,
|
|
2074
2151
|
title: note.title,
|
|
@@ -2081,6 +2158,7 @@ server.registerTool("get", {
|
|
|
2081
2158
|
createdAt: note.createdAt,
|
|
2082
2159
|
updatedAt: note.updatedAt,
|
|
2083
2160
|
vault: storageLabel(vault),
|
|
2161
|
+
relationships,
|
|
2084
2162
|
});
|
|
2085
2163
|
}
|
|
2086
2164
|
const lines = [];
|
|
@@ -2091,6 +2169,10 @@ server.registerTool("get", {
|
|
|
2091
2169
|
lines.push(`tags: ${note.tags.join(", ")}`);
|
|
2092
2170
|
lines.push("");
|
|
2093
2171
|
lines.push(note.content);
|
|
2172
|
+
if (note.relationships) {
|
|
2173
|
+
lines.push("");
|
|
2174
|
+
lines.push(formatRelationshipPreview(note.relationships));
|
|
2175
|
+
}
|
|
2094
2176
|
lines.push("");
|
|
2095
2177
|
}
|
|
2096
2178
|
if (notFound.length > 0) {
|
|
@@ -2102,6 +2184,7 @@ server.registerTool("get", {
|
|
|
2102
2184
|
notes: found,
|
|
2103
2185
|
notFound,
|
|
2104
2186
|
};
|
|
2187
|
+
console.error(`[get:timing] ${(performance.now() - t0Get).toFixed(1)}ms`);
|
|
2105
2188
|
return { content: [{ type: "text", text: lines.join("\n").trim() }], structuredContent };
|
|
2106
2189
|
});
|
|
2107
2190
|
// ── where_is_memory ───────────────────────────────────────────────────────────
|
|
@@ -2247,24 +2330,42 @@ server.registerTool("list", {
|
|
|
2247
2330
|
};
|
|
2248
2331
|
return { content: [{ type: "text", text: textContent }], structuredContent };
|
|
2249
2332
|
});
|
|
2250
|
-
|
|
2333
|
+
function tokenizeTagDiscoveryText(value) {
|
|
2334
|
+
return value
|
|
2335
|
+
.toLowerCase()
|
|
2336
|
+
.split(/[^a-z0-9]+/)
|
|
2337
|
+
.filter(Boolean);
|
|
2338
|
+
}
|
|
2339
|
+
function countTokenOverlap(tokens, other) {
|
|
2340
|
+
let matches = 0;
|
|
2341
|
+
for (const token of other) {
|
|
2342
|
+
if (tokens.has(token)) {
|
|
2343
|
+
matches++;
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
return matches;
|
|
2347
|
+
}
|
|
2348
|
+
function hasExactTagContextMatch(tag, values) {
|
|
2349
|
+
const normalizedTag = tag.toLowerCase();
|
|
2350
|
+
return values.some(value => value?.toLowerCase().includes(normalizedTag) ?? false);
|
|
2351
|
+
}
|
|
2251
2352
|
server.registerTool("discover_tags", {
|
|
2252
2353
|
title: "Discover Tags",
|
|
2253
|
-
description: "
|
|
2354
|
+
description: "Suggest canonical tags for a specific note before `remember` when tag choice is ambiguous.\n\n" +
|
|
2254
2355
|
"Use this when:\n" +
|
|
2255
|
-
"-
|
|
2256
|
-
"-
|
|
2257
|
-
"-
|
|
2356
|
+
"- You have a note title, content, or query and want compact tag suggestions\n" +
|
|
2357
|
+
"- You want canonical project terminology without exposing lots of unrelated tags\n" +
|
|
2358
|
+
"- You want to demote temporary-only tags unless they fit the note\n\n" +
|
|
2258
2359
|
"Do not use this when:\n" +
|
|
2259
2360
|
"- You need to browse notes by tag; use `list` with `tags` filter instead\n" +
|
|
2260
|
-
"- You already know the exact tags you want to use\n
|
|
2361
|
+
"- You already know the exact tags you want to use\n" +
|
|
2362
|
+
"- You want broad inventory output but are not explicitly requesting `mode: \"browse\"`\n\n" +
|
|
2261
2363
|
"Returns:\n" +
|
|
2262
|
-
"-
|
|
2263
|
-
"-
|
|
2264
|
-
"-
|
|
2265
|
-
"- isTemporaryOnly flag identifying cleanup candidates\n\n" +
|
|
2364
|
+
"- Default: bounded `recommendedTags` ranked by note relevance first and usage count second\n" +
|
|
2365
|
+
"- Each suggestion includes canonicality and lifecycle signals plus one compact example\n" +
|
|
2366
|
+
"- Optional `mode: \"browse\"` returns broader inventory output\n\n" +
|
|
2266
2367
|
"Typical next step:\n" +
|
|
2267
|
-
"-
|
|
2368
|
+
"- Reuse suggested canonical tags when they fit, or create a new tag only when genuinely novel.\n\n" +
|
|
2268
2369
|
"Performance: O(n) where n = total notes scanned. Expect 100-200ms for 500 notes.\n\n" +
|
|
2269
2370
|
"Read-only.",
|
|
2270
2371
|
annotations: {
|
|
@@ -2274,6 +2375,12 @@ server.registerTool("discover_tags", {
|
|
|
2274
2375
|
},
|
|
2275
2376
|
inputSchema: z.object({
|
|
2276
2377
|
cwd: projectParam,
|
|
2378
|
+
mode: z.enum(["suggest", "browse"]).optional().default("suggest"),
|
|
2379
|
+
title: z.string().optional(),
|
|
2380
|
+
content: z.string().optional(),
|
|
2381
|
+
query: z.string().optional(),
|
|
2382
|
+
candidateTags: z.array(z.string()).optional(),
|
|
2383
|
+
lifecycle: z.enum(NOTE_LIFECYCLES).optional(),
|
|
2277
2384
|
scope: z
|
|
2278
2385
|
.enum(["project", "global", "all"])
|
|
2279
2386
|
.optional()
|
|
@@ -2286,65 +2393,170 @@ server.registerTool("discover_tags", {
|
|
|
2286
2393
|
.optional()
|
|
2287
2394
|
.default("any")
|
|
2288
2395
|
.describe("Filter by vault storage label like list tool."),
|
|
2396
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
2289
2397
|
}),
|
|
2290
2398
|
outputSchema: DiscoverTagsResultSchema,
|
|
2291
|
-
}, async ({ cwd, scope, storedIn }) => {
|
|
2399
|
+
}, async ({ cwd, mode, title, content, query, candidateTags, lifecycle, scope, storedIn, limit }) => {
|
|
2292
2400
|
await ensureBranchSynced(cwd);
|
|
2293
2401
|
const startTime = Date.now();
|
|
2294
2402
|
const { project, entries } = await collectVisibleNotes(cwd, scope, undefined, storedIn);
|
|
2295
2403
|
const tagStats = new Map();
|
|
2404
|
+
const candidateTagSet = new Set((candidateTags || []).map(tag => tag.toLowerCase()));
|
|
2405
|
+
const contextValues = [title, content, query, ...(candidateTags || [])];
|
|
2406
|
+
const contextTokens = new Set(tokenizeTagDiscoveryText(contextValues.filter(Boolean).join(" ")));
|
|
2407
|
+
const isTemporaryTarget = lifecycle === "temporary";
|
|
2408
|
+
const effectiveLimit = limit ?? (mode === "browse" ? 20 : 10);
|
|
2296
2409
|
for (const { note } of entries) {
|
|
2410
|
+
const noteTokens = new Set(tokenizeTagDiscoveryText(`${note.title} ${note.content} ${note.tags.join(" ")}`));
|
|
2411
|
+
const contextMatches = contextTokens.size > 0 ? countTokenOverlap(contextTokens, noteTokens) : 0;
|
|
2297
2412
|
for (const tag of note.tags) {
|
|
2298
|
-
const stats = tagStats.get(tag) || {
|
|
2413
|
+
const stats = tagStats.get(tag) || {
|
|
2414
|
+
count: 0,
|
|
2415
|
+
examples: [],
|
|
2416
|
+
lifecycles: new Set(),
|
|
2417
|
+
contextMatches: 0,
|
|
2418
|
+
exactCandidateMatch: false,
|
|
2419
|
+
};
|
|
2299
2420
|
stats.count++;
|
|
2300
2421
|
if (stats.examples.length < 3) {
|
|
2301
2422
|
stats.examples.push(note.title);
|
|
2302
2423
|
}
|
|
2303
2424
|
stats.lifecycles.add(note.lifecycle);
|
|
2425
|
+
stats.contextMatches += contextMatches;
|
|
2426
|
+
if (candidateTagSet.has(tag.toLowerCase())) {
|
|
2427
|
+
stats.exactCandidateMatch = true;
|
|
2428
|
+
}
|
|
2304
2429
|
tagStats.set(tag, stats);
|
|
2305
2430
|
}
|
|
2306
2431
|
}
|
|
2307
|
-
const
|
|
2308
|
-
.map(([tag, stats]) =>
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2432
|
+
const rawTags = Array.from(tagStats.entries())
|
|
2433
|
+
.map(([tag, stats]) => {
|
|
2434
|
+
const lifecycleTypes = Array.from(stats.lifecycles);
|
|
2435
|
+
const isTemporaryOnly = stats.lifecycles.size === 1 && stats.lifecycles.has("temporary");
|
|
2436
|
+
const tagTokens = tokenizeTagDiscoveryText(tag);
|
|
2437
|
+
const exactContextMatch = stats.exactCandidateMatch || hasExactTagContextMatch(tag, contextValues);
|
|
2438
|
+
const tagTokenOverlap = contextTokens.size > 0
|
|
2439
|
+
? countTokenOverlap(contextTokens, tagTokens)
|
|
2440
|
+
: 0;
|
|
2441
|
+
const averageContextMatch = stats.count > 0 ? stats.contextMatches / stats.count : 0;
|
|
2442
|
+
const reason = exactContextMatch
|
|
2443
|
+
? "matches a candidate tag already present in the note context"
|
|
2444
|
+
: tagTokenOverlap > 0 || stats.contextMatches > 0
|
|
2445
|
+
? "matches the note context and existing project usage"
|
|
2446
|
+
: "high-usage canonical tag from existing project notes";
|
|
2447
|
+
return {
|
|
2448
|
+
tag,
|
|
2449
|
+
usageCount: stats.count,
|
|
2450
|
+
examples: stats.examples,
|
|
2451
|
+
example: stats.examples[0],
|
|
2452
|
+
reason,
|
|
2453
|
+
lifecycleTypes,
|
|
2454
|
+
isTemporaryOnly,
|
|
2455
|
+
exactContextMatch,
|
|
2456
|
+
tagTokenOverlap,
|
|
2457
|
+
averageContextMatch,
|
|
2458
|
+
isBroadSingleToken: tagTokens.length === 1,
|
|
2459
|
+
isHighFrequency: stats.count >= 4,
|
|
2460
|
+
specificityBoost: tagTokens.length > 1 ? 4 : 0,
|
|
2461
|
+
};
|
|
2462
|
+
});
|
|
2463
|
+
const hasStrongSpecificCandidate = rawTags.some(tag => tag.exactContextMatch && (!tag.isBroadSingleToken || tag.usageCount > 1));
|
|
2464
|
+
const tags = rawTags
|
|
2465
|
+
.map((tag) => {
|
|
2466
|
+
const hasWeakDirectMatch = tag.tagTokenOverlap <= 1 && tag.averageContextMatch <= 2;
|
|
2467
|
+
const genericPenalty = hasStrongSpecificCandidate && tag.isBroadSingleToken && tag.isHighFrequency && hasWeakDirectMatch
|
|
2468
|
+
? 10
|
|
2469
|
+
: 0;
|
|
2470
|
+
const score = hasStrongSpecificCandidate
|
|
2471
|
+
? (tag.exactContextMatch ? 12 : 0) +
|
|
2472
|
+
(tag.tagTokenOverlap * 5) +
|
|
2473
|
+
tag.specificityBoost +
|
|
2474
|
+
(tag.averageContextMatch * 3) +
|
|
2475
|
+
(tag.usageCount * 0.1) -
|
|
2476
|
+
genericPenalty -
|
|
2477
|
+
(!isTemporaryTarget && tag.isTemporaryOnly ? 2 : 0)
|
|
2478
|
+
: (tag.usageCount * 1.5) +
|
|
2479
|
+
(tag.isTemporaryOnly ? -3 : 1) -
|
|
2480
|
+
(!isTemporaryTarget && tag.isTemporaryOnly ? 2 : 0);
|
|
2481
|
+
return {
|
|
2482
|
+
...tag,
|
|
2483
|
+
score,
|
|
2484
|
+
};
|
|
2485
|
+
})
|
|
2486
|
+
.sort((a, b) => b.score - a.score || b.usageCount - a.usageCount || a.tag.localeCompare(b.tag));
|
|
2316
2487
|
const durationMs = Date.now() - startTime;
|
|
2488
|
+
const vaultsSearched = new Set(entries.map(e => storageLabel(e.vault))).size;
|
|
2317
2489
|
const lines = [];
|
|
2318
|
-
if (
|
|
2319
|
-
|
|
2490
|
+
if (mode === "browse") {
|
|
2491
|
+
if (project && scope !== "global") {
|
|
2492
|
+
lines.push(`Tags for ${project.name} (scope: ${scope}):`);
|
|
2493
|
+
}
|
|
2494
|
+
else {
|
|
2495
|
+
lines.push(`Tags (scope: ${scope}):`);
|
|
2496
|
+
}
|
|
2497
|
+
lines.push("");
|
|
2498
|
+
lines.push(`Total: ${tags.length} unique tags across ${entries.length} notes (${durationMs}ms)`);
|
|
2499
|
+
lines.push("");
|
|
2500
|
+
lines.push("Tags sorted by usage:");
|
|
2501
|
+
for (const t of tags.slice(0, effectiveLimit)) {
|
|
2502
|
+
const lifecycleMark = t.isTemporaryOnly ? " [temp-only]" : "";
|
|
2503
|
+
lines.push(` ${t.tag} (${t.usageCount})${lifecycleMark}`);
|
|
2504
|
+
if (t.examples.length > 0) {
|
|
2505
|
+
lines.push(` Example: "${t.examples[0]}"`);
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
if (tags.length > effectiveLimit) {
|
|
2509
|
+
lines.push(` ... and ${tags.length - effectiveLimit} more`);
|
|
2510
|
+
}
|
|
2320
2511
|
}
|
|
2321
2512
|
else {
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
lines.push(`
|
|
2331
|
-
|
|
2332
|
-
|
|
2513
|
+
const suggestedTags = tags.slice(0, effectiveLimit);
|
|
2514
|
+
if (project && scope !== "global") {
|
|
2515
|
+
lines.push(`Suggested tags for ${project.name} (scope: ${scope}):`);
|
|
2516
|
+
}
|
|
2517
|
+
else {
|
|
2518
|
+
lines.push(`Suggested tags (scope: ${scope}):`);
|
|
2519
|
+
}
|
|
2520
|
+
lines.push("");
|
|
2521
|
+
lines.push(`Considered ${tags.length} unique tags across ${entries.length} notes (${durationMs}ms)`);
|
|
2522
|
+
lines.push("");
|
|
2523
|
+
if (contextTokens.size === 0) {
|
|
2524
|
+
lines.push("No note context provided; showing the most canonical existing tags.");
|
|
2525
|
+
lines.push("");
|
|
2526
|
+
}
|
|
2527
|
+
lines.push("Recommended tags:");
|
|
2528
|
+
for (const t of suggestedTags) {
|
|
2529
|
+
const lifecycleMark = t.isTemporaryOnly ? " [temp-only]" : "";
|
|
2530
|
+
lines.push(` ${t.tag} (${t.usageCount})${lifecycleMark}`);
|
|
2531
|
+
if (t.example) {
|
|
2532
|
+
lines.push(` Example: "${t.example}"`);
|
|
2533
|
+
}
|
|
2534
|
+
lines.push(` Why: ${t.reason}`);
|
|
2333
2535
|
}
|
|
2334
2536
|
}
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2537
|
+
const structuredContent = mode === "browse"
|
|
2538
|
+
? {
|
|
2539
|
+
action: "tags_discovered",
|
|
2540
|
+
project: project ? { id: project.id, name: project.name } : undefined,
|
|
2541
|
+
mode,
|
|
2542
|
+
scope: scope || "all",
|
|
2543
|
+
tags: tags.slice(0, effectiveLimit).map(({ score, example, reason, ...tag }) => tag),
|
|
2544
|
+
totalTags: tags.length,
|
|
2545
|
+
totalNotes: entries.length,
|
|
2546
|
+
vaultsSearched,
|
|
2547
|
+
durationMs,
|
|
2548
|
+
}
|
|
2549
|
+
: {
|
|
2550
|
+
action: "tags_discovered",
|
|
2551
|
+
project: project ? { id: project.id, name: project.name } : undefined,
|
|
2552
|
+
mode,
|
|
2553
|
+
scope: scope || "all",
|
|
2554
|
+
recommendedTags: tags.slice(0, effectiveLimit).map(({ score, examples, ...tag }) => tag),
|
|
2555
|
+
totalTags: tags.length,
|
|
2556
|
+
totalNotes: entries.length,
|
|
2557
|
+
vaultsSearched,
|
|
2558
|
+
durationMs,
|
|
2559
|
+
};
|
|
2348
2560
|
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
|
|
2349
2561
|
});
|
|
2350
2562
|
// ── recent_memories ───────────────────────────────────────────────────────────
|
|
@@ -2505,7 +2717,8 @@ server.registerTool("project_memory_summary", {
|
|
|
2505
2717
|
"- You need exact note contents; use `get`\n" +
|
|
2506
2718
|
"- You need direct semantic matches for a query; use `recall`\n\n" +
|
|
2507
2719
|
"Returns:\n" +
|
|
2508
|
-
"- A synthesized project-level summary based on stored memories\n
|
|
2720
|
+
"- A synthesized project-level summary based on stored memories\n" +
|
|
2721
|
+
"- Bounded 1-hop relationship previews on orientation entry points (primaryEntry and suggestedNext)\n\n" +
|
|
2509
2722
|
"Read-only.\n\n" +
|
|
2510
2723
|
"Typical next step:\n" +
|
|
2511
2724
|
"- Use `recall` or `list` to drill down into specific areas.",
|
|
@@ -2524,8 +2737,11 @@ server.registerTool("project_memory_summary", {
|
|
|
2524
2737
|
}),
|
|
2525
2738
|
outputSchema: ProjectSummaryResultSchema,
|
|
2526
2739
|
}, async ({ cwd, maxPerTheme, recentLimit, anchorLimit, includeRelatedGlobal, relatedGlobalLimit }) => {
|
|
2740
|
+
const t0Summary = performance.now();
|
|
2527
2741
|
await ensureBranchSynced(cwd);
|
|
2528
|
-
|
|
2742
|
+
// Pre-resolve project so we can pass its id to collectVisibleNotes for session caching
|
|
2743
|
+
const preProject = await resolveProject(cwd);
|
|
2744
|
+
const { project, entries } = await collectVisibleNotes(cwd, "all", undefined, "any", preProject?.id);
|
|
2529
2745
|
if (!project) {
|
|
2530
2746
|
return { content: [{ type: "text", text: `Could not detect a project for: ${cwd}` }], isError: true };
|
|
2531
2747
|
}
|
|
@@ -2729,10 +2945,24 @@ server.registerTool("project_memory_summary", {
|
|
|
2729
2945
|
const confidence = computeConfidence("permanent", anchor.updatedAt, anchor.centrality);
|
|
2730
2946
|
return { provenance, confidence };
|
|
2731
2947
|
};
|
|
2948
|
+
// Helper to enrich an anchor with relationships (1-hop expansion)
|
|
2949
|
+
const enrichOrientationNoteWithRelationships = async (anchor) => {
|
|
2950
|
+
const vault = noteVaultMap.get(anchor.id);
|
|
2951
|
+
if (!vault)
|
|
2952
|
+
return {};
|
|
2953
|
+
const note = await vault.storage.readNote(anchor.id);
|
|
2954
|
+
if (!note)
|
|
2955
|
+
return {};
|
|
2956
|
+
const relationships = await getRelationshipPreview(note, vaultManager.allKnownVaults(), { activeProjectId: project.id, limit: 3 });
|
|
2957
|
+
return { relationships };
|
|
2958
|
+
};
|
|
2732
2959
|
const primaryEnriched = primaryAnchor ? await enrichOrientationNote(primaryAnchor) : {};
|
|
2960
|
+
const primaryRelationships = primaryAnchor ? await enrichOrientationNoteWithRelationships(primaryAnchor) : {};
|
|
2733
2961
|
const suggestedEnriched = await Promise.all(anchors.slice(1, 4).map(enrichOrientationNote));
|
|
2962
|
+
const suggestedRelationships = await Promise.all(anchors.slice(1, 4).map(enrichOrientationNoteWithRelationships));
|
|
2734
2963
|
// Enrich fallback primaryEntry when no anchors exist
|
|
2735
2964
|
let fallbackEnriched = {};
|
|
2965
|
+
let fallbackRelationships = {};
|
|
2736
2966
|
if (!primaryAnchor && recent[0]) {
|
|
2737
2967
|
const fallbackNote = recent[0].note;
|
|
2738
2968
|
const vault = noteVaultMap.get(fallbackNote.id);
|
|
@@ -2742,6 +2972,9 @@ server.registerTool("project_memory_summary", {
|
|
|
2742
2972
|
const confidence = computeConfidence(fallbackNote.lifecycle, fallbackNote.updatedAt, 0);
|
|
2743
2973
|
fallbackEnriched = { provenance, confidence };
|
|
2744
2974
|
}
|
|
2975
|
+
const preview = await getRelationshipPreview(fallbackNote, vaultManager.allKnownVaults(), { activeProjectId: project.id, limit: 3 });
|
|
2976
|
+
if (preview)
|
|
2977
|
+
fallbackRelationships = { relationships: preview };
|
|
2745
2978
|
}
|
|
2746
2979
|
const orientation = {
|
|
2747
2980
|
primaryEntry: primaryAnchor
|
|
@@ -2750,6 +2983,7 @@ server.registerTool("project_memory_summary", {
|
|
|
2750
2983
|
title: primaryAnchor.title,
|
|
2751
2984
|
rationale: `Centrality ${primaryAnchor.centrality}, connects ${primaryAnchor.connectionDiversity} themes`,
|
|
2752
2985
|
...primaryEnriched,
|
|
2986
|
+
...primaryRelationships,
|
|
2753
2987
|
}
|
|
2754
2988
|
: {
|
|
2755
2989
|
id: recent[0]?.note.id ?? projectEntries[0]?.note.id ?? "",
|
|
@@ -2758,12 +2992,14 @@ server.registerTool("project_memory_summary", {
|
|
|
2758
2992
|
? "Most recent note — no high-centrality anchors found"
|
|
2759
2993
|
: "Only note available",
|
|
2760
2994
|
...fallbackEnriched,
|
|
2995
|
+
...fallbackRelationships,
|
|
2761
2996
|
},
|
|
2762
2997
|
suggestedNext: anchors.slice(1, 4).map((a, i) => ({
|
|
2763
2998
|
id: a.id,
|
|
2764
2999
|
title: a.title,
|
|
2765
3000
|
rationale: `Centrality ${a.centrality}, connects ${a.connectionDiversity} themes`,
|
|
2766
3001
|
...suggestedEnriched[i],
|
|
3002
|
+
...suggestedRelationships[i],
|
|
2767
3003
|
})),
|
|
2768
3004
|
};
|
|
2769
3005
|
// Warning for taxonomy dilution
|
|
@@ -2782,10 +3018,16 @@ server.registerTool("project_memory_summary", {
|
|
|
2782
3018
|
if (orientation.primaryEntry.confidence) {
|
|
2783
3019
|
sections.push(` Confidence: ${orientation.primaryEntry.confidence}`);
|
|
2784
3020
|
}
|
|
3021
|
+
if (orientation.primaryEntry.relationships) {
|
|
3022
|
+
sections.push(` ${formatRelationshipPreview(orientation.primaryEntry.relationships)}`);
|
|
3023
|
+
}
|
|
2785
3024
|
if (orientation.suggestedNext.length > 0) {
|
|
2786
3025
|
sections.push(`Then check:`);
|
|
2787
3026
|
for (const next of orientation.suggestedNext) {
|
|
2788
3027
|
sections.push(` - ${next.title} (\`${next.id}\`) — ${next.rationale}${next.confidence ? ` [${next.confidence}]` : ""}`);
|
|
3028
|
+
if (next.relationships) {
|
|
3029
|
+
sections.push(` ${formatRelationshipPreview(next.relationships)}`);
|
|
3030
|
+
}
|
|
2789
3031
|
}
|
|
2790
3032
|
}
|
|
2791
3033
|
if (orientation.warnings && orientation.warnings.length > 0) {
|
|
@@ -2815,6 +3057,7 @@ server.registerTool("project_memory_summary", {
|
|
|
2815
3057
|
orientation,
|
|
2816
3058
|
relatedGlobal,
|
|
2817
3059
|
};
|
|
3060
|
+
console.error(`[summary:timing] ${(performance.now() - t0Summary).toFixed(1)}ms`);
|
|
2818
3061
|
return { content: [{ type: "text", text: sections.join("\n") }], structuredContent };
|
|
2819
3062
|
});
|
|
2820
3063
|
// ── sync ──────────────────────────────────────────────────────────────────────
|
|
@@ -2901,6 +3144,8 @@ server.registerTool("sync", {
|
|
|
2901
3144
|
action: "synced",
|
|
2902
3145
|
vaults: vaultResults,
|
|
2903
3146
|
};
|
|
3147
|
+
// Vault contents may have changed via pull — discard session cache
|
|
3148
|
+
invalidateActiveProjectCache();
|
|
2904
3149
|
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
|
|
2905
3150
|
});
|
|
2906
3151
|
// ── move_memory ───────────────────────────────────────────────────────────────
|
|
@@ -3052,6 +3297,7 @@ server.registerTool("move_memory", {
|
|
|
3052
3297
|
const associationText = metadataRewritten
|
|
3053
3298
|
? `Project association is now ${associationValue}.`
|
|
3054
3299
|
: `Project association remains ${associationValue}.`;
|
|
3300
|
+
invalidateActiveProjectCache();
|
|
3055
3301
|
return {
|
|
3056
3302
|
content: [{
|
|
3057
3303
|
type: "text",
|
|
@@ -3224,6 +3470,7 @@ server.registerTool("relate", {
|
|
|
3224
3470
|
retry,
|
|
3225
3471
|
};
|
|
3226
3472
|
const retrySummary = formatRetrySummary(retry);
|
|
3473
|
+
invalidateActiveProjectCache();
|
|
3227
3474
|
return {
|
|
3228
3475
|
content: [{
|
|
3229
3476
|
type: "text",
|
|
@@ -3390,6 +3637,7 @@ server.registerTool("unrelate", {
|
|
|
3390
3637
|
retry,
|
|
3391
3638
|
};
|
|
3392
3639
|
const retrySummary = formatRetrySummary(retry);
|
|
3640
|
+
invalidateActiveProjectCache();
|
|
3393
3641
|
return {
|
|
3394
3642
|
content: [{
|
|
3395
3643
|
type: "text",
|
|
@@ -3488,13 +3736,19 @@ server.registerTool("consolidate", {
|
|
|
3488
3736
|
return findClusters(projectNotes, project);
|
|
3489
3737
|
case "suggest-merges":
|
|
3490
3738
|
return suggestMerges(projectNotes, threshold, defaultConsolidationMode, project, mode);
|
|
3491
|
-
case "execute-merge":
|
|
3739
|
+
case "execute-merge": {
|
|
3492
3740
|
if (!mergePlan) {
|
|
3493
3741
|
return { content: [{ type: "text", text: "execute-merge strategy requires a mergePlan with sourceIds and targetTitle." }], isError: true };
|
|
3494
3742
|
}
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
return
|
|
3743
|
+
const mergeResult = await executeMerge(entries, mergePlan, defaultConsolidationMode, project, cwd, mode, policy, allowProtectedBranch);
|
|
3744
|
+
invalidateActiveProjectCache();
|
|
3745
|
+
return mergeResult;
|
|
3746
|
+
}
|
|
3747
|
+
case "prune-superseded": {
|
|
3748
|
+
const pruneResult = await pruneSuperseded(projectNotes, mode ?? defaultConsolidationMode, project, cwd, policy, allowProtectedBranch);
|
|
3749
|
+
invalidateActiveProjectCache();
|
|
3750
|
+
return pruneResult;
|
|
3751
|
+
}
|
|
3498
3752
|
case "dry-run":
|
|
3499
3753
|
return dryRunAll(projectNotes, threshold, defaultConsolidationMode, project, mode);
|
|
3500
3754
|
default:
|
|
@@ -4268,7 +4522,7 @@ server.registerPrompt("mnemonic-workflow-hint", {
|
|
|
4268
4522
|
"1. Need to store, refine, or connect knowledge about a topic? Start with `recall`, or use `list` when you need deterministic browsing by scope, storage, or tags.\n" +
|
|
4269
4523
|
"2. If `recall` or `list` returns matching ids, use `get` to inspect the best match.\n" +
|
|
4270
4524
|
"3. If one memory should be refined, call `update`.\n" +
|
|
4271
|
-
"4. If no memory covers the topic, call `discover_tags`
|
|
4525
|
+
"4. If no memory covers the topic and tag choice is ambiguous, call `discover_tags` with note context, then call `remember`.\n" +
|
|
4272
4526
|
"5. After storing or updating, use `relate` for strong connections, `consolidate` for overlap, and `move_memory` for wrong storage location.\n\n" +
|
|
4273
4527
|
"### Anti-patterns\n\n" +
|
|
4274
4528
|
"- Bad: call `remember` immediately because the user said 'remember'.\n" +
|
|
@@ -4286,7 +4540,7 @@ server.registerPrompt("mnemonic-workflow-hint", {
|
|
|
4286
4540
|
"- project memory policy lookup\n\n" +
|
|
4287
4541
|
"### Tiny examples\n\n" +
|
|
4288
4542
|
"- Existing bug note found by `recall` -> inspect with `get` -> refine with `update`.\n" +
|
|
4289
|
-
"- No matching note found by `recall` -> optional `discover_tags` -> create with `remember`.\n" +
|
|
4543
|
+
"- No matching note found by `recall` -> optional `discover_tags` with note context -> create with `remember`.\n" +
|
|
4290
4544
|
"- Two notes overlap heavily -> inspect -> clean up with `consolidate`.",
|
|
4291
4545
|
},
|
|
4292
4546
|
},
|