smoking-mirror 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +48 -0
  2. package/dist/index.js +456 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -188,6 +188,54 @@ Output: [{ path: "work/Current.md", title: "Current", frontmatter: {...}, tags:
188
188
  - `order` - "asc" or "desc"
189
189
  - `limit` - Maximum results to return
190
190
 
191
+ ### System (v1.1)
192
+
193
+ #### `refresh_index`
194
+ Rebuild the vault index without restarting the server.
195
+
196
+ ```
197
+ Output: { success: true, notes_count: 1444, entities_count: 3421, duration_ms: 1823 }
198
+ ```
199
+
200
+ #### `get_all_entities`
201
+ Get all linkable entities (note titles and aliases).
202
+
203
+ ```
204
+ Input: { include_aliases: true, limit: 100 }
205
+ Output: { entity_count: 100, entities: [{ name: "John Smith", path: "people/John Smith.md", is_alias: false }] }
206
+ ```
207
+
208
+ #### `get_recent_notes`
209
+ Get notes modified within the last N days.
210
+
211
+ ```
212
+ Input: { days: 7, limit: 50 }
213
+ Output: { count: 23, days: 7, notes: [{ path: "daily/2024-01-15.md", title: "2024-01-15", modified: "...", tags: [...] }] }
214
+ ```
215
+
216
+ #### `get_unlinked_mentions`
217
+ Find mentions of an entity that aren't linked (linking opportunities).
218
+
219
+ ```
220
+ Input: { entity: "John Smith", limit: 50 }
221
+ Output: { entity: "John Smith", mention_count: 12, mentions: [{ path: "meetings/standup.md", line: 5, context: "Talked to John Smith about..." }] }
222
+ ```
223
+
224
+ #### `get_note_metadata`
225
+ Get metadata about a note without reading full content.
226
+
227
+ ```
228
+ Input: { path: "projects/API.md", include_word_count: true }
229
+ Output: { path: "...", title: "API", frontmatter: {...}, tags: [...], outlink_count: 15, backlink_count: 8, word_count: 2340 }
230
+ ```
231
+
232
+ #### `get_folder_structure`
233
+ Get the folder structure of the vault with note counts.
234
+
235
+ ```
236
+ Output: { folder_count: 12, folders: [{ path: "daily", note_count: 365, subfolder_count: 0 }] }
237
+ ```
238
+
191
239
  ## Architecture
192
240
 
193
241
  - **File-first**: Parses markdown directly, no database required
package/dist/index.js CHANGED
@@ -212,8 +212,8 @@ var PROGRESS_INTERVAL = 100;
212
212
  function normalizeTarget(target) {
213
213
  return target.toLowerCase().replace(/\.md$/, "");
214
214
  }
215
- function normalizeNotePath(path3) {
216
- return path3.toLowerCase().replace(/\.md$/, "");
215
+ function normalizeNotePath(path4) {
216
+ return path4.toLowerCase().replace(/\.md$/, "");
217
217
  }
218
218
  async function buildVaultIndex(vaultPath2, options = {}) {
219
219
  const { timeoutMs = DEFAULT_TIMEOUT_MS, onProgress } = options;
@@ -717,14 +717,14 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
717
717
  };
718
718
  function findSimilarEntity(target, entities) {
719
719
  const targetLower = target.toLowerCase();
720
- for (const [name, path3] of entities) {
720
+ for (const [name, path4] of entities) {
721
721
  if (name.startsWith(targetLower) || targetLower.startsWith(name)) {
722
- return path3;
722
+ return path4;
723
723
  }
724
724
  }
725
- for (const [name, path3] of entities) {
725
+ for (const [name, path4] of entities) {
726
726
  if (name.includes(targetLower) || targetLower.includes(name)) {
727
- return path3;
727
+ return path4;
728
728
  }
729
729
  }
730
730
  return void 0;
@@ -1143,6 +1143,447 @@ function registerQueryTools(server2, getIndex, getVaultPath) {
1143
1143
  );
1144
1144
  }
1145
1145
 
1146
+ // src/tools/system.ts
1147
+ import * as fs4 from "fs";
1148
+ import * as path3 from "path";
1149
+ import { z as z5 } from "zod";
1150
+ function registerSystemTools(server2, getIndex, setIndex, getVaultPath) {
1151
+ const RefreshIndexOutputSchema = {
1152
+ success: z5.boolean().describe("Whether the refresh succeeded"),
1153
+ notes_count: z5.number().describe("Number of notes indexed"),
1154
+ entities_count: z5.number().describe("Number of entities (titles + aliases)"),
1155
+ duration_ms: z5.number().describe("Time taken to rebuild index")
1156
+ };
1157
+ server2.registerTool(
1158
+ "refresh_index",
1159
+ {
1160
+ title: "Refresh Index",
1161
+ description: "Rebuild the vault index without restarting the server. Use after making changes to notes in Obsidian.",
1162
+ inputSchema: {},
1163
+ outputSchema: RefreshIndexOutputSchema
1164
+ },
1165
+ async () => {
1166
+ const vaultPath2 = getVaultPath();
1167
+ const startTime = Date.now();
1168
+ try {
1169
+ const newIndex = await buildVaultIndex(vaultPath2);
1170
+ setIndex(newIndex);
1171
+ const output = {
1172
+ success: true,
1173
+ notes_count: newIndex.notes.size,
1174
+ entities_count: newIndex.entities.size,
1175
+ duration_ms: Date.now() - startTime
1176
+ };
1177
+ return {
1178
+ content: [
1179
+ {
1180
+ type: "text",
1181
+ text: JSON.stringify(output, null, 2)
1182
+ }
1183
+ ],
1184
+ structuredContent: output
1185
+ };
1186
+ } catch (err) {
1187
+ const output = {
1188
+ success: false,
1189
+ notes_count: 0,
1190
+ entities_count: 0,
1191
+ duration_ms: Date.now() - startTime
1192
+ };
1193
+ return {
1194
+ content: [
1195
+ {
1196
+ type: "text",
1197
+ text: `Error refreshing index: ${err instanceof Error ? err.message : String(err)}`
1198
+ }
1199
+ ],
1200
+ structuredContent: output
1201
+ };
1202
+ }
1203
+ }
1204
+ );
1205
+ const GetAllEntitiesOutputSchema = {
1206
+ entity_count: z5.number().describe("Total number of entities"),
1207
+ entities: z5.array(
1208
+ z5.object({
1209
+ name: z5.string().describe("Entity name (title or alias)"),
1210
+ path: z5.string().describe("Path to the note"),
1211
+ is_alias: z5.boolean().describe("Whether this is an alias vs title")
1212
+ })
1213
+ ).describe("List of all entities")
1214
+ };
1215
+ server2.registerTool(
1216
+ "get_all_entities",
1217
+ {
1218
+ title: "Get All Entities",
1219
+ description: "Get all linkable entities in the vault (note titles and aliases). Useful for understanding what can be linked to.",
1220
+ inputSchema: {
1221
+ include_aliases: z5.boolean().default(true).describe("Include aliases in addition to titles"),
1222
+ limit: z5.number().optional().describe("Maximum number of entities to return")
1223
+ },
1224
+ outputSchema: GetAllEntitiesOutputSchema
1225
+ },
1226
+ async ({
1227
+ include_aliases,
1228
+ limit
1229
+ }) => {
1230
+ const index = getIndex();
1231
+ const entities = [];
1232
+ for (const note of index.notes.values()) {
1233
+ entities.push({
1234
+ name: note.title,
1235
+ path: note.path,
1236
+ is_alias: false
1237
+ });
1238
+ if (include_aliases) {
1239
+ for (const alias of note.aliases) {
1240
+ entities.push({
1241
+ name: alias,
1242
+ path: note.path,
1243
+ is_alias: true
1244
+ });
1245
+ }
1246
+ }
1247
+ }
1248
+ entities.sort((a, b) => a.name.localeCompare(b.name));
1249
+ const limitedEntities = limit ? entities.slice(0, limit) : entities;
1250
+ const output = {
1251
+ entity_count: limitedEntities.length,
1252
+ entities: limitedEntities
1253
+ };
1254
+ return {
1255
+ content: [
1256
+ {
1257
+ type: "text",
1258
+ text: JSON.stringify(output, null, 2)
1259
+ }
1260
+ ],
1261
+ structuredContent: output
1262
+ };
1263
+ }
1264
+ );
1265
+ const GetRecentNotesOutputSchema = {
1266
+ count: z5.number().describe("Number of notes returned"),
1267
+ days: z5.number().describe("Number of days looked back"),
1268
+ notes: z5.array(
1269
+ z5.object({
1270
+ path: z5.string().describe("Path to the note"),
1271
+ title: z5.string().describe("Note title"),
1272
+ modified: z5.string().describe("Last modified date (ISO format)"),
1273
+ tags: z5.array(z5.string()).describe("Tags on this note")
1274
+ })
1275
+ ).describe("List of recently modified notes")
1276
+ };
1277
+ server2.registerTool(
1278
+ "get_recent_notes",
1279
+ {
1280
+ title: "Get Recent Notes",
1281
+ description: "Get notes modified within the last N days. Useful for generating reviews and understanding recent activity.",
1282
+ inputSchema: {
1283
+ days: z5.number().default(7).describe("Number of days to look back"),
1284
+ limit: z5.number().default(50).describe("Maximum number of notes to return"),
1285
+ folder: z5.string().optional().describe("Limit to notes in this folder")
1286
+ },
1287
+ outputSchema: GetRecentNotesOutputSchema
1288
+ },
1289
+ async ({
1290
+ days,
1291
+ limit,
1292
+ folder
1293
+ }) => {
1294
+ const index = getIndex();
1295
+ const cutoffDate = /* @__PURE__ */ new Date();
1296
+ cutoffDate.setDate(cutoffDate.getDate() - days);
1297
+ const recentNotes = [];
1298
+ for (const note of index.notes.values()) {
1299
+ if (folder && !note.path.startsWith(folder)) {
1300
+ continue;
1301
+ }
1302
+ if (note.modified >= cutoffDate) {
1303
+ recentNotes.push({
1304
+ path: note.path,
1305
+ title: note.title,
1306
+ modified: note.modified.toISOString(),
1307
+ tags: note.tags,
1308
+ modifiedDate: note.modified
1309
+ });
1310
+ }
1311
+ }
1312
+ recentNotes.sort(
1313
+ (a, b) => b.modifiedDate.getTime() - a.modifiedDate.getTime()
1314
+ );
1315
+ const limitedNotes = recentNotes.slice(0, limit);
1316
+ const output = {
1317
+ count: limitedNotes.length,
1318
+ days,
1319
+ notes: limitedNotes.map((n) => ({
1320
+ path: n.path,
1321
+ title: n.title,
1322
+ modified: n.modified,
1323
+ tags: n.tags
1324
+ }))
1325
+ };
1326
+ return {
1327
+ content: [
1328
+ {
1329
+ type: "text",
1330
+ text: JSON.stringify(output, null, 2)
1331
+ }
1332
+ ],
1333
+ structuredContent: output
1334
+ };
1335
+ }
1336
+ );
1337
+ const GetUnlinkedMentionsOutputSchema = {
1338
+ entity: z5.string().describe("The entity searched for"),
1339
+ resolved_path: z5.string().optional().describe("Path of the note this entity refers to"),
1340
+ mention_count: z5.number().describe("Total unlinked mentions found"),
1341
+ mentions: z5.array(
1342
+ z5.object({
1343
+ path: z5.string().describe("Path of note with unlinked mention"),
1344
+ line: z5.number().describe("Line number of mention"),
1345
+ context: z5.string().describe("Surrounding text")
1346
+ })
1347
+ ).describe("List of unlinked mentions")
1348
+ };
1349
+ server2.registerTool(
1350
+ "get_unlinked_mentions",
1351
+ {
1352
+ title: "Get Unlinked Mentions",
1353
+ description: "Find places where an entity (note title or alias) is mentioned in text but not linked. Useful for finding linking opportunities.",
1354
+ inputSchema: {
1355
+ entity: z5.string().describe('Entity to search for (e.g., "John Smith")'),
1356
+ limit: z5.number().default(50).describe("Maximum number of mentions to return")
1357
+ },
1358
+ outputSchema: GetUnlinkedMentionsOutputSchema
1359
+ },
1360
+ async ({
1361
+ entity,
1362
+ limit
1363
+ }) => {
1364
+ const index = getIndex();
1365
+ const vaultPath2 = getVaultPath();
1366
+ const normalizedEntity = entity.toLowerCase();
1367
+ const resolvedPath = index.entities.get(normalizedEntity);
1368
+ const mentions = [];
1369
+ for (const note of index.notes.values()) {
1370
+ if (resolvedPath && note.path === resolvedPath) {
1371
+ continue;
1372
+ }
1373
+ try {
1374
+ const fullPath = path3.join(vaultPath2, note.path);
1375
+ const content = await fs4.promises.readFile(fullPath, "utf-8");
1376
+ const lines = content.split("\n");
1377
+ for (let i = 0; i < lines.length; i++) {
1378
+ const line = lines[i];
1379
+ const lowerLine = line.toLowerCase();
1380
+ if (!lowerLine.includes(normalizedEntity)) {
1381
+ continue;
1382
+ }
1383
+ const linkPattern = new RegExp(
1384
+ `\\[\\[[^\\]]*${entity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\]]*\\]\\]`,
1385
+ "i"
1386
+ );
1387
+ if (linkPattern.test(line)) {
1388
+ continue;
1389
+ }
1390
+ mentions.push({
1391
+ path: note.path,
1392
+ line: i + 1,
1393
+ context: line.trim().slice(0, 200)
1394
+ });
1395
+ if (mentions.length >= limit) {
1396
+ break;
1397
+ }
1398
+ }
1399
+ } catch {
1400
+ }
1401
+ if (mentions.length >= limit) {
1402
+ break;
1403
+ }
1404
+ }
1405
+ const output = {
1406
+ entity,
1407
+ resolved_path: resolvedPath,
1408
+ mention_count: mentions.length,
1409
+ mentions
1410
+ };
1411
+ return {
1412
+ content: [
1413
+ {
1414
+ type: "text",
1415
+ text: JSON.stringify(output, null, 2)
1416
+ }
1417
+ ],
1418
+ structuredContent: output
1419
+ };
1420
+ }
1421
+ );
1422
+ const GetNoteMetadataOutputSchema = {
1423
+ path: z5.string().describe("Path to the note"),
1424
+ title: z5.string().describe("Note title"),
1425
+ exists: z5.boolean().describe("Whether the note exists"),
1426
+ frontmatter: z5.record(z5.unknown()).describe("Frontmatter properties"),
1427
+ tags: z5.array(z5.string()).describe("Tags on this note"),
1428
+ aliases: z5.array(z5.string()).describe("Aliases for this note"),
1429
+ outlink_count: z5.number().describe("Number of outgoing links"),
1430
+ backlink_count: z5.number().describe("Number of incoming links"),
1431
+ word_count: z5.number().optional().describe("Approximate word count"),
1432
+ created: z5.string().optional().describe("Created date (ISO format)"),
1433
+ modified: z5.string().describe("Last modified date (ISO format)")
1434
+ };
1435
+ server2.registerTool(
1436
+ "get_note_metadata",
1437
+ {
1438
+ title: "Get Note Metadata",
1439
+ description: "Get metadata about a note (frontmatter, tags, link counts) without reading full content. Useful for quick analysis.",
1440
+ inputSchema: {
1441
+ path: z5.string().describe("Path to the note"),
1442
+ include_word_count: z5.boolean().default(false).describe("Count words (requires reading file)")
1443
+ },
1444
+ outputSchema: GetNoteMetadataOutputSchema
1445
+ },
1446
+ async ({
1447
+ path: notePath,
1448
+ include_word_count
1449
+ }) => {
1450
+ const index = getIndex();
1451
+ const vaultPath2 = getVaultPath();
1452
+ let resolvedPath = notePath;
1453
+ if (!notePath.endsWith(".md")) {
1454
+ const resolved = index.entities.get(notePath.toLowerCase());
1455
+ if (resolved) {
1456
+ resolvedPath = resolved;
1457
+ } else {
1458
+ resolvedPath = notePath + ".md";
1459
+ }
1460
+ }
1461
+ const note = index.notes.get(resolvedPath);
1462
+ if (!note) {
1463
+ const output2 = {
1464
+ path: resolvedPath,
1465
+ title: resolvedPath.replace(/\.md$/, "").split("/").pop() || "",
1466
+ exists: false,
1467
+ frontmatter: {},
1468
+ tags: [],
1469
+ aliases: [],
1470
+ outlink_count: 0,
1471
+ backlink_count: 0,
1472
+ modified: (/* @__PURE__ */ new Date()).toISOString()
1473
+ };
1474
+ return {
1475
+ content: [
1476
+ {
1477
+ type: "text",
1478
+ text: JSON.stringify(output2, null, 2)
1479
+ }
1480
+ ],
1481
+ structuredContent: output2
1482
+ };
1483
+ }
1484
+ const normalizedPath = resolvedPath.toLowerCase().replace(/\.md$/, "");
1485
+ const backlinks = index.backlinks.get(normalizedPath) || [];
1486
+ let wordCount;
1487
+ if (include_word_count) {
1488
+ try {
1489
+ const fullPath = path3.join(vaultPath2, resolvedPath);
1490
+ const content = await fs4.promises.readFile(fullPath, "utf-8");
1491
+ wordCount = content.split(/\s+/).filter((w) => w.length > 0).length;
1492
+ } catch {
1493
+ }
1494
+ }
1495
+ const output = {
1496
+ path: note.path,
1497
+ title: note.title,
1498
+ exists: true,
1499
+ frontmatter: note.frontmatter,
1500
+ tags: note.tags,
1501
+ aliases: note.aliases,
1502
+ outlink_count: note.outlinks.length,
1503
+ backlink_count: backlinks.length,
1504
+ word_count: wordCount,
1505
+ created: note.created?.toISOString(),
1506
+ modified: note.modified.toISOString()
1507
+ };
1508
+ return {
1509
+ content: [
1510
+ {
1511
+ type: "text",
1512
+ text: JSON.stringify(output, null, 2)
1513
+ }
1514
+ ],
1515
+ structuredContent: output
1516
+ };
1517
+ }
1518
+ );
1519
+ const GetFolderStructureOutputSchema = {
1520
+ folder_count: z5.number().describe("Total number of folders"),
1521
+ folders: z5.array(
1522
+ z5.object({
1523
+ path: z5.string().describe("Folder path"),
1524
+ note_count: z5.number().describe("Number of notes in this folder"),
1525
+ subfolder_count: z5.number().describe("Number of direct subfolders")
1526
+ })
1527
+ ).describe("List of folders with note counts")
1528
+ };
1529
+ server2.registerTool(
1530
+ "get_folder_structure",
1531
+ {
1532
+ title: "Get Folder Structure",
1533
+ description: "Get the folder structure of the vault with note counts. Useful for understanding vault organization.",
1534
+ inputSchema: {},
1535
+ outputSchema: GetFolderStructureOutputSchema
1536
+ },
1537
+ async () => {
1538
+ const index = getIndex();
1539
+ const folderCounts = /* @__PURE__ */ new Map();
1540
+ const subfolders = /* @__PURE__ */ new Map();
1541
+ for (const note of index.notes.values()) {
1542
+ const parts = note.path.split("/");
1543
+ if (parts.length === 1) {
1544
+ folderCounts.set("/", (folderCounts.get("/") || 0) + 1);
1545
+ continue;
1546
+ }
1547
+ const folderPath = parts.slice(0, -1).join("/");
1548
+ folderCounts.set(folderPath, (folderCounts.get(folderPath) || 0) + 1);
1549
+ for (let i = 1; i < parts.length - 1; i++) {
1550
+ const parent = parts.slice(0, i).join("/") || "/";
1551
+ const child = parts.slice(0, i + 1).join("/");
1552
+ if (!subfolders.has(parent)) {
1553
+ subfolders.set(parent, /* @__PURE__ */ new Set());
1554
+ }
1555
+ subfolders.get(parent).add(child);
1556
+ if (!folderCounts.has(parent)) {
1557
+ folderCounts.set(parent, 0);
1558
+ }
1559
+ }
1560
+ }
1561
+ const folders = [];
1562
+ for (const [folderPath, noteCount] of folderCounts) {
1563
+ folders.push({
1564
+ path: folderPath,
1565
+ note_count: noteCount,
1566
+ subfolder_count: subfolders.get(folderPath)?.size || 0
1567
+ });
1568
+ }
1569
+ folders.sort((a, b) => a.path.localeCompare(b.path));
1570
+ const output = {
1571
+ folder_count: folders.length,
1572
+ folders
1573
+ };
1574
+ return {
1575
+ content: [
1576
+ {
1577
+ type: "text",
1578
+ text: JSON.stringify(output, null, 2)
1579
+ }
1580
+ ],
1581
+ structuredContent: output
1582
+ };
1583
+ }
1584
+ );
1585
+ }
1586
+
1146
1587
  // src/index.ts
1147
1588
  var VAULT_PATH = process.env.OBSIDIAN_VAULT_PATH;
1148
1589
  if (!VAULT_PATH) {
@@ -1153,7 +1594,7 @@ var vaultPath = VAULT_PATH;
1153
1594
  var vaultIndex;
1154
1595
  var server = new McpServer({
1155
1596
  name: "smoking-mirror",
1156
- version: "1.0.0"
1597
+ version: "1.1.0"
1157
1598
  });
1158
1599
  registerGraphTools(
1159
1600
  server,
@@ -1175,6 +1616,14 @@ registerQueryTools(
1175
1616
  () => vaultIndex,
1176
1617
  () => vaultPath
1177
1618
  );
1619
+ registerSystemTools(
1620
+ server,
1621
+ () => vaultIndex,
1622
+ (newIndex) => {
1623
+ vaultIndex = newIndex;
1624
+ },
1625
+ () => vaultPath
1626
+ );
1178
1627
  async function main() {
1179
1628
  console.error("Building vault index...");
1180
1629
  const startTime = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoking-mirror",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Obsidian vault intelligence MCP server - graph queries, wikilink suggestions, vault health",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",