@velvetmonkey/flywheel-memory 2.0.33 → 2.0.35
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/dist/index.js +855 -61
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -832,7 +832,8 @@ function createContext(variables = {}) {
|
|
|
832
832
|
today: formatDate2(now),
|
|
833
833
|
time: formatTime(now),
|
|
834
834
|
date: formatDate2(now)
|
|
835
|
-
}
|
|
835
|
+
},
|
|
836
|
+
steps: {}
|
|
836
837
|
};
|
|
837
838
|
}
|
|
838
839
|
function resolvePath(obj, path30) {
|
|
@@ -878,6 +879,9 @@ function resolveExpression(expr, context) {
|
|
|
878
879
|
if (trimmed.startsWith("builtins.")) {
|
|
879
880
|
return resolvePath(context.builtins, trimmed.slice("builtins.".length));
|
|
880
881
|
}
|
|
882
|
+
if (trimmed.startsWith("steps.")) {
|
|
883
|
+
return resolvePath(context.steps, trimmed.slice("steps.".length));
|
|
884
|
+
}
|
|
881
885
|
return resolvePath(context.variables, trimmed);
|
|
882
886
|
}
|
|
883
887
|
function interpolate(template, context) {
|
|
@@ -1056,7 +1060,7 @@ function validatePolicySchema(policy) {
|
|
|
1056
1060
|
const match = ref.match(/\{\{(?:variables\.)?(\w+)/);
|
|
1057
1061
|
if (match) {
|
|
1058
1062
|
const varName = match[1];
|
|
1059
|
-
if (["now", "today", "time", "date", "conditions"].includes(varName)) {
|
|
1063
|
+
if (["now", "today", "time", "date", "conditions", "steps"].includes(varName)) {
|
|
1060
1064
|
continue;
|
|
1061
1065
|
}
|
|
1062
1066
|
if (!varNames.has(varName)) {
|
|
@@ -1549,6 +1553,9 @@ var init_taskHelpers = __esm({
|
|
|
1549
1553
|
|
|
1550
1554
|
// src/index.ts
|
|
1551
1555
|
import * as path29 from "path";
|
|
1556
|
+
import { readFileSync as readFileSync4, realpathSync } from "fs";
|
|
1557
|
+
import { fileURLToPath } from "url";
|
|
1558
|
+
import { dirname as dirname4, join as join16 } from "path";
|
|
1552
1559
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1553
1560
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1554
1561
|
|
|
@@ -2260,7 +2267,10 @@ async function buildVaultIndexInternal(vaultPath2, startTime, onProgress) {
|
|
|
2260
2267
|
}
|
|
2261
2268
|
}
|
|
2262
2269
|
if (parseErrors.length > 0) {
|
|
2263
|
-
console.error(`Failed to parse ${parseErrors.length} files
|
|
2270
|
+
console.error(`Failed to parse ${parseErrors.length} files:`);
|
|
2271
|
+
for (const errorPath of parseErrors) {
|
|
2272
|
+
console.error(` - ${errorPath}`);
|
|
2273
|
+
}
|
|
2264
2274
|
}
|
|
2265
2275
|
const entities = /* @__PURE__ */ new Map();
|
|
2266
2276
|
for (const note of notes.values()) {
|
|
@@ -2487,7 +2497,10 @@ var DEFAULT_CONFIG = {
|
|
|
2487
2497
|
exclude_task_tags: [],
|
|
2488
2498
|
exclude_analysis_tags: [],
|
|
2489
2499
|
exclude_entities: [],
|
|
2490
|
-
exclude_entity_folders: []
|
|
2500
|
+
exclude_entity_folders: [],
|
|
2501
|
+
wikilink_strictness: "balanced",
|
|
2502
|
+
implicit_detection: true,
|
|
2503
|
+
adaptive_strictness: true
|
|
2491
2504
|
};
|
|
2492
2505
|
function loadConfig(stateDb2) {
|
|
2493
2506
|
if (stateDb2) {
|
|
@@ -3238,9 +3251,14 @@ var FEEDBACK_BOOST_TIERS = [
|
|
|
3238
3251
|
{ minAccuracy: 0, minSamples: 5, boost: -4 }
|
|
3239
3252
|
];
|
|
3240
3253
|
function recordFeedback(stateDb2, entity, context, notePath, correct) {
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3254
|
+
try {
|
|
3255
|
+
stateDb2.db.prepare(
|
|
3256
|
+
"INSERT INTO wikilink_feedback (entity, context, note_path, correct) VALUES (?, ?, ?, ?)"
|
|
3257
|
+
).run(entity, context, notePath, correct ? 1 : 0);
|
|
3258
|
+
} catch (e) {
|
|
3259
|
+
console.error(`[Flywheel] recordFeedback failed for entity="${entity}": ${e}`);
|
|
3260
|
+
throw e;
|
|
3261
|
+
}
|
|
3244
3262
|
}
|
|
3245
3263
|
function getFeedback(stateDb2, entity, limit = 20) {
|
|
3246
3264
|
let rows;
|
|
@@ -3551,6 +3569,159 @@ function getDashboardData(stateDb2) {
|
|
|
3551
3569
|
}))
|
|
3552
3570
|
};
|
|
3553
3571
|
}
|
|
3572
|
+
function getEntityScoreTimeline(stateDb2, entityName, daysBack = 30, limit = 100) {
|
|
3573
|
+
const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
|
|
3574
|
+
const rows = stateDb2.db.prepare(`
|
|
3575
|
+
SELECT timestamp, total_score, breakdown_json, note_path, passed, threshold
|
|
3576
|
+
FROM suggestion_events
|
|
3577
|
+
WHERE entity = ? AND timestamp >= ?
|
|
3578
|
+
ORDER BY timestamp ASC
|
|
3579
|
+
LIMIT ?
|
|
3580
|
+
`).all(entityName, cutoff, limit);
|
|
3581
|
+
return rows.map((r) => ({
|
|
3582
|
+
timestamp: r.timestamp,
|
|
3583
|
+
score: r.total_score,
|
|
3584
|
+
breakdown: JSON.parse(r.breakdown_json),
|
|
3585
|
+
notePath: r.note_path,
|
|
3586
|
+
passed: r.passed === 1,
|
|
3587
|
+
threshold: r.threshold
|
|
3588
|
+
}));
|
|
3589
|
+
}
|
|
3590
|
+
function getLayerContributionTimeseries(stateDb2, granularity = "day", daysBack = 30) {
|
|
3591
|
+
const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
|
|
3592
|
+
const rows = stateDb2.db.prepare(`
|
|
3593
|
+
SELECT timestamp, breakdown_json
|
|
3594
|
+
FROM suggestion_events
|
|
3595
|
+
WHERE timestamp >= ?
|
|
3596
|
+
ORDER BY timestamp ASC
|
|
3597
|
+
`).all(cutoff);
|
|
3598
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
3599
|
+
for (const row of rows) {
|
|
3600
|
+
const date = new Date(row.timestamp);
|
|
3601
|
+
let bucket;
|
|
3602
|
+
if (granularity === "week") {
|
|
3603
|
+
const jan4 = new Date(date.getFullYear(), 0, 4);
|
|
3604
|
+
const weekNum = Math.ceil(((date.getTime() - jan4.getTime()) / 864e5 + jan4.getDay() + 1) / 7);
|
|
3605
|
+
bucket = `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
|
|
3606
|
+
} else {
|
|
3607
|
+
bucket = date.toISOString().slice(0, 10);
|
|
3608
|
+
}
|
|
3609
|
+
if (!buckets.has(bucket)) {
|
|
3610
|
+
buckets.set(bucket, { count: 0, layers: {} });
|
|
3611
|
+
}
|
|
3612
|
+
const acc = buckets.get(bucket);
|
|
3613
|
+
acc.count++;
|
|
3614
|
+
const breakdown = JSON.parse(row.breakdown_json);
|
|
3615
|
+
const layerMap = {
|
|
3616
|
+
contentMatch: breakdown.contentMatch,
|
|
3617
|
+
cooccurrenceBoost: breakdown.cooccurrenceBoost,
|
|
3618
|
+
typeBoost: breakdown.typeBoost,
|
|
3619
|
+
contextBoost: breakdown.contextBoost,
|
|
3620
|
+
recencyBoost: breakdown.recencyBoost,
|
|
3621
|
+
crossFolderBoost: breakdown.crossFolderBoost,
|
|
3622
|
+
hubBoost: breakdown.hubBoost,
|
|
3623
|
+
feedbackAdjustment: breakdown.feedbackAdjustment
|
|
3624
|
+
};
|
|
3625
|
+
if (breakdown.semanticBoost !== void 0) {
|
|
3626
|
+
layerMap.semanticBoost = breakdown.semanticBoost;
|
|
3627
|
+
}
|
|
3628
|
+
for (const [layer, value] of Object.entries(layerMap)) {
|
|
3629
|
+
acc.layers[layer] = (acc.layers[layer] ?? 0) + value;
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
const result = [];
|
|
3633
|
+
for (const [bucket, acc] of buckets) {
|
|
3634
|
+
const avgLayers = {};
|
|
3635
|
+
for (const [layer, sum] of Object.entries(acc.layers)) {
|
|
3636
|
+
avgLayers[layer] = Math.round(sum / acc.count * 1e3) / 1e3;
|
|
3637
|
+
}
|
|
3638
|
+
result.push({ bucket, layers: avgLayers });
|
|
3639
|
+
}
|
|
3640
|
+
return result;
|
|
3641
|
+
}
|
|
3642
|
+
function getExtendedDashboardData(stateDb2) {
|
|
3643
|
+
const base = getDashboardData(stateDb2);
|
|
3644
|
+
const recentCutoff = Date.now() - 7 * 24 * 60 * 60 * 1e3;
|
|
3645
|
+
const eventRows = stateDb2.db.prepare(`
|
|
3646
|
+
SELECT breakdown_json FROM suggestion_events WHERE timestamp >= ?
|
|
3647
|
+
`).all(recentCutoff);
|
|
3648
|
+
const layerSums = {};
|
|
3649
|
+
const LAYER_NAMES = [
|
|
3650
|
+
"contentMatch",
|
|
3651
|
+
"cooccurrenceBoost",
|
|
3652
|
+
"typeBoost",
|
|
3653
|
+
"contextBoost",
|
|
3654
|
+
"recencyBoost",
|
|
3655
|
+
"crossFolderBoost",
|
|
3656
|
+
"hubBoost",
|
|
3657
|
+
"feedbackAdjustment",
|
|
3658
|
+
"semanticBoost"
|
|
3659
|
+
];
|
|
3660
|
+
for (const name of LAYER_NAMES) {
|
|
3661
|
+
layerSums[name] = { sum: 0, count: 0 };
|
|
3662
|
+
}
|
|
3663
|
+
for (const row of eventRows) {
|
|
3664
|
+
const breakdown = JSON.parse(row.breakdown_json);
|
|
3665
|
+
for (const name of LAYER_NAMES) {
|
|
3666
|
+
const val = breakdown[name];
|
|
3667
|
+
if (val !== void 0) {
|
|
3668
|
+
layerSums[name].sum += Math.abs(val);
|
|
3669
|
+
layerSums[name].count++;
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
const layerHealth = LAYER_NAMES.map((layer) => {
|
|
3674
|
+
const s = layerSums[layer];
|
|
3675
|
+
const avg = s.count > 0 ? Math.round(s.sum / s.count * 1e3) / 1e3 : 0;
|
|
3676
|
+
let status;
|
|
3677
|
+
if (s.count === 0) status = "zero-data";
|
|
3678
|
+
else if (avg > 0) status = "contributing";
|
|
3679
|
+
else status = "dormant";
|
|
3680
|
+
return { layer, status, avgContribution: avg, eventCount: s.count };
|
|
3681
|
+
});
|
|
3682
|
+
const topEntityRows = stateDb2.db.prepare(`
|
|
3683
|
+
SELECT entity, COUNT(*) as cnt, AVG(total_score) as avg_score,
|
|
3684
|
+
SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) * 1.0 / COUNT(*) as pass_rate
|
|
3685
|
+
FROM suggestion_events
|
|
3686
|
+
GROUP BY entity
|
|
3687
|
+
ORDER BY cnt DESC
|
|
3688
|
+
LIMIT 10
|
|
3689
|
+
`).all();
|
|
3690
|
+
const topEntities = topEntityRows.map((r) => ({
|
|
3691
|
+
entity: r.entity,
|
|
3692
|
+
suggestionCount: r.cnt,
|
|
3693
|
+
avgScore: Math.round(r.avg_score * 100) / 100,
|
|
3694
|
+
passRate: Math.round(r.pass_rate * 1e3) / 1e3
|
|
3695
|
+
}));
|
|
3696
|
+
const feedbackTrendRows = stateDb2.db.prepare(`
|
|
3697
|
+
SELECT date(created_at) as day, COUNT(*) as count
|
|
3698
|
+
FROM wikilink_feedback
|
|
3699
|
+
WHERE created_at >= datetime('now', '-30 days')
|
|
3700
|
+
GROUP BY day
|
|
3701
|
+
ORDER BY day
|
|
3702
|
+
`).all();
|
|
3703
|
+
const feedbackTrend = feedbackTrendRows.map((r) => ({
|
|
3704
|
+
day: r.day,
|
|
3705
|
+
count: r.count
|
|
3706
|
+
}));
|
|
3707
|
+
const suppressionRows = stateDb2.db.prepare(`
|
|
3708
|
+
SELECT entity, false_positive_rate, updated_at
|
|
3709
|
+
FROM wikilink_suppressions
|
|
3710
|
+
ORDER BY updated_at DESC
|
|
3711
|
+
`).all();
|
|
3712
|
+
const suppressionChanges = suppressionRows.map((r) => ({
|
|
3713
|
+
entity: r.entity,
|
|
3714
|
+
falsePositiveRate: r.false_positive_rate,
|
|
3715
|
+
updatedAt: r.updated_at
|
|
3716
|
+
}));
|
|
3717
|
+
return {
|
|
3718
|
+
...base,
|
|
3719
|
+
layerHealth,
|
|
3720
|
+
topEntities,
|
|
3721
|
+
feedbackTrend,
|
|
3722
|
+
suppressionChanges
|
|
3723
|
+
};
|
|
3724
|
+
}
|
|
3554
3725
|
|
|
3555
3726
|
// src/core/write/git.ts
|
|
3556
3727
|
import { simpleGit, CheckRepoActions } from "simple-git";
|
|
@@ -3983,14 +4154,16 @@ function loadRecencyFromStateDb() {
|
|
|
3983
4154
|
}
|
|
3984
4155
|
function saveRecencyToStateDb(index) {
|
|
3985
4156
|
if (!moduleStateDb3) {
|
|
3986
|
-
console.error("[Flywheel] No StateDb available
|
|
4157
|
+
console.error("[Flywheel] saveRecencyToStateDb: No StateDb available (moduleStateDb is null)");
|
|
3987
4158
|
return;
|
|
3988
4159
|
}
|
|
4160
|
+
console.error(`[Flywheel] saveRecencyToStateDb: Saving ${index.lastMentioned.size} entries...`);
|
|
3989
4161
|
try {
|
|
3990
4162
|
for (const [entityNameLower, timestamp] of index.lastMentioned) {
|
|
3991
4163
|
recordEntityMention(moduleStateDb3, entityNameLower, new Date(timestamp));
|
|
3992
4164
|
}
|
|
3993
|
-
|
|
4165
|
+
const count = moduleStateDb3.db.prepare("SELECT COUNT(*) as cnt FROM recency").get();
|
|
4166
|
+
console.error(`[Flywheel] Saved recency: ${index.lastMentioned.size} entries \u2192 ${count.cnt} rows in table`);
|
|
3994
4167
|
} catch (e) {
|
|
3995
4168
|
console.error("[Flywheel] Failed to save recency to StateDb:", e);
|
|
3996
4169
|
}
|
|
@@ -4902,6 +5075,24 @@ function setWriteStateDb(stateDb2) {
|
|
|
4902
5075
|
function getWriteStateDb() {
|
|
4903
5076
|
return moduleStateDb4;
|
|
4904
5077
|
}
|
|
5078
|
+
var moduleConfig = null;
|
|
5079
|
+
var ALL_IMPLICIT_PATTERNS = ["proper-nouns", "single-caps", "quoted-terms", "camel-case", "acronyms"];
|
|
5080
|
+
function setWikilinkConfig(config) {
|
|
5081
|
+
moduleConfig = config;
|
|
5082
|
+
}
|
|
5083
|
+
function getWikilinkStrictness() {
|
|
5084
|
+
return moduleConfig?.wikilink_strictness ?? "balanced";
|
|
5085
|
+
}
|
|
5086
|
+
function getEffectiveStrictness(notePath) {
|
|
5087
|
+
const base = getWikilinkStrictness();
|
|
5088
|
+
if (moduleConfig?.adaptive_strictness === false) return base;
|
|
5089
|
+
const context = notePath ? getNoteContext(notePath) : "general";
|
|
5090
|
+
if (context === "daily") return "aggressive";
|
|
5091
|
+
return base;
|
|
5092
|
+
}
|
|
5093
|
+
function getCooccurrenceIndex() {
|
|
5094
|
+
return cooccurrenceIndex;
|
|
5095
|
+
}
|
|
4905
5096
|
var entityIndex = null;
|
|
4906
5097
|
var indexReady = false;
|
|
4907
5098
|
var indexError2 = null;
|
|
@@ -5062,9 +5253,12 @@ function processWikilinks(content, notePath) {
|
|
|
5062
5253
|
firstOccurrenceOnly: true,
|
|
5063
5254
|
caseInsensitive: true
|
|
5064
5255
|
});
|
|
5256
|
+
const implicitEnabled = moduleConfig?.implicit_detection !== false;
|
|
5257
|
+
const validPatterns = new Set(ALL_IMPLICIT_PATTERNS);
|
|
5258
|
+
const implicitPatterns = moduleConfig?.implicit_patterns?.length ? moduleConfig.implicit_patterns.filter((p) => validPatterns.has(p)) : [...ALL_IMPLICIT_PATTERNS];
|
|
5065
5259
|
const implicitMatches = detectImplicitEntities(result.content, {
|
|
5066
|
-
detectImplicit:
|
|
5067
|
-
implicitPatterns
|
|
5260
|
+
detectImplicit: implicitEnabled,
|
|
5261
|
+
implicitPatterns,
|
|
5068
5262
|
minEntityLength: 3
|
|
5069
5263
|
});
|
|
5070
5264
|
const alreadyLinked = new Set(
|
|
@@ -5294,7 +5488,6 @@ var STRICTNESS_CONFIGS = {
|
|
|
5294
5488
|
// Standard bonus for exact matches
|
|
5295
5489
|
}
|
|
5296
5490
|
};
|
|
5297
|
-
var DEFAULT_STRICTNESS = "conservative";
|
|
5298
5491
|
var TYPE_BOOST = {
|
|
5299
5492
|
people: 5,
|
|
5300
5493
|
// Names are high value for connections
|
|
@@ -5481,10 +5674,12 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5481
5674
|
const {
|
|
5482
5675
|
maxSuggestions = 3,
|
|
5483
5676
|
excludeLinked = true,
|
|
5484
|
-
strictness =
|
|
5677
|
+
strictness = getEffectiveStrictness(options.notePath),
|
|
5485
5678
|
notePath,
|
|
5486
|
-
detail = false
|
|
5679
|
+
detail = false,
|
|
5680
|
+
disabledLayers = []
|
|
5487
5681
|
} = options;
|
|
5682
|
+
const disabled = new Set(disabledLayers);
|
|
5488
5683
|
const config = STRICTNESS_CONFIGS[strictness];
|
|
5489
5684
|
const adaptiveMinScore = getAdaptiveMinScore(content.length, config.minSuggestionScore);
|
|
5490
5685
|
const noteContext = notePath ? getNoteContext(notePath) : "general";
|
|
@@ -5525,31 +5720,31 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5525
5720
|
for (const { entity, category } of entitiesWithTypes) {
|
|
5526
5721
|
const entityName = entity.name;
|
|
5527
5722
|
if (!entityName) continue;
|
|
5528
|
-
if (entityName.length > MAX_ENTITY_LENGTH) {
|
|
5723
|
+
if (!disabled.has("length_filter") && entityName.length > MAX_ENTITY_LENGTH) {
|
|
5529
5724
|
continue;
|
|
5530
5725
|
}
|
|
5531
|
-
if (isLikelyArticleTitle(entityName)) {
|
|
5726
|
+
if (!disabled.has("article_filter") && isLikelyArticleTitle(entityName)) {
|
|
5532
5727
|
continue;
|
|
5533
5728
|
}
|
|
5534
5729
|
if (linkedEntities.has(entityName.toLowerCase())) {
|
|
5535
5730
|
continue;
|
|
5536
5731
|
}
|
|
5537
|
-
const contentScore = scoreEntity(entity, contentTokens, contentStems, config);
|
|
5732
|
+
const contentScore = disabled.has("exact_match") && disabled.has("stem_match") ? 0 : scoreEntity(entity, contentTokens, contentStems, config);
|
|
5538
5733
|
let score = contentScore;
|
|
5539
5734
|
if (contentScore > 0) {
|
|
5540
5735
|
entitiesWithContentMatch.add(entityName);
|
|
5541
5736
|
}
|
|
5542
|
-
const layerTypeBoost = TYPE_BOOST[category] || 0;
|
|
5737
|
+
const layerTypeBoost = disabled.has("type_boost") ? 0 : TYPE_BOOST[category] || 0;
|
|
5543
5738
|
score += layerTypeBoost;
|
|
5544
|
-
const layerContextBoost = contextBoosts[category] || 0;
|
|
5739
|
+
const layerContextBoost = disabled.has("context_boost") ? 0 : contextBoosts[category] || 0;
|
|
5545
5740
|
score += layerContextBoost;
|
|
5546
|
-
const layerRecencyBoost = recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
|
|
5741
|
+
const layerRecencyBoost = disabled.has("recency") ? 0 : recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
|
|
5547
5742
|
score += layerRecencyBoost;
|
|
5548
|
-
const layerCrossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
|
|
5743
|
+
const layerCrossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
|
|
5549
5744
|
score += layerCrossFolderBoost;
|
|
5550
|
-
const layerHubBoost = getHubBoost(entity);
|
|
5745
|
+
const layerHubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
|
|
5551
5746
|
score += layerHubBoost;
|
|
5552
|
-
const layerFeedbackAdj = feedbackBoosts.get(entityName) ?? 0;
|
|
5747
|
+
const layerFeedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(entityName) ?? 0;
|
|
5553
5748
|
score += layerFeedbackAdj;
|
|
5554
5749
|
if (score > 0) {
|
|
5555
5750
|
directlyMatchedEntities.add(entityName);
|
|
@@ -5573,12 +5768,12 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5573
5768
|
});
|
|
5574
5769
|
}
|
|
5575
5770
|
}
|
|
5576
|
-
if (cooccurrenceIndex && directlyMatchedEntities.size > 0) {
|
|
5771
|
+
if (!disabled.has("cooccurrence") && cooccurrenceIndex && directlyMatchedEntities.size > 0) {
|
|
5577
5772
|
for (const { entity, category } of entitiesWithTypes) {
|
|
5578
5773
|
const entityName = entity.name;
|
|
5579
5774
|
if (!entityName) continue;
|
|
5580
|
-
if (entityName.length > MAX_ENTITY_LENGTH) continue;
|
|
5581
|
-
if (isLikelyArticleTitle(entityName)) continue;
|
|
5775
|
+
if (!disabled.has("length_filter") && entityName.length > MAX_ENTITY_LENGTH) continue;
|
|
5776
|
+
if (!disabled.has("article_filter") && isLikelyArticleTitle(entityName)) continue;
|
|
5582
5777
|
if (linkedEntities.has(entityName.toLowerCase())) continue;
|
|
5583
5778
|
const boost = getCooccurrenceBoost(entityName, directlyMatchedEntities, cooccurrenceIndex, recencyIndex);
|
|
5584
5779
|
if (boost > 0) {
|
|
@@ -5595,12 +5790,12 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5595
5790
|
continue;
|
|
5596
5791
|
}
|
|
5597
5792
|
entitiesWithContentMatch.add(entityName);
|
|
5598
|
-
const typeBoost = TYPE_BOOST[category] || 0;
|
|
5599
|
-
const contextBoost = contextBoosts[category] || 0;
|
|
5600
|
-
const recencyBoostVal = recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
|
|
5601
|
-
const crossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
|
|
5602
|
-
const hubBoost = getHubBoost(entity);
|
|
5603
|
-
const feedbackAdj = feedbackBoosts.get(entityName) ?? 0;
|
|
5793
|
+
const typeBoost = disabled.has("type_boost") ? 0 : TYPE_BOOST[category] || 0;
|
|
5794
|
+
const contextBoost = disabled.has("context_boost") ? 0 : contextBoosts[category] || 0;
|
|
5795
|
+
const recencyBoostVal = disabled.has("recency") ? 0 : recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
|
|
5796
|
+
const crossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
|
|
5797
|
+
const hubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
|
|
5798
|
+
const feedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(entityName) ?? 0;
|
|
5604
5799
|
const totalBoost = boost + typeBoost + contextBoost + recencyBoostVal + crossFolderBoost + hubBoost + feedbackAdj;
|
|
5605
5800
|
if (totalBoost >= adaptiveMinScore) {
|
|
5606
5801
|
scoredEntities.push({
|
|
@@ -5624,7 +5819,7 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5624
5819
|
}
|
|
5625
5820
|
}
|
|
5626
5821
|
}
|
|
5627
|
-
if (content.length >= 20 && hasEntityEmbeddingsIndex()) {
|
|
5822
|
+
if (!disabled.has("semantic") && content.length >= 20 && hasEntityEmbeddingsIndex()) {
|
|
5628
5823
|
try {
|
|
5629
5824
|
const contentEmbedding = await embedTextCached(content);
|
|
5630
5825
|
const alreadyScoredNames = new Set(scoredEntities.map((e) => e.name));
|
|
@@ -5646,14 +5841,14 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5646
5841
|
(et) => et.entity.name === match.entityName
|
|
5647
5842
|
);
|
|
5648
5843
|
if (!entityWithType) continue;
|
|
5649
|
-
if (match.entityName.length > MAX_ENTITY_LENGTH) continue;
|
|
5650
|
-
if (isLikelyArticleTitle(match.entityName)) continue;
|
|
5844
|
+
if (!disabled.has("length_filter") && match.entityName.length > MAX_ENTITY_LENGTH) continue;
|
|
5845
|
+
if (!disabled.has("article_filter") && isLikelyArticleTitle(match.entityName)) continue;
|
|
5651
5846
|
const { entity, category } = entityWithType;
|
|
5652
|
-
const layerTypeBoost = TYPE_BOOST[category] || 0;
|
|
5653
|
-
const layerContextBoost = contextBoosts[category] || 0;
|
|
5654
|
-
const layerHubBoost = getHubBoost(entity);
|
|
5655
|
-
const layerCrossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
|
|
5656
|
-
const layerFeedbackAdj = feedbackBoosts.get(match.entityName) ?? 0;
|
|
5847
|
+
const layerTypeBoost = disabled.has("type_boost") ? 0 : TYPE_BOOST[category] || 0;
|
|
5848
|
+
const layerContextBoost = disabled.has("context_boost") ? 0 : contextBoosts[category] || 0;
|
|
5849
|
+
const layerHubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
|
|
5850
|
+
const layerCrossFolderBoost = disabled.has("cross_folder") ? 0 : notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
|
|
5851
|
+
const layerFeedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(match.entityName) ?? 0;
|
|
5657
5852
|
const totalScore = boost + layerTypeBoost + layerContextBoost + layerHubBoost + layerCrossFolderBoost + layerFeedbackAdj;
|
|
5658
5853
|
if (totalScore >= adaptiveMinScore) {
|
|
5659
5854
|
scoredEntities.push({
|
|
@@ -5695,7 +5890,51 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5695
5890
|
}
|
|
5696
5891
|
return 0;
|
|
5697
5892
|
});
|
|
5698
|
-
|
|
5893
|
+
if (moduleStateDb4 && notePath) {
|
|
5894
|
+
try {
|
|
5895
|
+
const now = Date.now();
|
|
5896
|
+
const insertStmt = moduleStateDb4.db.prepare(`
|
|
5897
|
+
INSERT OR IGNORE INTO suggestion_events
|
|
5898
|
+
(timestamp, note_path, entity, total_score, breakdown_json, threshold, passed, strictness, applied, pipeline_event_id)
|
|
5899
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL)
|
|
5900
|
+
`);
|
|
5901
|
+
const persistTransaction = moduleStateDb4.db.transaction(() => {
|
|
5902
|
+
for (const e of relevantEntities) {
|
|
5903
|
+
insertStmt.run(
|
|
5904
|
+
now,
|
|
5905
|
+
notePath,
|
|
5906
|
+
e.name,
|
|
5907
|
+
e.score,
|
|
5908
|
+
JSON.stringify(e.breakdown),
|
|
5909
|
+
adaptiveMinScore,
|
|
5910
|
+
1,
|
|
5911
|
+
// passed threshold (these are relevantEntities)
|
|
5912
|
+
strictness
|
|
5913
|
+
);
|
|
5914
|
+
}
|
|
5915
|
+
for (const e of scoredEntities) {
|
|
5916
|
+
if (!entitiesWithContentMatch.has(e.name)) continue;
|
|
5917
|
+
if (relevantEntities.some((r) => r.name === e.name)) continue;
|
|
5918
|
+
insertStmt.run(
|
|
5919
|
+
now,
|
|
5920
|
+
notePath,
|
|
5921
|
+
e.name,
|
|
5922
|
+
e.score,
|
|
5923
|
+
JSON.stringify(e.breakdown),
|
|
5924
|
+
adaptiveMinScore,
|
|
5925
|
+
0,
|
|
5926
|
+
// did not pass threshold
|
|
5927
|
+
strictness
|
|
5928
|
+
);
|
|
5929
|
+
}
|
|
5930
|
+
});
|
|
5931
|
+
persistTransaction();
|
|
5932
|
+
} catch {
|
|
5933
|
+
}
|
|
5934
|
+
}
|
|
5935
|
+
const currentNoteStem = notePath ? notePath.replace(/\.md$/, "").split("/").pop()?.toLowerCase() : null;
|
|
5936
|
+
const filtered = currentNoteStem ? relevantEntities.filter((e) => e.name.toLowerCase() !== currentNoteStem) : relevantEntities;
|
|
5937
|
+
const topEntries = filtered.slice(0, maxSuggestions);
|
|
5699
5938
|
const topSuggestions = topEntries.map((e) => e.name);
|
|
5700
5939
|
if (topSuggestions.length === 0) {
|
|
5701
5940
|
return emptyResult;
|
|
@@ -6046,6 +6285,17 @@ function searchFTS5(_vaultPath, query, limit = 10) {
|
|
|
6046
6285
|
function getFTS5State() {
|
|
6047
6286
|
return { ...state };
|
|
6048
6287
|
}
|
|
6288
|
+
function countFTS5Mentions(term) {
|
|
6289
|
+
if (!db2) return 0;
|
|
6290
|
+
try {
|
|
6291
|
+
const result = db2.prepare(
|
|
6292
|
+
"SELECT COUNT(*) as cnt FROM notes_fts WHERE content MATCH ?"
|
|
6293
|
+
).get(`"${term}"`);
|
|
6294
|
+
return result?.cnt ?? 0;
|
|
6295
|
+
} catch {
|
|
6296
|
+
return 0;
|
|
6297
|
+
}
|
|
6298
|
+
}
|
|
6049
6299
|
|
|
6050
6300
|
// src/core/read/taskCache.ts
|
|
6051
6301
|
import * as path10 from "path";
|
|
@@ -7057,12 +7307,13 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
|
|
|
7057
7307
|
inputSchema: {
|
|
7058
7308
|
path: z2.string().optional().describe("Path to a specific note to validate. If omitted, validates all notes."),
|
|
7059
7309
|
typos_only: z2.boolean().default(false).describe("If true, only report broken links that have a similar existing note (likely typos)"),
|
|
7310
|
+
group_by_target: z2.boolean().default(false).describe("If true, aggregate dead links by target and rank by mention frequency. Returns targets[] instead of broken[]."),
|
|
7060
7311
|
limit: z2.coerce.number().default(50).describe("Maximum number of broken links to return"),
|
|
7061
7312
|
offset: z2.coerce.number().default(0).describe("Number of broken links to skip (for pagination)")
|
|
7062
7313
|
},
|
|
7063
7314
|
outputSchema: ValidateLinksOutputSchema
|
|
7064
7315
|
},
|
|
7065
|
-
async ({ path: notePath, typos_only, limit: requestedLimit, offset }) => {
|
|
7316
|
+
async ({ path: notePath, typos_only, group_by_target, limit: requestedLimit, offset }) => {
|
|
7066
7317
|
const limit = Math.min(requestedLimit ?? 50, MAX_LIMIT);
|
|
7067
7318
|
const index = getIndex();
|
|
7068
7319
|
const allBroken = [];
|
|
@@ -7103,6 +7354,41 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
|
|
|
7103
7354
|
}
|
|
7104
7355
|
}
|
|
7105
7356
|
}
|
|
7357
|
+
if (group_by_target) {
|
|
7358
|
+
const targetMap = /* @__PURE__ */ new Map();
|
|
7359
|
+
for (const broken2 of allBroken) {
|
|
7360
|
+
const key = broken2.target.toLowerCase();
|
|
7361
|
+
const existing = targetMap.get(key);
|
|
7362
|
+
if (existing) {
|
|
7363
|
+
existing.count++;
|
|
7364
|
+
if (existing.sources.size < 5) existing.sources.add(broken2.source);
|
|
7365
|
+
if (!existing.suggestion && broken2.suggestion) existing.suggestion = broken2.suggestion;
|
|
7366
|
+
} else {
|
|
7367
|
+
targetMap.set(key, {
|
|
7368
|
+
count: 1,
|
|
7369
|
+
sources: /* @__PURE__ */ new Set([broken2.source]),
|
|
7370
|
+
suggestion: broken2.suggestion
|
|
7371
|
+
});
|
|
7372
|
+
}
|
|
7373
|
+
}
|
|
7374
|
+
const targets = Array.from(targetMap.entries()).map(([target, data]) => ({
|
|
7375
|
+
target,
|
|
7376
|
+
mention_count: data.count,
|
|
7377
|
+
sources: Array.from(data.sources),
|
|
7378
|
+
...data.suggestion ? { suggestion: data.suggestion } : {}
|
|
7379
|
+
})).sort((a, b) => b.mention_count - a.mention_count).slice(offset, offset + limit);
|
|
7380
|
+
const grouped = {
|
|
7381
|
+
scope: notePath || "all",
|
|
7382
|
+
total_dead_targets: targetMap.size,
|
|
7383
|
+
total_broken_links: allBroken.length,
|
|
7384
|
+
returned_count: targets.length,
|
|
7385
|
+
targets
|
|
7386
|
+
};
|
|
7387
|
+
return {
|
|
7388
|
+
content: [{ type: "text", text: JSON.stringify(grouped, null, 2) }],
|
|
7389
|
+
structuredContent: grouped
|
|
7390
|
+
};
|
|
7391
|
+
}
|
|
7106
7392
|
const broken = allBroken.slice(offset, offset + limit);
|
|
7107
7393
|
const output = {
|
|
7108
7394
|
scope: notePath || "all",
|
|
@@ -7123,6 +7409,106 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
|
|
|
7123
7409
|
};
|
|
7124
7410
|
}
|
|
7125
7411
|
);
|
|
7412
|
+
server2.registerTool(
|
|
7413
|
+
"discover_stub_candidates",
|
|
7414
|
+
{
|
|
7415
|
+
title: "Discover Stub Candidates",
|
|
7416
|
+
description: `Find terms referenced via dead wikilinks across the vault that have no backing note. These are "invisible concepts" \u2014 topics your vault considers important enough to link to but that don't have their own notes yet. Ranked by reference frequency.`,
|
|
7417
|
+
inputSchema: {
|
|
7418
|
+
min_frequency: z2.coerce.number().default(2).describe("Minimum number of references to include (default 2)"),
|
|
7419
|
+
limit: z2.coerce.number().default(20).describe("Maximum candidates to return (default 20)")
|
|
7420
|
+
}
|
|
7421
|
+
},
|
|
7422
|
+
async ({ min_frequency, limit: requestedLimit }) => {
|
|
7423
|
+
const index = getIndex();
|
|
7424
|
+
const limit = Math.min(requestedLimit ?? 20, 100);
|
|
7425
|
+
const minFreq = min_frequency ?? 2;
|
|
7426
|
+
const targetMap = /* @__PURE__ */ new Map();
|
|
7427
|
+
for (const note of index.notes.values()) {
|
|
7428
|
+
for (const link of note.outlinks) {
|
|
7429
|
+
if (!resolveTarget(index, link.target)) {
|
|
7430
|
+
const key = link.target.toLowerCase();
|
|
7431
|
+
const existing = targetMap.get(key);
|
|
7432
|
+
if (existing) {
|
|
7433
|
+
existing.count++;
|
|
7434
|
+
if (existing.sources.size < 3) existing.sources.add(note.path);
|
|
7435
|
+
} else {
|
|
7436
|
+
targetMap.set(key, { count: 1, sources: /* @__PURE__ */ new Set([note.path]) });
|
|
7437
|
+
}
|
|
7438
|
+
}
|
|
7439
|
+
}
|
|
7440
|
+
}
|
|
7441
|
+
const candidates = Array.from(targetMap.entries()).filter(([, data]) => data.count >= minFreq).map(([target, data]) => {
|
|
7442
|
+
const fts5Mentions = countFTS5Mentions(target);
|
|
7443
|
+
return {
|
|
7444
|
+
term: target,
|
|
7445
|
+
wikilink_references: data.count,
|
|
7446
|
+
content_mentions: fts5Mentions,
|
|
7447
|
+
sample_notes: Array.from(data.sources)
|
|
7448
|
+
};
|
|
7449
|
+
}).sort((a, b) => b.wikilink_references - a.wikilink_references).slice(0, limit);
|
|
7450
|
+
const output = {
|
|
7451
|
+
total_dead_targets: targetMap.size,
|
|
7452
|
+
candidates_above_threshold: candidates.length,
|
|
7453
|
+
candidates
|
|
7454
|
+
};
|
|
7455
|
+
return {
|
|
7456
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
7457
|
+
};
|
|
7458
|
+
}
|
|
7459
|
+
);
|
|
7460
|
+
server2.registerTool(
|
|
7461
|
+
"discover_cooccurrence_gaps",
|
|
7462
|
+
{
|
|
7463
|
+
title: "Discover Co-occurrence Gaps",
|
|
7464
|
+
description: "Find entity pairs that frequently co-occur across vault notes but where one or both entities lack a backing note. These represent relationship patterns worth making explicit with hub notes or links.",
|
|
7465
|
+
inputSchema: {
|
|
7466
|
+
min_cooccurrence: z2.coerce.number().default(3).describe("Minimum co-occurrence count to include (default 3)"),
|
|
7467
|
+
limit: z2.coerce.number().default(20).describe("Maximum gaps to return (default 20)")
|
|
7468
|
+
}
|
|
7469
|
+
},
|
|
7470
|
+
async ({ min_cooccurrence, limit: requestedLimit }) => {
|
|
7471
|
+
const index = getIndex();
|
|
7472
|
+
const coocIndex = getCooccurrenceIndex();
|
|
7473
|
+
const limit = Math.min(requestedLimit ?? 20, 100);
|
|
7474
|
+
const minCount = min_cooccurrence ?? 3;
|
|
7475
|
+
if (!coocIndex) {
|
|
7476
|
+
return {
|
|
7477
|
+
content: [{ type: "text", text: JSON.stringify({ error: "Co-occurrence index not built yet. Wait for entity index initialization." }) }]
|
|
7478
|
+
};
|
|
7479
|
+
}
|
|
7480
|
+
const gaps = [];
|
|
7481
|
+
const seenPairs = /* @__PURE__ */ new Set();
|
|
7482
|
+
for (const [entityA, associations] of Object.entries(coocIndex.associations)) {
|
|
7483
|
+
for (const [entityB, count] of associations) {
|
|
7484
|
+
if (count < minCount) continue;
|
|
7485
|
+
const pairKey = [entityA, entityB].sort().join("||");
|
|
7486
|
+
if (seenPairs.has(pairKey)) continue;
|
|
7487
|
+
seenPairs.add(pairKey);
|
|
7488
|
+
const aHasNote = resolveTarget(index, entityA) !== null;
|
|
7489
|
+
const bHasNote = resolveTarget(index, entityB) !== null;
|
|
7490
|
+
if (aHasNote && bHasNote) continue;
|
|
7491
|
+
gaps.push({
|
|
7492
|
+
entity_a: entityA,
|
|
7493
|
+
entity_b: entityB,
|
|
7494
|
+
cooccurrence_count: count,
|
|
7495
|
+
a_has_note: aHasNote,
|
|
7496
|
+
b_has_note: bHasNote
|
|
7497
|
+
});
|
|
7498
|
+
}
|
|
7499
|
+
}
|
|
7500
|
+
gaps.sort((a, b) => b.cooccurrence_count - a.cooccurrence_count);
|
|
7501
|
+
const top = gaps.slice(0, limit);
|
|
7502
|
+
const output = {
|
|
7503
|
+
total_gaps: gaps.length,
|
|
7504
|
+
returned_count: top.length,
|
|
7505
|
+
gaps: top
|
|
7506
|
+
};
|
|
7507
|
+
return {
|
|
7508
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
7509
|
+
};
|
|
7510
|
+
}
|
|
7511
|
+
);
|
|
7126
7512
|
}
|
|
7127
7513
|
|
|
7128
7514
|
// src/tools/read/health.ts
|
|
@@ -7488,6 +7874,93 @@ function purgeOldIndexEvents(stateDb2, retentionDays = 90) {
|
|
|
7488
7874
|
return result.changes;
|
|
7489
7875
|
}
|
|
7490
7876
|
|
|
7877
|
+
// src/core/read/sweep.ts
|
|
7878
|
+
var DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1e3;
|
|
7879
|
+
var MIN_SWEEP_INTERVAL_MS = 30 * 1e3;
|
|
7880
|
+
var cachedResults = null;
|
|
7881
|
+
var sweepTimer = null;
|
|
7882
|
+
var sweepRunning = false;
|
|
7883
|
+
function runSweep(index) {
|
|
7884
|
+
const start = Date.now();
|
|
7885
|
+
let deadLinkCount = 0;
|
|
7886
|
+
const deadTargetCounts = /* @__PURE__ */ new Map();
|
|
7887
|
+
for (const note of index.notes.values()) {
|
|
7888
|
+
for (const link of note.outlinks) {
|
|
7889
|
+
if (!resolveTarget(index, link.target)) {
|
|
7890
|
+
deadLinkCount++;
|
|
7891
|
+
const key = link.target.toLowerCase();
|
|
7892
|
+
deadTargetCounts.set(key, (deadTargetCounts.get(key) || 0) + 1);
|
|
7893
|
+
}
|
|
7894
|
+
}
|
|
7895
|
+
}
|
|
7896
|
+
const topDeadTargets = Array.from(deadTargetCounts.entries()).filter(([, count]) => count >= 2).map(([target, wikilink_references]) => ({
|
|
7897
|
+
target,
|
|
7898
|
+
wikilink_references,
|
|
7899
|
+
content_mentions: countFTS5Mentions(target)
|
|
7900
|
+
})).sort((a, b) => b.wikilink_references - a.wikilink_references).slice(0, 10);
|
|
7901
|
+
const linkedCounts = /* @__PURE__ */ new Map();
|
|
7902
|
+
for (const note of index.notes.values()) {
|
|
7903
|
+
for (const link of note.outlinks) {
|
|
7904
|
+
const key = link.target.toLowerCase();
|
|
7905
|
+
linkedCounts.set(key, (linkedCounts.get(key) || 0) + 1);
|
|
7906
|
+
}
|
|
7907
|
+
}
|
|
7908
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7909
|
+
const unlinkedEntities = [];
|
|
7910
|
+
for (const [name, entityPath] of index.entities) {
|
|
7911
|
+
if (seen.has(entityPath)) continue;
|
|
7912
|
+
seen.add(entityPath);
|
|
7913
|
+
const totalMentions = countFTS5Mentions(name);
|
|
7914
|
+
if (totalMentions === 0) continue;
|
|
7915
|
+
const pathKey = entityPath.toLowerCase().replace(/\.md$/, "");
|
|
7916
|
+
const linked = Math.max(linkedCounts.get(name) || 0, linkedCounts.get(pathKey) || 0);
|
|
7917
|
+
const unlinked = Math.max(0, totalMentions - linked - 1);
|
|
7918
|
+
if (unlinked <= 0) continue;
|
|
7919
|
+
const note = index.notes.get(entityPath);
|
|
7920
|
+
const displayName = note?.title || name;
|
|
7921
|
+
unlinkedEntities.push({ entity: displayName, path: entityPath, unlinked_mentions: unlinked });
|
|
7922
|
+
}
|
|
7923
|
+
unlinkedEntities.sort((a, b) => b.unlinked_mentions - a.unlinked_mentions);
|
|
7924
|
+
const results = {
|
|
7925
|
+
last_sweep_at: Date.now(),
|
|
7926
|
+
sweep_duration_ms: Date.now() - start,
|
|
7927
|
+
dead_link_count: deadLinkCount,
|
|
7928
|
+
top_dead_targets: topDeadTargets,
|
|
7929
|
+
top_unlinked_entities: unlinkedEntities.slice(0, 10)
|
|
7930
|
+
};
|
|
7931
|
+
cachedResults = results;
|
|
7932
|
+
return results;
|
|
7933
|
+
}
|
|
7934
|
+
function startSweepTimer(getIndex, intervalMs) {
|
|
7935
|
+
const interval = Math.max(intervalMs ?? DEFAULT_SWEEP_INTERVAL_MS, MIN_SWEEP_INTERVAL_MS);
|
|
7936
|
+
setTimeout(() => {
|
|
7937
|
+
doSweep(getIndex);
|
|
7938
|
+
}, 5e3);
|
|
7939
|
+
sweepTimer = setInterval(() => {
|
|
7940
|
+
doSweep(getIndex);
|
|
7941
|
+
}, interval);
|
|
7942
|
+
if (sweepTimer && typeof sweepTimer === "object" && "unref" in sweepTimer) {
|
|
7943
|
+
sweepTimer.unref();
|
|
7944
|
+
}
|
|
7945
|
+
}
|
|
7946
|
+
function doSweep(getIndex) {
|
|
7947
|
+
if (sweepRunning) return;
|
|
7948
|
+
sweepRunning = true;
|
|
7949
|
+
try {
|
|
7950
|
+
const index = getIndex();
|
|
7951
|
+
if (index && index.notes && index.notes.size > 0) {
|
|
7952
|
+
runSweep(index);
|
|
7953
|
+
}
|
|
7954
|
+
} catch (err) {
|
|
7955
|
+
console.error("[Flywheel] Sweep error:", err);
|
|
7956
|
+
} finally {
|
|
7957
|
+
sweepRunning = false;
|
|
7958
|
+
}
|
|
7959
|
+
}
|
|
7960
|
+
function getSweepResults() {
|
|
7961
|
+
return cachedResults;
|
|
7962
|
+
}
|
|
7963
|
+
|
|
7491
7964
|
// src/tools/read/health.ts
|
|
7492
7965
|
var STALE_THRESHOLD_SECONDS = 300;
|
|
7493
7966
|
function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () => ({}), getStateDb = () => null) {
|
|
@@ -7563,6 +8036,26 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
|
|
|
7563
8036
|
embeddings_count: z3.coerce.number().describe("Number of notes with semantic embeddings"),
|
|
7564
8037
|
tasks_ready: z3.boolean().describe("Whether the task cache is ready to serve queries"),
|
|
7565
8038
|
tasks_building: z3.boolean().describe("Whether the task cache is currently rebuilding"),
|
|
8039
|
+
dead_link_count: z3.coerce.number().describe("Total number of broken/dead wikilinks across the vault"),
|
|
8040
|
+
top_dead_link_targets: z3.array(z3.object({
|
|
8041
|
+
target: z3.string().describe("The dead link target"),
|
|
8042
|
+
mention_count: z3.coerce.number().describe("How many notes reference this dead target")
|
|
8043
|
+
})).describe("Top 5 most-referenced dead link targets (highest-ROI candidates to create)"),
|
|
8044
|
+
sweep: z3.object({
|
|
8045
|
+
last_sweep_at: z3.number().describe("When the last background sweep completed (ms epoch)"),
|
|
8046
|
+
sweep_duration_ms: z3.number().describe("How long the last sweep took"),
|
|
8047
|
+
dead_link_count: z3.number().describe("Dead links found by sweep"),
|
|
8048
|
+
top_dead_targets: z3.array(z3.object({
|
|
8049
|
+
target: z3.string(),
|
|
8050
|
+
wikilink_references: z3.number(),
|
|
8051
|
+
content_mentions: z3.number()
|
|
8052
|
+
})).describe("Top dead link targets with FTS5 content mention counts"),
|
|
8053
|
+
top_unlinked_entities: z3.array(z3.object({
|
|
8054
|
+
entity: z3.string(),
|
|
8055
|
+
path: z3.string(),
|
|
8056
|
+
unlinked_mentions: z3.number()
|
|
8057
|
+
})).describe("Entities with the most unlinked plain-text mentions")
|
|
8058
|
+
}).optional().describe("Background sweep results (graph hygiene metrics, updated every 5 min)"),
|
|
7566
8059
|
recommendations: z3.array(z3.string()).describe("Suggested actions if any issues detected")
|
|
7567
8060
|
};
|
|
7568
8061
|
server2.registerTool(
|
|
@@ -7685,6 +8178,20 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
|
|
|
7685
8178
|
}
|
|
7686
8179
|
}
|
|
7687
8180
|
const ftsState = getFTS5State();
|
|
8181
|
+
let deadLinkCount = 0;
|
|
8182
|
+
const deadTargetCounts = /* @__PURE__ */ new Map();
|
|
8183
|
+
if (indexBuilt) {
|
|
8184
|
+
for (const note of index.notes.values()) {
|
|
8185
|
+
for (const link of note.outlinks) {
|
|
8186
|
+
if (!resolveTarget(index, link.target)) {
|
|
8187
|
+
deadLinkCount++;
|
|
8188
|
+
const key = link.target.toLowerCase();
|
|
8189
|
+
deadTargetCounts.set(key, (deadTargetCounts.get(key) || 0) + 1);
|
|
8190
|
+
}
|
|
8191
|
+
}
|
|
8192
|
+
}
|
|
8193
|
+
}
|
|
8194
|
+
const topDeadLinkTargets = Array.from(deadTargetCounts.entries()).map(([target, mention_count]) => ({ target, mention_count })).sort((a, b) => b.mention_count - a.mention_count).slice(0, 5);
|
|
7688
8195
|
const output = {
|
|
7689
8196
|
status,
|
|
7690
8197
|
schema_version: SCHEMA_VERSION,
|
|
@@ -7712,6 +8219,9 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig = () =>
|
|
|
7712
8219
|
embeddings_count: getEmbeddingsCount(),
|
|
7713
8220
|
tasks_ready: isTaskCacheReady(),
|
|
7714
8221
|
tasks_building: isTaskCacheBuilding(),
|
|
8222
|
+
dead_link_count: deadLinkCount,
|
|
8223
|
+
top_dead_link_targets: topDeadLinkTargets,
|
|
8224
|
+
sweep: getSweepResults() ?? void 0,
|
|
7715
8225
|
recommendations
|
|
7716
8226
|
};
|
|
7717
8227
|
return {
|
|
@@ -8205,6 +8715,14 @@ function generateAliasCandidates(entityName, existingAliases) {
|
|
|
8205
8715
|
candidates.push({ candidate: short, type: "short_form" });
|
|
8206
8716
|
}
|
|
8207
8717
|
}
|
|
8718
|
+
const STOPWORDS2 = /* @__PURE__ */ new Set(["the", "and", "for", "with", "from", "into", "that", "this", "are", "was", "has", "its"]);
|
|
8719
|
+
for (const word of words) {
|
|
8720
|
+
if (word.length < 4) continue;
|
|
8721
|
+
if (STOPWORDS2.has(word.toLowerCase())) continue;
|
|
8722
|
+
if (existing.has(word.toLowerCase())) continue;
|
|
8723
|
+
if (words.length >= 3 && word === words[0]) continue;
|
|
8724
|
+
candidates.push({ candidate: word, type: "name_fragment" });
|
|
8725
|
+
}
|
|
8208
8726
|
}
|
|
8209
8727
|
return candidates;
|
|
8210
8728
|
}
|
|
@@ -8231,6 +8749,7 @@ function suggestEntityAliases(stateDb2, folder) {
|
|
|
8231
8749
|
mentions = result?.cnt ?? 0;
|
|
8232
8750
|
} catch {
|
|
8233
8751
|
}
|
|
8752
|
+
if (type === "name_fragment" && mentions < 5) continue;
|
|
8234
8753
|
suggestions.push({
|
|
8235
8754
|
entity: row.name,
|
|
8236
8755
|
entity_path: row.path,
|
|
@@ -8761,6 +9280,61 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
|
|
|
8761
9280
|
};
|
|
8762
9281
|
}
|
|
8763
9282
|
);
|
|
9283
|
+
server2.registerTool(
|
|
9284
|
+
"unlinked_mentions_report",
|
|
9285
|
+
{
|
|
9286
|
+
title: "Unlinked Mentions Report",
|
|
9287
|
+
description: "Find which entities have the most unlinked mentions across the vault \u2014 highest-ROI linking opportunities. Uses FTS5 to count mentions and subtracts known wikilinks.",
|
|
9288
|
+
inputSchema: {
|
|
9289
|
+
limit: z5.coerce.number().default(20).describe("Maximum entities to return (default 20)")
|
|
9290
|
+
}
|
|
9291
|
+
},
|
|
9292
|
+
async ({ limit: requestedLimit }) => {
|
|
9293
|
+
requireIndex();
|
|
9294
|
+
const index = getIndex();
|
|
9295
|
+
const limit = Math.min(requestedLimit ?? 20, 100);
|
|
9296
|
+
const linkedCounts = /* @__PURE__ */ new Map();
|
|
9297
|
+
for (const note of index.notes.values()) {
|
|
9298
|
+
for (const link of note.outlinks) {
|
|
9299
|
+
const key = link.target.toLowerCase();
|
|
9300
|
+
linkedCounts.set(key, (linkedCounts.get(key) || 0) + 1);
|
|
9301
|
+
}
|
|
9302
|
+
}
|
|
9303
|
+
const results = [];
|
|
9304
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9305
|
+
for (const [name, entityPath] of index.entities) {
|
|
9306
|
+
if (seen.has(entityPath)) continue;
|
|
9307
|
+
seen.add(entityPath);
|
|
9308
|
+
const totalMentions = countFTS5Mentions(name);
|
|
9309
|
+
if (totalMentions === 0) continue;
|
|
9310
|
+
const pathKey = entityPath.toLowerCase().replace(/\.md$/, "");
|
|
9311
|
+
const linkedByName = linkedCounts.get(name) || 0;
|
|
9312
|
+
const linkedByPath = linkedCounts.get(pathKey) || 0;
|
|
9313
|
+
const linked = Math.max(linkedByName, linkedByPath);
|
|
9314
|
+
const unlinked = Math.max(0, totalMentions - linked - 1);
|
|
9315
|
+
if (unlinked <= 0) continue;
|
|
9316
|
+
const note = index.notes.get(entityPath);
|
|
9317
|
+
const displayName = note?.title || name;
|
|
9318
|
+
results.push({
|
|
9319
|
+
entity: displayName,
|
|
9320
|
+
path: entityPath,
|
|
9321
|
+
total_mentions: totalMentions,
|
|
9322
|
+
linked_mentions: linked,
|
|
9323
|
+
unlinked_mentions: unlinked
|
|
9324
|
+
});
|
|
9325
|
+
}
|
|
9326
|
+
results.sort((a, b) => b.unlinked_mentions - a.unlinked_mentions);
|
|
9327
|
+
const top = results.slice(0, limit);
|
|
9328
|
+
const output = {
|
|
9329
|
+
total_entities_checked: seen.size,
|
|
9330
|
+
entities_with_unlinked: results.length,
|
|
9331
|
+
top_entities: top
|
|
9332
|
+
};
|
|
9333
|
+
return {
|
|
9334
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
9335
|
+
};
|
|
9336
|
+
}
|
|
9337
|
+
);
|
|
8764
9338
|
}
|
|
8765
9339
|
|
|
8766
9340
|
// src/tools/read/primitives.ts
|
|
@@ -9938,6 +10512,49 @@ function getEmergingHubs(stateDb2, daysBack = 30) {
|
|
|
9938
10512
|
emerging.sort((a, b) => b.growth - a.growth);
|
|
9939
10513
|
return emerging;
|
|
9940
10514
|
}
|
|
10515
|
+
function compareGraphSnapshots(stateDb2, timestampBefore, timestampAfter) {
|
|
10516
|
+
const SCALAR_METRICS = ["avg_degree", "max_degree", "cluster_count", "largest_cluster_size"];
|
|
10517
|
+
function getSnapshotAt(ts) {
|
|
10518
|
+
const row = stateDb2.db.prepare(
|
|
10519
|
+
`SELECT DISTINCT timestamp FROM graph_snapshots WHERE timestamp <= ? ORDER BY timestamp DESC LIMIT 1`
|
|
10520
|
+
).get(ts);
|
|
10521
|
+
if (!row) return null;
|
|
10522
|
+
const rows = stateDb2.db.prepare(
|
|
10523
|
+
`SELECT metric, value, details FROM graph_snapshots WHERE timestamp = ?`
|
|
10524
|
+
).all(row.timestamp);
|
|
10525
|
+
return rows;
|
|
10526
|
+
}
|
|
10527
|
+
const beforeRows = getSnapshotAt(timestampBefore) ?? [];
|
|
10528
|
+
const afterRows = getSnapshotAt(timestampAfter) ?? [];
|
|
10529
|
+
const beforeMap = /* @__PURE__ */ new Map();
|
|
10530
|
+
const afterMap = /* @__PURE__ */ new Map();
|
|
10531
|
+
for (const r of beforeRows) beforeMap.set(r.metric, { value: r.value, details: r.details });
|
|
10532
|
+
for (const r of afterRows) afterMap.set(r.metric, { value: r.value, details: r.details });
|
|
10533
|
+
const metricChanges = SCALAR_METRICS.map((metric) => {
|
|
10534
|
+
const before = beforeMap.get(metric)?.value ?? 0;
|
|
10535
|
+
const after = afterMap.get(metric)?.value ?? 0;
|
|
10536
|
+
const delta = after - before;
|
|
10537
|
+
const deltaPercent = before !== 0 ? Math.round(delta / before * 1e4) / 100 : delta !== 0 ? 100 : 0;
|
|
10538
|
+
return { metric, before, after, delta, deltaPercent };
|
|
10539
|
+
});
|
|
10540
|
+
const beforeHubs = beforeMap.get("hub_scores_top10")?.details ? JSON.parse(beforeMap.get("hub_scores_top10").details) : [];
|
|
10541
|
+
const afterHubs = afterMap.get("hub_scores_top10")?.details ? JSON.parse(afterMap.get("hub_scores_top10").details) : [];
|
|
10542
|
+
const beforeHubMap = /* @__PURE__ */ new Map();
|
|
10543
|
+
for (const h of beforeHubs) beforeHubMap.set(h.entity, h.degree);
|
|
10544
|
+
const afterHubMap = /* @__PURE__ */ new Map();
|
|
10545
|
+
for (const h of afterHubs) afterHubMap.set(h.entity, h.degree);
|
|
10546
|
+
const allHubEntities = /* @__PURE__ */ new Set([...beforeHubMap.keys(), ...afterHubMap.keys()]);
|
|
10547
|
+
const hubScoreChanges = [];
|
|
10548
|
+
for (const entity of allHubEntities) {
|
|
10549
|
+
const before = beforeHubMap.get(entity) ?? 0;
|
|
10550
|
+
const after = afterHubMap.get(entity) ?? 0;
|
|
10551
|
+
if (before !== after) {
|
|
10552
|
+
hubScoreChanges.push({ entity, before, after, delta: after - before });
|
|
10553
|
+
}
|
|
10554
|
+
}
|
|
10555
|
+
hubScoreChanges.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
|
|
10556
|
+
return { metricChanges, hubScoreChanges };
|
|
10557
|
+
}
|
|
9941
10558
|
function purgeOldSnapshots(stateDb2, retentionDays = 90) {
|
|
9942
10559
|
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
|
|
9943
10560
|
const result = stateDb2.db.prepare(
|
|
@@ -11609,10 +12226,11 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
|
|
|
11609
12226
|
validate: z11.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
|
|
11610
12227
|
normalize: z11.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
|
|
11611
12228
|
guardrails: z11.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables'),
|
|
12229
|
+
linkedEntities: z11.array(z11.string()).optional().describe("Entity names already linked in the content. When skipWikilinks=true, these are tracked for feedback without re-processing the content."),
|
|
11612
12230
|
agent_id: z11.string().optional().describe('Agent identifier for multi-agent scoping (e.g., "claude-opus", "planning-agent")'),
|
|
11613
12231
|
session_id: z11.string().optional().describe('Session identifier for conversation scoping (e.g., "sess-abc123")')
|
|
11614
12232
|
},
|
|
11615
|
-
async ({ path: notePath, section, content, create_if_missing, position, format, commit, skipWikilinks, preserveListNesting, bumpHeadings, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails, agent_id, session_id }) => {
|
|
12233
|
+
async ({ path: notePath, section, content, create_if_missing, position, format, commit, skipWikilinks, preserveListNesting, bumpHeadings, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails, linkedEntities, agent_id, session_id }) => {
|
|
11616
12234
|
let noteCreated = false;
|
|
11617
12235
|
let templateUsed;
|
|
11618
12236
|
if (create_if_missing) {
|
|
@@ -11647,6 +12265,12 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
|
|
|
11647
12265
|
}
|
|
11648
12266
|
let workingContent = validationResult.content;
|
|
11649
12267
|
let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(workingContent, skipWikilinks, notePath);
|
|
12268
|
+
if (linkedEntities?.length) {
|
|
12269
|
+
const stateDb2 = getWriteStateDb();
|
|
12270
|
+
if (stateDb2) {
|
|
12271
|
+
trackWikilinkApplications(stateDb2, notePath, linkedEntities);
|
|
12272
|
+
}
|
|
12273
|
+
}
|
|
11650
12274
|
const _debug = {
|
|
11651
12275
|
entityCount: getEntityIndexStats().totalEntities,
|
|
11652
12276
|
indexReady: getEntityIndexStats().ready,
|
|
@@ -12082,7 +12706,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
|
|
|
12082
12706
|
overwrite: z14.boolean().default(false).describe("If true, overwrite existing file"),
|
|
12083
12707
|
commit: z14.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
|
|
12084
12708
|
skipWikilinks: z14.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
|
|
12085
|
-
suggestOutgoingLinks: z14.boolean().default(
|
|
12709
|
+
suggestOutgoingLinks: z14.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]").'),
|
|
12086
12710
|
maxSuggestions: z14.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
|
|
12087
12711
|
agent_id: z14.string().optional().describe("Agent identifier for multi-agent scoping"),
|
|
12088
12712
|
session_id: z14.string().optional().describe("Session identifier for conversation scoping")
|
|
@@ -12119,8 +12743,12 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
|
|
|
12119
12743
|
return formatMcpResult(errorResult(notePath, `Template not found: ${template}`));
|
|
12120
12744
|
}
|
|
12121
12745
|
}
|
|
12746
|
+
const now = /* @__PURE__ */ new Date();
|
|
12122
12747
|
if (!effectiveFrontmatter.date) {
|
|
12123
|
-
effectiveFrontmatter.date =
|
|
12748
|
+
effectiveFrontmatter.date = now.toISOString().split("T")[0];
|
|
12749
|
+
}
|
|
12750
|
+
if (!effectiveFrontmatter.created) {
|
|
12751
|
+
effectiveFrontmatter.created = now.toISOString();
|
|
12124
12752
|
}
|
|
12125
12753
|
const warnings = [];
|
|
12126
12754
|
const noteName = path21.basename(notePath, ".md");
|
|
@@ -13095,12 +13723,17 @@ async function executeStep(step, vaultPath2, context, conditionResults) {
|
|
|
13095
13723
|
const resolvedParams = interpolateObject(step.params, context);
|
|
13096
13724
|
try {
|
|
13097
13725
|
const result = await executeToolCall(step.tool, resolvedParams, vaultPath2, context);
|
|
13726
|
+
const outputs = {};
|
|
13727
|
+
if (result.path) {
|
|
13728
|
+
outputs.path = result.path;
|
|
13729
|
+
}
|
|
13098
13730
|
return {
|
|
13099
13731
|
stepId: step.id,
|
|
13100
13732
|
success: result.success,
|
|
13101
13733
|
message: result.message,
|
|
13102
13734
|
path: result.path,
|
|
13103
|
-
preview: result.preview
|
|
13735
|
+
preview: result.preview,
|
|
13736
|
+
outputs
|
|
13104
13737
|
};
|
|
13105
13738
|
} catch (error) {
|
|
13106
13739
|
return {
|
|
@@ -13491,6 +14124,9 @@ async function executePolicy(policy, vaultPath2, variables, commit = false) {
|
|
|
13491
14124
|
if (result.path && result.success && !result.skipped) {
|
|
13492
14125
|
filesModified.add(result.path);
|
|
13493
14126
|
}
|
|
14127
|
+
if (result.success && !result.skipped && result.outputs) {
|
|
14128
|
+
context.steps[step.id] = result.outputs;
|
|
14129
|
+
}
|
|
13494
14130
|
if (!result.success && !result.skipped) {
|
|
13495
14131
|
if (commit && filesModified.size > 0) {
|
|
13496
14132
|
await rollbackChanges(vaultPath2, originalContents, filesModified);
|
|
@@ -14451,21 +15087,26 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
|
|
|
14451
15087
|
"wikilink_feedback",
|
|
14452
15088
|
{
|
|
14453
15089
|
title: "Wikilink Feedback",
|
|
14454
|
-
description: 'Report and query wikilink accuracy feedback. Modes: "report" (record feedback), "list" (view recent feedback), "stats" (entity accuracy statistics), "dashboard" (full feedback loop data
|
|
15090
|
+
description: 'Report and query wikilink accuracy feedback. Modes: "report" (record feedback), "list" (view recent feedback), "stats" (entity accuracy statistics), "dashboard" (full feedback loop data), "entity_timeline" (score history for an entity), "layer_timeseries" (per-layer contribution over time), "snapshot_diff" (compare two graph snapshots).',
|
|
14455
15091
|
inputSchema: {
|
|
14456
|
-
mode: z21.enum(["report", "list", "stats", "dashboard"]).describe("Operation mode"),
|
|
14457
|
-
entity: z21.string().optional().describe("Entity name (required for report
|
|
15092
|
+
mode: z21.enum(["report", "list", "stats", "dashboard", "entity_timeline", "layer_timeseries", "snapshot_diff"]).describe("Operation mode"),
|
|
15093
|
+
entity: z21.string().optional().describe("Entity name (required for report and entity_timeline modes, optional filter for list/stats)"),
|
|
14458
15094
|
note_path: z21.string().optional().describe("Note path where the wikilink appeared (for report mode)"),
|
|
14459
15095
|
context: z21.string().optional().describe("Surrounding text context (for report mode)"),
|
|
14460
15096
|
correct: z21.boolean().optional().describe("Whether the wikilink was correct (for report mode)"),
|
|
14461
|
-
limit: z21.number().optional().describe("Max entries to return for list
|
|
15097
|
+
limit: z21.number().optional().describe("Max entries to return (default: 20 for list, 100 for entity_timeline)"),
|
|
15098
|
+
days_back: z21.number().optional().describe("Days to look back (default: 30)"),
|
|
15099
|
+
granularity: z21.enum(["day", "week"]).optional().describe("Time bucket granularity for layer_timeseries (default: day)"),
|
|
15100
|
+
timestamp_before: z21.number().optional().describe("Earlier timestamp for snapshot_diff"),
|
|
15101
|
+
timestamp_after: z21.number().optional().describe("Later timestamp for snapshot_diff")
|
|
14462
15102
|
}
|
|
14463
15103
|
},
|
|
14464
|
-
async ({ mode, entity, note_path, context, correct, limit }) => {
|
|
15104
|
+
async ({ mode, entity, note_path, context, correct, limit, days_back, granularity, timestamp_before, timestamp_after }) => {
|
|
14465
15105
|
const stateDb2 = getStateDb();
|
|
14466
15106
|
if (!stateDb2) {
|
|
14467
15107
|
return {
|
|
14468
|
-
content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
|
|
15108
|
+
content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available \u2014 database not initialized yet" }) }],
|
|
15109
|
+
isError: true
|
|
14469
15110
|
};
|
|
14470
15111
|
}
|
|
14471
15112
|
let result;
|
|
@@ -14476,7 +15117,15 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
|
|
|
14476
15117
|
content: [{ type: "text", text: JSON.stringify({ error: "entity and correct are required for report mode" }) }]
|
|
14477
15118
|
};
|
|
14478
15119
|
}
|
|
14479
|
-
|
|
15120
|
+
try {
|
|
15121
|
+
recordFeedback(stateDb2, entity, context || "", note_path || "", correct);
|
|
15122
|
+
} catch (e) {
|
|
15123
|
+
return {
|
|
15124
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
15125
|
+
error: `Failed to record feedback: ${e instanceof Error ? e.message : String(e)}`
|
|
15126
|
+
}) }]
|
|
15127
|
+
};
|
|
15128
|
+
}
|
|
14480
15129
|
const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
|
|
14481
15130
|
result = {
|
|
14482
15131
|
mode: "report",
|
|
@@ -14509,7 +15158,7 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
|
|
|
14509
15158
|
break;
|
|
14510
15159
|
}
|
|
14511
15160
|
case "dashboard": {
|
|
14512
|
-
const dashboard =
|
|
15161
|
+
const dashboard = getExtendedDashboardData(stateDb2);
|
|
14513
15162
|
result = {
|
|
14514
15163
|
mode: "dashboard",
|
|
14515
15164
|
dashboard,
|
|
@@ -14518,6 +15167,44 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
|
|
|
14518
15167
|
};
|
|
14519
15168
|
break;
|
|
14520
15169
|
}
|
|
15170
|
+
case "entity_timeline": {
|
|
15171
|
+
if (!entity) {
|
|
15172
|
+
return {
|
|
15173
|
+
content: [{ type: "text", text: JSON.stringify({ error: "entity is required for entity_timeline mode" }) }]
|
|
15174
|
+
};
|
|
15175
|
+
}
|
|
15176
|
+
const timeline = getEntityScoreTimeline(stateDb2, entity, days_back ?? 30, limit ?? 100);
|
|
15177
|
+
result = {
|
|
15178
|
+
mode: "entity_timeline",
|
|
15179
|
+
entity,
|
|
15180
|
+
timeline,
|
|
15181
|
+
count: timeline.length
|
|
15182
|
+
};
|
|
15183
|
+
break;
|
|
15184
|
+
}
|
|
15185
|
+
case "layer_timeseries": {
|
|
15186
|
+
const timeseries = getLayerContributionTimeseries(stateDb2, granularity ?? "day", days_back ?? 30);
|
|
15187
|
+
result = {
|
|
15188
|
+
mode: "layer_timeseries",
|
|
15189
|
+
granularity: granularity ?? "day",
|
|
15190
|
+
timeseries,
|
|
15191
|
+
buckets: timeseries.length
|
|
15192
|
+
};
|
|
15193
|
+
break;
|
|
15194
|
+
}
|
|
15195
|
+
case "snapshot_diff": {
|
|
15196
|
+
if (!timestamp_before || !timestamp_after) {
|
|
15197
|
+
return {
|
|
15198
|
+
content: [{ type: "text", text: JSON.stringify({ error: "timestamp_before and timestamp_after are required for snapshot_diff mode" }) }]
|
|
15199
|
+
};
|
|
15200
|
+
}
|
|
15201
|
+
const diff = compareGraphSnapshots(stateDb2, timestamp_before, timestamp_after);
|
|
15202
|
+
result = {
|
|
15203
|
+
mode: "snapshot_diff",
|
|
15204
|
+
diff
|
|
15205
|
+
};
|
|
15206
|
+
break;
|
|
15207
|
+
}
|
|
14521
15208
|
}
|
|
14522
15209
|
return {
|
|
14523
15210
|
content: [
|
|
@@ -15693,7 +16380,16 @@ function registerVaultResources(server2, getIndex) {
|
|
|
15693
16380
|
}
|
|
15694
16381
|
|
|
15695
16382
|
// src/index.ts
|
|
16383
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
16384
|
+
var __dirname = dirname4(__filename);
|
|
16385
|
+
var pkg = JSON.parse(readFileSync4(join16(__dirname, "../package.json"), "utf-8"));
|
|
15696
16386
|
var vaultPath = process.env.PROJECT_PATH || process.env.VAULT_PATH || findVaultRoot();
|
|
16387
|
+
var resolvedVaultPath;
|
|
16388
|
+
try {
|
|
16389
|
+
resolvedVaultPath = realpathSync(vaultPath).replace(/\\/g, "/");
|
|
16390
|
+
} catch {
|
|
16391
|
+
resolvedVaultPath = vaultPath.replace(/\\/g, "/");
|
|
16392
|
+
}
|
|
15697
16393
|
var vaultIndex;
|
|
15698
16394
|
var flywheelConfig = {};
|
|
15699
16395
|
var stateDb = null;
|
|
@@ -15847,7 +16543,7 @@ var TOOL_CATEGORY = {
|
|
|
15847
16543
|
};
|
|
15848
16544
|
var server = new McpServer({
|
|
15849
16545
|
name: "flywheel-memory",
|
|
15850
|
-
version:
|
|
16546
|
+
version: pkg.version
|
|
15851
16547
|
});
|
|
15852
16548
|
var _registeredCount = 0;
|
|
15853
16549
|
var _skippedCount = 0;
|
|
@@ -15931,6 +16627,7 @@ registerSystemTools(
|
|
|
15931
16627
|
() => vaultPath,
|
|
15932
16628
|
(newConfig) => {
|
|
15933
16629
|
flywheelConfig = newConfig;
|
|
16630
|
+
setWikilinkConfig(newConfig);
|
|
15934
16631
|
},
|
|
15935
16632
|
() => stateDb
|
|
15936
16633
|
);
|
|
@@ -15957,6 +16654,7 @@ registerConfigTools(
|
|
|
15957
16654
|
() => flywheelConfig,
|
|
15958
16655
|
(newConfig) => {
|
|
15959
16656
|
flywheelConfig = newConfig;
|
|
16657
|
+
setWikilinkConfig(newConfig);
|
|
15960
16658
|
},
|
|
15961
16659
|
() => stateDb
|
|
15962
16660
|
);
|
|
@@ -15974,7 +16672,7 @@ registerMergeTools2(server, () => stateDb);
|
|
|
15974
16672
|
registerVaultResources(server, () => vaultIndex ?? null);
|
|
15975
16673
|
serverLog("server", `Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
|
|
15976
16674
|
async function main() {
|
|
15977
|
-
serverLog("server",
|
|
16675
|
+
serverLog("server", `Starting Flywheel Memory v${pkg.version}...`);
|
|
15978
16676
|
serverLog("server", `Vault: ${vaultPath}`);
|
|
15979
16677
|
const startTime = Date.now();
|
|
15980
16678
|
try {
|
|
@@ -15986,6 +16684,7 @@ async function main() {
|
|
|
15986
16684
|
serverLog("statedb", "Injected FTS5, embeddings, task cache handles");
|
|
15987
16685
|
loadEntityEmbeddingsToMemory();
|
|
15988
16686
|
setWriteStateDb(stateDb);
|
|
16687
|
+
setRecencyStateDb(stateDb);
|
|
15989
16688
|
} catch (err) {
|
|
15990
16689
|
const msg = err instanceof Error ? err.message : String(err);
|
|
15991
16690
|
serverLog("statedb", `StateDb initialization failed: ${msg}`, "error");
|
|
@@ -16139,6 +16838,7 @@ async function runPostIndexWork(index) {
|
|
|
16139
16838
|
saveConfig(stateDb, inferred, existing);
|
|
16140
16839
|
}
|
|
16141
16840
|
flywheelConfig = loadConfig(stateDb);
|
|
16841
|
+
setWikilinkConfig(flywheelConfig);
|
|
16142
16842
|
const configKeys = Object.keys(flywheelConfig).filter((k) => flywheelConfig[k] != null);
|
|
16143
16843
|
serverLog("config", `Config inferred: ${configKeys.join(", ")}`);
|
|
16144
16844
|
if (stateDb) {
|
|
@@ -16190,6 +16890,48 @@ async function runPostIndexWork(index) {
|
|
|
16190
16890
|
vaultPath,
|
|
16191
16891
|
config,
|
|
16192
16892
|
onBatch: async (batch) => {
|
|
16893
|
+
const vaultPrefixes = /* @__PURE__ */ new Set([
|
|
16894
|
+
vaultPath.replace(/\\/g, "/"),
|
|
16895
|
+
resolvedVaultPath
|
|
16896
|
+
]);
|
|
16897
|
+
for (const event of batch.events) {
|
|
16898
|
+
const normalized = event.path.replace(/\\/g, "/");
|
|
16899
|
+
let matched = false;
|
|
16900
|
+
for (const prefix of vaultPrefixes) {
|
|
16901
|
+
if (normalized.startsWith(prefix + "/")) {
|
|
16902
|
+
event.path = normalized.slice(prefix.length + 1);
|
|
16903
|
+
matched = true;
|
|
16904
|
+
break;
|
|
16905
|
+
}
|
|
16906
|
+
}
|
|
16907
|
+
if (!matched) {
|
|
16908
|
+
try {
|
|
16909
|
+
const resolved = realpathSync(event.path).replace(/\\/g, "/");
|
|
16910
|
+
for (const prefix of vaultPrefixes) {
|
|
16911
|
+
if (resolved.startsWith(prefix + "/")) {
|
|
16912
|
+
event.path = resolved.slice(prefix.length + 1);
|
|
16913
|
+
matched = true;
|
|
16914
|
+
break;
|
|
16915
|
+
}
|
|
16916
|
+
}
|
|
16917
|
+
} catch {
|
|
16918
|
+
try {
|
|
16919
|
+
const dir = path29.dirname(event.path);
|
|
16920
|
+
const base = path29.basename(event.path);
|
|
16921
|
+
const resolvedDir = realpathSync(dir).replace(/\\/g, "/");
|
|
16922
|
+
for (const prefix of vaultPrefixes) {
|
|
16923
|
+
if (resolvedDir.startsWith(prefix + "/") || resolvedDir === prefix) {
|
|
16924
|
+
const relDir = resolvedDir === prefix ? "" : resolvedDir.slice(prefix.length + 1);
|
|
16925
|
+
event.path = relDir ? `${relDir}/${base}` : base;
|
|
16926
|
+
matched = true;
|
|
16927
|
+
break;
|
|
16928
|
+
}
|
|
16929
|
+
}
|
|
16930
|
+
} catch {
|
|
16931
|
+
}
|
|
16932
|
+
}
|
|
16933
|
+
}
|
|
16934
|
+
}
|
|
16193
16935
|
serverLog("watcher", `Processing ${batch.events.length} file changes`);
|
|
16194
16936
|
const batchStart = Date.now();
|
|
16195
16937
|
const changedPaths = batch.events.map((e) => e.path);
|
|
@@ -16200,6 +16942,11 @@ async function runPostIndexWork(index) {
|
|
|
16200
16942
|
setIndexState("ready");
|
|
16201
16943
|
tracker.end({ note_count: vaultIndex.notes.size, entity_count: vaultIndex.entities.size, tag_count: vaultIndex.tags.size });
|
|
16202
16944
|
serverLog("watcher", `Index rebuilt: ${vaultIndex.notes.size} notes, ${vaultIndex.entities.size} entities`);
|
|
16945
|
+
const hubBefore = /* @__PURE__ */ new Map();
|
|
16946
|
+
if (stateDb) {
|
|
16947
|
+
const rows = stateDb.db.prepare("SELECT name, hub_score FROM entities").all();
|
|
16948
|
+
for (const r of rows) hubBefore.set(r.name, r.hub_score);
|
|
16949
|
+
}
|
|
16203
16950
|
const entitiesBefore = stateDb ? getAllEntitiesFromDb3(stateDb) : [];
|
|
16204
16951
|
tracker.start("entity_scan", { note_count: vaultIndex.notes.size });
|
|
16205
16952
|
await updateEntitiesInStateDb();
|
|
@@ -16207,11 +16954,6 @@ async function runPostIndexWork(index) {
|
|
|
16207
16954
|
const entityDiff = computeEntityDiff(entitiesBefore, entitiesAfter);
|
|
16208
16955
|
tracker.end({ entity_count: entitiesAfter.length, ...entityDiff });
|
|
16209
16956
|
serverLog("watcher", `Entity scan: ${entitiesAfter.length} entities`);
|
|
16210
|
-
const hubBefore = /* @__PURE__ */ new Map();
|
|
16211
|
-
if (stateDb) {
|
|
16212
|
-
const rows = stateDb.db.prepare("SELECT name, hub_score FROM entities").all();
|
|
16213
|
-
for (const r of rows) hubBefore.set(r.name, r.hub_score);
|
|
16214
|
-
}
|
|
16215
16957
|
tracker.start("hub_scores", { entity_count: entitiesAfter.length });
|
|
16216
16958
|
const hubUpdated = await exportHubScores(vaultIndex, stateDb);
|
|
16217
16959
|
const hubDiffs = [];
|
|
@@ -16224,6 +16966,24 @@ async function runPostIndexWork(index) {
|
|
|
16224
16966
|
}
|
|
16225
16967
|
tracker.end({ updated: hubUpdated ?? 0, diffs: hubDiffs.slice(0, 10) });
|
|
16226
16968
|
serverLog("watcher", `Hub scores: ${hubUpdated ?? 0} updated`);
|
|
16969
|
+
tracker.start("recency", { entity_count: entitiesAfter.length });
|
|
16970
|
+
try {
|
|
16971
|
+
const cachedRecency = loadRecencyFromStateDb();
|
|
16972
|
+
const cacheAgeMs = cachedRecency ? Date.now() - (cachedRecency.lastUpdated ?? 0) : Infinity;
|
|
16973
|
+
if (cacheAgeMs >= 60 * 60 * 1e3) {
|
|
16974
|
+
const entities = entitiesAfter.map((e) => ({ name: e.name, path: e.path, aliases: e.aliases }));
|
|
16975
|
+
const recencyIndex2 = await buildRecencyIndex(vaultPath, entities);
|
|
16976
|
+
saveRecencyToStateDb(recencyIndex2);
|
|
16977
|
+
tracker.end({ rebuilt: true, entities: recencyIndex2.lastMentioned.size });
|
|
16978
|
+
serverLog("watcher", `Recency: rebuilt ${recencyIndex2.lastMentioned.size} entities`);
|
|
16979
|
+
} else {
|
|
16980
|
+
tracker.end({ rebuilt: false, cached_age_ms: cacheAgeMs });
|
|
16981
|
+
serverLog("watcher", `Recency: cache valid (${Math.round(cacheAgeMs / 1e3)}s old)`);
|
|
16982
|
+
}
|
|
16983
|
+
} catch (e) {
|
|
16984
|
+
tracker.end({ error: String(e) });
|
|
16985
|
+
serverLog("watcher", `Recency: failed: ${e}`);
|
|
16986
|
+
}
|
|
16227
16987
|
if (hasEmbeddingsIndex()) {
|
|
16228
16988
|
tracker.start("note_embeddings", { files: batch.events.length });
|
|
16229
16989
|
let embUpdated = 0;
|
|
@@ -16303,6 +17063,38 @@ async function runPostIndexWork(index) {
|
|
|
16303
17063
|
}
|
|
16304
17064
|
tracker.end({ updated: taskUpdated, removed: taskRemoved });
|
|
16305
17065
|
serverLog("watcher", `Task cache: ${taskUpdated} updated, ${taskRemoved} removed`);
|
|
17066
|
+
tracker.start("forward_links", { files: batch.events.length });
|
|
17067
|
+
const forwardLinkResults = [];
|
|
17068
|
+
let totalResolved = 0;
|
|
17069
|
+
let totalDead = 0;
|
|
17070
|
+
for (const event of batch.events) {
|
|
17071
|
+
if (event.type === "delete" || !event.path.endsWith(".md")) continue;
|
|
17072
|
+
try {
|
|
17073
|
+
const links = getForwardLinksForNote(vaultIndex, event.path);
|
|
17074
|
+
const resolved = [];
|
|
17075
|
+
const dead = [];
|
|
17076
|
+
const seen = /* @__PURE__ */ new Set();
|
|
17077
|
+
for (const link of links) {
|
|
17078
|
+
const name = link.target;
|
|
17079
|
+
if (seen.has(name.toLowerCase())) continue;
|
|
17080
|
+
seen.add(name.toLowerCase());
|
|
17081
|
+
if (link.exists) resolved.push(name);
|
|
17082
|
+
else dead.push(name);
|
|
17083
|
+
}
|
|
17084
|
+
if (resolved.length > 0 || dead.length > 0) {
|
|
17085
|
+
forwardLinkResults.push({ file: event.path, resolved, dead });
|
|
17086
|
+
}
|
|
17087
|
+
totalResolved += resolved.length;
|
|
17088
|
+
totalDead += dead.length;
|
|
17089
|
+
} catch {
|
|
17090
|
+
}
|
|
17091
|
+
}
|
|
17092
|
+
tracker.end({
|
|
17093
|
+
total_resolved: totalResolved,
|
|
17094
|
+
total_dead: totalDead,
|
|
17095
|
+
links: forwardLinkResults
|
|
17096
|
+
});
|
|
17097
|
+
serverLog("watcher", `Forward links: ${totalResolved} resolved, ${totalDead} dead`);
|
|
16306
17098
|
tracker.start("wikilink_check", { files: batch.events.length });
|
|
16307
17099
|
const trackedLinks = [];
|
|
16308
17100
|
if (stateDb) {
|
|
@@ -16376,6 +17168,8 @@ async function runPostIndexWork(index) {
|
|
|
16376
17168
|
watcher.start();
|
|
16377
17169
|
serverLog("watcher", "File watcher started");
|
|
16378
17170
|
}
|
|
17171
|
+
startSweepTimer(() => vaultIndex);
|
|
17172
|
+
serverLog("server", "Sweep timer started (5 min interval)");
|
|
16379
17173
|
const postDuration = Date.now() - postStart;
|
|
16380
17174
|
serverLog("server", `Post-index work complete in ${postDuration}ms`);
|
|
16381
17175
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velvetmonkey/flywheel-memory",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.35",
|
|
4
4
|
"description": "MCP server that gives Claude full read/write access to your Obsidian vault. 42 tools for search, backlinks, graph queries, mutations, and hybrid semantic search.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
"test:write": "vitest run test/write/",
|
|
43
43
|
"test:security": "vitest run test/write/security/",
|
|
44
44
|
"test:stress": "vitest run test/write/stress/ test/write/battle-hardening/",
|
|
45
|
+
"test:quality": "vitest run test/graph-quality/",
|
|
46
|
+
"test:quality:report": "tsx test/graph-quality/generate-proof.ts",
|
|
45
47
|
"test:coverage": "vitest run --coverage",
|
|
46
48
|
"test:ci": "vitest run --reporter=github-actions",
|
|
47
49
|
"lint": "tsc --noEmit",
|
|
@@ -50,7 +52,7 @@
|
|
|
50
52
|
},
|
|
51
53
|
"dependencies": {
|
|
52
54
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
53
|
-
"@velvetmonkey/vault-core": "^2.0.
|
|
55
|
+
"@velvetmonkey/vault-core": "^2.0.34",
|
|
54
56
|
"better-sqlite3": "^11.0.0",
|
|
55
57
|
"chokidar": "^4.0.0",
|
|
56
58
|
"gray-matter": "^4.0.3",
|