@velvetmonkey/flywheel-memory 2.4.1 → 2.4.3

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.
Files changed (2) hide show
  1. package/dist/index.js +420 -72
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -2069,14 +2069,15 @@ function getAllSuppressionPenalties(stateDb2, now) {
2069
2069
  }
2070
2070
  return penalties;
2071
2071
  }
2072
- function trackWikilinkApplications(stateDb2, notePath, entities) {
2072
+ function trackWikilinkApplications(stateDb2, notePath, entities, source = "tool") {
2073
2073
  const upsert = stateDb2.db.prepare(`
2074
- INSERT INTO wikilink_applications (entity, note_path, matched_term, applied_at, status)
2075
- VALUES (?, ?, ?, datetime('now'), 'applied')
2074
+ INSERT INTO wikilink_applications (entity, note_path, matched_term, applied_at, status, source)
2075
+ VALUES (?, ?, ?, datetime('now'), 'applied', ?)
2076
2076
  ON CONFLICT(entity, note_path) DO UPDATE SET
2077
2077
  matched_term = COALESCE(?, matched_term),
2078
2078
  applied_at = datetime('now'),
2079
- status = 'applied'
2079
+ status = 'applied',
2080
+ source = ?
2080
2081
  `);
2081
2082
  const lookupCanonical = stateDb2.db.prepare(
2082
2083
  `SELECT name FROM entities WHERE LOWER(name) = LOWER(?) LIMIT 1`
@@ -2087,7 +2088,7 @@ function trackWikilinkApplications(stateDb2, notePath, entities) {
2087
2088
  const matchedTerm = typeof item === "string" ? null : item.matchedTerm ?? null;
2088
2089
  const row = lookupCanonical.get(entityName);
2089
2090
  const canonicalName = row?.name ?? entityName;
2090
- upsert.run(canonicalName, notePath, matchedTerm, matchedTerm);
2091
+ upsert.run(canonicalName, notePath, matchedTerm, source, matchedTerm, source);
2091
2092
  }
2092
2093
  });
2093
2094
  transaction();
@@ -4718,7 +4719,7 @@ async function applyProactiveSuggestions(filePath, vaultPath2, suggestions, conf
4718
4719
  return { applied: [], skipped: candidates.map((c) => c.entity) };
4719
4720
  }
4720
4721
  if (stateDb2) {
4721
- trackWikilinkApplications(stateDb2, filePath, result.linkedEntities);
4722
+ trackWikilinkApplications(stateDb2, filePath, result.linkedEntities, "proactive");
4722
4723
  try {
4723
4724
  const markApplied = stateDb2.db.prepare(
4724
4725
  `UPDATE suggestion_events SET applied = 1
@@ -5001,7 +5002,7 @@ async function drainProactiveQueue(stateDb2, vaultPath2, config, applyFn) {
5001
5002
  todayMidnight.setHours(0, 0, 0, 0);
5002
5003
  const todayStr = todayMidnight.toISOString().slice(0, 10);
5003
5004
  const countTodayApplied = stateDb2.db.prepare(
5004
- `SELECT COUNT(*) as cnt FROM wikilink_applications WHERE note_path = ? AND applied_at >= ?`
5005
+ `SELECT COUNT(*) as cnt FROM wikilink_applications WHERE note_path = ? AND applied_at >= ? AND source = 'proactive'`
5005
5006
  );
5006
5007
  for (const [filePath, suggestions] of byFile) {
5007
5008
  const fullPath = path12.join(vaultPath2, filePath);
@@ -8391,6 +8392,155 @@ function createStepTracker() {
8391
8392
  }
8392
8393
  };
8393
8394
  }
8395
+ function compactStep(step) {
8396
+ const out = step.output ?? {};
8397
+ let summary;
8398
+ switch (step.name) {
8399
+ case "entity_scan":
8400
+ summary = {
8401
+ entity_count: asNum(out.entity_count),
8402
+ added_count: asArrayLen(out.added),
8403
+ removed_count: asArrayLen(out.removed),
8404
+ alias_change_count: asArrayLen(out.alias_changes),
8405
+ category_change_count: asArrayLen(out.category_changes)
8406
+ };
8407
+ break;
8408
+ case "hub_scores":
8409
+ summary = {
8410
+ updated: asNum(out.updated),
8411
+ diff_count: asArrayLen(out.diffs)
8412
+ };
8413
+ break;
8414
+ case "forward_links":
8415
+ summary = {
8416
+ total_resolved: asNum(out.total_resolved),
8417
+ total_dead: asNum(out.total_dead),
8418
+ new_dead_count: asArrayLen(out.new_dead_links),
8419
+ diff_count: asArrayLen(out.link_diffs)
8420
+ };
8421
+ break;
8422
+ case "wikilink_check":
8423
+ summary = {
8424
+ tracked_count: asArrayLen(out.tracked),
8425
+ mention_count: asArrayLen(out.mentions)
8426
+ };
8427
+ break;
8428
+ case "prospect_scan": {
8429
+ const prospects = Array.isArray(out.prospects) ? out.prospects : [];
8430
+ summary = {
8431
+ implicit_count: prospects.reduce((s, p) => s + (Array.isArray(p.implicit) ? p.implicit.length : 0), 0),
8432
+ dead_match_count: prospects.reduce((s, p) => s + (Array.isArray(p.deadLinkMatches) ? p.deadLinkMatches.length : 0), 0)
8433
+ };
8434
+ break;
8435
+ }
8436
+ case "suggestion_scoring":
8437
+ summary = { scored_files: asNum(out.scored_files) };
8438
+ break;
8439
+ case "implicit_feedback":
8440
+ summary = {
8441
+ removal_count: asArrayLen(out.removals),
8442
+ addition_count: asArrayLen(out.additions),
8443
+ suppressed_count: asArrayLen(out.newly_suppressed)
8444
+ };
8445
+ break;
8446
+ case "note_embeddings":
8447
+ summary = { updated: asNum(out.updated), removed: asNum(out.removed) };
8448
+ break;
8449
+ case "entity_embeddings":
8450
+ summary = { updated: asNum(out.updated) };
8451
+ break;
8452
+ case "fts5_incremental":
8453
+ summary = { updated: asNum(out.updated), removed: asNum(out.removed) };
8454
+ break;
8455
+ case "index_rebuild":
8456
+ summary = {
8457
+ note_count: asNum(out.note_count),
8458
+ entity_count: asNum(out.entity_count),
8459
+ tag_count: asNum(out.tag_count)
8460
+ };
8461
+ break;
8462
+ case "index_cache":
8463
+ summary = { saved: asBool(out.saved) };
8464
+ break;
8465
+ case "task_cache":
8466
+ summary = { updated: asNum(out.updated), removed: asNum(out.removed) };
8467
+ break;
8468
+ case "tag_scan":
8469
+ summary = { added_count: asNum(out.total_added), removed_count: asNum(out.total_removed) };
8470
+ break;
8471
+ case "drain_proactive_queue":
8472
+ summary = {
8473
+ total_applied: asNum(out.total_applied),
8474
+ expired: asNum(out.expired),
8475
+ skipped_mtime: asNum(out.skipped_mtime),
8476
+ skipped_daily_cap: asNum(out.skipped_daily_cap)
8477
+ };
8478
+ break;
8479
+ case "proactive_enqueue":
8480
+ summary = { enqueued: asNum(out.enqueued), total_candidates: asNum(out.total_candidates) };
8481
+ break;
8482
+ case "recency":
8483
+ case "cooccurrence":
8484
+ case "edge_weights":
8485
+ summary = { rebuilt: asBool(out.rebuilt) };
8486
+ break;
8487
+ case "incremental_recency":
8488
+ summary = { entities_updated: asNum(out.entities_updated) };
8489
+ break;
8490
+ case "corrections":
8491
+ summary = { processed: asNum(out.processed) };
8492
+ break;
8493
+ case "retrieval_cooccurrence":
8494
+ summary = { pairs_inserted: asNum(out.pairs_inserted) };
8495
+ break;
8496
+ case "integrity_check":
8497
+ case "maintenance":
8498
+ summary = {
8499
+ ...out.skipped != null ? { skipped: asBool(out.skipped) } : {},
8500
+ ...typeof out.reason === "string" ? { reason: out.reason } : {}
8501
+ };
8502
+ break;
8503
+ case "note_moves":
8504
+ summary = { count: asNum(out.count ?? (Array.isArray(out.renames) ? out.renames.length : 0)) };
8505
+ break;
8506
+ default:
8507
+ summary = {};
8508
+ for (const [k, v] of Object.entries(out)) {
8509
+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "string") {
8510
+ summary[k] = v;
8511
+ }
8512
+ }
8513
+ break;
8514
+ }
8515
+ return {
8516
+ name: step.name,
8517
+ duration_ms: step.duration_ms,
8518
+ ...step.skipped ? { skipped: true, skip_reason: step.skip_reason } : {},
8519
+ summary
8520
+ };
8521
+ }
8522
+ function compactPipelineRun(event) {
8523
+ const paths = event.changed_paths ?? [];
8524
+ return {
8525
+ timestamp: event.timestamp,
8526
+ trigger: event.trigger,
8527
+ duration_ms: event.duration_ms,
8528
+ files_changed: event.files_changed,
8529
+ changed_paths_total: event.files_changed ?? paths.length,
8530
+ changed_paths_sample: paths.slice(0, 3),
8531
+ step_count: event.steps?.length ?? 0,
8532
+ steps: (event.steps ?? []).map(compactStep)
8533
+ };
8534
+ }
8535
+ function asNum(v) {
8536
+ return typeof v === "number" ? v : 0;
8537
+ }
8538
+ function asBool(v) {
8539
+ return typeof v === "boolean" ? v : false;
8540
+ }
8541
+ function asArrayLen(v) {
8542
+ return Array.isArray(v) ? v.length : 0;
8543
+ }
8394
8544
  function recordIndexEvent(stateDb2, event) {
8395
8545
  stateDb2.db.prepare(
8396
8546
  `INSERT INTO index_events (timestamp, trigger, duration_ms, success, note_count, files_changed, changed_paths, error, steps)
@@ -10013,7 +10163,7 @@ var PipelineRunner = class {
10013
10163
  }
10014
10164
  }
10015
10165
  if (newlyTracked.length > 0) {
10016
- trackWikilinkApplications(p.sd, diff.file, newlyTracked);
10166
+ trackWikilinkApplications(p.sd, diff.file, newlyTracked, "manual_detected");
10017
10167
  }
10018
10168
  }
10019
10169
  }
@@ -11406,7 +11556,7 @@ function getSessionHistory(stateDb2, sessionId) {
11406
11556
  }));
11407
11557
  }
11408
11558
  function getSessionDetail(stateDb2, sessionId, options = {}) {
11409
- const { include_children = true, limit = 200 } = options;
11559
+ const { include_children = true, limit = 50 } = options;
11410
11560
  const rows = include_children ? stateDb2.db.prepare(`
11411
11561
  SELECT * FROM tool_invocations
11412
11562
  WHERE session_id = ? OR session_id LIKE ?
@@ -13329,7 +13479,8 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb4) {
13329
13479
  async ({ query, where, has_tag, has_any_tag, has_all_tags, include_children, folder, title_contains, modified_after, modified_before, sort_by, order, prefix, limit: requestedLimit, detail_count: requestedDetailCount, context_note, consumer }) => {
13330
13480
  requireIndex();
13331
13481
  const limit = Math.min(requestedLimit ?? 10, MAX_LIMIT);
13332
- const detailN = requestedDetailCount ?? 5;
13482
+ const enrichN = Math.min(requestedDetailCount ?? 5, limit);
13483
+ const expandN = Math.min(enrichN, 8);
13333
13484
  const index = getIndex();
13334
13485
  const vaultPath2 = getVaultPath();
13335
13486
  if (prefix && query) {
@@ -13383,7 +13534,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb4) {
13383
13534
  const limitedNotes = matchingNotes.slice(0, limit);
13384
13535
  const stateDb2 = getStateDb4();
13385
13536
  const notes = limitedNotes.map(
13386
- (note, i) => (i < detailN ? enrichResult : enrichResultLight)({ path: note.path, title: note.title }, index, stateDb2)
13537
+ (note, i) => (i < enrichN ? enrichResult : enrichResultLight)({ path: note.path, title: note.title }, index, stateDb2)
13387
13538
  );
13388
13539
  return { content: [{ type: "text", text: JSON.stringify({
13389
13540
  total_matches: totalMatches,
@@ -13518,7 +13669,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb4) {
13518
13669
  await enhanceSnippets(results2, query, vaultPath2);
13519
13670
  if (consumer === "llm") {
13520
13671
  applySandwichOrdering(results2);
13521
- await expandToSections(results2, index, vaultPath2, detailN);
13672
+ await expandToSections(results2, index, vaultPath2, expandN);
13522
13673
  stripInternalFields(results2);
13523
13674
  }
13524
13675
  const entitySection2 = await entitySectionPromise;
@@ -13566,7 +13717,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb4) {
13566
13717
  await enhanceSnippets(results2, query, vaultPath2);
13567
13718
  if (consumer === "llm") {
13568
13719
  applySandwichOrdering(results2);
13569
- await expandToSections(results2, index, vaultPath2, detailN);
13720
+ await expandToSections(results2, index, vaultPath2, expandN);
13570
13721
  stripInternalFields(results2);
13571
13722
  }
13572
13723
  const entitySection2 = await entitySectionPromise;
@@ -13593,7 +13744,7 @@ function registerQueryTools(server2, getIndex, getVaultPath, getStateDb4) {
13593
13744
  await enhanceSnippets(results, query, vaultPath2);
13594
13745
  if (consumer === "llm") {
13595
13746
  applySandwichOrdering(results);
13596
- await expandToSections(results, index, vaultPath2, detailN);
13747
+ await expandToSections(results, index, vaultPath2, expandN);
13597
13748
  stripInternalFields(results);
13598
13749
  }
13599
13750
  const entitySection = await entitySectionPromise;
@@ -14532,13 +14683,28 @@ function registerGraphExportTools(server2, getIndex, getVaultPath, getStateDb4)
14532
14683
  include_cooccurrence: z3.boolean().default(true).describe("Include co-occurrence edges between entities"),
14533
14684
  min_edge_weight: z3.number().default(0).describe("Minimum edge weight threshold (filters weighted edges)"),
14534
14685
  center_entity: z3.string().optional().describe("Center the export on this entity (ego network). Only includes nodes within `depth` hops."),
14535
- depth: z3.number().default(1).describe("Hops from center_entity to include (default 1). Ignored without center_entity.")
14686
+ depth: z3.number().default(1).describe("Hops from center_entity to include (default 1). Ignored without center_entity."),
14687
+ max_nodes: z3.number().default(500).describe("Maximum nodes in export when no center_entity. Pass higher for full vault exports.")
14536
14688
  },
14537
- async ({ format, include_cooccurrence, min_edge_weight, center_entity, depth }) => {
14689
+ async ({ format, include_cooccurrence, min_edge_weight, center_entity, depth, max_nodes }) => {
14538
14690
  requireIndex();
14539
14691
  const index = getIndex();
14540
14692
  const stateDb2 = getStateDb4?.() ?? null;
14541
14693
  const data = buildGraphData(index, stateDb2, { include_cooccurrence, min_edge_weight, center_entity, depth });
14694
+ if (!center_entity && data.nodes.length > max_nodes) {
14695
+ return {
14696
+ content: [{
14697
+ type: "text",
14698
+ text: JSON.stringify({
14699
+ error: `Graph has ${data.nodes.length} nodes and ${data.edges.length} edges, exceeding max_nodes=${max_nodes}. Use center_entity to scope, or pass a higher max_nodes.`,
14700
+ node_count: data.nodes.length,
14701
+ edge_count: data.edges.length,
14702
+ max_nodes
14703
+ })
14704
+ }],
14705
+ isError: true
14706
+ };
14707
+ }
14542
14708
  let output;
14543
14709
  if (format === "json") {
14544
14710
  output = JSON.stringify(data, null, 2);
@@ -15402,6 +15568,55 @@ function getActivitySummary(index, days) {
15402
15568
  import { SCHEMA_VERSION } from "@velvetmonkey/vault-core";
15403
15569
  init_embeddings();
15404
15570
  init_serverLog();
15571
+
15572
+ // src/core/shared/proactiveLinkingStats.ts
15573
+ function toSqliteTimestamp(date) {
15574
+ return date.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "");
15575
+ }
15576
+ function getProactiveLinkingSummary(stateDb2, daysBack = 1) {
15577
+ const now = /* @__PURE__ */ new Date();
15578
+ const since = new Date(now.getTime() - daysBack * 24 * 60 * 60 * 1e3);
15579
+ const sinceStr = toSqliteTimestamp(since);
15580
+ const untilStr = toSqliteTimestamp(now);
15581
+ const survived = stateDb2.db.prepare(
15582
+ `SELECT COUNT(*) as cnt FROM wikilink_applications
15583
+ WHERE source = 'proactive' AND applied_at >= ? AND status = 'applied'`
15584
+ ).get(sinceStr);
15585
+ const removed = stateDb2.db.prepare(
15586
+ `SELECT COUNT(*) as cnt FROM wikilink_applications
15587
+ WHERE source = 'proactive' AND applied_at >= ? AND status = 'removed'`
15588
+ ).get(sinceStr);
15589
+ const files = stateDb2.db.prepare(
15590
+ `SELECT COUNT(DISTINCT note_path) as cnt FROM wikilink_applications
15591
+ WHERE source = 'proactive' AND applied_at >= ?`
15592
+ ).get(sinceStr);
15593
+ const recent = stateDb2.db.prepare(
15594
+ `SELECT entity, note_path, applied_at, status FROM wikilink_applications
15595
+ WHERE source = 'proactive' AND applied_at >= ?
15596
+ ORDER BY applied_at DESC LIMIT 10`
15597
+ ).all(sinceStr);
15598
+ const totalApplied = survived.cnt + removed.cnt;
15599
+ const survivalRate = totalApplied > 0 ? survived.cnt / totalApplied : null;
15600
+ return {
15601
+ window: { kind: "rolling_24h", since: sinceStr, until: untilStr },
15602
+ total_applied: totalApplied,
15603
+ survived: survived.cnt,
15604
+ removed: removed.cnt,
15605
+ files_touched: files.cnt,
15606
+ survival_rate: survivalRate,
15607
+ recent
15608
+ };
15609
+ }
15610
+ function getProactiveLinkingOneLiner(stateDb2, daysBack = 1) {
15611
+ const summary = getProactiveLinkingSummary(stateDb2, daysBack);
15612
+ if (summary.total_applied === 0) return null;
15613
+ const linkWord = summary.total_applied === 1 ? "link" : "links";
15614
+ const noteWord = summary.files_touched === 1 ? "note" : "notes";
15615
+ const rate = summary.survival_rate !== null ? `${Math.round(summary.survival_rate * 100)}%` : "n/a";
15616
+ return `${summary.total_applied} ${linkWord} applied across ${summary.files_touched} ${noteWord} (${summary.survived} survived, ${rate} rate)`;
15617
+ }
15618
+
15619
+ // src/tools/read/health.ts
15405
15620
  init_wikilinkFeedback();
15406
15621
  init_embeddings();
15407
15622
  var STALE_THRESHOLD_SECONDS = 300;
@@ -15447,31 +15662,33 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig2 = () =>
15447
15662
  trigger: z5.string(),
15448
15663
  duration_ms: z5.number(),
15449
15664
  files_changed: z5.number().nullable(),
15450
- changed_paths: z5.array(z5.string()).nullable(),
15665
+ changed_paths_total: z5.number().describe("Total number of changed files"),
15666
+ changed_paths_sample: z5.array(z5.string()).describe("Up to 3 sample changed paths"),
15667
+ step_count: z5.number().describe("Number of pipeline steps"),
15451
15668
  steps: z5.array(z5.object({
15452
15669
  name: z5.string(),
15453
15670
  duration_ms: z5.number(),
15454
- input: z5.record(z5.unknown()),
15455
- output: z5.record(z5.unknown()),
15456
15671
  skipped: z5.boolean().optional(),
15457
- skip_reason: z5.string().optional()
15458
- }))
15459
- }).optional().describe("Most recent watcher pipeline run with per-step timing"),
15672
+ skip_reason: z5.string().optional(),
15673
+ summary: z5.record(z5.union([z5.number(), z5.boolean(), z5.string()]))
15674
+ })).optional().describe("Compact step summaries (full mode only)")
15675
+ }).optional().describe("Most recent watcher pipeline run"),
15460
15676
  recent_pipelines: z5.array(z5.object({
15461
15677
  timestamp: z5.number(),
15462
15678
  trigger: z5.string(),
15463
15679
  duration_ms: z5.number(),
15464
15680
  files_changed: z5.number().nullable(),
15465
- changed_paths: z5.array(z5.string()).nullable(),
15681
+ changed_paths_total: z5.number(),
15682
+ changed_paths_sample: z5.array(z5.string()),
15683
+ step_count: z5.number(),
15466
15684
  steps: z5.array(z5.object({
15467
15685
  name: z5.string(),
15468
15686
  duration_ms: z5.number(),
15469
- input: z5.record(z5.unknown()),
15470
- output: z5.record(z5.unknown()),
15471
15687
  skipped: z5.boolean().optional(),
15472
- skip_reason: z5.string().optional()
15688
+ skip_reason: z5.string().optional(),
15689
+ summary: z5.record(z5.union([z5.number(), z5.boolean(), z5.string()]))
15473
15690
  }))
15474
- })).optional().describe("Up to 5 most recent pipeline runs with steps data"),
15691
+ })).optional().describe("Up to 5 most recent pipeline runs with compact step summaries (full mode only)"),
15475
15692
  fts5_ready: z5.boolean().describe("Whether the FTS5 keyword search index is ready"),
15476
15693
  fts5_building: z5.boolean().describe("Whether the FTS5 keyword search index is currently building"),
15477
15694
  embeddings_building: z5.boolean().describe("Whether semantic embeddings are currently building"),
@@ -15527,6 +15744,15 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig2 = () =>
15527
15744
  unlinked_mentions: z5.number()
15528
15745
  })).describe("Entities with the most unlinked plain-text mentions")
15529
15746
  }).optional().describe("Background sweep results (graph hygiene metrics, updated every 5 min)"),
15747
+ proactive_linking: z5.object({
15748
+ enabled: z5.boolean().describe("Whether proactive linking is enabled in config"),
15749
+ queue_pending: z5.number().describe("Number of queued proactive suggestions awaiting drain"),
15750
+ summary: z5.string().nullable().describe('One-liner: "12 links applied across 8 notes (11 survived, 92% rate)"'),
15751
+ total_applied_24h: z5.number().describe("Total proactive applications in last 24h"),
15752
+ survived_24h: z5.number().describe("Proactive links still present"),
15753
+ removed_24h: z5.number().describe("Proactive links removed by user"),
15754
+ files_24h: z5.number().describe("Distinct files touched by proactive linking")
15755
+ }).optional().describe("Proactive linking observability (full mode only)"),
15530
15756
  recommendations: z5.array(z5.string()).describe("Suggested actions if any issues detected")
15531
15757
  };
15532
15758
  server2.registerTool(
@@ -15659,14 +15885,13 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig2 = () =>
15659
15885
  try {
15660
15886
  const evt = getRecentPipelineEvent(stateDb2);
15661
15887
  if (evt && evt.steps && evt.steps.length > 0) {
15662
- lastPipeline = {
15663
- timestamp: evt.timestamp,
15664
- trigger: evt.trigger,
15665
- duration_ms: evt.duration_ms,
15666
- files_changed: evt.files_changed,
15667
- changed_paths: evt.changed_paths,
15668
- steps: evt.steps
15669
- };
15888
+ const compact = compactPipelineRun(evt);
15889
+ if (isFull) {
15890
+ lastPipeline = compact;
15891
+ } else {
15892
+ const { steps: _steps, ...metadataOnly } = compact;
15893
+ lastPipeline = metadataOnly;
15894
+ }
15670
15895
  }
15671
15896
  } catch {
15672
15897
  }
@@ -15674,14 +15899,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig2 = () =>
15674
15899
  try {
15675
15900
  const events = getRecentIndexEvents(stateDb2, 10).filter((e) => e.steps && e.steps.length > 0).slice(0, 5);
15676
15901
  if (events.length > 0) {
15677
- recentPipelines = events.map((e) => ({
15678
- timestamp: e.timestamp,
15679
- trigger: e.trigger,
15680
- duration_ms: e.duration_ms,
15681
- files_changed: e.files_changed,
15682
- changed_paths: e.changed_paths,
15683
- steps: e.steps
15684
- }));
15902
+ recentPipelines = events.map((e) => compactPipelineRun(e));
15685
15903
  }
15686
15904
  } catch {
15687
15905
  }
@@ -15779,6 +15997,24 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig2 = () =>
15779
15997
  dead_link_count: isFull ? deadLinkCount : void 0,
15780
15998
  top_dead_link_targets: isFull ? topDeadLinkTargets : void 0,
15781
15999
  sweep: isFull ? getSweepResults() ?? void 0 : void 0,
16000
+ proactive_linking: isFull && stateDb2 ? (() => {
16001
+ const config = getConfig2();
16002
+ const enabled = config.proactive_linking !== false;
16003
+ const queuePending = stateDb2.db.prepare(
16004
+ `SELECT COUNT(*) as cnt FROM proactive_queue WHERE status = 'pending'`
16005
+ ).get();
16006
+ const summary = getProactiveLinkingSummary(stateDb2, 1);
16007
+ const oneLiner = getProactiveLinkingOneLiner(stateDb2, 1);
16008
+ return {
16009
+ enabled,
16010
+ queue_pending: queuePending.cnt,
16011
+ summary: oneLiner,
16012
+ total_applied_24h: summary.total_applied,
16013
+ survived_24h: summary.survived,
16014
+ removed_24h: summary.removed,
16015
+ files_24h: summary.files_touched
16016
+ };
16017
+ })() : void 0,
15782
16018
  recommendations
15783
16019
  };
15784
16020
  return {
@@ -15826,13 +16062,7 @@ function registerHealthTools(server2, getIndex, getVaultPath, getConfig2 = () =>
15826
16062
  if (stateDb2) {
15827
16063
  try {
15828
16064
  const events = getRecentIndexEvents(stateDb2, 10).filter((e) => e.steps && e.steps.length > 0).slice(0, 5);
15829
- output.recent_runs = events.map((e) => ({
15830
- timestamp: e.timestamp,
15831
- trigger: e.trigger,
15832
- duration_ms: e.duration_ms,
15833
- files_changed: e.files_changed,
15834
- steps: e.steps
15835
- }));
16065
+ output.recent_runs = events.map((e) => compactPipelineRun(e));
15836
16066
  } catch {
15837
16067
  }
15838
16068
  }
@@ -17060,7 +17290,7 @@ function registerSystemTools(server2, getIndex, setIndex, getVaultPath, setConfi
17060
17290
  description: "Use when listing all linkable entities grouped by category. Produces the full entity index from the state database with names, aliases, hub scores, and categories. Returns an array of entity profiles. Does not search note content \u2014 only returns entity metadata from the index.",
17061
17291
  inputSchema: {
17062
17292
  category: z6.string().optional().describe('Filter to a specific category (e.g. "people", "technologies")'),
17063
- limit: z6.coerce.number().default(2e3).describe("Maximum entities per category")
17293
+ limit: z6.coerce.number().default(200).describe("Maximum entities per category (default 200; pass higher for full hydration)")
17064
17294
  }
17065
17295
  },
17066
17296
  async ({
@@ -17215,10 +17445,11 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig2 = ()
17215
17445
  description: "Use after search identifies a note you need detail on. Produces heading tree, frontmatter, tags, word count, backlink and outlink counts, and optionally full section content. Returns enriched note structure with entity metadata when available. Does not search \u2014 requires an exact path from a prior search result.",
17216
17446
  inputSchema: {
17217
17447
  path: z7.string().describe("Path to the note"),
17218
- include_content: z7.boolean().default(true).describe("Include the text content under each top-level section. Set false to get structure only.")
17448
+ include_content: z7.boolean().default(false).describe("Include the text content under each top-level section. Default false for structure only."),
17449
+ max_content_chars: z7.number().default(2e4).describe("Max total chars of section content to include. Sections are truncated at paragraph boundaries.")
17219
17450
  }
17220
17451
  },
17221
- async ({ path: path40, include_content }) => {
17452
+ async ({ path: path40, include_content, max_content_chars }) => {
17222
17453
  const index = getIndex();
17223
17454
  const vaultPath2 = getVaultPath();
17224
17455
  const result = await getNoteStructure(index, path40, vaultPath2);
@@ -17227,11 +17458,26 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig2 = ()
17227
17458
  content: [{ type: "text", text: JSON.stringify({ error: "Note not found", path: path40 }, null, 2) }]
17228
17459
  };
17229
17460
  }
17461
+ let totalChars = 0;
17462
+ let truncated = false;
17230
17463
  if (include_content) {
17231
17464
  for (const section of result.sections) {
17465
+ if (totalChars >= max_content_chars) {
17466
+ truncated = true;
17467
+ break;
17468
+ }
17232
17469
  const sectionResult = await getSectionContent(index, path40, section.heading.text, vaultPath2, true);
17233
17470
  if (sectionResult) {
17234
- section.content = sectionResult.content;
17471
+ let content = sectionResult.content;
17472
+ const remaining = max_content_chars - totalChars;
17473
+ if (content.length > remaining) {
17474
+ const sliced = content.slice(0, remaining);
17475
+ const lastBreak = sliced.lastIndexOf("\n\n");
17476
+ content = lastBreak > 0 ? sliced.slice(0, lastBreak) : sliced;
17477
+ truncated = true;
17478
+ }
17479
+ section.content = content;
17480
+ totalChars += content.length;
17235
17481
  }
17236
17482
  }
17237
17483
  }
@@ -17258,6 +17504,10 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig2 = ()
17258
17504
  } catch {
17259
17505
  }
17260
17506
  }
17507
+ if (include_content) {
17508
+ enriched.truncated = truncated;
17509
+ enriched.returned_chars = totalChars;
17510
+ }
17261
17511
  return {
17262
17512
  content: [{ type: "text", text: JSON.stringify(enriched, null, 2) }]
17263
17513
  };
@@ -17271,10 +17521,11 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig2 = ()
17271
17521
  inputSchema: {
17272
17522
  path: z7.string().describe("Path to the note"),
17273
17523
  heading: z7.string().describe("Heading text to find"),
17274
- include_subheadings: z7.boolean().default(true).describe("Include content under subheadings")
17524
+ include_subheadings: z7.boolean().default(true).describe("Include content under subheadings"),
17525
+ max_content_chars: z7.number().default(1e4).describe("Max chars of section content. Truncated at paragraph boundaries.")
17275
17526
  }
17276
17527
  },
17277
- async ({ path: path40, heading, include_subheadings }) => {
17528
+ async ({ path: path40, heading, include_subheadings, max_content_chars }) => {
17278
17529
  const index = getIndex();
17279
17530
  const vaultPath2 = getVaultPath();
17280
17531
  const result = await getSectionContent(index, path40, heading, vaultPath2, include_subheadings);
@@ -17287,8 +17538,15 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath, getConfig2 = ()
17287
17538
  }, null, 2) }]
17288
17539
  };
17289
17540
  }
17541
+ let truncated = false;
17542
+ if (result.content.length > max_content_chars) {
17543
+ const sliced = result.content.slice(0, max_content_chars);
17544
+ const lastBreak = sliced.lastIndexOf("\n\n");
17545
+ result.content = lastBreak > 0 ? sliced.slice(0, lastBreak) : sliced;
17546
+ truncated = true;
17547
+ }
17290
17548
  return {
17291
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
17549
+ content: [{ type: "text", text: JSON.stringify({ ...result, truncated }, null, 2) }]
17292
17550
  };
17293
17551
  }
17294
17552
  );
@@ -24028,6 +24286,24 @@ function buildVaultPulseSection(stateDb2) {
24028
24286
  estimated_tokens: estimateTokens2(content)
24029
24287
  };
24030
24288
  }
24289
+ function buildProactiveLinkingSection(stateDb2) {
24290
+ const summary = getProactiveLinkingSummary(stateDb2, 1);
24291
+ if (summary.total_applied === 0) return null;
24292
+ const content = {
24293
+ summary: `${summary.total_applied} ${summary.total_applied === 1 ? "link" : "links"} applied across ${summary.files_touched} ${summary.files_touched === 1 ? "note" : "notes"} (${summary.survived} survived, ${summary.survival_rate !== null ? Math.round(summary.survival_rate * 100) + "%" : "n/a"} rate)`,
24294
+ total_applied_24h: summary.total_applied,
24295
+ survived_24h: summary.survived,
24296
+ removed_24h: summary.removed,
24297
+ files_24h: summary.files_touched,
24298
+ recent: summary.recent
24299
+ };
24300
+ return {
24301
+ name: "proactive_linking",
24302
+ priority: 6,
24303
+ content,
24304
+ estimated_tokens: estimateTokens2(content)
24305
+ };
24306
+ }
24031
24307
  function registerBriefTools(server2, getStateDb4) {
24032
24308
  server2.tool(
24033
24309
  "brief",
@@ -24049,23 +24325,33 @@ function registerBriefTools(server2, getStateDb4) {
24049
24325
  buildActiveEntitiesSection(stateDb2, 10),
24050
24326
  buildActiveMemoriesSection(stateDb2, 20),
24051
24327
  buildCorrectionsSection(stateDb2, 10),
24052
- buildVaultPulseSection(stateDb2)
24053
- ];
24328
+ buildVaultPulseSection(stateDb2),
24329
+ buildProactiveLinkingSection(stateDb2)
24330
+ ].filter((s) => s !== null);
24054
24331
  if (args.max_tokens) {
24055
24332
  let totalTokens2 = 0;
24056
24333
  sections.sort((a, b) => a.priority - b.priority);
24334
+ const kept = [];
24057
24335
  for (const section of sections) {
24058
- totalTokens2 += section.estimated_tokens;
24059
- if (totalTokens2 > args.max_tokens) {
24060
- if (Array.isArray(section.content)) {
24061
- const remaining = Math.max(0, args.max_tokens - (totalTokens2 - section.estimated_tokens));
24062
- const itemTokens = section.estimated_tokens / Math.max(1, section.content.length);
24063
- const keepCount = Math.max(1, Math.floor(remaining / itemTokens));
24064
- section.content = section.content.slice(0, keepCount);
24065
- section.estimated_tokens = estimateTokens2(section.content);
24066
- }
24336
+ if (totalTokens2 >= args.max_tokens) {
24337
+ break;
24338
+ }
24339
+ if (totalTokens2 + section.estimated_tokens <= args.max_tokens) {
24340
+ totalTokens2 += section.estimated_tokens;
24341
+ kept.push(section);
24342
+ } else if (Array.isArray(section.content)) {
24343
+ const remaining = args.max_tokens - totalTokens2;
24344
+ const itemTokens = section.estimated_tokens / Math.max(1, section.content.length);
24345
+ const keepCount = Math.max(1, Math.floor(remaining / itemTokens));
24346
+ section.content = section.content.slice(0, keepCount);
24347
+ section.estimated_tokens = estimateTokens2(section.content);
24348
+ totalTokens2 += section.estimated_tokens;
24349
+ kept.push(section);
24350
+ break;
24067
24351
  }
24068
24352
  }
24353
+ sections.length = 0;
24354
+ sections.push(...kept);
24069
24355
  }
24070
24356
  const response = {};
24071
24357
  let totalTokens = 0;
@@ -24448,7 +24734,7 @@ async function executeEnrich(stateDb2, vaultPath2, dryRun, batchSize, offset) {
24448
24734
  await fs33.writeFile(fullPath, result.content, "utf-8");
24449
24735
  notesModified++;
24450
24736
  if (stateDb2) {
24451
- trackWikilinkApplications(stateDb2, relativePath, entities);
24737
+ trackWikilinkApplications(stateDb2, relativePath, entities, "enrichment");
24452
24738
  const newLinks = extractLinkedEntities(result.content);
24453
24739
  updateStoredNoteLinks(stateDb2, relativePath, newLinks);
24454
24740
  }
@@ -24576,7 +24862,32 @@ function registerMetricsTools(server2, getIndex, getStateDb4) {
24576
24862
  const recentEvents = getRecentIndexEvents(stateDb2, eventLimit ?? 20);
24577
24863
  result = {
24578
24864
  mode: "index_activity",
24579
- index_activity: { summary, recent_events: recentEvents }
24865
+ index_activity: {
24866
+ summary,
24867
+ recent_events: recentEvents.map((e) => {
24868
+ const base = {
24869
+ id: e.id,
24870
+ timestamp: e.timestamp,
24871
+ trigger: e.trigger,
24872
+ duration_ms: e.duration_ms,
24873
+ success: e.success,
24874
+ note_count: e.note_count,
24875
+ files_changed: e.files_changed,
24876
+ error: e.error
24877
+ };
24878
+ if (e.steps) {
24879
+ const compact = compactPipelineRun(e);
24880
+ return {
24881
+ ...base,
24882
+ changed_paths_total: compact.changed_paths_total,
24883
+ changed_paths_sample: compact.changed_paths_sample,
24884
+ step_count: compact.step_count,
24885
+ steps: compact.steps
24886
+ };
24887
+ }
24888
+ return base;
24889
+ })
24890
+ }
24580
24891
  };
24581
24892
  break;
24582
24893
  }
@@ -24602,7 +24913,7 @@ function registerActivityTools(server2, getStateDb4, getSessionId2) {
24602
24913
  title: "Vault Activity",
24603
24914
  description: "Use when checking what tools have been used and what notes have been accessed. Produces tool invocation records with session context and note paths. Returns activity entries filtered by tool name, session, or time range. Does not modify tracking data \u2014 read-only activity log.",
24604
24915
  inputSchema: {
24605
- mode: z31.enum(["session", "sessions", "note_access", "tool_usage"]).describe("Activity query mode"),
24916
+ mode: z31.enum(["session", "sessions", "note_access", "tool_usage", "proactive_linking"]).describe("Activity query mode"),
24606
24917
  session_id: z31.string().optional().describe("Specific session ID (for session mode, defaults to current)"),
24607
24918
  days_back: z31.number().optional().describe("Number of days to look back (default: 30)"),
24608
24919
  limit: z31.number().optional().describe("Maximum results to return (default: 20)")
@@ -24666,6 +24977,24 @@ function registerActivityTools(server2, getStateDb4, getSessionId2) {
24666
24977
  }, null, 2) }]
24667
24978
  };
24668
24979
  }
24980
+ case "proactive_linking": {
24981
+ const since = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1e3);
24982
+ const sinceStr = since.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "");
24983
+ const rows = stateDb2.db.prepare(
24984
+ `SELECT entity, note_path, applied_at, status, matched_term
24985
+ FROM wikilink_applications
24986
+ WHERE source = 'proactive' AND applied_at >= ?
24987
+ ORDER BY applied_at DESC LIMIT ?`
24988
+ ).all(sinceStr, limit);
24989
+ return {
24990
+ content: [{ type: "text", text: JSON.stringify({
24991
+ mode: "proactive_linking",
24992
+ days_back: daysBack,
24993
+ count: rows.length,
24994
+ applications: rows
24995
+ }, null, 2) }]
24996
+ };
24997
+ }
24669
24998
  }
24670
24999
  }
24671
25000
  );
@@ -25816,7 +26145,7 @@ function registerSessionHistoryTools(server2, getStateDb4) {
25816
26145
  {
25817
26146
  session_id: z36.string().optional().describe("Session ID for detail view. Omit for recent sessions list."),
25818
26147
  include_children: z36.boolean().optional().describe("Include child sessions (default: true)"),
25819
- limit: z36.number().min(1).max(500).optional().describe("Max invocations to return in detail view (default: 200)")
26148
+ limit: z36.number().min(1).max(500).optional().describe("Max invocations to return in detail view (default: 50)")
25820
26149
  },
25821
26150
  async (args) => {
25822
26151
  const stateDb2 = getStateDb4();
@@ -26178,6 +26507,25 @@ function getLearningReport(stateDb2, entityCount, linkCount, daysBack = 7, compa
26178
26507
  funnel: queryFunnel(stateDb2, bounds.start, bounds.end, bounds.startMs, bounds.endMs),
26179
26508
  graph: { link_count: linkCount, entity_count: entityCount }
26180
26509
  };
26510
+ const sourceRows = stateDb2.db.prepare(`
26511
+ SELECT source,
26512
+ COUNT(*) as total_applied,
26513
+ SUM(CASE WHEN status = 'applied' THEN 1 ELSE 0 END) as survived,
26514
+ SUM(CASE WHEN status = 'removed' THEN 1 ELSE 0 END) as removed
26515
+ FROM wikilink_applications
26516
+ WHERE applied_at >= ? AND applied_at <= ?
26517
+ GROUP BY source
26518
+ ORDER BY total_applied DESC
26519
+ `).all(bounds.start, bounds.end);
26520
+ if (sourceRows.length > 0) {
26521
+ report.source_breakdown = sourceRows.map((r) => ({
26522
+ source: r.source,
26523
+ total_applied: r.total_applied,
26524
+ survived: r.survived,
26525
+ removed: r.removed,
26526
+ survival_rate: r.total_applied > 0 ? r.survived / r.total_applied : null
26527
+ }));
26528
+ }
26181
26529
  const toolSelection = getToolSelectionReport(stateDb2, daysBack);
26182
26530
  if (toolSelection) {
26183
26531
  report.tool_selection = toolSelection;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velvetmonkey/flywheel-memory",
3
- "version": "2.4.1",
3
+ "version": "2.4.3",
4
4
  "description": "MCP tools that search, write, and auto-link your Obsidian vault — and learn from your edits.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@huggingface/transformers": "^3.8.1",
57
57
  "@modelcontextprotocol/sdk": "^1.25.1",
58
- "@velvetmonkey/vault-core": "^2.4.1",
58
+ "@velvetmonkey/vault-core": "^2.4.3",
59
59
  "better-sqlite3": "^12.0.0",
60
60
  "chokidar": "^4.0.0",
61
61
  "gray-matter": "^4.0.3",