@velvetmonkey/flywheel-memory 2.0.34 → 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 +483 -55
- package/package.json +3 -1
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()) {
|
|
@@ -3241,9 +3251,14 @@ var FEEDBACK_BOOST_TIERS = [
|
|
|
3241
3251
|
{ minAccuracy: 0, minSamples: 5, boost: -4 }
|
|
3242
3252
|
];
|
|
3243
3253
|
function recordFeedback(stateDb2, entity, context, notePath, correct) {
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
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
|
+
}
|
|
3247
3262
|
}
|
|
3248
3263
|
function getFeedback(stateDb2, entity, limit = 20) {
|
|
3249
3264
|
let rows;
|
|
@@ -3554,6 +3569,159 @@ function getDashboardData(stateDb2) {
|
|
|
3554
3569
|
}))
|
|
3555
3570
|
};
|
|
3556
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
|
+
}
|
|
3557
3725
|
|
|
3558
3726
|
// src/core/write/git.ts
|
|
3559
3727
|
import { simpleGit, CheckRepoActions } from "simple-git";
|
|
@@ -3986,14 +4154,16 @@ function loadRecencyFromStateDb() {
|
|
|
3986
4154
|
}
|
|
3987
4155
|
function saveRecencyToStateDb(index) {
|
|
3988
4156
|
if (!moduleStateDb3) {
|
|
3989
|
-
console.error("[Flywheel] No StateDb available
|
|
4157
|
+
console.error("[Flywheel] saveRecencyToStateDb: No StateDb available (moduleStateDb is null)");
|
|
3990
4158
|
return;
|
|
3991
4159
|
}
|
|
4160
|
+
console.error(`[Flywheel] saveRecencyToStateDb: Saving ${index.lastMentioned.size} entries...`);
|
|
3992
4161
|
try {
|
|
3993
4162
|
for (const [entityNameLower, timestamp] of index.lastMentioned) {
|
|
3994
4163
|
recordEntityMention(moduleStateDb3, entityNameLower, new Date(timestamp));
|
|
3995
4164
|
}
|
|
3996
|
-
|
|
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`);
|
|
3997
4167
|
} catch (e) {
|
|
3998
4168
|
console.error("[Flywheel] Failed to save recency to StateDb:", e);
|
|
3999
4169
|
}
|
|
@@ -5084,7 +5254,8 @@ function processWikilinks(content, notePath) {
|
|
|
5084
5254
|
caseInsensitive: true
|
|
5085
5255
|
});
|
|
5086
5256
|
const implicitEnabled = moduleConfig?.implicit_detection !== false;
|
|
5087
|
-
const
|
|
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];
|
|
5088
5259
|
const implicitMatches = detectImplicitEntities(result.content, {
|
|
5089
5260
|
detectImplicit: implicitEnabled,
|
|
5090
5261
|
implicitPatterns,
|
|
@@ -5505,8 +5676,10 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5505
5676
|
excludeLinked = true,
|
|
5506
5677
|
strictness = getEffectiveStrictness(options.notePath),
|
|
5507
5678
|
notePath,
|
|
5508
|
-
detail = false
|
|
5679
|
+
detail = false,
|
|
5680
|
+
disabledLayers = []
|
|
5509
5681
|
} = options;
|
|
5682
|
+
const disabled = new Set(disabledLayers);
|
|
5510
5683
|
const config = STRICTNESS_CONFIGS[strictness];
|
|
5511
5684
|
const adaptiveMinScore = getAdaptiveMinScore(content.length, config.minSuggestionScore);
|
|
5512
5685
|
const noteContext = notePath ? getNoteContext(notePath) : "general";
|
|
@@ -5547,31 +5720,31 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5547
5720
|
for (const { entity, category } of entitiesWithTypes) {
|
|
5548
5721
|
const entityName = entity.name;
|
|
5549
5722
|
if (!entityName) continue;
|
|
5550
|
-
if (entityName.length > MAX_ENTITY_LENGTH) {
|
|
5723
|
+
if (!disabled.has("length_filter") && entityName.length > MAX_ENTITY_LENGTH) {
|
|
5551
5724
|
continue;
|
|
5552
5725
|
}
|
|
5553
|
-
if (isLikelyArticleTitle(entityName)) {
|
|
5726
|
+
if (!disabled.has("article_filter") && isLikelyArticleTitle(entityName)) {
|
|
5554
5727
|
continue;
|
|
5555
5728
|
}
|
|
5556
5729
|
if (linkedEntities.has(entityName.toLowerCase())) {
|
|
5557
5730
|
continue;
|
|
5558
5731
|
}
|
|
5559
|
-
const contentScore = scoreEntity(entity, contentTokens, contentStems, config);
|
|
5732
|
+
const contentScore = disabled.has("exact_match") && disabled.has("stem_match") ? 0 : scoreEntity(entity, contentTokens, contentStems, config);
|
|
5560
5733
|
let score = contentScore;
|
|
5561
5734
|
if (contentScore > 0) {
|
|
5562
5735
|
entitiesWithContentMatch.add(entityName);
|
|
5563
5736
|
}
|
|
5564
|
-
const layerTypeBoost = TYPE_BOOST[category] || 0;
|
|
5737
|
+
const layerTypeBoost = disabled.has("type_boost") ? 0 : TYPE_BOOST[category] || 0;
|
|
5565
5738
|
score += layerTypeBoost;
|
|
5566
|
-
const layerContextBoost = contextBoosts[category] || 0;
|
|
5739
|
+
const layerContextBoost = disabled.has("context_boost") ? 0 : contextBoosts[category] || 0;
|
|
5567
5740
|
score += layerContextBoost;
|
|
5568
|
-
const layerRecencyBoost = recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
|
|
5741
|
+
const layerRecencyBoost = disabled.has("recency") ? 0 : recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
|
|
5569
5742
|
score += layerRecencyBoost;
|
|
5570
|
-
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;
|
|
5571
5744
|
score += layerCrossFolderBoost;
|
|
5572
|
-
const layerHubBoost = getHubBoost(entity);
|
|
5745
|
+
const layerHubBoost = disabled.has("hub_boost") ? 0 : getHubBoost(entity);
|
|
5573
5746
|
score += layerHubBoost;
|
|
5574
|
-
const layerFeedbackAdj = feedbackBoosts.get(entityName) ?? 0;
|
|
5747
|
+
const layerFeedbackAdj = disabled.has("feedback") ? 0 : feedbackBoosts.get(entityName) ?? 0;
|
|
5575
5748
|
score += layerFeedbackAdj;
|
|
5576
5749
|
if (score > 0) {
|
|
5577
5750
|
directlyMatchedEntities.add(entityName);
|
|
@@ -5595,12 +5768,12 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5595
5768
|
});
|
|
5596
5769
|
}
|
|
5597
5770
|
}
|
|
5598
|
-
if (cooccurrenceIndex && directlyMatchedEntities.size > 0) {
|
|
5771
|
+
if (!disabled.has("cooccurrence") && cooccurrenceIndex && directlyMatchedEntities.size > 0) {
|
|
5599
5772
|
for (const { entity, category } of entitiesWithTypes) {
|
|
5600
5773
|
const entityName = entity.name;
|
|
5601
5774
|
if (!entityName) continue;
|
|
5602
|
-
if (entityName.length > MAX_ENTITY_LENGTH) continue;
|
|
5603
|
-
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;
|
|
5604
5777
|
if (linkedEntities.has(entityName.toLowerCase())) continue;
|
|
5605
5778
|
const boost = getCooccurrenceBoost(entityName, directlyMatchedEntities, cooccurrenceIndex, recencyIndex);
|
|
5606
5779
|
if (boost > 0) {
|
|
@@ -5617,12 +5790,12 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5617
5790
|
continue;
|
|
5618
5791
|
}
|
|
5619
5792
|
entitiesWithContentMatch.add(entityName);
|
|
5620
|
-
const typeBoost = TYPE_BOOST[category] || 0;
|
|
5621
|
-
const contextBoost = contextBoosts[category] || 0;
|
|
5622
|
-
const recencyBoostVal = recencyIndex ? getRecencyBoost(entityName, recencyIndex) : 0;
|
|
5623
|
-
const crossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
|
|
5624
|
-
const hubBoost = getHubBoost(entity);
|
|
5625
|
-
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;
|
|
5626
5799
|
const totalBoost = boost + typeBoost + contextBoost + recencyBoostVal + crossFolderBoost + hubBoost + feedbackAdj;
|
|
5627
5800
|
if (totalBoost >= adaptiveMinScore) {
|
|
5628
5801
|
scoredEntities.push({
|
|
@@ -5646,7 +5819,7 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5646
5819
|
}
|
|
5647
5820
|
}
|
|
5648
5821
|
}
|
|
5649
|
-
if (content.length >= 20 && hasEntityEmbeddingsIndex()) {
|
|
5822
|
+
if (!disabled.has("semantic") && content.length >= 20 && hasEntityEmbeddingsIndex()) {
|
|
5650
5823
|
try {
|
|
5651
5824
|
const contentEmbedding = await embedTextCached(content);
|
|
5652
5825
|
const alreadyScoredNames = new Set(scoredEntities.map((e) => e.name));
|
|
@@ -5668,14 +5841,14 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5668
5841
|
(et) => et.entity.name === match.entityName
|
|
5669
5842
|
);
|
|
5670
5843
|
if (!entityWithType) continue;
|
|
5671
|
-
if (match.entityName.length > MAX_ENTITY_LENGTH) continue;
|
|
5672
|
-
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;
|
|
5673
5846
|
const { entity, category } = entityWithType;
|
|
5674
|
-
const layerTypeBoost = TYPE_BOOST[category] || 0;
|
|
5675
|
-
const layerContextBoost = contextBoosts[category] || 0;
|
|
5676
|
-
const layerHubBoost = getHubBoost(entity);
|
|
5677
|
-
const layerCrossFolderBoost = notePath && entity.path ? getCrossFolderBoost(entity.path, notePath) : 0;
|
|
5678
|
-
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;
|
|
5679
5852
|
const totalScore = boost + layerTypeBoost + layerContextBoost + layerHubBoost + layerCrossFolderBoost + layerFeedbackAdj;
|
|
5680
5853
|
if (totalScore >= adaptiveMinScore) {
|
|
5681
5854
|
scoredEntities.push({
|
|
@@ -5717,7 +5890,51 @@ async function suggestRelatedLinks(content, options = {}) {
|
|
|
5717
5890
|
}
|
|
5718
5891
|
return 0;
|
|
5719
5892
|
});
|
|
5720
|
-
|
|
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);
|
|
5721
5938
|
const topSuggestions = topEntries.map((e) => e.name);
|
|
5722
5939
|
if (topSuggestions.length === 0) {
|
|
5723
5940
|
return emptyResult;
|
|
@@ -10295,6 +10512,49 @@ function getEmergingHubs(stateDb2, daysBack = 30) {
|
|
|
10295
10512
|
emerging.sort((a, b) => b.growth - a.growth);
|
|
10296
10513
|
return emerging;
|
|
10297
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
|
+
}
|
|
10298
10558
|
function purgeOldSnapshots(stateDb2, retentionDays = 90) {
|
|
10299
10559
|
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
|
|
10300
10560
|
const result = stateDb2.db.prepare(
|
|
@@ -11966,10 +12226,11 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
|
|
|
11966
12226
|
validate: z11.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
|
|
11967
12227
|
normalize: z11.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
|
|
11968
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."),
|
|
11969
12230
|
agent_id: z11.string().optional().describe('Agent identifier for multi-agent scoping (e.g., "claude-opus", "planning-agent")'),
|
|
11970
12231
|
session_id: z11.string().optional().describe('Session identifier for conversation scoping (e.g., "sess-abc123")')
|
|
11971
12232
|
},
|
|
11972
|
-
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 }) => {
|
|
11973
12234
|
let noteCreated = false;
|
|
11974
12235
|
let templateUsed;
|
|
11975
12236
|
if (create_if_missing) {
|
|
@@ -12004,6 +12265,12 @@ Example: vault_add_to_section({ path: "daily/2026-02-15.md", section: "Log", con
|
|
|
12004
12265
|
}
|
|
12005
12266
|
let workingContent = validationResult.content;
|
|
12006
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
|
+
}
|
|
12007
12274
|
const _debug = {
|
|
12008
12275
|
entityCount: getEntityIndexStats().totalEntities,
|
|
12009
12276
|
indexReady: getEntityIndexStats().ready,
|
|
@@ -12439,7 +12706,7 @@ function registerNoteTools(server2, vaultPath2, getIndex) {
|
|
|
12439
12706
|
overwrite: z14.boolean().default(false).describe("If true, overwrite existing file"),
|
|
12440
12707
|
commit: z14.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
|
|
12441
12708
|
skipWikilinks: z14.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
|
|
12442
|
-
suggestOutgoingLinks: z14.boolean().default(
|
|
12709
|
+
suggestOutgoingLinks: z14.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]], [[Philosophy]]").'),
|
|
12443
12710
|
maxSuggestions: z14.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
|
|
12444
12711
|
agent_id: z14.string().optional().describe("Agent identifier for multi-agent scoping"),
|
|
12445
12712
|
session_id: z14.string().optional().describe("Session identifier for conversation scoping")
|
|
@@ -13456,12 +13723,17 @@ async function executeStep(step, vaultPath2, context, conditionResults) {
|
|
|
13456
13723
|
const resolvedParams = interpolateObject(step.params, context);
|
|
13457
13724
|
try {
|
|
13458
13725
|
const result = await executeToolCall(step.tool, resolvedParams, vaultPath2, context);
|
|
13726
|
+
const outputs = {};
|
|
13727
|
+
if (result.path) {
|
|
13728
|
+
outputs.path = result.path;
|
|
13729
|
+
}
|
|
13459
13730
|
return {
|
|
13460
13731
|
stepId: step.id,
|
|
13461
13732
|
success: result.success,
|
|
13462
13733
|
message: result.message,
|
|
13463
13734
|
path: result.path,
|
|
13464
|
-
preview: result.preview
|
|
13735
|
+
preview: result.preview,
|
|
13736
|
+
outputs
|
|
13465
13737
|
};
|
|
13466
13738
|
} catch (error) {
|
|
13467
13739
|
return {
|
|
@@ -13852,6 +14124,9 @@ async function executePolicy(policy, vaultPath2, variables, commit = false) {
|
|
|
13852
14124
|
if (result.path && result.success && !result.skipped) {
|
|
13853
14125
|
filesModified.add(result.path);
|
|
13854
14126
|
}
|
|
14127
|
+
if (result.success && !result.skipped && result.outputs) {
|
|
14128
|
+
context.steps[step.id] = result.outputs;
|
|
14129
|
+
}
|
|
13855
14130
|
if (!result.success && !result.skipped) {
|
|
13856
14131
|
if (commit && filesModified.size > 0) {
|
|
13857
14132
|
await rollbackChanges(vaultPath2, originalContents, filesModified);
|
|
@@ -14812,21 +15087,26 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
|
|
|
14812
15087
|
"wikilink_feedback",
|
|
14813
15088
|
{
|
|
14814
15089
|
title: "Wikilink Feedback",
|
|
14815
|
-
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).',
|
|
14816
15091
|
inputSchema: {
|
|
14817
|
-
mode: z21.enum(["report", "list", "stats", "dashboard"]).describe("Operation mode"),
|
|
14818
|
-
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)"),
|
|
14819
15094
|
note_path: z21.string().optional().describe("Note path where the wikilink appeared (for report mode)"),
|
|
14820
15095
|
context: z21.string().optional().describe("Surrounding text context (for report mode)"),
|
|
14821
15096
|
correct: z21.boolean().optional().describe("Whether the wikilink was correct (for report mode)"),
|
|
14822
|
-
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")
|
|
14823
15102
|
}
|
|
14824
15103
|
},
|
|
14825
|
-
async ({ mode, entity, note_path, context, correct, limit }) => {
|
|
15104
|
+
async ({ mode, entity, note_path, context, correct, limit, days_back, granularity, timestamp_before, timestamp_after }) => {
|
|
14826
15105
|
const stateDb2 = getStateDb();
|
|
14827
15106
|
if (!stateDb2) {
|
|
14828
15107
|
return {
|
|
14829
|
-
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
|
|
14830
15110
|
};
|
|
14831
15111
|
}
|
|
14832
15112
|
let result;
|
|
@@ -14837,7 +15117,15 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
|
|
|
14837
15117
|
content: [{ type: "text", text: JSON.stringify({ error: "entity and correct are required for report mode" }) }]
|
|
14838
15118
|
};
|
|
14839
15119
|
}
|
|
14840
|
-
|
|
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
|
+
}
|
|
14841
15129
|
const suppressionUpdated = updateSuppressionList(stateDb2) > 0;
|
|
14842
15130
|
result = {
|
|
14843
15131
|
mode: "report",
|
|
@@ -14870,7 +15158,7 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
|
|
|
14870
15158
|
break;
|
|
14871
15159
|
}
|
|
14872
15160
|
case "dashboard": {
|
|
14873
|
-
const dashboard =
|
|
15161
|
+
const dashboard = getExtendedDashboardData(stateDb2);
|
|
14874
15162
|
result = {
|
|
14875
15163
|
mode: "dashboard",
|
|
14876
15164
|
dashboard,
|
|
@@ -14879,6 +15167,44 @@ function registerWikilinkFeedbackTools(server2, getStateDb) {
|
|
|
14879
15167
|
};
|
|
14880
15168
|
break;
|
|
14881
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
|
+
}
|
|
14882
15208
|
}
|
|
14883
15209
|
return {
|
|
14884
15210
|
content: [
|
|
@@ -16054,7 +16380,16 @@ function registerVaultResources(server2, getIndex) {
|
|
|
16054
16380
|
}
|
|
16055
16381
|
|
|
16056
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"));
|
|
16057
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
|
+
}
|
|
16058
16393
|
var vaultIndex;
|
|
16059
16394
|
var flywheelConfig = {};
|
|
16060
16395
|
var stateDb = null;
|
|
@@ -16208,7 +16543,7 @@ var TOOL_CATEGORY = {
|
|
|
16208
16543
|
};
|
|
16209
16544
|
var server = new McpServer({
|
|
16210
16545
|
name: "flywheel-memory",
|
|
16211
|
-
version:
|
|
16546
|
+
version: pkg.version
|
|
16212
16547
|
});
|
|
16213
16548
|
var _registeredCount = 0;
|
|
16214
16549
|
var _skippedCount = 0;
|
|
@@ -16337,7 +16672,7 @@ registerMergeTools2(server, () => stateDb);
|
|
|
16337
16672
|
registerVaultResources(server, () => vaultIndex ?? null);
|
|
16338
16673
|
serverLog("server", `Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
|
|
16339
16674
|
async function main() {
|
|
16340
|
-
serverLog("server",
|
|
16675
|
+
serverLog("server", `Starting Flywheel Memory v${pkg.version}...`);
|
|
16341
16676
|
serverLog("server", `Vault: ${vaultPath}`);
|
|
16342
16677
|
const startTime = Date.now();
|
|
16343
16678
|
try {
|
|
@@ -16349,6 +16684,7 @@ async function main() {
|
|
|
16349
16684
|
serverLog("statedb", "Injected FTS5, embeddings, task cache handles");
|
|
16350
16685
|
loadEntityEmbeddingsToMemory();
|
|
16351
16686
|
setWriteStateDb(stateDb);
|
|
16687
|
+
setRecencyStateDb(stateDb);
|
|
16352
16688
|
} catch (err) {
|
|
16353
16689
|
const msg = err instanceof Error ? err.message : String(err);
|
|
16354
16690
|
serverLog("statedb", `StateDb initialization failed: ${msg}`, "error");
|
|
@@ -16554,6 +16890,48 @@ async function runPostIndexWork(index) {
|
|
|
16554
16890
|
vaultPath,
|
|
16555
16891
|
config,
|
|
16556
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
|
+
}
|
|
16557
16935
|
serverLog("watcher", `Processing ${batch.events.length} file changes`);
|
|
16558
16936
|
const batchStart = Date.now();
|
|
16559
16937
|
const changedPaths = batch.events.map((e) => e.path);
|
|
@@ -16564,6 +16942,11 @@ async function runPostIndexWork(index) {
|
|
|
16564
16942
|
setIndexState("ready");
|
|
16565
16943
|
tracker.end({ note_count: vaultIndex.notes.size, entity_count: vaultIndex.entities.size, tag_count: vaultIndex.tags.size });
|
|
16566
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
|
+
}
|
|
16567
16950
|
const entitiesBefore = stateDb ? getAllEntitiesFromDb3(stateDb) : [];
|
|
16568
16951
|
tracker.start("entity_scan", { note_count: vaultIndex.notes.size });
|
|
16569
16952
|
await updateEntitiesInStateDb();
|
|
@@ -16571,11 +16954,6 @@ async function runPostIndexWork(index) {
|
|
|
16571
16954
|
const entityDiff = computeEntityDiff(entitiesBefore, entitiesAfter);
|
|
16572
16955
|
tracker.end({ entity_count: entitiesAfter.length, ...entityDiff });
|
|
16573
16956
|
serverLog("watcher", `Entity scan: ${entitiesAfter.length} entities`);
|
|
16574
|
-
const hubBefore = /* @__PURE__ */ new Map();
|
|
16575
|
-
if (stateDb) {
|
|
16576
|
-
const rows = stateDb.db.prepare("SELECT name, hub_score FROM entities").all();
|
|
16577
|
-
for (const r of rows) hubBefore.set(r.name, r.hub_score);
|
|
16578
|
-
}
|
|
16579
16957
|
tracker.start("hub_scores", { entity_count: entitiesAfter.length });
|
|
16580
16958
|
const hubUpdated = await exportHubScores(vaultIndex, stateDb);
|
|
16581
16959
|
const hubDiffs = [];
|
|
@@ -16588,6 +16966,24 @@ async function runPostIndexWork(index) {
|
|
|
16588
16966
|
}
|
|
16589
16967
|
tracker.end({ updated: hubUpdated ?? 0, diffs: hubDiffs.slice(0, 10) });
|
|
16590
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
|
+
}
|
|
16591
16987
|
if (hasEmbeddingsIndex()) {
|
|
16592
16988
|
tracker.start("note_embeddings", { files: batch.events.length });
|
|
16593
16989
|
let embUpdated = 0;
|
|
@@ -16667,6 +17063,38 @@ async function runPostIndexWork(index) {
|
|
|
16667
17063
|
}
|
|
16668
17064
|
tracker.end({ updated: taskUpdated, removed: taskRemoved });
|
|
16669
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`);
|
|
16670
17098
|
tracker.start("wikilink_check", { files: batch.events.length });
|
|
16671
17099
|
const trackedLinks = [];
|
|
16672
17100
|
if (stateDb) {
|
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",
|