@velvetmonkey/flywheel-memory 2.0.50 → 2.0.52

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 +1443 -379
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1834,6 +1834,15 @@ var embeddingsBuilding = false;
1834
1834
  var embeddingCache = /* @__PURE__ */ new Map();
1835
1835
  var EMBEDDING_CACHE_MAX = 500;
1836
1836
  var entityEmbeddingsMap = /* @__PURE__ */ new Map();
1837
+ function getEmbeddingsBuildState() {
1838
+ if (!db) return "none";
1839
+ const row = db.prepare(`SELECT value FROM fts_metadata WHERE key = 'embeddings_state'`).get();
1840
+ return row?.value || "none";
1841
+ }
1842
+ function setEmbeddingsBuildState(state2) {
1843
+ if (!db) return;
1844
+ db.prepare(`INSERT OR REPLACE INTO fts_metadata (key, value) VALUES ('embeddings_state', ?)`).run(state2);
1845
+ }
1837
1846
  function setEmbeddingsDatabase(database) {
1838
1847
  db = database;
1839
1848
  }
@@ -1887,6 +1896,7 @@ async function buildEmbeddingsIndex(vaultPath2, onProgress) {
1887
1896
  throw new Error("Embeddings database not initialized. Call setEmbeddingsDatabase() first.");
1888
1897
  }
1889
1898
  embeddingsBuilding = true;
1899
+ setEmbeddingsBuildState("building_notes");
1890
1900
  await initEmbeddings();
1891
1901
  const files = await scanVault(vaultPath2);
1892
1902
  const indexable = files.filter((f) => shouldIndexFile(f.path));
@@ -2037,8 +2047,13 @@ function setEmbeddingsBuilding(value) {
2037
2047
  function hasEmbeddingsIndex() {
2038
2048
  if (!db) return false;
2039
2049
  try {
2040
- const row = db.prepare("SELECT COUNT(*) as count FROM note_embeddings").get();
2041
- return row.count > 0;
2050
+ const state2 = getEmbeddingsBuildState();
2051
+ if (state2 === "complete") return true;
2052
+ if (state2 === "none") {
2053
+ const row = db.prepare("SELECT COUNT(*) as count FROM note_embeddings").get();
2054
+ return row.count > 0;
2055
+ }
2056
+ return false;
2042
2057
  } catch {
2043
2058
  return false;
2044
2059
  }
@@ -2090,6 +2105,7 @@ async function buildEntityEmbeddingsIndex(vaultPath2, entities, onProgress) {
2090
2105
  throw new Error("Embeddings database not initialized. Call setEmbeddingsDatabase() first.");
2091
2106
  }
2092
2107
  await initEmbeddings();
2108
+ setEmbeddingsBuildState("building_entities");
2093
2109
  const existingHashes = /* @__PURE__ */ new Map();
2094
2110
  const rows = db.prepare("SELECT entity_name, source_hash FROM entity_embeddings").all();
2095
2111
  for (const row of rows) {
@@ -3915,6 +3931,20 @@ function processImplicitFeedback(stateDb2, notePath, currentContent) {
3915
3931
  if (removed.length > 0) {
3916
3932
  updateSuppressionList(stateDb2);
3917
3933
  }
3934
+ const SURVIVAL_COOLDOWN_MS = 24 * 60 * 60 * 1e3;
3935
+ const getLastSurvival = stateDb2.db.prepare(
3936
+ `SELECT MAX(created_at) as last FROM wikilink_feedback
3937
+ WHERE entity = ? COLLATE NOCASE AND context = 'implicit:survived' AND note_path = ?`
3938
+ );
3939
+ for (const { entity } of trackedWithTime) {
3940
+ if (currentLinks.has(entity.toLowerCase())) {
3941
+ const lastSurvival = getLastSurvival.get(entity, notePath);
3942
+ const lastAt = lastSurvival?.last ? new Date(lastSurvival.last).getTime() : 0;
3943
+ if (Date.now() - lastAt > SURVIVAL_COOLDOWN_MS) {
3944
+ recordFeedback(stateDb2, entity, "implicit:survived", notePath, true, 0.8);
3945
+ }
3946
+ }
3947
+ }
3918
3948
  return removed;
3919
3949
  }
3920
3950
  var TIER_LABELS = [
@@ -5534,6 +5564,68 @@ function tokenIdf(token, coocIndex) {
5534
5564
  const rawIdf = Math.log((N + 1) / (df + 1));
5535
5565
  return Math.max(0.5, Math.min(2.5, rawIdf));
5536
5566
  }
5567
+ function serializeCooccurrenceIndex(index) {
5568
+ const serialized = {};
5569
+ for (const [entity, assocs] of Object.entries(index.associations)) {
5570
+ serialized[entity] = Object.fromEntries(assocs);
5571
+ }
5572
+ return {
5573
+ associations: serialized,
5574
+ minCount: index.minCount,
5575
+ documentFrequency: Object.fromEntries(index.documentFrequency),
5576
+ totalNotesScanned: index.totalNotesScanned,
5577
+ _metadata: index._metadata
5578
+ };
5579
+ }
5580
+ function deserializeCooccurrenceIndex(data) {
5581
+ try {
5582
+ const associations = {};
5583
+ const assocData = data.associations;
5584
+ if (!assocData) return null;
5585
+ for (const [entity, assocs] of Object.entries(assocData)) {
5586
+ associations[entity] = new Map(Object.entries(assocs));
5587
+ }
5588
+ const dfData = data.documentFrequency;
5589
+ const documentFrequency = dfData ? new Map(Object.entries(dfData).map(([k, v]) => [k, v])) : /* @__PURE__ */ new Map();
5590
+ const totalNotesScanned = data.totalNotesScanned || 0;
5591
+ return {
5592
+ associations,
5593
+ minCount: data.minCount || DEFAULT_MIN_COOCCURRENCE,
5594
+ documentFrequency,
5595
+ totalNotesScanned,
5596
+ _metadata: data._metadata
5597
+ };
5598
+ } catch {
5599
+ return null;
5600
+ }
5601
+ }
5602
+ var COOCCURRENCE_CACHE_MAX_AGE_MS = 60 * 60 * 1e3;
5603
+ function saveCooccurrenceToStateDb(stateDb2, index) {
5604
+ const serialized = serializeCooccurrenceIndex(index);
5605
+ const data = JSON.stringify(serialized);
5606
+ const entityCount = Object.keys(index.associations).length;
5607
+ const associationCount = index._metadata.total_associations;
5608
+ stateDb2.db.prepare(`
5609
+ INSERT OR REPLACE INTO cooccurrence_cache (id, data, built_at, entity_count, association_count)
5610
+ VALUES (1, ?, ?, ?, ?)
5611
+ `).run(data, Date.now(), entityCount, associationCount);
5612
+ }
5613
+ function loadCooccurrenceFromStateDb(stateDb2) {
5614
+ const row = stateDb2.db.prepare(
5615
+ "SELECT data, built_at FROM cooccurrence_cache WHERE id = 1"
5616
+ ).get();
5617
+ if (!row) return null;
5618
+ const ageMs = Date.now() - row.built_at;
5619
+ if (ageMs > COOCCURRENCE_CACHE_MAX_AGE_MS) return null;
5620
+ try {
5621
+ const parsed = JSON.parse(row.data);
5622
+ const index = deserializeCooccurrenceIndex(parsed);
5623
+ if (!index) return null;
5624
+ return { index, builtAt: row.built_at };
5625
+ } catch {
5626
+ return null;
5627
+ }
5628
+ }
5537
5629
 
5538
5630
  // src/core/write/edgeWeights.ts
5539
5631
  var moduleStateDb4 = null;
@@ -7909,7 +8001,7 @@ function registerGraphTools(server2, getIndex, getVaultPath, getStateDb) {
7909
8001
 
7910
8002
  // src/tools/read/wikilinks.ts
7911
8003
  import { z as z2 } from "zod";
7912
- import { detectImplicitEntities as detectImplicitEntities2 } from "@velvetmonkey/vault-core";
8004
+ import { detectImplicitEntities as detectImplicitEntities2, IMPLICIT_EXCLUDE_WORDS } from "@velvetmonkey/vault-core";
7913
8005
  function findEntityMatches(text, entities) {
7914
8006
  const matches = [];
7915
8007
  const sortedEntities = Array.from(entities.entries()).filter(([name]) => name.length >= 2).sort((a, b) => b[0].length - a[0].length);
@@ -8031,6 +8123,8 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
8031
8123
  if (links.length < 2) continue;
8032
8124
  if (index.entities.has(target.toLowerCase())) continue;
8033
8125
  if (linkedSet.has(target.toLowerCase())) continue;
8126
+ if (IMPLICIT_EXCLUDE_WORDS.has(target.toLowerCase())) continue;
8127
+ if (target.length < 4) continue;
8034
8128
  const targetLower = target.toLowerCase();
8035
8129
  const textLower = text.toLowerCase();
8036
8130
  let searchPos = 0;
@@ -16294,6 +16388,24 @@ function resolveCorrection(stateDb2, id, newStatus) {
16294
16388
  `).run(newStatus, id);
16295
16389
  return result.changes > 0;
16296
16390
  }
16391
+ function processPendingCorrections(stateDb2) {
16392
+ const pending = listCorrections(stateDb2, "pending");
16393
+ let processed = 0;
16394
+ for (const correction of pending) {
16395
+ if (!correction.entity) {
16396
+ resolveCorrection(stateDb2, correction.id, "dismissed");
16397
+ continue;
16398
+ }
16399
+ if (correction.correction_type === "wrong_link") {
16400
+ recordFeedback(stateDb2, correction.entity, "correction:wrong_link", correction.note_path || "", false, 1);
16401
+ } else if (correction.correction_type === "wrong_category") {
16402
+ recordFeedback(stateDb2, correction.entity, "correction:wrong_category", "", false, 0.5);
16403
+ }
16404
+ resolveCorrection(stateDb2, correction.id, "applied");
16405
+ processed++;
16406
+ }
16407
+ return processed;
16408
+ }
16297
16409
 
16298
16410
  // src/tools/write/corrections.ts
16299
16411
  function registerCorrectionTools(server2, getStateDb) {
@@ -16390,99 +16502,1150 @@ function registerCorrectionTools(server2, getStateDb) {
16390
16502
  );
16391
16503
  }
16392
16504
 
16393
- // src/tools/write/config.ts
16505
+ // src/tools/write/memory.ts
16394
16506
  import { z as z23 } from "zod";
16395
- import { saveFlywheelConfigToDb as saveFlywheelConfigToDb2 } from "@velvetmonkey/vault-core";
16396
- function registerConfigTools(server2, getConfig, setConfig, getStateDb) {
16397
- server2.registerTool(
16398
- "flywheel_config",
16399
- {
16400
- title: "Flywheel Config",
16401
- description: 'Read or update Flywheel configuration.\n- "get": Returns the current FlywheelConfig\n- "set": Updates a single config key and returns the updated config\n\nExample: flywheel_config({ mode: "get" })\nExample: flywheel_config({ mode: "set", key: "exclude_analysis_tags", value: ["habit", "daily"] })',
16402
- inputSchema: {
16403
- mode: z23.enum(["get", "set"]).describe("Operation mode"),
16404
- key: z23.string().optional().describe("Config key to update (required for set mode)"),
16405
- value: z23.unknown().optional().describe("New value for the key (required for set mode)")
16507
+
16508
+ // src/core/write/memory.ts
16509
+ import { recordEntityMention as recordEntityMention2 } from "@velvetmonkey/vault-core";
16510
+ function detectEntities(stateDb2, text) {
16511
+ const allEntities = stateDb2.getAllEntities.all();
16512
+ const detected = /* @__PURE__ */ new Set();
16513
+ const textLower = text.toLowerCase();
16514
+ for (const entity of allEntities) {
16515
+ if (textLower.includes(entity.name_lower)) {
16516
+ detected.add(entity.name);
16517
+ }
16518
+ if (entity.aliases_json) {
16519
+ try {
16520
+ const aliases = JSON.parse(entity.aliases_json);
16521
+ for (const alias of aliases) {
16522
+ if (alias.length >= 3 && textLower.includes(alias.toLowerCase())) {
16523
+ detected.add(entity.name);
16524
+ break;
16525
+ }
16526
+ }
16527
+ } catch {
16528
+ }
16529
+ }
16530
+ }
16531
+ return [...detected];
16532
+ }
16533
+ function updateGraphSignals(stateDb2, memoryKey, entities) {
16534
+ if (entities.length === 0) return;
16535
+ const now = /* @__PURE__ */ new Date();
16536
+ for (const entity of entities) {
16537
+ recordEntityMention2(stateDb2, entity, now);
16538
+ }
16539
+ const sourcePath = `memory:${memoryKey}`;
16540
+ const targets = new Set(entities.map((e) => e.toLowerCase()));
16541
+ updateStoredNoteLinks(stateDb2, sourcePath, targets);
16542
+ }
16543
+ function removeGraphSignals(stateDb2, memoryKey) {
16544
+ const sourcePath = `memory:${memoryKey}`;
16545
+ updateStoredNoteLinks(stateDb2, sourcePath, /* @__PURE__ */ new Set());
16546
+ }
16547
+ function storeMemory(stateDb2, options) {
16548
+ const {
16549
+ key,
16550
+ value,
16551
+ type,
16552
+ entity,
16553
+ confidence = 1,
16554
+ ttl_days,
16555
+ agent_id,
16556
+ session_id,
16557
+ visibility = "shared"
16558
+ } = options;
16559
+ const now = Date.now();
16560
+ const detectedEntities = detectEntities(stateDb2, value);
16561
+ if (entity && !detectedEntities.includes(entity)) {
16562
+ detectedEntities.push(entity);
16563
+ }
16564
+ const entitiesJson = detectedEntities.length > 0 ? JSON.stringify(detectedEntities) : null;
16565
+ const existing = stateDb2.db.prepare(
16566
+ "SELECT id FROM memories WHERE key = ?"
16567
+ ).get(key);
16568
+ if (existing) {
16569
+ stateDb2.db.prepare(`
16570
+ UPDATE memories SET
16571
+ value = ?, memory_type = ?, entity = ?, entities_json = ?,
16572
+ source_agent_id = ?, source_session_id = ?,
16573
+ confidence = ?, updated_at = ?, accessed_at = ?,
16574
+ ttl_days = ?, visibility = ?, superseded_by = NULL
16575
+ WHERE key = ?
16576
+ `).run(
16577
+ value,
16578
+ type,
16579
+ entity ?? null,
16580
+ entitiesJson,
16581
+ agent_id ?? null,
16582
+ session_id ?? null,
16583
+ confidence,
16584
+ now,
16585
+ now,
16586
+ ttl_days ?? null,
16587
+ visibility,
16588
+ key
16589
+ );
16590
+ } else {
16591
+ stateDb2.db.prepare(`
16592
+ INSERT INTO memories (key, value, memory_type, entity, entities_json,
16593
+ source_agent_id, source_session_id, confidence,
16594
+ created_at, updated_at, accessed_at, ttl_days, visibility)
16595
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
16596
+ `).run(
16597
+ key,
16598
+ value,
16599
+ type,
16600
+ entity ?? null,
16601
+ entitiesJson,
16602
+ agent_id ?? null,
16603
+ session_id ?? null,
16604
+ confidence,
16605
+ now,
16606
+ now,
16607
+ now,
16608
+ ttl_days ?? null,
16609
+ visibility
16610
+ );
16611
+ }
16612
+ updateGraphSignals(stateDb2, key, detectedEntities);
16613
+ return stateDb2.db.prepare(
16614
+ "SELECT * FROM memories WHERE key = ?"
16615
+ ).get(key);
16616
+ }
16617
+ function getMemory(stateDb2, key) {
16618
+ const memory = stateDb2.db.prepare(
16619
+ "SELECT * FROM memories WHERE key = ? AND superseded_by IS NULL"
16620
+ ).get(key);
16621
+ if (!memory) return null;
16622
+ stateDb2.db.prepare(
16623
+ "UPDATE memories SET accessed_at = ? WHERE id = ?"
16624
+ ).run(Date.now(), memory.id);
16625
+ return memory;
16626
+ }
16627
+ function searchMemories(stateDb2, options) {
16628
+ const { query, type, entity, limit = 20, agent_id } = options;
16629
+ const conditions = ["m.superseded_by IS NULL"];
16630
+ const params = [];
16631
+ if (type) {
16632
+ conditions.push("m.memory_type = ?");
16633
+ params.push(type);
16634
+ }
16635
+ if (entity) {
16636
+ conditions.push("(m.entity = ? COLLATE NOCASE OR m.entities_json LIKE ?)");
16637
+ params.push(entity, `%"${entity}"%`);
16638
+ }
16639
+ if (agent_id) {
16640
+ conditions.push("(m.visibility = 'shared' OR m.source_agent_id = ?)");
16641
+ params.push(agent_id);
16642
+ }
16643
+ const where = conditions.length > 0 ? `AND ${conditions.join(" AND ")}` : "";
16644
+ try {
16645
+ const results = stateDb2.db.prepare(`
16646
+ SELECT m.* FROM memories_fts
16647
+ JOIN memories m ON m.id = memories_fts.rowid
16648
+ WHERE memories_fts MATCH ?
16649
+ ${where}
16650
+ ORDER BY bm25(memories_fts)
16651
+ LIMIT ?
16652
+ `).all(query, ...params, limit);
16653
+ return results;
16654
+ } catch (err) {
16655
+ if (err instanceof Error && err.message.includes("fts5: syntax error")) {
16656
+ throw new Error(`Invalid search query: ${query}. Check FTS5 syntax.`);
16657
+ }
16658
+ throw err;
16659
+ }
16660
+ }
16661
+ function listMemories(stateDb2, options = {}) {
16662
+ const { type, entity, limit = 50, agent_id, include_expired = false } = options;
16663
+ const conditions = [];
16664
+ const params = [];
16665
+ if (!include_expired) {
16666
+ conditions.push("superseded_by IS NULL");
16667
+ }
16668
+ if (type) {
16669
+ conditions.push("memory_type = ?");
16670
+ params.push(type);
16671
+ }
16672
+ if (entity) {
16673
+ conditions.push("(entity = ? COLLATE NOCASE OR entities_json LIKE ?)");
16674
+ params.push(entity, `%"${entity}"%`);
16675
+ }
16676
+ if (agent_id) {
16677
+ conditions.push("(visibility = 'shared' OR source_agent_id = ?)");
16678
+ params.push(agent_id);
16679
+ }
16680
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
16681
+ params.push(limit);
16682
+ return stateDb2.db.prepare(
16683
+ `SELECT * FROM memories ${where} ORDER BY updated_at DESC LIMIT ?`
16684
+ ).all(...params);
16685
+ }
16686
+ function forgetMemory(stateDb2, key) {
16687
+ const memory = stateDb2.db.prepare(
16688
+ "SELECT id FROM memories WHERE key = ?"
16689
+ ).get(key);
16690
+ if (!memory) return false;
16691
+ removeGraphSignals(stateDb2, key);
16692
+ stateDb2.db.prepare("DELETE FROM memories WHERE key = ?").run(key);
16693
+ return true;
16694
+ }
16695
+ function storeSessionSummary(stateDb2, sessionId, summary, options = {}) {
16696
+ const now = Date.now();
16697
+ const { topics, notes_modified, agent_id, started_at, tool_count } = options;
16698
+ stateDb2.db.prepare(`
16699
+ INSERT OR REPLACE INTO session_summaries
16700
+ (session_id, summary, topics_json, notes_modified_json,
16701
+ agent_id, started_at, ended_at, tool_count)
16702
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
16703
+ `).run(
16704
+ sessionId,
16705
+ summary,
16706
+ topics ? JSON.stringify(topics) : null,
16707
+ notes_modified ? JSON.stringify(notes_modified) : null,
16708
+ agent_id ?? null,
16709
+ started_at ?? null,
16710
+ now,
16711
+ tool_count ?? null
16712
+ );
16713
+ return stateDb2.db.prepare(
16714
+ "SELECT * FROM session_summaries WHERE session_id = ?"
16715
+ ).get(sessionId);
16716
+ }
16717
+ function getRecentSessionSummaries(stateDb2, limit = 5, agent_id) {
16718
+ if (agent_id) {
16719
+ return stateDb2.db.prepare(
16720
+ "SELECT * FROM session_summaries WHERE agent_id = ? ORDER BY ended_at DESC LIMIT ?"
16721
+ ).all(agent_id, limit);
16722
+ }
16723
+ return stateDb2.db.prepare(
16724
+ "SELECT * FROM session_summaries ORDER BY ended_at DESC LIMIT ?"
16725
+ ).all(limit);
16726
+ }
16727
+ function findContradictions2(stateDb2, entity) {
16728
+ const conditions = ["superseded_by IS NULL"];
16729
+ const params = [];
16730
+ if (entity) {
16731
+ conditions.push("(entity = ? COLLATE NOCASE OR entities_json LIKE ?)");
16732
+ params.push(entity, `%"${entity}"%`);
16733
+ }
16734
+ const where = `WHERE ${conditions.join(" AND ")}`;
16735
+ const memories = stateDb2.db.prepare(
16736
+ `SELECT * FROM memories ${where} ORDER BY entity, key, updated_at DESC`
16737
+ ).all(...params);
16738
+ const contradictions = [];
16739
+ const byKey = /* @__PURE__ */ new Map();
16740
+ for (const m of memories) {
16741
+ const group = m.key;
16742
+ const list = byKey.get(group) || [];
16743
+ list.push(m);
16744
+ byKey.set(group, list);
16745
+ }
16746
+ for (const [, mems] of byKey) {
16747
+ if (mems.length > 1) {
16748
+ for (let i = 0; i < mems.length - 1; i++) {
16749
+ contradictions.push({ memory_a: mems[i], memory_b: mems[i + 1] });
16406
16750
  }
16751
+ }
16752
+ }
16753
+ return contradictions;
16754
+ }
16755
+
16756
+ // src/tools/write/memory.ts
16757
+ function registerMemoryTools(server2, getStateDb) {
16758
+ server2.tool(
16759
+ "memory",
16760
+ "Store, retrieve, search, and manage agent working memory. Actions: store, get, search, list, forget, summarize_session.",
16761
+ {
16762
+ action: z23.enum(["store", "get", "search", "list", "forget", "summarize_session"]).describe("Action to perform"),
16763
+ // store params
16764
+ key: z23.string().optional().describe('Memory key (e.g., "user.pref.theme", "project.x.deadline")'),
16765
+ value: z23.string().optional().describe("The fact/preference/observation to store (up to 2000 chars)"),
16766
+ type: z23.enum(["fact", "preference", "observation", "summary"]).optional().describe("Memory type"),
16767
+ entity: z23.string().optional().describe("Primary entity association"),
16768
+ confidence: z23.number().min(0).max(1).optional().describe("Confidence level (0-1, default 1.0)"),
16769
+ ttl_days: z23.number().min(1).optional().describe("Time-to-live in days (null = permanent)"),
16770
+ // search params
16771
+ query: z23.string().optional().describe("FTS5 search query"),
16772
+ // list/search params
16773
+ limit: z23.number().min(1).max(200).optional().describe("Max results to return"),
16774
+ // summarize_session params
16775
+ session_id: z23.string().optional().describe("Session ID for summarize_session"),
16776
+ summary: z23.string().optional().describe("Session summary text"),
16777
+ topics: z23.array(z23.string()).optional().describe("Topics discussed in session"),
16778
+ notes_modified: z23.array(z23.string()).optional().describe("Note paths modified during session"),
16779
+ tool_count: z23.number().optional().describe("Number of tool calls in session")
16407
16780
  },
16408
- async ({ mode, key, value }) => {
16409
- switch (mode) {
16781
+ async (args) => {
16782
+ const stateDb2 = getStateDb();
16783
+ if (!stateDb2) {
16784
+ return {
16785
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }],
16786
+ isError: true
16787
+ };
16788
+ }
16789
+ const agentId = process.env.FLYWHEEL_AGENT_ID || void 0;
16790
+ let sessionId;
16791
+ try {
16792
+ const { getSessionId: getSessionId2 } = await import("@velvetmonkey/vault-core");
16793
+ sessionId = getSessionId2();
16794
+ } catch {
16795
+ }
16796
+ switch (args.action) {
16797
+ case "store": {
16798
+ if (!args.key || !args.value || !args.type) {
16799
+ return {
16800
+ content: [{ type: "text", text: JSON.stringify({ error: "store requires key, value, and type" }) }],
16801
+ isError: true
16802
+ };
16803
+ }
16804
+ if (args.value.length > 2e3) {
16805
+ return {
16806
+ content: [{ type: "text", text: JSON.stringify({ error: "value must be 2000 chars or less" }) }],
16807
+ isError: true
16808
+ };
16809
+ }
16810
+ const memory = storeMemory(stateDb2, {
16811
+ key: args.key,
16812
+ value: args.value,
16813
+ type: args.type,
16814
+ entity: args.entity,
16815
+ confidence: args.confidence,
16816
+ ttl_days: args.ttl_days,
16817
+ agent_id: agentId,
16818
+ session_id: sessionId
16819
+ });
16820
+ return {
16821
+ content: [{
16822
+ type: "text",
16823
+ text: JSON.stringify({
16824
+ stored: true,
16825
+ memory: {
16826
+ key: memory.key,
16827
+ value: memory.value,
16828
+ type: memory.memory_type,
16829
+ entity: memory.entity,
16830
+ entities_detected: memory.entities_json ? JSON.parse(memory.entities_json) : [],
16831
+ confidence: memory.confidence
16832
+ }
16833
+ }, null, 2)
16834
+ }]
16835
+ };
16836
+ }
16410
16837
  case "get": {
16411
- const config = getConfig();
16838
+ if (!args.key) {
16839
+ return {
16840
+ content: [{ type: "text", text: JSON.stringify({ error: "get requires key" }) }],
16841
+ isError: true
16842
+ };
16843
+ }
16844
+ const memory = getMemory(stateDb2, args.key);
16845
+ if (!memory) {
16846
+ return {
16847
+ content: [{ type: "text", text: JSON.stringify({ found: false, key: args.key }) }]
16848
+ };
16849
+ }
16412
16850
  return {
16413
- content: [{ type: "text", text: JSON.stringify(config, null, 2) }]
16851
+ content: [{
16852
+ type: "text",
16853
+ text: JSON.stringify({
16854
+ found: true,
16855
+ memory: {
16856
+ key: memory.key,
16857
+ value: memory.value,
16858
+ type: memory.memory_type,
16859
+ entity: memory.entity,
16860
+ entities: memory.entities_json ? JSON.parse(memory.entities_json) : [],
16861
+ confidence: memory.confidence,
16862
+ created_at: memory.created_at,
16863
+ updated_at: memory.updated_at,
16864
+ accessed_at: memory.accessed_at
16865
+ }
16866
+ }, null, 2)
16867
+ }]
16414
16868
  };
16415
16869
  }
16416
- case "set": {
16417
- if (!key) {
16870
+ case "search": {
16871
+ if (!args.query) {
16418
16872
  return {
16419
- content: [{ type: "text", text: JSON.stringify({ error: "key is required for set mode" }) }]
16873
+ content: [{ type: "text", text: JSON.stringify({ error: "search requires query" }) }],
16874
+ isError: true
16420
16875
  };
16421
16876
  }
16422
- const stateDb2 = getStateDb();
16423
- if (!stateDb2) {
16877
+ const results = searchMemories(stateDb2, {
16878
+ query: args.query,
16879
+ type: args.type,
16880
+ entity: args.entity,
16881
+ limit: args.limit,
16882
+ agent_id: agentId
16883
+ });
16884
+ return {
16885
+ content: [{
16886
+ type: "text",
16887
+ text: JSON.stringify({
16888
+ results: results.map((m) => ({
16889
+ key: m.key,
16890
+ value: m.value,
16891
+ type: m.memory_type,
16892
+ entity: m.entity,
16893
+ confidence: m.confidence,
16894
+ updated_at: m.updated_at
16895
+ })),
16896
+ count: results.length
16897
+ }, null, 2)
16898
+ }]
16899
+ };
16900
+ }
16901
+ case "list": {
16902
+ const results = listMemories(stateDb2, {
16903
+ type: args.type,
16904
+ entity: args.entity,
16905
+ limit: args.limit,
16906
+ agent_id: agentId
16907
+ });
16908
+ return {
16909
+ content: [{
16910
+ type: "text",
16911
+ text: JSON.stringify({
16912
+ memories: results.map((m) => ({
16913
+ key: m.key,
16914
+ value: m.value,
16915
+ type: m.memory_type,
16916
+ entity: m.entity,
16917
+ confidence: m.confidence,
16918
+ updated_at: m.updated_at
16919
+ })),
16920
+ count: results.length
16921
+ }, null, 2)
16922
+ }]
16923
+ };
16924
+ }
16925
+ case "forget": {
16926
+ if (!args.key) {
16424
16927
  return {
16425
- content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
16928
+ content: [{ type: "text", text: JSON.stringify({ error: "forget requires key" }) }],
16929
+ isError: true
16426
16930
  };
16427
16931
  }
16428
- const current = getConfig();
16429
- const updated = { ...current, [key]: value };
16430
- saveFlywheelConfigToDb2(stateDb2, updated);
16431
- const reloaded = loadConfig(stateDb2);
16432
- setConfig(reloaded);
16932
+ const deleted = forgetMemory(stateDb2, args.key);
16433
16933
  return {
16434
- content: [{ type: "text", text: JSON.stringify(reloaded, null, 2) }]
16934
+ content: [{
16935
+ type: "text",
16936
+ text: JSON.stringify({ forgotten: deleted, key: args.key }, null, 2)
16937
+ }]
16938
+ };
16939
+ }
16940
+ case "summarize_session": {
16941
+ const sid = args.session_id || sessionId;
16942
+ if (!sid || !args.summary) {
16943
+ return {
16944
+ content: [{ type: "text", text: JSON.stringify({ error: "summarize_session requires session_id and summary" }) }],
16945
+ isError: true
16946
+ };
16947
+ }
16948
+ const result = storeSessionSummary(stateDb2, sid, args.summary, {
16949
+ topics: args.topics,
16950
+ notes_modified: args.notes_modified,
16951
+ agent_id: agentId,
16952
+ tool_count: args.tool_count
16953
+ });
16954
+ return {
16955
+ content: [{
16956
+ type: "text",
16957
+ text: JSON.stringify({
16958
+ stored: true,
16959
+ session_id: result.session_id,
16960
+ summary_length: result.summary.length
16961
+ }, null, 2)
16962
+ }]
16435
16963
  };
16436
16964
  }
16965
+ default:
16966
+ return {
16967
+ content: [{ type: "text", text: JSON.stringify({ error: `Unknown action: ${args.action}` }) }],
16968
+ isError: true
16969
+ };
16437
16970
  }
16438
16971
  }
16439
16972
  );
16440
16973
  }
16441
16974
 
16442
- // src/tools/write/enrich.ts
16975
+ // src/tools/read/recall.ts
16443
16976
  import { z as z24 } from "zod";
16444
- import * as fs29 from "fs/promises";
16445
- import * as path30 from "path";
16446
- function hasSkipWikilinks(content) {
16447
- if (!content.startsWith("---")) return false;
16448
- const endIndex = content.indexOf("\n---", 3);
16449
- if (endIndex === -1) return false;
16450
- const frontmatter = content.substring(4, endIndex);
16451
- return /^skipWikilinks:\s*true\s*$/m.test(frontmatter);
16977
+ import { searchEntities as searchEntitiesDb2 } from "@velvetmonkey/vault-core";
16978
+ function scoreTextRelevance(query, content) {
16979
+ const queryTokens = tokenize(query).map((t) => t.toLowerCase());
16980
+ const queryStems = queryTokens.map((t) => stem(t));
16981
+ const contentLower = content.toLowerCase();
16982
+ const contentTokens = new Set(tokenize(contentLower));
16983
+ const contentStems = new Set([...contentTokens].map((t) => stem(t)));
16984
+ let score = 0;
16985
+ for (let i = 0; i < queryTokens.length; i++) {
16986
+ const token = queryTokens[i];
16987
+ const stemmed = queryStems[i];
16988
+ if (contentTokens.has(token)) {
16989
+ score += 10;
16990
+ } else if (contentStems.has(stemmed)) {
16991
+ score += 5;
16992
+ }
16993
+ }
16994
+ if (contentLower.includes(query.toLowerCase())) {
16995
+ score += 15;
16996
+ }
16997
+ return score;
16452
16998
  }
16453
- async function collectMarkdownFiles(dirPath, basePath, excludeFolders) {
16999
+ function getEdgeWeightBoost(entityName, edgeWeightMap) {
17000
+ const avgWeight = edgeWeightMap.get(entityName.toLowerCase());
17001
+ if (!avgWeight || avgWeight <= 1) return 0;
17002
+ return Math.min((avgWeight - 1) * 3, 6);
17003
+ }
17004
+ async function performRecall(stateDb2, query, options = {}) {
17005
+ const {
17006
+ max_results = 20,
17007
+ focus = "all",
17008
+ entity,
17009
+ max_tokens
17010
+ } = options;
16454
17011
  const results = [];
16455
- try {
16456
- const entries = await fs29.readdir(dirPath, { withFileTypes: true });
16457
- for (const entry of entries) {
16458
- if (entry.name.startsWith(".")) continue;
16459
- const fullPath = path30.join(dirPath, entry.name);
16460
- if (entry.isDirectory()) {
16461
- if (excludeFolders.some((f) => entry.name.toLowerCase() === f.toLowerCase())) continue;
16462
- const sub = await collectMarkdownFiles(fullPath, basePath, excludeFolders);
16463
- results.push(...sub);
16464
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
16465
- results.push(path30.relative(basePath, fullPath));
17012
+ const recencyIndex2 = loadRecencyFromStateDb();
17013
+ const edgeWeightMap = getEntityEdgeWeightMap(stateDb2);
17014
+ const feedbackBoosts = getAllFeedbackBoosts(stateDb2);
17015
+ if (focus === "all" || focus === "entities") {
17016
+ try {
17017
+ const entityResults = searchEntitiesDb2(stateDb2, query, max_results);
17018
+ for (const e of entityResults) {
17019
+ const textScore = scoreTextRelevance(query, `${e.name} ${e.description || ""}`);
17020
+ const recency = recencyIndex2 ? getRecencyBoost(e.name, recencyIndex2) : 0;
17021
+ const feedback = feedbackBoosts.get(e.name) ?? 0;
17022
+ const edgeWeight = getEdgeWeightBoost(e.name, edgeWeightMap);
17023
+ const total = textScore + recency + feedback + edgeWeight;
17024
+ if (total > 0) {
17025
+ results.push({
17026
+ type: "entity",
17027
+ id: e.name,
17028
+ content: e.description || `Entity: ${e.name} (${e.category})`,
17029
+ score: total,
17030
+ breakdown: {
17031
+ textRelevance: textScore,
17032
+ recencyBoost: recency,
17033
+ cooccurrenceBoost: 0,
17034
+ feedbackBoost: feedback,
17035
+ edgeWeightBoost: edgeWeight,
17036
+ semanticBoost: 0
17037
+ }
17038
+ });
17039
+ }
16466
17040
  }
17041
+ } catch {
16467
17042
  }
16468
- } catch {
16469
17043
  }
16470
- return results;
16471
- }
16472
- var EXCLUDE_FOLDERS = [
16473
- "daily-notes",
16474
- "daily",
16475
- "weekly",
16476
- "weekly-notes",
16477
- "monthly",
16478
- "monthly-notes",
16479
- "quarterly",
16480
- "yearly-notes",
16481
- "periodic",
16482
- "journal",
16483
- "inbox",
16484
- "templates",
16485
- "attachments",
17044
+ if (focus === "all" || focus === "notes") {
17045
+ try {
17046
+ const noteResults = searchFTS5("", query, max_results);
17047
+ for (const n of noteResults) {
17048
+ const textScore = Math.max(10, scoreTextRelevance(query, `${n.title || ""} ${n.snippet || ""}`));
17049
+ results.push({
17050
+ type: "note",
17051
+ id: n.path,
17052
+ content: n.snippet || n.title || n.path,
17053
+ score: textScore,
17054
+ breakdown: {
17055
+ textRelevance: textScore,
17056
+ recencyBoost: 0,
17057
+ cooccurrenceBoost: 0,
17058
+ feedbackBoost: 0,
17059
+ edgeWeightBoost: 0,
17060
+ semanticBoost: 0
17061
+ }
17062
+ });
17063
+ }
17064
+ } catch {
17065
+ }
17066
+ }
17067
+ if (focus === "all" || focus === "memories") {
17068
+ try {
17069
+ const memResults = searchMemories(stateDb2, {
17070
+ query,
17071
+ entity,
17072
+ limit: max_results
17073
+ });
17074
+ for (const m of memResults) {
17075
+ const textScore = scoreTextRelevance(query, `${m.key} ${m.value}`);
17076
+ const memScore = textScore + m.confidence * 5;
17077
+ results.push({
17078
+ type: "memory",
17079
+ id: m.key,
17080
+ content: m.value,
17081
+ score: memScore,
17082
+ breakdown: {
17083
+ textRelevance: textScore,
17084
+ recencyBoost: 0,
17085
+ cooccurrenceBoost: 0,
17086
+ feedbackBoost: m.confidence * 5,
17087
+ edgeWeightBoost: 0,
17088
+ semanticBoost: 0
17089
+ }
17090
+ });
17091
+ }
17092
+ } catch {
17093
+ }
17094
+ }
17095
+ if ((focus === "all" || focus === "entities") && query.length >= 20 && hasEntityEmbeddingsIndex()) {
17096
+ try {
17097
+ const embedding = await embedTextCached(query);
17098
+ const semanticMatches = findSemanticallySimilarEntities(embedding, max_results);
17099
+ for (const match of semanticMatches) {
17100
+ if (match.similarity < 0.3) continue;
17101
+ const boost = match.similarity * 15;
17102
+ const existing = results.find((r) => r.type === "entity" && r.id === match.entityName);
17103
+ if (existing) {
17104
+ existing.score += boost;
17105
+ existing.breakdown.semanticBoost = boost;
17106
+ } else {
17107
+ results.push({
17108
+ type: "entity",
17109
+ id: match.entityName,
17110
+ content: `Semantically similar to: "${query}"`,
17111
+ score: boost,
17112
+ breakdown: {
17113
+ textRelevance: 0,
17114
+ recencyBoost: 0,
17115
+ cooccurrenceBoost: 0,
17116
+ feedbackBoost: 0,
17117
+ edgeWeightBoost: 0,
17118
+ semanticBoost: boost
17119
+ }
17120
+ });
17121
+ }
17122
+ }
17123
+ } catch {
17124
+ }
17125
+ }
17126
+ results.sort((a, b) => b.score - a.score);
17127
+ const seen = /* @__PURE__ */ new Set();
17128
+ const deduped = results.filter((r) => {
17129
+ const key = `${r.type}:${r.id}`;
17130
+ if (seen.has(key)) return false;
17131
+ seen.add(key);
17132
+ return true;
17133
+ });
17134
+ const truncated = deduped.slice(0, max_results);
17135
+ if (max_tokens) {
17136
+ let tokenBudget = max_tokens;
17137
+ const budgeted = [];
17138
+ for (const r of truncated) {
17139
+ const estimatedTokens = Math.ceil(r.content.length / 4);
17140
+ if (tokenBudget - estimatedTokens < 0 && budgeted.length > 0) break;
17141
+ tokenBudget -= estimatedTokens;
17142
+ budgeted.push(r);
17143
+ }
17144
+ return budgeted;
17145
+ }
17146
+ return truncated;
17147
+ }
17148
+ function registerRecallTools(server2, getStateDb) {
17149
+ server2.tool(
17150
+ "recall",
17151
+ "Query everything the system knows about a topic. Searches across entities, notes, and memories with graph-boosted ranking.",
17152
+ {
17153
+ query: z24.string().describe('What to recall (e.g., "Project X", "meetings about auth")'),
17154
+ max_results: z24.number().min(1).max(100).optional().describe("Max results (default: 20)"),
17155
+ focus: z24.enum(["entities", "notes", "memories", "all"]).optional().describe("Limit search to specific type (default: all)"),
17156
+ entity: z24.string().optional().describe("Filter memories by entity association"),
17157
+ max_tokens: z24.number().optional().describe("Token budget for response (truncates lower-ranked results)")
17158
+ },
17159
+ async (args) => {
17160
+ const stateDb2 = getStateDb();
17161
+ if (!stateDb2) {
17162
+ return {
17163
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }],
17164
+ isError: true
17165
+ };
17166
+ }
17167
+ const results = await performRecall(stateDb2, args.query, {
17168
+ max_results: args.max_results,
17169
+ focus: args.focus,
17170
+ entity: args.entity,
17171
+ max_tokens: args.max_tokens
17172
+ });
17173
+ const entities = results.filter((r) => r.type === "entity");
17174
+ const notes = results.filter((r) => r.type === "note");
17175
+ const memories = results.filter((r) => r.type === "memory");
17176
+ return {
17177
+ content: [{
17178
+ type: "text",
17179
+ text: JSON.stringify({
17180
+ query: args.query,
17181
+ total: results.length,
17182
+ entities: entities.map((e) => ({
17183
+ name: e.id,
17184
+ description: e.content,
17185
+ score: Math.round(e.score * 10) / 10,
17186
+ breakdown: e.breakdown
17187
+ })),
17188
+ notes: notes.map((n) => ({
17189
+ path: n.id,
17190
+ snippet: n.content,
17191
+ score: Math.round(n.score * 10) / 10
17192
+ })),
17193
+ memories: memories.map((m) => ({
17194
+ key: m.id,
17195
+ value: m.content,
17196
+ score: Math.round(m.score * 10) / 10
17197
+ }))
17198
+ }, null, 2)
17199
+ }]
17200
+ };
17201
+ }
17202
+ );
17203
+ }
17204
+
17205
+ // src/tools/read/brief.ts
17206
+ import { z as z25 } from "zod";
17207
+
17208
+ // src/core/shared/toolTracking.ts
17209
+ function recordToolInvocation(stateDb2, event) {
17210
+ stateDb2.db.prepare(
17211
+ `INSERT INTO tool_invocations (timestamp, tool_name, session_id, note_paths, duration_ms, success)
17212
+ VALUES (?, ?, ?, ?, ?, ?)`
17213
+ ).run(
17214
+ Date.now(),
17215
+ event.tool_name,
17216
+ event.session_id ?? null,
17217
+ event.note_paths ? JSON.stringify(event.note_paths) : null,
17218
+ event.duration_ms ?? null,
17219
+ event.success !== false ? 1 : 0
17220
+ );
17221
+ }
17222
+ function rowToInvocation(row) {
17223
+ return {
17224
+ id: row.id,
17225
+ timestamp: row.timestamp,
17226
+ tool_name: row.tool_name,
17227
+ session_id: row.session_id,
17228
+ note_paths: row.note_paths ? JSON.parse(row.note_paths) : null,
17229
+ duration_ms: row.duration_ms,
17230
+ success: row.success === 1
17231
+ };
17232
+ }
17233
+ function getToolUsageSummary(stateDb2, daysBack = 30) {
17234
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
17235
+ const rows = stateDb2.db.prepare(`
17236
+ SELECT
17237
+ tool_name,
17238
+ COUNT(*) as invocation_count,
17239
+ AVG(duration_ms) as avg_duration_ms,
17240
+ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) * 1.0 / COUNT(*) as success_rate,
17241
+ MAX(timestamp) as last_used
17242
+ FROM tool_invocations
17243
+ WHERE timestamp >= ?
17244
+ GROUP BY tool_name
17245
+ ORDER BY invocation_count DESC
17246
+ `).all(cutoff);
17247
+ return rows.map((r) => ({
17248
+ tool_name: r.tool_name,
17249
+ invocation_count: r.invocation_count,
17250
+ avg_duration_ms: Math.round(r.avg_duration_ms ?? 0),
17251
+ success_rate: Math.round(r.success_rate * 1e3) / 1e3,
17252
+ last_used: r.last_used
17253
+ }));
17254
+ }
17255
+ function getNoteAccessFrequency(stateDb2, daysBack = 30) {
17256
+ const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
17257
+ const rows = stateDb2.db.prepare(`
17258
+ SELECT note_paths, tool_name, timestamp
17259
+ FROM tool_invocations
17260
+ WHERE timestamp >= ? AND note_paths IS NOT NULL
17261
+ ORDER BY timestamp DESC
17262
+ `).all(cutoff);
17263
+ const noteMap = /* @__PURE__ */ new Map();
17264
+ for (const row of rows) {
17265
+ let paths;
17266
+ try {
17267
+ paths = JSON.parse(row.note_paths);
17268
+ } catch {
17269
+ continue;
17270
+ }
17271
+ for (const p of paths) {
17272
+ const existing = noteMap.get(p);
17273
+ if (existing) {
17274
+ existing.access_count++;
17275
+ existing.last_accessed = Math.max(existing.last_accessed, row.timestamp);
17276
+ existing.tools.add(row.tool_name);
17277
+ } else {
17278
+ noteMap.set(p, {
17279
+ access_count: 1,
17280
+ last_accessed: row.timestamp,
17281
+ tools: /* @__PURE__ */ new Set([row.tool_name])
17282
+ });
17283
+ }
17284
+ }
17285
+ }
17286
+ return Array.from(noteMap.entries()).map(([path33, stats]) => ({
17287
+ path: path33,
17288
+ access_count: stats.access_count,
17289
+ last_accessed: stats.last_accessed,
17290
+ tools_used: Array.from(stats.tools)
17291
+ })).sort((a, b) => b.access_count - a.access_count);
17292
+ }
17293
+ function getSessionHistory(stateDb2, sessionId) {
17294
+ if (sessionId) {
17295
+ const rows2 = stateDb2.db.prepare(`
17296
+ SELECT * FROM tool_invocations
17297
+ WHERE session_id = ?
17298
+ ORDER BY timestamp
17299
+ `).all(sessionId);
17300
+ if (rows2.length === 0) return [];
17301
+ const tools = /* @__PURE__ */ new Set();
17302
+ const notes = /* @__PURE__ */ new Set();
17303
+ for (const row of rows2) {
17304
+ tools.add(row.tool_name);
17305
+ if (row.note_paths) {
17306
+ try {
17307
+ for (const p of JSON.parse(row.note_paths)) {
17308
+ notes.add(p);
17309
+ }
17310
+ } catch {
17311
+ }
17312
+ }
17313
+ }
17314
+ return [{
17315
+ session_id: sessionId,
17316
+ started_at: rows2[0].timestamp,
17317
+ last_activity: rows2[rows2.length - 1].timestamp,
17318
+ tool_count: rows2.length,
17319
+ unique_tools: Array.from(tools),
17320
+ notes_accessed: Array.from(notes)
17321
+ }];
17322
+ }
17323
+ const rows = stateDb2.db.prepare(`
17324
+ SELECT
17325
+ session_id,
17326
+ MIN(timestamp) as started_at,
17327
+ MAX(timestamp) as last_activity,
17328
+ COUNT(*) as tool_count
17329
+ FROM tool_invocations
17330
+ WHERE session_id IS NOT NULL
17331
+ GROUP BY session_id
17332
+ ORDER BY last_activity DESC
17333
+ LIMIT 20
17334
+ `).all();
17335
+ return rows.map((r) => ({
17336
+ session_id: r.session_id,
17337
+ started_at: r.started_at,
17338
+ last_activity: r.last_activity,
17339
+ tool_count: r.tool_count,
17340
+ unique_tools: [],
17341
+ notes_accessed: []
17342
+ }));
17343
+ }
17344
+ function getRecentInvocations(stateDb2, limit = 20) {
17345
+ const rows = stateDb2.db.prepare(
17346
+ "SELECT * FROM tool_invocations ORDER BY timestamp DESC LIMIT ?"
17347
+ ).all(limit);
17348
+ return rows.map(rowToInvocation);
17349
+ }
17350
+ function purgeOldInvocations(stateDb2, retentionDays = 90) {
17351
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
17352
+ const result = stateDb2.db.prepare(
17353
+ "DELETE FROM tool_invocations WHERE timestamp < ?"
17354
+ ).run(cutoff);
17355
+ return result.changes;
17356
+ }
17357
+
17358
+ // src/tools/read/brief.ts
17359
+ function estimateTokens2(value) {
17360
+ const str = JSON.stringify(value);
17361
+ return Math.ceil(str.length / 4);
17362
+ }
17363
+ function buildSessionSection(stateDb2, limit) {
17364
+ const summaries = getRecentSessionSummaries(stateDb2, limit);
17365
+ if (summaries.length > 0) {
17366
+ const content2 = summaries.map((s) => ({
17367
+ session_id: s.session_id,
17368
+ summary: s.summary,
17369
+ topics: s.topics_json ? JSON.parse(s.topics_json) : [],
17370
+ ended_at: s.ended_at,
17371
+ tool_count: s.tool_count
17372
+ }));
17373
+ return {
17374
+ name: "recent_sessions",
17375
+ priority: 1,
17376
+ content: content2,
17377
+ estimated_tokens: estimateTokens2(content2)
17378
+ };
17379
+ }
17380
+ const sessions = getSessionHistory(stateDb2);
17381
+ const recentSessions = sessions.slice(0, limit);
17382
+ if (recentSessions.length === 0) {
17383
+ return { name: "recent_sessions", priority: 1, content: [], estimated_tokens: 0 };
17384
+ }
17385
+ const content = recentSessions.map((s) => ({
17386
+ session_id: s.session_id,
17387
+ started_at: s.started_at,
17388
+ last_activity: s.last_activity,
17389
+ tool_count: s.tool_count,
17390
+ tools_used: s.unique_tools
17391
+ }));
17392
+ return {
17393
+ name: "recent_sessions",
17394
+ priority: 1,
17395
+ content,
17396
+ estimated_tokens: estimateTokens2(content)
17397
+ };
17398
+ }
17399
+ function buildActiveEntitiesSection(stateDb2, limit) {
17400
+ const rows = stateDb2.db.prepare(`
17401
+ SELECT r.entity_name_lower, r.last_mentioned_at, r.mention_count,
17402
+ e.name, e.category, e.description
17403
+ FROM recency r
17404
+ LEFT JOIN entities e ON e.name_lower = r.entity_name_lower
17405
+ ORDER BY r.last_mentioned_at DESC
17406
+ LIMIT ?
17407
+ `).all(limit);
17408
+ const content = rows.map((r) => ({
17409
+ name: r.name || r.entity_name_lower,
17410
+ category: r.category,
17411
+ description: r.description,
17412
+ last_mentioned: r.last_mentioned_at,
17413
+ mentions: r.mention_count
17414
+ }));
17415
+ return {
17416
+ name: "active_entities",
17417
+ priority: 2,
17418
+ content,
17419
+ estimated_tokens: estimateTokens2(content)
17420
+ };
17421
+ }
17422
+ function buildActiveMemoriesSection(stateDb2, limit) {
17423
+ const memories = listMemories(stateDb2, { limit });
17424
+ const content = memories.map((m) => ({
17425
+ key: m.key,
17426
+ value: m.value,
17427
+ type: m.memory_type,
17428
+ entity: m.entity,
17429
+ confidence: m.confidence,
17430
+ updated_at: m.updated_at
17431
+ }));
17432
+ return {
17433
+ name: "active_memories",
17434
+ priority: 3,
17435
+ content,
17436
+ estimated_tokens: estimateTokens2(content)
17437
+ };
17438
+ }
17439
+ function buildCorrectionsSection(stateDb2, limit) {
17440
+ const corrections = listCorrections(stateDb2, "pending", void 0, limit);
17441
+ const content = corrections.map((c) => ({
17442
+ id: c.id,
17443
+ type: c.correction_type,
17444
+ description: c.description,
17445
+ entity: c.entity,
17446
+ created_at: c.created_at
17447
+ }));
17448
+ return {
17449
+ name: "pending_corrections",
17450
+ priority: 4,
17451
+ content,
17452
+ estimated_tokens: estimateTokens2(content)
17453
+ };
17454
+ }
17455
+ function buildVaultPulseSection(stateDb2) {
17456
+ const now = Date.now();
17457
+ const day = 864e5;
17458
+ const recentToolCount = stateDb2.db.prepare(
17459
+ "SELECT COUNT(*) as cnt FROM tool_invocations WHERE timestamp > ?"
17460
+ ).get(now - day).cnt;
17461
+ const entityCount = stateDb2.db.prepare(
17462
+ "SELECT COUNT(*) as cnt FROM entities"
17463
+ ).get().cnt;
17464
+ const memoryCount = stateDb2.db.prepare(
17465
+ "SELECT COUNT(*) as cnt FROM memories WHERE superseded_by IS NULL"
17466
+ ).get().cnt;
17467
+ let noteCount = 0;
17468
+ try {
17469
+ noteCount = stateDb2.db.prepare(
17470
+ "SELECT COUNT(*) as cnt FROM notes_fts"
17471
+ ).get().cnt;
17472
+ } catch {
17473
+ }
17474
+ const contradictions = findContradictions2(stateDb2);
17475
+ const content = {
17476
+ notes: noteCount,
17477
+ entities: entityCount,
17478
+ memories: memoryCount,
17479
+ tool_calls_24h: recentToolCount,
17480
+ contradictions: contradictions.length
17481
+ };
17482
+ return {
17483
+ name: "vault_pulse",
17484
+ priority: 5,
17485
+ content,
17486
+ estimated_tokens: estimateTokens2(content)
17487
+ };
17488
+ }
17489
+ function registerBriefTools(server2, getStateDb) {
17490
+ server2.tool(
17491
+ "brief",
17492
+ "Get a startup context briefing: recent sessions, active entities, memories, pending corrections, and vault stats. Call at conversation start.",
17493
+ {
17494
+ max_tokens: z25.number().optional().describe("Token budget (lower-priority sections truncated first)"),
17495
+ focus: z25.string().optional().describe("Focus entity or topic (filters content)"),
17496
+ sections: z25.array(z25.enum(["recent_sessions", "active_entities", "active_memories", "pending_corrections", "vault_pulse"])).optional().describe("Which sections to include (default: all)")
17497
+ },
17498
+ async (args) => {
17499
+ const stateDb2 = getStateDb();
17500
+ if (!stateDb2) {
17501
+ return {
17502
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }],
17503
+ isError: true
17504
+ };
17505
+ }
17506
+ const requestedSections = args.sections ? new Set(args.sections) : /* @__PURE__ */ new Set(["recent_sessions", "active_entities", "active_memories", "pending_corrections", "vault_pulse"]);
17507
+ const sections = [];
17508
+ if (requestedSections.has("recent_sessions")) {
17509
+ sections.push(buildSessionSection(stateDb2, 5));
17510
+ }
17511
+ if (requestedSections.has("active_entities")) {
17512
+ sections.push(buildActiveEntitiesSection(stateDb2, 10));
17513
+ }
17514
+ if (requestedSections.has("active_memories")) {
17515
+ sections.push(buildActiveMemoriesSection(stateDb2, 20));
17516
+ }
17517
+ if (requestedSections.has("pending_corrections")) {
17518
+ sections.push(buildCorrectionsSection(stateDb2, 10));
17519
+ }
17520
+ if (requestedSections.has("vault_pulse")) {
17521
+ sections.push(buildVaultPulseSection(stateDb2));
17522
+ }
17523
+ if (args.max_tokens) {
17524
+ let totalTokens2 = 0;
17525
+ sections.sort((a, b) => a.priority - b.priority);
17526
+ for (const section of sections) {
17527
+ totalTokens2 += section.estimated_tokens;
17528
+ if (totalTokens2 > args.max_tokens) {
17529
+ if (Array.isArray(section.content)) {
17530
+ const remaining = Math.max(0, args.max_tokens - (totalTokens2 - section.estimated_tokens));
17531
+ const itemTokens = section.estimated_tokens / Math.max(1, section.content.length);
17532
+ const keepCount = Math.max(1, Math.floor(remaining / itemTokens));
17533
+ section.content = section.content.slice(0, keepCount);
17534
+ section.estimated_tokens = estimateTokens2(section.content);
17535
+ }
17536
+ }
17537
+ }
17538
+ }
17539
+ const response = {};
17540
+ let totalTokens = 0;
17541
+ for (const section of sections) {
17542
+ response[section.name] = section.content;
17543
+ totalTokens += section.estimated_tokens;
17544
+ }
17545
+ response._meta = { total_estimated_tokens: totalTokens };
17546
+ return {
17547
+ content: [{
17548
+ type: "text",
17549
+ text: JSON.stringify(response, null, 2)
17550
+ }]
17551
+ };
17552
+ }
17553
+ );
17554
+ }
17555
+
17556
+ // src/tools/write/config.ts
17557
+ import { z as z26 } from "zod";
17558
+ import { saveFlywheelConfigToDb as saveFlywheelConfigToDb2 } from "@velvetmonkey/vault-core";
17559
+ function registerConfigTools(server2, getConfig, setConfig, getStateDb) {
17560
+ server2.registerTool(
17561
+ "flywheel_config",
17562
+ {
17563
+ title: "Flywheel Config",
17564
+ description: 'Read or update Flywheel configuration.\n- "get": Returns the current FlywheelConfig\n- "set": Updates a single config key and returns the updated config\n\nExample: flywheel_config({ mode: "get" })\nExample: flywheel_config({ mode: "set", key: "exclude_analysis_tags", value: ["habit", "daily"] })',
17565
+ inputSchema: {
17566
+ mode: z26.enum(["get", "set"]).describe("Operation mode"),
17567
+ key: z26.string().optional().describe("Config key to update (required for set mode)"),
17568
+ value: z26.unknown().optional().describe("New value for the key (required for set mode)")
17569
+ }
17570
+ },
17571
+ async ({ mode, key, value }) => {
17572
+ switch (mode) {
17573
+ case "get": {
17574
+ const config = getConfig();
17575
+ return {
17576
+ content: [{ type: "text", text: JSON.stringify(config, null, 2) }]
17577
+ };
17578
+ }
17579
+ case "set": {
17580
+ if (!key) {
17581
+ return {
17582
+ content: [{ type: "text", text: JSON.stringify({ error: "key is required for set mode" }) }]
17583
+ };
17584
+ }
17585
+ const stateDb2 = getStateDb();
17586
+ if (!stateDb2) {
17587
+ return {
17588
+ content: [{ type: "text", text: JSON.stringify({ error: "StateDb not available" }) }]
17589
+ };
17590
+ }
17591
+ const current = getConfig();
17592
+ const updated = { ...current, [key]: value };
17593
+ saveFlywheelConfigToDb2(stateDb2, updated);
17594
+ const reloaded = loadConfig(stateDb2);
17595
+ setConfig(reloaded);
17596
+ return {
17597
+ content: [{ type: "text", text: JSON.stringify(reloaded, null, 2) }]
17598
+ };
17599
+ }
17600
+ }
17601
+ }
17602
+ );
17603
+ }
17604
+
17605
+ // src/tools/write/enrich.ts
17606
+ import { z as z27 } from "zod";
17607
+ import * as fs29 from "fs/promises";
17608
+ import * as path30 from "path";
17609
+ function hasSkipWikilinks(content) {
17610
+ if (!content.startsWith("---")) return false;
17611
+ const endIndex = content.indexOf("\n---", 3);
17612
+ if (endIndex === -1) return false;
17613
+ const frontmatter = content.substring(4, endIndex);
17614
+ return /^skipWikilinks:\s*true\s*$/m.test(frontmatter);
17615
+ }
17616
+ async function collectMarkdownFiles(dirPath, basePath, excludeFolders) {
17617
+ const results = [];
17618
+ try {
17619
+ const entries = await fs29.readdir(dirPath, { withFileTypes: true });
17620
+ for (const entry of entries) {
17621
+ if (entry.name.startsWith(".")) continue;
17622
+ const fullPath = path30.join(dirPath, entry.name);
17623
+ if (entry.isDirectory()) {
17624
+ if (excludeFolders.some((f) => entry.name.toLowerCase() === f.toLowerCase())) continue;
17625
+ const sub = await collectMarkdownFiles(fullPath, basePath, excludeFolders);
17626
+ results.push(...sub);
17627
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
17628
+ results.push(path30.relative(basePath, fullPath));
17629
+ }
17630
+ }
17631
+ } catch {
17632
+ }
17633
+ return results;
17634
+ }
17635
+ var EXCLUDE_FOLDERS = [
17636
+ "daily-notes",
17637
+ "daily",
17638
+ "weekly",
17639
+ "weekly-notes",
17640
+ "monthly",
17641
+ "monthly-notes",
17642
+ "quarterly",
17643
+ "yearly-notes",
17644
+ "periodic",
17645
+ "journal",
17646
+ "inbox",
17647
+ "templates",
17648
+ "attachments",
16486
17649
  "tmp",
16487
17650
  "clippings",
16488
17651
  "readwise",
@@ -16495,9 +17658,9 @@ function registerInitTools(server2, vaultPath2, getStateDb) {
16495
17658
  "vault_init",
16496
17659
  "Initialize vault for Flywheel \u2014 scans legacy notes with zero wikilinks and applies entity links. Safe to re-run (idempotent). Use dry_run (default) to preview.",
16497
17660
  {
16498
- dry_run: z24.boolean().default(true).describe("If true (default), preview what would be linked without modifying files"),
16499
- batch_size: z24.number().default(50).describe("Maximum notes to process per invocation (default: 50)"),
16500
- offset: z24.number().default(0).describe("Skip this many eligible notes (for pagination across invocations)")
17661
+ dry_run: z27.boolean().default(true).describe("If true (default), preview what would be linked without modifying files"),
17662
+ batch_size: z27.number().default(50).describe("Maximum notes to process per invocation (default: 50)"),
17663
+ offset: z27.number().default(0).describe("Skip this many eligible notes (for pagination across invocations)")
16501
17664
  },
16502
17665
  async ({ dry_run, batch_size, offset }) => {
16503
17666
  const startTime = Date.now();
@@ -16592,7 +17755,7 @@ function registerInitTools(server2, vaultPath2, getStateDb) {
16592
17755
  }
16593
17756
 
16594
17757
  // src/tools/read/metrics.ts
16595
- import { z as z25 } from "zod";
17758
+ import { z as z28 } from "zod";
16596
17759
 
16597
17760
  // src/core/shared/metrics.ts
16598
17761
  var ALL_METRICS = [
@@ -16758,10 +17921,10 @@ function registerMetricsTools(server2, getIndex, getStateDb) {
16758
17921
  title: "Vault Growth",
16759
17922
  description: 'Track vault growth over time. Modes: "current" (live snapshot), "history" (time series), "trends" (deltas vs N days ago), "index_activity" (rebuild history). Tracks 11 metrics: note_count, link_count, orphan_count, tag_count, entity_count, avg_links_per_note, link_density, connected_ratio, wikilink_accuracy, wikilink_feedback_volume, wikilink_suppressed_count.',
16760
17923
  inputSchema: {
16761
- mode: z25.enum(["current", "history", "trends", "index_activity"]).describe("Query mode: current snapshot, historical time series, trend analysis, or index rebuild activity"),
16762
- metric: z25.string().optional().describe('Filter to specific metric (e.g., "note_count"). Omit for all metrics.'),
16763
- days_back: z25.number().optional().describe("Number of days to look back for history/trends (default: 30)"),
16764
- limit: z25.number().optional().describe("Number of recent events to return for index_activity mode (default: 20)")
17924
+ mode: z28.enum(["current", "history", "trends", "index_activity"]).describe("Query mode: current snapshot, historical time series, trend analysis, or index rebuild activity"),
17925
+ metric: z28.string().optional().describe('Filter to specific metric (e.g., "note_count"). Omit for all metrics.'),
17926
+ days_back: z28.number().optional().describe("Number of days to look back for history/trends (default: 30)"),
17927
+ limit: z28.number().optional().describe("Number of recent events to return for index_activity mode (default: 20)")
16765
17928
  }
16766
17929
  },
16767
17930
  async ({ mode, metric, days_back, limit: eventLimit }) => {
@@ -16834,159 +17997,7 @@ function registerMetricsTools(server2, getIndex, getStateDb) {
16834
17997
  }
16835
17998
 
16836
17999
  // src/tools/read/activity.ts
16837
- import { z as z26 } from "zod";
16838
-
16839
- // src/core/shared/toolTracking.ts
16840
- function recordToolInvocation(stateDb2, event) {
16841
- stateDb2.db.prepare(
16842
- `INSERT INTO tool_invocations (timestamp, tool_name, session_id, note_paths, duration_ms, success)
16843
- VALUES (?, ?, ?, ?, ?, ?)`
16844
- ).run(
16845
- Date.now(),
16846
- event.tool_name,
16847
- event.session_id ?? null,
16848
- event.note_paths ? JSON.stringify(event.note_paths) : null,
16849
- event.duration_ms ?? null,
16850
- event.success !== false ? 1 : 0
16851
- );
16852
- }
16853
- function rowToInvocation(row) {
16854
- return {
16855
- id: row.id,
16856
- timestamp: row.timestamp,
16857
- tool_name: row.tool_name,
16858
- session_id: row.session_id,
16859
- note_paths: row.note_paths ? JSON.parse(row.note_paths) : null,
16860
- duration_ms: row.duration_ms,
16861
- success: row.success === 1
16862
- };
16863
- }
16864
- function getToolUsageSummary(stateDb2, daysBack = 30) {
16865
- const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
16866
- const rows = stateDb2.db.prepare(`
16867
- SELECT
16868
- tool_name,
16869
- COUNT(*) as invocation_count,
16870
- AVG(duration_ms) as avg_duration_ms,
16871
- SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) * 1.0 / COUNT(*) as success_rate,
16872
- MAX(timestamp) as last_used
16873
- FROM tool_invocations
16874
- WHERE timestamp >= ?
16875
- GROUP BY tool_name
16876
- ORDER BY invocation_count DESC
16877
- `).all(cutoff);
16878
- return rows.map((r) => ({
16879
- tool_name: r.tool_name,
16880
- invocation_count: r.invocation_count,
16881
- avg_duration_ms: Math.round(r.avg_duration_ms ?? 0),
16882
- success_rate: Math.round(r.success_rate * 1e3) / 1e3,
16883
- last_used: r.last_used
16884
- }));
16885
- }
16886
- function getNoteAccessFrequency(stateDb2, daysBack = 30) {
16887
- const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1e3;
16888
- const rows = stateDb2.db.prepare(`
16889
- SELECT note_paths, tool_name, timestamp
16890
- FROM tool_invocations
16891
- WHERE timestamp >= ? AND note_paths IS NOT NULL
16892
- ORDER BY timestamp DESC
16893
- `).all(cutoff);
16894
- const noteMap = /* @__PURE__ */ new Map();
16895
- for (const row of rows) {
16896
- let paths;
16897
- try {
16898
- paths = JSON.parse(row.note_paths);
16899
- } catch {
16900
- continue;
16901
- }
16902
- for (const p of paths) {
16903
- const existing = noteMap.get(p);
16904
- if (existing) {
16905
- existing.access_count++;
16906
- existing.last_accessed = Math.max(existing.last_accessed, row.timestamp);
16907
- existing.tools.add(row.tool_name);
16908
- } else {
16909
- noteMap.set(p, {
16910
- access_count: 1,
16911
- last_accessed: row.timestamp,
16912
- tools: /* @__PURE__ */ new Set([row.tool_name])
16913
- });
16914
- }
16915
- }
16916
- }
16917
- return Array.from(noteMap.entries()).map(([path33, stats]) => ({
16918
- path: path33,
16919
- access_count: stats.access_count,
16920
- last_accessed: stats.last_accessed,
16921
- tools_used: Array.from(stats.tools)
16922
- })).sort((a, b) => b.access_count - a.access_count);
16923
- }
16924
- function getSessionHistory(stateDb2, sessionId) {
16925
- if (sessionId) {
16926
- const rows2 = stateDb2.db.prepare(`
16927
- SELECT * FROM tool_invocations
16928
- WHERE session_id = ?
16929
- ORDER BY timestamp
16930
- `).all(sessionId);
16931
- if (rows2.length === 0) return [];
16932
- const tools = /* @__PURE__ */ new Set();
16933
- const notes = /* @__PURE__ */ new Set();
16934
- for (const row of rows2) {
16935
- tools.add(row.tool_name);
16936
- if (row.note_paths) {
16937
- try {
16938
- for (const p of JSON.parse(row.note_paths)) {
16939
- notes.add(p);
16940
- }
16941
- } catch {
16942
- }
16943
- }
16944
- }
16945
- return [{
16946
- session_id: sessionId,
16947
- started_at: rows2[0].timestamp,
16948
- last_activity: rows2[rows2.length - 1].timestamp,
16949
- tool_count: rows2.length,
16950
- unique_tools: Array.from(tools),
16951
- notes_accessed: Array.from(notes)
16952
- }];
16953
- }
16954
- const rows = stateDb2.db.prepare(`
16955
- SELECT
16956
- session_id,
16957
- MIN(timestamp) as started_at,
16958
- MAX(timestamp) as last_activity,
16959
- COUNT(*) as tool_count
16960
- FROM tool_invocations
16961
- WHERE session_id IS NOT NULL
16962
- GROUP BY session_id
16963
- ORDER BY last_activity DESC
16964
- LIMIT 20
16965
- `).all();
16966
- return rows.map((r) => ({
16967
- session_id: r.session_id,
16968
- started_at: r.started_at,
16969
- last_activity: r.last_activity,
16970
- tool_count: r.tool_count,
16971
- unique_tools: [],
16972
- notes_accessed: []
16973
- }));
16974
- }
16975
- function getRecentInvocations(stateDb2, limit = 20) {
16976
- const rows = stateDb2.db.prepare(
16977
- "SELECT * FROM tool_invocations ORDER BY timestamp DESC LIMIT ?"
16978
- ).all(limit);
16979
- return rows.map(rowToInvocation);
16980
- }
16981
- function purgeOldInvocations(stateDb2, retentionDays = 90) {
16982
- const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
16983
- const result = stateDb2.db.prepare(
16984
- "DELETE FROM tool_invocations WHERE timestamp < ?"
16985
- ).run(cutoff);
16986
- return result.changes;
16987
- }
16988
-
16989
- // src/tools/read/activity.ts
18000
+ import { z as z29 } from "zod";
16990
18001
  function registerActivityTools(server2, getStateDb, getSessionId2) {
16991
18002
  server2.registerTool(
16992
18003
  "vault_activity",
@@ -16994,10 +18005,10 @@ function registerActivityTools(server2, getStateDb, getSessionId2) {
16994
18005
  title: "Vault Activity",
16995
18006
  description: 'Track tool usage patterns and session activity. Modes:\n- "session": Current session summary (tools called, notes accessed)\n- "sessions": List of recent sessions\n- "note_access": Notes ranked by query frequency\n- "tool_usage": Tool usage patterns (most-used tools, avg duration)',
16996
18007
  inputSchema: {
16997
- mode: z26.enum(["session", "sessions", "note_access", "tool_usage"]).describe("Activity query mode"),
16998
- session_id: z26.string().optional().describe("Specific session ID (for session mode, defaults to current)"),
16999
- days_back: z26.number().optional().describe("Number of days to look back (default: 30)"),
17000
- limit: z26.number().optional().describe("Maximum results to return (default: 20)")
18008
+ mode: z29.enum(["session", "sessions", "note_access", "tool_usage"]).describe("Activity query mode"),
18009
+ session_id: z29.string().optional().describe("Specific session ID (for session mode, defaults to current)"),
18010
+ days_back: z29.number().optional().describe("Number of days to look back (default: 30)"),
18011
+ limit: z29.number().optional().describe("Maximum results to return (default: 20)")
17001
18012
  }
17002
18013
  },
17003
18014
  async ({ mode, session_id, days_back, limit: resultLimit }) => {
@@ -17064,7 +18075,7 @@ function registerActivityTools(server2, getStateDb, getSessionId2) {
17064
18075
  }
17065
18076
 
17066
18077
  // src/tools/read/similarity.ts
17067
- import { z as z27 } from "zod";
18078
+ import { z as z30 } from "zod";
17068
18079
 
17069
18080
  // src/core/read/similarity.ts
17070
18081
  import * as fs30 from "fs";
@@ -17328,9 +18339,9 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
17328
18339
  title: "Find Similar Notes",
17329
18340
  description: "Find notes similar to a given note using FTS5 keyword matching. When embeddings have been built (via init_semantic), automatically uses hybrid ranking (BM25 + embedding similarity via Reciprocal Rank Fusion). Use exclude_linked to filter out notes already connected via wikilinks.",
17330
18341
  inputSchema: {
17331
- path: z27.string().describe('Path to the source note (relative to vault root, e.g. "projects/alpha.md")'),
17332
- limit: z27.number().optional().describe("Maximum number of similar notes to return (default: 10)"),
17333
- exclude_linked: z27.boolean().optional().describe("Exclude notes already linked to/from the source note (default: true)")
18342
+ path: z30.string().describe('Path to the source note (relative to vault root, e.g. "projects/alpha.md")'),
18343
+ limit: z30.number().optional().describe("Maximum number of similar notes to return (default: 10)"),
18344
+ exclude_linked: z30.boolean().optional().describe("Exclude notes already linked to/from the source note (default: true)")
17334
18345
  }
17335
18346
  },
17336
18347
  async ({ path: path33, limit, exclude_linked }) => {
@@ -17374,7 +18385,7 @@ function registerSimilarityTools(server2, getIndex, getVaultPath, getStateDb) {
17374
18385
  }
17375
18386
 
17376
18387
  // src/tools/read/semantic.ts
17377
- import { z as z28 } from "zod";
18388
+ import { z as z31 } from "zod";
17378
18389
  import { getAllEntitiesFromDb } from "@velvetmonkey/vault-core";
17379
18390
  function registerSemanticTools(server2, getVaultPath, getStateDb) {
17380
18391
  server2.registerTool(
@@ -17383,7 +18394,7 @@ function registerSemanticTools(server2, getVaultPath, getStateDb) {
17383
18394
  title: "Initialize Semantic Search",
17384
18395
  description: "Download the embedding model and build semantic search index for this vault. After running, search and find_similar automatically use hybrid ranking (BM25 + semantic). Run once per vault \u2014 subsequent calls skip already-embedded notes unless force=true.",
17385
18396
  inputSchema: {
17386
- force: z28.boolean().optional().describe(
18397
+ force: z31.boolean().optional().describe(
17387
18398
  "Rebuild all embeddings even if they already exist (default: false)"
17388
18399
  )
17389
18400
  }
@@ -17463,7 +18474,7 @@ function registerSemanticTools(server2, getVaultPath, getStateDb) {
17463
18474
 
17464
18475
  // src/tools/read/merges.ts
17465
18476
  init_levenshtein();
17466
- import { z as z29 } from "zod";
18477
+ import { z as z32 } from "zod";
17467
18478
  import { getAllEntitiesFromDb as getAllEntitiesFromDb2, getDismissedMergePairs, recordMergeDismissal } from "@velvetmonkey/vault-core";
17468
18479
  function normalizeName(name) {
17469
18480
  return name.toLowerCase().replace(/[.\-_]/g, "").replace(/js$/, "").replace(/ts$/, "");
@@ -17473,7 +18484,7 @@ function registerMergeTools2(server2, getStateDb) {
17473
18484
  "suggest_entity_merges",
17474
18485
  "Find potential duplicate entities that could be merged based on name similarity",
17475
18486
  {
17476
- limit: z29.number().optional().default(50).describe("Maximum number of suggestions to return")
18487
+ limit: z32.number().optional().default(50).describe("Maximum number of suggestions to return")
17477
18488
  },
17478
18489
  async ({ limit }) => {
17479
18490
  const stateDb2 = getStateDb();
@@ -17575,11 +18586,11 @@ function registerMergeTools2(server2, getStateDb) {
17575
18586
  "dismiss_merge_suggestion",
17576
18587
  "Permanently dismiss a merge suggestion so it never reappears",
17577
18588
  {
17578
- source_path: z29.string().describe("Path of the source entity"),
17579
- target_path: z29.string().describe("Path of the target entity"),
17580
- source_name: z29.string().describe("Name of the source entity"),
17581
- target_name: z29.string().describe("Name of the target entity"),
17582
- reason: z29.string().describe("Original suggestion reason")
18589
+ source_path: z32.string().describe("Path of the source entity"),
18590
+ target_path: z32.string().describe("Path of the target entity"),
18591
+ source_name: z32.string().describe("Name of the source entity"),
18592
+ target_name: z32.string().describe("Name of the target entity"),
18593
+ reason: z32.string().describe("Original suggestion reason")
17583
18594
  },
17584
18595
  async ({ source_path, target_path, source_name, target_name, reason }) => {
17585
18596
  const stateDb2 = getStateDb();
@@ -17740,8 +18751,10 @@ var PRESETS = {
17740
18751
  "frontmatter",
17741
18752
  "notes",
17742
18753
  "git",
17743
- "policy"
18754
+ "policy",
18755
+ "memory"
17744
18756
  ],
18757
+ agent: ["search", "structure", "append", "frontmatter", "notes", "memory"],
17745
18758
  // Composable bundles
17746
18759
  graph: ["backlinks", "orphans", "hubs", "paths"],
17747
18760
  analysis: ["schema", "wikilinks"],
@@ -17764,7 +18777,8 @@ var ALL_CATEGORIES = [
17764
18777
  "frontmatter",
17765
18778
  "notes",
17766
18779
  "git",
17767
- "policy"
18780
+ "policy",
18781
+ "memory"
17768
18782
  ];
17769
18783
  var DEFAULT_PRESET = "full";
17770
18784
  function parseEnabledCategories() {
@@ -17868,7 +18882,11 @@ var TOOL_CATEGORY = {
17868
18882
  suggest_entity_merges: "health",
17869
18883
  dismiss_merge_suggestion: "health",
17870
18884
  // notes (entity merge)
17871
- merge_entities: "notes"
18885
+ merge_entities: "notes",
18886
+ // memory (agent working memory)
18887
+ memory: "memory",
18888
+ recall: "memory",
18889
+ brief: "memory"
17872
18890
  };
17873
18891
  var server = new McpServer({
17874
18892
  name: "flywheel-memory",
@@ -18000,6 +19018,9 @@ registerActivityTools(server, () => stateDb, () => {
18000
19018
  registerSimilarityTools(server, () => vaultIndex, () => vaultPath, () => stateDb);
18001
19019
  registerSemanticTools(server, () => vaultPath, () => stateDb);
18002
19020
  registerMergeTools2(server, () => stateDb);
19021
+ registerMemoryTools(server, () => stateDb);
19022
+ registerRecallTools(server, () => stateDb);
19023
+ registerBriefTools(server, () => stateDb);
18003
19024
  registerVaultResources(server, () => vaultIndex ?? null);
18004
19025
  serverLog("server", `Registered ${_registeredCount} tools, skipped ${_skippedCount}`);
18005
19026
  async function main() {
@@ -18017,6 +19038,12 @@ async function main() {
18017
19038
  setWriteStateDb(stateDb);
18018
19039
  setRecencyStateDb(stateDb);
18019
19040
  setEdgeWeightStateDb(stateDb);
19041
+ const cachedCooc = loadCooccurrenceFromStateDb(stateDb);
19042
+ if (cachedCooc) {
19043
+ setCooccurrenceIndex(cachedCooc.index);
19044
+ lastCooccurrenceRebuildAt = cachedCooc.builtAt;
19045
+ serverLog("index", `Co-occurrence: loaded from cache (${Object.keys(cachedCooc.index.associations).length} entities, ${cachedCooc.index._metadata.total_associations} associations)`);
19046
+ }
18020
19047
  const vaultInitRow = stateDb.getMetadataValue.get("vault_init_last_run_at");
18021
19048
  if (!vaultInitRow) {
18022
19049
  serverLog("server", "Vault not initialized \u2014 call vault_init to enrich legacy notes");
@@ -18245,6 +19272,7 @@ async function runPostIndexWork(index) {
18245
19272
  }
18246
19273
  }
18247
19274
  loadEntityEmbeddingsToMemory();
19275
+ setEmbeddingsBuildState("complete");
18248
19276
  serverLog("semantic", "Embeddings ready");
18249
19277
  }).catch((err) => {
18250
19278
  serverLog("semantic", `Embeddings build failed: ${err instanceof Error ? err.message : err}`, "error");
@@ -18430,17 +19458,22 @@ async function runPostIndexWork(index) {
18430
19458
  tracker.end({ entity_count: entitiesAfter.length, ...entityDiff, category_changes: categoryChanges, description_changes: descriptionChanges });
18431
19459
  serverLog("watcher", `Entity scan: ${entitiesAfter.length} entities`);
18432
19460
  tracker.start("hub_scores", { entity_count: entitiesAfter.length });
18433
- const hubUpdated = await exportHubScores(vaultIndex, stateDb);
18434
- const hubDiffs = [];
18435
- if (stateDb) {
18436
- const rows = stateDb.db.prepare("SELECT name, hub_score FROM entities").all();
18437
- for (const r of rows) {
18438
- const prev = hubBefore.get(r.name) ?? 0;
18439
- if (prev !== r.hub_score) hubDiffs.push({ entity: r.name, before: prev, after: r.hub_score });
19461
+ try {
19462
+ const hubUpdated = await exportHubScores(vaultIndex, stateDb);
19463
+ const hubDiffs = [];
19464
+ if (stateDb) {
19465
+ const rows = stateDb.db.prepare("SELECT name, hub_score FROM entities").all();
19466
+ for (const r of rows) {
19467
+ const prev = hubBefore.get(r.name) ?? 0;
19468
+ if (prev !== r.hub_score) hubDiffs.push({ entity: r.name, before: prev, after: r.hub_score });
19469
+ }
18440
19470
  }
19471
+ tracker.end({ updated: hubUpdated ?? 0, diffs: hubDiffs.slice(0, 10) });
19472
+ serverLog("watcher", `Hub scores: ${hubUpdated ?? 0} updated`);
19473
+ } catch (e) {
19474
+ tracker.end({ error: String(e) });
19475
+ serverLog("watcher", `Hub scores: failed: ${e}`, "error");
18441
19476
  }
18442
- tracker.end({ updated: hubUpdated ?? 0, diffs: hubDiffs.slice(0, 10) });
18443
- serverLog("watcher", `Hub scores: ${hubUpdated ?? 0} updated`);
18444
19477
  tracker.start("recency", { entity_count: entitiesAfter.length });
18445
19478
  try {
18446
19479
  const cachedRecency = loadRecencyFromStateDb();
@@ -18467,6 +19500,9 @@ async function runPostIndexWork(index) {
18467
19500
  const cooccurrenceIdx = await mineCooccurrences(vaultPath, entityNames);
18468
19501
  setCooccurrenceIndex(cooccurrenceIdx);
18469
19502
  lastCooccurrenceRebuildAt = Date.now();
19503
+ if (stateDb) {
19504
+ saveCooccurrenceToStateDb(stateDb, cooccurrenceIdx);
19505
+ }
18470
19506
  tracker.end({ rebuilt: true, associations: cooccurrenceIdx._metadata.total_associations });
18471
19507
  serverLog("watcher", `Co-occurrence: rebuilt ${cooccurrenceIdx._metadata.total_associations} associations`);
18472
19508
  } else {
@@ -18582,87 +19618,99 @@ async function runPostIndexWork(index) {
18582
19618
  }
18583
19619
  tracker.end({ updated: taskUpdated, removed: taskRemoved });
18584
19620
  serverLog("watcher", `Task cache: ${taskUpdated} updated, ${taskRemoved} removed`);
18585
- tracker.start("forward_links", { files: filteredEvents.length });
18586
- const eventTypeMap = new Map(filteredEvents.map((e) => [e.path, e.type]));
18587
19621
  const forwardLinkResults = [];
18588
19622
  let totalResolved = 0;
18589
19623
  let totalDead = 0;
18590
- for (const event of filteredEvents) {
18591
- if (event.type === "delete" || !event.path.endsWith(".md")) continue;
18592
- try {
18593
- const links = getForwardLinksForNote(vaultIndex, event.path);
18594
- const resolved = [];
18595
- const dead = [];
18596
- const seen = /* @__PURE__ */ new Set();
18597
- for (const link of links) {
18598
- const name = link.target;
18599
- if (seen.has(name.toLowerCase())) continue;
18600
- seen.add(name.toLowerCase());
18601
- if (link.exists) resolved.push(name);
18602
- else dead.push(name);
18603
- }
18604
- if (resolved.length > 0 || dead.length > 0) {
18605
- forwardLinkResults.push({ file: event.path, resolved, dead });
18606
- }
18607
- totalResolved += resolved.length;
18608
- totalDead += dead.length;
18609
- } catch {
18610
- }
18611
- }
18612
19624
  const linkDiffs = [];
18613
19625
  const survivedLinks = [];
18614
- if (stateDb) {
18615
- const upsertHistory = stateDb.db.prepare(`
19626
+ tracker.start("forward_links", { files: filteredEvents.length });
19627
+ try {
19628
+ const eventTypeMap = new Map(filteredEvents.map((e) => [e.path, e.type]));
19629
+ for (const event of filteredEvents) {
19630
+ if (event.type === "delete" || !event.path.endsWith(".md")) continue;
19631
+ try {
19632
+ const links = getForwardLinksForNote(vaultIndex, event.path);
19633
+ const resolved = [];
19634
+ const dead = [];
19635
+ const seen = /* @__PURE__ */ new Set();
19636
+ for (const link of links) {
19637
+ const name = link.target;
19638
+ if (seen.has(name.toLowerCase())) continue;
19639
+ seen.add(name.toLowerCase());
19640
+ if (link.exists) resolved.push(name);
19641
+ else dead.push(name);
19642
+ }
19643
+ if (resolved.length > 0 || dead.length > 0) {
19644
+ forwardLinkResults.push({ file: event.path, resolved, dead });
19645
+ }
19646
+ totalResolved += resolved.length;
19647
+ totalDead += dead.length;
19648
+ } catch {
19649
+ }
19650
+ }
19651
+ if (stateDb) {
19652
+ const upsertHistory = stateDb.db.prepare(`
18616
19653
  INSERT INTO note_link_history (note_path, target) VALUES (?, ?)
18617
19654
  ON CONFLICT(note_path, target) DO UPDATE SET edits_survived = edits_survived + 1
18618
19655
  `);
18619
- const checkThreshold = stateDb.db.prepare(`
19656
+ const checkThreshold = stateDb.db.prepare(`
18620
19657
  SELECT target FROM note_link_history
18621
19658
  WHERE note_path = ? AND target = ? AND edits_survived >= 3 AND last_positive_at IS NULL
18622
19659
  `);
18623
- const markPositive = stateDb.db.prepare(`
19660
+ const markPositive = stateDb.db.prepare(`
18624
19661
  UPDATE note_link_history SET last_positive_at = datetime('now') WHERE note_path = ? AND target = ?
18625
19662
  `);
18626
- const getEdgeCount = stateDb.db.prepare(
18627
- "SELECT edits_survived FROM note_link_history WHERE note_path=? AND target=?"
18628
- );
18629
- for (const entry of forwardLinkResults) {
18630
- const currentSet = /* @__PURE__ */ new Set([
18631
- ...entry.resolved.map((n) => n.toLowerCase()),
18632
- ...entry.dead.map((n) => n.toLowerCase())
18633
- ]);
18634
- const previousSet = getStoredNoteLinks(stateDb, entry.file);
18635
- if (previousSet.size === 0) {
19663
+ const getEdgeCount = stateDb.db.prepare(
19664
+ "SELECT edits_survived FROM note_link_history WHERE note_path=? AND target=?"
19665
+ );
19666
+ for (const entry of forwardLinkResults) {
19667
+ const currentSet = /* @__PURE__ */ new Set([
19668
+ ...entry.resolved.map((n) => n.toLowerCase()),
19669
+ ...entry.dead.map((n) => n.toLowerCase())
19670
+ ]);
19671
+ const previousSet = getStoredNoteLinks(stateDb, entry.file);
19672
+ if (previousSet.size === 0) {
19673
+ updateStoredNoteLinks(stateDb, entry.file, currentSet);
19674
+ continue;
19675
+ }
19676
+ const diff = diffNoteLinks(previousSet, currentSet);
19677
+ if (diff.added.length > 0 || diff.removed.length > 0) {
19678
+ linkDiffs.push({ file: entry.file, ...diff });
19679
+ }
18636
19680
  updateStoredNoteLinks(stateDb, entry.file, currentSet);
18637
- continue;
18638
- }
18639
- const diff = diffNoteLinks(previousSet, currentSet);
18640
- if (diff.added.length > 0 || diff.removed.length > 0) {
18641
- linkDiffs.push({ file: entry.file, ...diff });
18642
- }
18643
- updateStoredNoteLinks(stateDb, entry.file, currentSet);
18644
- if (diff.removed.length === 0) continue;
18645
- for (const link of currentSet) {
18646
- if (!previousSet.has(link)) continue;
18647
- upsertHistory.run(entry.file, link);
18648
- const countRow = getEdgeCount.get(entry.file, link);
18649
- if (countRow) {
18650
- survivedLinks.push({ entity: link, file: entry.file, count: countRow.edits_survived });
19681
+ if (diff.removed.length === 0) continue;
19682
+ for (const link of currentSet) {
19683
+ if (!previousSet.has(link)) continue;
19684
+ upsertHistory.run(entry.file, link);
19685
+ const countRow = getEdgeCount.get(entry.file, link);
19686
+ if (countRow) {
19687
+ survivedLinks.push({ entity: link, file: entry.file, count: countRow.edits_survived });
19688
+ }
19689
+ const hit = checkThreshold.get(entry.file, link);
19690
+ if (hit) {
19691
+ const entity = entitiesAfter.find(
19692
+ (e) => e.nameLower === link || (e.aliases ?? []).some((a) => a.toLowerCase() === link)
19693
+ );
19694
+ if (entity) {
19695
+ recordFeedback(stateDb, entity.name, "implicit:kept", entry.file, true, 0.8);
19696
+ markPositive.run(entry.file, link);
19697
+ }
19698
+ }
18651
19699
  }
18652
- const hit = checkThreshold.get(entry.file, link);
18653
- if (hit) {
18654
- const entity = entitiesAfter.find(
18655
- (e) => e.nameLower === link || (e.aliases ?? []).some((a) => a.toLowerCase() === link)
18656
- );
18657
- if (entity) {
18658
- recordFeedback(stateDb, entity.name, "implicit:kept", entry.file, true, 0.8);
18659
- markPositive.run(entry.file, link);
19700
+ }
19701
+ for (const event of filteredEvents) {
19702
+ if (event.type === "delete") {
19703
+ const previousSet = getStoredNoteLinks(stateDb, event.path);
19704
+ if (previousSet.size > 0) {
19705
+ linkDiffs.push({ file: event.path, added: [], removed: [...previousSet] });
19706
+ updateStoredNoteLinks(stateDb, event.path, /* @__PURE__ */ new Set());
18660
19707
  }
18661
19708
  }
18662
19709
  }
18663
- }
18664
- for (const event of filteredEvents) {
18665
- if (event.type === "delete") {
19710
+ const processedFiles = new Set(forwardLinkResults.map((r) => r.file));
19711
+ for (const event of filteredEvents) {
19712
+ if (event.type === "delete" || !event.path.endsWith(".md")) continue;
19713
+ if (processedFiles.has(event.path)) continue;
18666
19714
  const previousSet = getStoredNoteLinks(stateDb, event.path);
18667
19715
  if (previousSet.size > 0) {
18668
19716
  linkDiffs.push({ file: event.path, added: [], removed: [...previousSet] });
@@ -18670,33 +19718,26 @@ async function runPostIndexWork(index) {
18670
19718
  }
18671
19719
  }
18672
19720
  }
18673
- const processedFiles = new Set(forwardLinkResults.map((r) => r.file));
18674
- for (const event of filteredEvents) {
18675
- if (event.type === "delete" || !event.path.endsWith(".md")) continue;
18676
- if (processedFiles.has(event.path)) continue;
18677
- const previousSet = getStoredNoteLinks(stateDb, event.path);
18678
- if (previousSet.size > 0) {
18679
- linkDiffs.push({ file: event.path, added: [], removed: [...previousSet] });
18680
- updateStoredNoteLinks(stateDb, event.path, /* @__PURE__ */ new Set());
19721
+ const newDeadLinks = [];
19722
+ for (const diff of linkDiffs) {
19723
+ const newDead = diff.added.filter((target) => !vaultIndex.entities.has(target.toLowerCase()));
19724
+ if (newDead.length > 0) {
19725
+ newDeadLinks.push({ file: diff.file, targets: newDead });
18681
19726
  }
18682
19727
  }
19728
+ tracker.end({
19729
+ total_resolved: totalResolved,
19730
+ total_dead: totalDead,
19731
+ links: forwardLinkResults,
19732
+ link_diffs: linkDiffs,
19733
+ survived: survivedLinks,
19734
+ new_dead_links: newDeadLinks
19735
+ });
19736
+ serverLog("watcher", `Forward links: ${totalResolved} resolved, ${totalDead} dead${newDeadLinks.length > 0 ? `, ${newDeadLinks.reduce((s, d) => s + d.targets.length, 0)} new dead` : ""}`);
19737
+ } catch (e) {
19738
+ tracker.end({ error: String(e) });
19739
+ serverLog("watcher", `Forward links: failed: ${e}`, "error");
18683
19740
  }
18684
- const newDeadLinks = [];
18685
- for (const diff of linkDiffs) {
18686
- const newDead = diff.added.filter((target) => !vaultIndex.entities.has(target.toLowerCase()));
18687
- if (newDead.length > 0) {
18688
- newDeadLinks.push({ file: diff.file, targets: newDead });
18689
- }
18690
- }
18691
- tracker.end({
18692
- total_resolved: totalResolved,
18693
- total_dead: totalDead,
18694
- links: forwardLinkResults,
18695
- link_diffs: linkDiffs,
18696
- survived: survivedLinks,
18697
- new_dead_links: newDeadLinks
18698
- });
18699
- serverLog("watcher", `Forward links: ${totalResolved} resolved, ${totalDead} dead${newDeadLinks.length > 0 ? `, ${newDeadLinks.reduce((s, d) => s + d.targets.length, 0)} new dead` : ""}`);
18700
19741
  tracker.start("wikilink_check", { files: filteredEvents.length });
18701
19742
  const trackedLinks = [];
18702
19743
  if (stateDb) {
@@ -18821,6 +19862,24 @@ async function runPostIndexWork(index) {
18821
19862
  if (newlySuppressed.length > 0) {
18822
19863
  serverLog("watcher", `Suppression: ${newlySuppressed.length} entities newly suppressed: ${newlySuppressed.join(", ")}`);
18823
19864
  }
19865
+ tracker.start("corrections", {});
19866
+ try {
19867
+ if (stateDb) {
19868
+ const corrProcessed = processPendingCorrections(stateDb);
19869
+ if (corrProcessed > 0) {
19870
+ updateSuppressionList(stateDb);
19871
+ }
19872
+ tracker.end({ processed: corrProcessed });
19873
+ if (corrProcessed > 0) {
19874
+ serverLog("watcher", `Corrections: ${corrProcessed} processed`);
19875
+ }
19876
+ } else {
19877
+ tracker.end({ skipped: true });
19878
+ }
19879
+ } catch (e) {
19880
+ tracker.end({ error: String(e) });
19881
+ serverLog("watcher", `Corrections: failed: ${e}`, "error");
19882
+ }
18824
19883
  tracker.start("prospect_scan", { files: filteredEvents.length });
18825
19884
  const prospectResults = [];
18826
19885
  for (const event of filteredEvents) {
@@ -18884,45 +19943,50 @@ async function runPostIndexWork(index) {
18884
19943
  serverLog("watcher", `Suggestion scoring: ${suggestionResults.length} files scored`);
18885
19944
  }
18886
19945
  tracker.start("tag_scan", { files: filteredEvents.length });
18887
- const tagDiffs = [];
18888
- if (stateDb) {
18889
- const noteTagsForward = /* @__PURE__ */ new Map();
18890
- for (const [tag, paths] of vaultIndex.tags) {
18891
- for (const notePath of paths) {
18892
- if (!noteTagsForward.has(notePath)) noteTagsForward.set(notePath, /* @__PURE__ */ new Set());
18893
- noteTagsForward.get(notePath).add(tag);
19946
+ try {
19947
+ const tagDiffs = [];
19948
+ if (stateDb) {
19949
+ const noteTagsForward = /* @__PURE__ */ new Map();
19950
+ for (const [tag, paths] of vaultIndex.tags) {
19951
+ for (const notePath of paths) {
19952
+ if (!noteTagsForward.has(notePath)) noteTagsForward.set(notePath, /* @__PURE__ */ new Set());
19953
+ noteTagsForward.get(notePath).add(tag);
19954
+ }
18894
19955
  }
18895
- }
18896
- for (const event of filteredEvents) {
18897
- if (event.type === "delete" || !event.path.endsWith(".md")) continue;
18898
- const currentSet = noteTagsForward.get(event.path) ?? /* @__PURE__ */ new Set();
18899
- const previousSet = getStoredNoteTags(stateDb, event.path);
18900
- if (previousSet.size === 0 && currentSet.size > 0) {
19956
+ for (const event of filteredEvents) {
19957
+ if (event.type === "delete" || !event.path.endsWith(".md")) continue;
19958
+ const currentSet = noteTagsForward.get(event.path) ?? /* @__PURE__ */ new Set();
19959
+ const previousSet = getStoredNoteTags(stateDb, event.path);
19960
+ if (previousSet.size === 0 && currentSet.size > 0) {
19961
+ updateStoredNoteTags(stateDb, event.path, currentSet);
19962
+ continue;
19963
+ }
19964
+ const added = [...currentSet].filter((t) => !previousSet.has(t));
19965
+ const removed = [...previousSet].filter((t) => !currentSet.has(t));
19966
+ if (added.length > 0 || removed.length > 0) {
19967
+ tagDiffs.push({ file: event.path, added, removed });
19968
+ }
18901
19969
  updateStoredNoteTags(stateDb, event.path, currentSet);
18902
- continue;
18903
- }
18904
- const added = [...currentSet].filter((t) => !previousSet.has(t));
18905
- const removed = [...previousSet].filter((t) => !currentSet.has(t));
18906
- if (added.length > 0 || removed.length > 0) {
18907
- tagDiffs.push({ file: event.path, added, removed });
18908
19970
  }
18909
- updateStoredNoteTags(stateDb, event.path, currentSet);
18910
- }
18911
- for (const event of filteredEvents) {
18912
- if (event.type === "delete") {
18913
- const previousSet = getStoredNoteTags(stateDb, event.path);
18914
- if (previousSet.size > 0) {
18915
- tagDiffs.push({ file: event.path, added: [], removed: [...previousSet] });
18916
- updateStoredNoteTags(stateDb, event.path, /* @__PURE__ */ new Set());
19971
+ for (const event of filteredEvents) {
19972
+ if (event.type === "delete") {
19973
+ const previousSet = getStoredNoteTags(stateDb, event.path);
19974
+ if (previousSet.size > 0) {
19975
+ tagDiffs.push({ file: event.path, added: [], removed: [...previousSet] });
19976
+ updateStoredNoteTags(stateDb, event.path, /* @__PURE__ */ new Set());
19977
+ }
18917
19978
  }
18918
19979
  }
18919
19980
  }
18920
- }
18921
- const totalTagsAdded = tagDiffs.reduce((s, d) => s + d.added.length, 0);
18922
- const totalTagsRemoved = tagDiffs.reduce((s, d) => s + d.removed.length, 0);
18923
- tracker.end({ total_added: totalTagsAdded, total_removed: totalTagsRemoved, tag_diffs: tagDiffs });
18924
- if (tagDiffs.length > 0) {
18925
- serverLog("watcher", `Tag scan: ${totalTagsAdded} added, ${totalTagsRemoved} removed across ${tagDiffs.length} files`);
19981
+ const totalTagsAdded = tagDiffs.reduce((s, d) => s + d.added.length, 0);
19982
+ const totalTagsRemoved = tagDiffs.reduce((s, d) => s + d.removed.length, 0);
19983
+ tracker.end({ total_added: totalTagsAdded, total_removed: totalTagsRemoved, tag_diffs: tagDiffs });
19984
+ if (tagDiffs.length > 0) {
19985
+ serverLog("watcher", `Tag scan: ${totalTagsAdded} added, ${totalTagsRemoved} removed across ${tagDiffs.length} files`);
19986
+ }
19987
+ } catch (e) {
19988
+ tracker.end({ error: String(e) });
19989
+ serverLog("watcher", `Tag scan: failed: ${e}`, "error");
18926
19990
  }
18927
19991
  const duration = Date.now() - batchStart;
18928
19992
  if (stateDb) {