seekstone 0.2.1 → 0.4.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.
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ Usage:
30
30
  seekstone --help Print this help and exit
31
31
 
32
32
  "seekstone init" options:
33
- --vault <path> Vault to use (default: $SEEKSTONE_VAULT)
33
+ --vault <path> Vault to use (auto-detected from Obsidian if omitted; fallback: $SEEKSTONE_VAULT)
34
34
  --client <name> desktop (default) | code
35
35
  --write Patch the Claude Desktop config in place (backs it up first)
36
36
 
@@ -155,6 +155,25 @@ import { z as z2 } from "zod";
155
155
  import { basename, extname } from "path";
156
156
 
157
157
  // ../core/src/extract.ts
158
+ var LINK_WITH_LINES_RE_SRC = /(!?\[\[)([^\]|#\n]+)(#[^\]|\n]+)?(\|[^\]\n]+)?(\]\])/.source;
159
+ function extractLinksWithLines(raw) {
160
+ const out = [];
161
+ const lines = raw.split("\n");
162
+ for (const [lineIdx, lineText] of lines.entries()) {
163
+ for (const m of lineText.matchAll(new RegExp(LINK_WITH_LINES_RE_SRC, "g"))) {
164
+ const prefix = m[1] ?? "";
165
+ out.push({
166
+ raw: m[0] ?? "",
167
+ target: (m[2] ?? "").trim(),
168
+ fragment: m[3] ? m[3].slice(1).trim() : null,
169
+ alias: m[4] ? m[4].slice(1).trim() : null,
170
+ linkType: prefix.startsWith("!") ? "embed" : "wikilink",
171
+ line: lineIdx + 1
172
+ });
173
+ }
174
+ }
175
+ return out;
176
+ }
158
177
  var INLINE_TAG_RE = /(?:^|[^\w\]/])#([A-Za-z_][\w/-]*)/g;
159
178
  function extractInlineTags(body) {
160
179
  const out = [];
@@ -253,14 +272,102 @@ async function deleteNote(ctx2, input) {
253
272
  return { path: input.path };
254
273
  }
255
274
 
256
- // src/tools/list_notes.ts
275
+ // src/tools/get_backlinks.ts
276
+ import { join as join4 } from "path";
257
277
  import { z as z4 } from "zod";
258
- var ListNotesInput = z4.object({
259
- folder: z4.string().optional().describe(
278
+ var GetBacklinksInput = z4.object({
279
+ path: z4.string().describe("Vault-relative path to the target note."),
280
+ includeContext: z4.boolean().optional().describe("Include a short excerpt (~200 chars) from the linking line. Default true."),
281
+ limit: z4.number().int().min(1).max(500).optional().describe("Max backlinks to return. Default 50.")
282
+ });
283
+ var EXCERPT_MAX = 200;
284
+ function getBacklinks(ctx2, input) {
285
+ const absPath = join4(ctx2.vaultRoot, input.path);
286
+ if (!absPath.startsWith(ctx2.vaultRoot)) {
287
+ throw new Error(`Path outside vault: ${input.path}`);
288
+ }
289
+ const includeContext = input.includeContext ?? true;
290
+ const limit = input.limit ?? 50;
291
+ const refs = ctx2.backlinks.get(input.path) ?? [];
292
+ const total = refs.length;
293
+ const limited = refs.slice(0, limit);
294
+ const backlinks2 = [];
295
+ for (const ref of limited) {
296
+ const entry = { path: ref.path, line: ref.line, linkType: ref.linkType };
297
+ if (includeContext) {
298
+ const srcNote = ctx2.notes.get(ref.path);
299
+ if (srcNote !== void 0) {
300
+ const rawLines = srcNote.raw.split("\n");
301
+ const lineText = rawLines[ref.line - 1];
302
+ if (lineText !== void 0) {
303
+ entry.excerpt = lineText.length > EXCERPT_MAX ? `${lineText.slice(0, EXCERPT_MAX)}\u2026` : lineText;
304
+ }
305
+ }
306
+ }
307
+ backlinks2.push(entry);
308
+ }
309
+ return { target: input.path, backlinks: backlinks2, total };
310
+ }
311
+
312
+ // src/tools/get_links.ts
313
+ import { join as join5 } from "path";
314
+ import { z as z5 } from "zod";
315
+
316
+ // src/index/resolve.ts
317
+ function resolveLink(target, notes2) {
318
+ if (notes2.has(target)) return target;
319
+ const withMd = `${target}.md`;
320
+ if (notes2.has(withMd)) return withMd;
321
+ const targetLower = target.toLowerCase();
322
+ const targetBase = (target.split("/").pop() ?? target).toLowerCase();
323
+ for (const path2 of notes2.keys()) {
324
+ const pathNoExt = path2.replace(/\.md$/i, "");
325
+ if (pathNoExt.toLowerCase() === targetLower) return path2;
326
+ const pathBase = (pathNoExt.split("/").pop() ?? pathNoExt).toLowerCase();
327
+ if (pathBase === targetBase) return path2;
328
+ }
329
+ return void 0;
330
+ }
331
+
332
+ // src/tools/get_links.ts
333
+ var GetLinksInput = z5.object({
334
+ path: z5.string().describe("Vault-relative path to the note.")
335
+ });
336
+ function getLinks(ctx2, input) {
337
+ const absPath = join5(ctx2.vaultRoot, input.path);
338
+ if (!absPath.startsWith(ctx2.vaultRoot)) {
339
+ throw new Error(`Path outside vault: ${input.path}`);
340
+ }
341
+ const note = ctx2.notes.get(input.path);
342
+ if (note === void 0) throw new Error(`Note not found: ${input.path}`);
343
+ const rawLinks = extractLinksWithLines(note.raw);
344
+ const seen = /* @__PURE__ */ new Set();
345
+ const links = [];
346
+ for (const link of rawLinks) {
347
+ if (seen.has(link.target)) continue;
348
+ seen.add(link.target);
349
+ const resolvedPath = resolveLink(link.target, ctx2.notes);
350
+ const entry = {
351
+ raw: link.raw,
352
+ resolved: resolvedPath !== void 0,
353
+ linkType: link.linkType,
354
+ line: link.line
355
+ };
356
+ if (resolvedPath !== void 0) entry.target = resolvedPath;
357
+ links.push(entry);
358
+ }
359
+ links.sort((a, b) => a.line - b.line);
360
+ return { path: input.path, links };
361
+ }
362
+
363
+ // src/tools/list_notes.ts
364
+ import { z as z6 } from "zod";
365
+ var ListNotesInput = z6.object({
366
+ folder: z6.string().optional().describe(
260
367
  'Vault-relative folder prefix to list. Omit for all notes. Example: "Daily Notes/2026".'
261
368
  ),
262
- tag: z4.string().optional().describe("Filter by tag. Include or omit the leading #."),
263
- limit: z4.number().int().min(1).max(500).default(100).describe("Maximum number of results.")
369
+ tag: z6.string().optional().describe("Filter by tag. Include or omit the leading #."),
370
+ limit: z6.number().int().min(1).max(500).default(100).describe("Maximum number of results.")
264
371
  });
265
372
  function listNotes(ctx2, input) {
266
373
  const tag = input.tag?.replace(/^#/, "");
@@ -282,18 +389,49 @@ function listNotes(ctx2, input) {
282
389
  return results.sort((a, b) => a.path.localeCompare(b.path));
283
390
  }
284
391
 
392
+ // src/tools/list_tags.ts
393
+ import { z as z7 } from "zod";
394
+ var ListTagsInput = z7.object({
395
+ pattern: z7.string().optional().describe('Substring filter on tag name. Example: "work" matches "work" and "area/work".'),
396
+ minCount: z7.number().int().min(1).optional().describe("Only return tags used in at least this many notes."),
397
+ sort: z7.enum(["count", "name"]).default("count").describe("Sort by usage count descending (default) or alphabetically.")
398
+ });
399
+ function listTags(ctx2, input) {
400
+ const counts = /* @__PURE__ */ new Map();
401
+ for (const note of ctx2.notes.values()) {
402
+ for (const tag of note.tags.split(" ").filter(Boolean)) {
403
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
404
+ }
405
+ }
406
+ const tags = [];
407
+ for (const [tag, count] of counts) {
408
+ if (input.pattern !== void 0 && !tag.includes(input.pattern)) continue;
409
+ if (input.minCount !== void 0 && count < input.minCount) continue;
410
+ const slashIdx = tag.lastIndexOf("/");
411
+ const entry = { tag, count };
412
+ if (slashIdx !== -1) entry.parent = tag.slice(0, slashIdx);
413
+ tags.push(entry);
414
+ }
415
+ if (input.sort === "name") {
416
+ tags.sort((a, b) => a.tag.localeCompare(b.tag));
417
+ } else {
418
+ tags.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
419
+ }
420
+ return { tags, total: tags.length };
421
+ }
422
+
285
423
  // src/tools/move_note.ts
286
424
  import { access as access2, mkdir as mkdir2, rename } from "fs/promises";
287
- import { dirname as dirname2, join as join4 } from "path";
288
- import { z as z5 } from "zod";
289
- var MoveNoteInput = z5.object({
290
- from: z5.string().describe("Vault-relative path of the note to move."),
291
- to: z5.string().describe("Vault-relative destination path."),
292
- overwrite: z5.boolean().default(false).describe("Overwrite the destination if it exists. Defaults to false.")
425
+ import { dirname as dirname2, join as join6 } from "path";
426
+ import { z as z8 } from "zod";
427
+ var MoveNoteInput = z8.object({
428
+ from: z8.string().describe("Vault-relative path of the note to move."),
429
+ to: z8.string().describe("Vault-relative destination path."),
430
+ overwrite: z8.boolean().default(false).describe("Overwrite the destination if it exists. Defaults to false.")
293
431
  });
294
432
  async function moveNote(ctx2, input) {
295
- const absFrom = join4(ctx2.vaultRoot, input.from);
296
- const absTo = join4(ctx2.vaultRoot, input.to);
433
+ const absFrom = join6(ctx2.vaultRoot, input.from);
434
+ const absTo = join6(ctx2.vaultRoot, input.to);
297
435
  if (!absFrom.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${input.from}`);
298
436
  if (!absTo.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${input.to}`);
299
437
  try {
@@ -322,23 +460,93 @@ async function moveNote(ctx2, input) {
322
460
  return { from: input.from, to: input.to };
323
461
  }
324
462
 
463
+ // src/tools/outline_note.ts
464
+ import { readFile as readFile2 } from "fs/promises";
465
+ import { join as join7 } from "path";
466
+
467
+ // ../core/src/outline.ts
468
+ var HEADING_RE = /^(#{1,6})\s+(.+)/;
469
+ var BLOCK_REF_RE = /\^([a-zA-Z0-9-]+)$/;
470
+ function buildOutline(raw, options = {}) {
471
+ const { includeBlocks = true, includeSizes = false } = options;
472
+ const fm = parseFrontmatter(raw);
473
+ const totalBytes = Buffer.byteLength(raw, "utf8");
474
+ const headings = [];
475
+ const blocks = [];
476
+ let charOffset = 0;
477
+ for (const [i, line] of raw.split("\n").entries()) {
478
+ const lineCharOffset = charOffset;
479
+ const lineNum = i + 1;
480
+ if (charOffset >= fm.bodyStart) {
481
+ const hm = HEADING_RE.exec(line);
482
+ if (hm) {
483
+ headings.push({
484
+ text: (hm[2] ?? "").trim(),
485
+ level: (hm[1] ?? "").length,
486
+ line: lineNum,
487
+ byteOffset: lineCharOffset
488
+ });
489
+ }
490
+ if (includeBlocks) {
491
+ const trimmed = line.trimEnd();
492
+ const bm = BLOCK_REF_RE.exec(trimmed);
493
+ if (bm) {
494
+ const caretPos = trimmed.lastIndexOf("^");
495
+ blocks.push({
496
+ id: bm[1] ?? "",
497
+ line: lineNum,
498
+ byteOffset: lineCharOffset + caretPos
499
+ });
500
+ }
501
+ }
502
+ }
503
+ charOffset += line.length + 1;
504
+ }
505
+ if (includeSizes) {
506
+ for (const [i, heading] of headings.entries()) {
507
+ const next = headings[i + 1];
508
+ heading.byteLength = (next?.byteOffset ?? raw.length) - heading.byteOffset;
509
+ }
510
+ }
511
+ return { headings, blocks, frontmatterKeys: fm.keys, totalBytes };
512
+ }
513
+
514
+ // src/tools/outline_note.ts
515
+ import { z as z9 } from "zod";
516
+ var OutlineNoteInput = z9.object({
517
+ path: z9.string().describe("Vault-relative path to the note."),
518
+ includeBlocks: z9.boolean().default(true).describe("Include block-reference anchors (^id). Default true."),
519
+ includeSizes: z9.boolean().default(false).describe("Include per-section character length in each heading entry. Default false.")
520
+ });
521
+ async function outlineNote(ctx2, input) {
522
+ const absPath = join7(ctx2.vaultRoot, input.path);
523
+ if (!absPath.startsWith(ctx2.vaultRoot)) {
524
+ throw new Error(`Path outside vault: ${input.path}`);
525
+ }
526
+ const raw = await readFile2(absPath, "utf8");
527
+ return buildOutline(raw, {
528
+ includeBlocks: input.includeBlocks,
529
+ includeSizes: input.includeSizes
530
+ });
531
+ }
532
+
325
533
  // src/tools/patch_frontmatter.ts
326
- import { readFile as readFile2, writeFile as writeFile3 } from "fs/promises";
327
- import { join as join5 } from "path";
534
+ import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
535
+ import { join as join8 } from "path";
328
536
  import { parseDocument } from "yaml";
329
- import { z as z6 } from "zod";
330
- var PatchFrontmatterInput = z6.object({
331
- path: z6.string().describe("Vault-relative path to the note."),
332
- patch: z6.record(z6.string(), z6.unknown()).describe(
537
+ import { z as z10 } from "zod";
538
+ var PatchFrontmatterInput = z10.object({
539
+ path: z10.string().describe("Vault-relative path to the note."),
540
+ patch: z10.record(z10.string(), z10.unknown()).describe(
333
541
  "Key-value pairs to set in the frontmatter. Existing keys not in the patch are unchanged. Pass null as a value to remove a key."
334
542
  )
335
543
  });
336
544
  async function patchFrontmatter(ctx2, input) {
337
- const absPath = join5(ctx2.vaultRoot, input.path);
545
+ const absPath = join8(ctx2.vaultRoot, input.path);
338
546
  if (!absPath.startsWith(ctx2.vaultRoot)) {
339
547
  throw new Error(`Path outside vault: ${input.path}`);
340
548
  }
341
- const original = await readFile2(absPath, "utf8");
549
+ const original = await readFile3(absPath, "utf8");
342
550
  const fm = parseFrontmatter(original);
343
551
  const keysChanged = [];
344
552
  const keysAdded = [];
@@ -390,28 +598,258 @@ ${original}`;
390
598
  return { path: input.path, keysChanged, keysAdded, keysRemoved };
391
599
  }
392
600
 
601
+ // src/tools/patch_note.ts
602
+ import { readFile as readFile4, rename as rename2, writeFile as writeFile4 } from "fs/promises";
603
+ import { join as join9 } from "path";
604
+ import { z as z11 } from "zod";
605
+ var PatchNoteInput = z11.object({
606
+ path: z11.string().describe("Vault-relative path to the note."),
607
+ target: z11.union([
608
+ z11.object({ heading: z11.string().describe("Heading text (without # markers).") }),
609
+ z11.object({ block: z11.string().describe("Block reference ID (without the ^ prefix).") })
610
+ ]).describe("Target section. Provide exactly one of heading or block."),
611
+ operation: z11.enum(["append", "prepend", "replace"]).describe(
612
+ "append: insert after section content. prepend: insert after heading line. replace: swap section content."
613
+ ),
614
+ content: z11.string().min(1).describe("Content to insert or replace with."),
615
+ createIfMissing: z11.boolean().default(false).describe(
616
+ "If the heading target is not found, append a new heading (level 2) + content. Only valid for heading targets."
617
+ )
618
+ });
619
+ function lineEnd(raw, offset) {
620
+ const nl = raw.indexOf("\n", offset);
621
+ return nl === -1 ? raw.length : nl + 1;
622
+ }
623
+ function lineStart(raw, offset) {
624
+ if (offset === 0) return 0;
625
+ const prev = raw.lastIndexOf("\n", offset - 1);
626
+ return prev === -1 ? 0 : prev + 1;
627
+ }
628
+ async function atomicWrite(absPath, content) {
629
+ const tmpPath = `${absPath}.seekstone-patch-tmp`;
630
+ await writeFile4(tmpPath, content, "utf8");
631
+ await rename2(tmpPath, absPath);
632
+ }
633
+ async function patchNote(ctx2, input) {
634
+ const absPath = join9(ctx2.vaultRoot, input.path);
635
+ if (!absPath.startsWith(ctx2.vaultRoot)) {
636
+ throw new Error(`Path outside vault: ${input.path}`);
637
+ }
638
+ const raw = await readFile4(absPath, "utf8");
639
+ const fm = parseFrontmatter(raw);
640
+ const originalFmRegion = raw.slice(0, fm.bodyStart);
641
+ const outline = buildOutline(raw, { includeBlocks: true });
642
+ let contentStart;
643
+ let contentEnd;
644
+ let resolvedAt;
645
+ if ("heading" in input.target) {
646
+ const targetText = input.target.heading;
647
+ const heading = outline.headings.find((h) => h.text.toLowerCase() === targetText.toLowerCase());
648
+ if (!heading) {
649
+ if (input.createIfMissing) {
650
+ const sep3 = raw.endsWith("\n\n") ? "" : raw.endsWith("\n") ? "\n" : "\n\n";
651
+ const contentNl = input.content.endsWith("\n") ? "" : "\n";
652
+ const newContent2 = `${raw}${sep3}## ${targetText}
653
+
654
+ ${input.content}${contentNl}`;
655
+ await atomicWrite(absPath, newContent2);
656
+ await verifyFrontmatter(absPath, originalFmRegion);
657
+ updateCache(ctx2, input.path, newContent2, fm.bodyStart);
658
+ const headingOffset = raw.length + sep3.length;
659
+ return {
660
+ path: input.path,
661
+ bytesWritten: Buffer.byteLength(newContent2, "utf8"),
662
+ targetResolvedAt: {
663
+ line: newContent2.slice(0, headingOffset).split("\n").length,
664
+ byteOffset: headingOffset
665
+ },
666
+ frontmatterUnchanged: true
667
+ };
668
+ }
669
+ throw new Error(
670
+ JSON.stringify({
671
+ error: "heading_not_found",
672
+ target: targetText,
673
+ available: outline.headings.map((h) => h.text)
674
+ })
675
+ );
676
+ }
677
+ const headingLineEnd = lineEnd(raw, heading.byteOffset);
678
+ const nextSibling = outline.headings.find(
679
+ (h) => h.byteOffset > heading.byteOffset && h.level <= heading.level
680
+ );
681
+ contentStart = headingLineEnd;
682
+ contentEnd = nextSibling?.byteOffset ?? raw.length;
683
+ resolvedAt = { line: heading.line, byteOffset: heading.byteOffset };
684
+ } else {
685
+ const targetId = input.target.block;
686
+ const block = outline.blocks.find((b) => b.id === targetId);
687
+ if (!block) {
688
+ throw new Error(
689
+ JSON.stringify({
690
+ error: "block_not_found",
691
+ target: targetId,
692
+ available: outline.blocks.map((b) => b.id)
693
+ })
694
+ );
695
+ }
696
+ contentStart = lineStart(raw, block.byteOffset);
697
+ contentEnd = lineEnd(raw, block.byteOffset);
698
+ resolvedAt = { line: block.line, byteOffset: block.byteOffset };
699
+ }
700
+ const before = raw.slice(0, contentStart);
701
+ const section = raw.slice(contentStart, contentEnd);
702
+ const after = raw.slice(contentEnd);
703
+ let newContent;
704
+ switch (input.operation) {
705
+ case "prepend": {
706
+ const sep3 = input.content.endsWith("\n") ? "" : "\n";
707
+ newContent = `${before}${input.content}${sep3}${section}${after}`;
708
+ break;
709
+ }
710
+ case "append": {
711
+ const sectionNl = section.endsWith("\n") ? "" : "\n";
712
+ const contentNl = input.content.endsWith("\n") ? "" : "\n";
713
+ newContent = `${before}${section}${sectionNl}${input.content}${contentNl}${after}`;
714
+ break;
715
+ }
716
+ case "replace": {
717
+ const contentNl = input.content.endsWith("\n") ? "" : "\n";
718
+ newContent = `${before}${input.content}${contentNl}${after}`;
719
+ break;
720
+ }
721
+ default:
722
+ throw new Error(`Unknown operation: ${String(input.operation)}`);
723
+ }
724
+ await atomicWrite(absPath, newContent);
725
+ await verifyFrontmatter(absPath, originalFmRegion);
726
+ updateCache(ctx2, input.path, newContent, fm.bodyStart);
727
+ return {
728
+ path: input.path,
729
+ bytesWritten: Buffer.byteLength(newContent, "utf8"),
730
+ targetResolvedAt: resolvedAt,
731
+ frontmatterUnchanged: true
732
+ };
733
+ }
734
+ async function verifyFrontmatter(absPath, originalFmRegion) {
735
+ const written = await readFile4(absPath, "utf8");
736
+ if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
737
+ throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
738
+ }
739
+ }
740
+ function updateCache(ctx2, path2, newContent, bodyStart) {
741
+ const cached = ctx2.notes.get(path2);
742
+ if (cached) {
743
+ cached.body = newContent.slice(bodyStart);
744
+ cached.raw = newContent;
745
+ cached.sizeBytes = Buffer.byteLength(newContent, "utf8");
746
+ }
747
+ }
748
+
393
749
  // src/tools/read_note.ts
394
- import { readFile as readFile3 } from "fs/promises";
395
- import { join as join6 } from "path";
396
- import { z as z7 } from "zod";
397
- var ReadNoteInput = z7.object({
398
- path: z7.string().describe('Vault-relative path to the note, e.g. "Daily Notes/2026-05-29.md".')
750
+ import { readFile as readFile5 } from "fs/promises";
751
+ import { join as join10 } from "path";
752
+ import { z as z12 } from "zod";
753
+ var ReadNoteInput = z12.object({
754
+ path: z12.string().describe('Vault-relative path to the note, e.g. "Daily Notes/2026-05-29.md".'),
755
+ section: z12.string().optional().describe("Return only this heading section (heading text, # prefix optional)."),
756
+ block: z12.string().optional().describe("Return only the block anchored by this id (^ prefix optional)."),
757
+ lines: z12.object({
758
+ from: z12.number().int().min(1).describe("First line (1-indexed, inclusive)."),
759
+ to: z12.number().int().min(1).optional().describe("Last line (1-indexed, inclusive). Defaults to end of file.")
760
+ }).optional().describe("Return only this line range (1-indexed, inclusive)."),
761
+ includeFrontmatter: z12.boolean().optional().describe(
762
+ "Include frontmatter in span results. Default false for section/block spans. No effect on lines or whole-note reads."
763
+ )
764
+ }).refine((v) => [v.section, v.block, v.lines].filter((x) => x !== void 0).length <= 1, {
765
+ message: "At most one of section, block, or lines may be specified."
399
766
  });
400
767
  async function readNote(ctx2, input) {
401
- const absPath = join6(ctx2.vaultRoot, input.path);
768
+ const absPath = join10(ctx2.vaultRoot, input.path);
402
769
  if (!absPath.startsWith(ctx2.vaultRoot)) {
403
770
  throw new Error(`Path outside vault: ${input.path}`);
404
771
  }
405
- const content = await readFile3(absPath, "utf8");
772
+ const raw = await readFile5(absPath, "utf8");
773
+ const noteBytes = Buffer.byteLength(raw, "utf8");
774
+ if (input.section === void 0 && input.block === void 0 && input.lines === void 0) {
775
+ return { path: input.path, content: raw, bytesReturned: noteBytes, noteBytes };
776
+ }
777
+ const fm = parseFrontmatter(raw);
778
+ const outline = buildOutline(raw, { includeBlocks: true });
779
+ let charStart;
780
+ let charEnd;
781
+ if (input.section !== void 0) {
782
+ const target = input.section.replace(/^#+\s*/, "").trim();
783
+ const heading = outline.headings.find((h) => h.text === target);
784
+ if (!heading) {
785
+ throw new Error(
786
+ JSON.stringify({
787
+ error: "section_not_found",
788
+ target,
789
+ available: outline.headings.map((h) => h.text)
790
+ })
791
+ );
792
+ }
793
+ const nextSibling = outline.headings.find(
794
+ (h) => h.byteOffset > heading.byteOffset && h.level <= heading.level
795
+ );
796
+ charStart = heading.byteOffset;
797
+ charEnd = nextSibling?.byteOffset ?? raw.length;
798
+ } else if (input.block !== void 0) {
799
+ const targetId = input.block.replace(/^\^/, "");
800
+ const block = outline.blocks.find((b) => b.id === targetId);
801
+ if (!block) {
802
+ throw new Error(
803
+ JSON.stringify({
804
+ error: "block_not_found",
805
+ target: targetId,
806
+ available: outline.blocks.map((b) => b.id)
807
+ })
808
+ );
809
+ }
810
+ const lineS = block.byteOffset === 0 ? 0 : raw.lastIndexOf("\n", block.byteOffset - 1) + 1;
811
+ const nlAfter = raw.indexOf("\n", block.byteOffset);
812
+ const lineE = nlAfter === -1 ? raw.length : nlAfter + 1;
813
+ charStart = lineS;
814
+ charEnd = lineE;
815
+ } else {
816
+ const linesRange = input.lines;
817
+ if (linesRange === void 0) throw new Error("internal: unreachable");
818
+ const { from, to } = linesRange;
819
+ const rawLines = raw.split("\n");
820
+ const fromIdx = from - 1;
821
+ const toIdxInclusive = to !== void 0 ? Math.min(to - 1, rawLines.length - 1) : rawLines.length - 1;
822
+ let offset = 0;
823
+ charStart = 0;
824
+ charEnd = raw.length;
825
+ for (const [i, line] of rawLines.entries()) {
826
+ if (i === fromIdx) charStart = offset;
827
+ offset += line.length + 1;
828
+ if (i === toIdxInclusive) {
829
+ charEnd = Math.min(offset, raw.length);
830
+ break;
831
+ }
832
+ }
833
+ }
834
+ let content;
835
+ if (input.includeFrontmatter === true && input.lines === void 0) {
836
+ const fmRegion = raw.slice(0, fm.bodyStart);
837
+ const spanContent = raw.slice(charStart, charEnd);
838
+ content = charStart < fm.bodyStart ? spanContent : `${fmRegion}${spanContent}`;
839
+ } else {
840
+ content = raw.slice(charStart, charEnd);
841
+ }
406
842
  return {
407
843
  path: input.path,
408
844
  content,
409
- sizeBytes: Buffer.byteLength(content, "utf8")
845
+ bytesReturned: Buffer.byteLength(content, "utf8"),
846
+ noteBytes,
847
+ span: { charStart, charEnd }
410
848
  };
411
849
  }
412
850
 
413
851
  // src/tools/search.ts
414
- import { z as z8 } from "zod";
852
+ import { z as z13 } from "zod";
415
853
 
416
854
  // src/index/excerpt.ts
417
855
  function extractExcerpt(text, terms, maxLen = 200) {
@@ -438,11 +876,11 @@ function truncate(s, max) {
438
876
  }
439
877
 
440
878
  // src/tools/search.ts
441
- var SearchInput = z8.object({
442
- query: z8.string().min(1).describe("Search query. Supports fuzzy matching and prefix search."),
443
- limit: z8.number().int().min(1).max(50).default(10).describe("Maximum number of results to return."),
444
- folder: z8.string().optional().describe("Restrict results to notes under this vault-relative folder prefix."),
445
- tag: z8.string().optional().describe("Restrict results to notes containing this tag.")
879
+ var SearchInput = z13.object({
880
+ query: z13.string().min(1).describe("Search query. Supports fuzzy matching and prefix search."),
881
+ limit: z13.number().int().min(1).max(50).default(10).describe("Maximum number of results to return."),
882
+ folder: z13.string().optional().describe("Restrict results to notes under this vault-relative folder prefix."),
883
+ tag: z13.string().optional().describe("Restrict results to notes containing this tag.")
446
884
  });
447
885
  function search(ctx2, input) {
448
886
  const results = ctx2.index.search(input.query, {
@@ -476,7 +914,18 @@ function search(ctx2, input) {
476
914
  }
477
915
 
478
916
  // src/dispatch.ts
479
- var META_KEYS = ["path", "from", "to", "folder", "tag", "limit", "overwrite"];
917
+ var META_KEYS = [
918
+ "path",
919
+ "from",
920
+ "to",
921
+ "folder",
922
+ "tag",
923
+ "limit",
924
+ "overwrite",
925
+ "pattern",
926
+ "minCount",
927
+ "sort"
928
+ ];
480
929
  function safeMeta(args) {
481
930
  if (typeof args !== "object" || args === null) return {};
482
931
  const a = args;
@@ -524,13 +973,18 @@ async function run(ctx2, name, args) {
524
973
  case "read_note": {
525
974
  const input = ReadNoteInput.parse(args);
526
975
  const result = await readNote(ctx2, input);
527
- return { content: [{ type: "text", text: result.content }] };
976
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
528
977
  }
529
978
  case "list_notes": {
530
979
  const input = ListNotesInput.parse(args);
531
980
  const entries = listNotes(ctx2, input);
532
981
  return { content: [{ type: "text", text: JSON.stringify(entries, null, 2) }] };
533
982
  }
983
+ case "list_tags": {
984
+ const input = ListTagsInput.parse(args);
985
+ const result = listTags(ctx2, input);
986
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
987
+ }
534
988
  case "create_note": {
535
989
  const input = CreateNoteInput.parse(args);
536
990
  const result = await createNote(ctx2, input);
@@ -562,18 +1016,38 @@ async function run(ctx2, name, args) {
562
1016
  const result = await patchFrontmatter(ctx2, input);
563
1017
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
564
1018
  }
1019
+ case "outline_note": {
1020
+ const input = OutlineNoteInput.parse(args);
1021
+ const result = await outlineNote(ctx2, input);
1022
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1023
+ }
1024
+ case "patch_note": {
1025
+ const input = PatchNoteInput.parse(args);
1026
+ const result = await patchNote(ctx2, input);
1027
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1028
+ }
1029
+ case "get_backlinks": {
1030
+ const input = GetBacklinksInput.parse(args);
1031
+ const result = getBacklinks(ctx2, input);
1032
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1033
+ }
1034
+ case "get_links": {
1035
+ const input = GetLinksInput.parse(args);
1036
+ const result = getLinks(ctx2, input);
1037
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1038
+ }
565
1039
  default:
566
1040
  throw new Error(`Unknown tool: ${name}`);
567
1041
  }
568
1042
  }
569
1043
 
570
1044
  // src/index/build.ts
571
- import { readFile as readFile4 } from "fs/promises";
1045
+ import { readFile as readFile6 } from "fs/promises";
572
1046
  import { basename as basename2, extname as extname3 } from "path";
573
1047
 
574
1048
  // ../core/src/walk.ts
575
1049
  import { stat } from "fs/promises";
576
- import { extname as extname2, join as join7, relative, sep } from "path";
1050
+ import { extname as extname2, join as join11, relative, sep } from "path";
577
1051
  import fg from "fast-glob";
578
1052
  var IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]);
579
1053
  var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".mkv", ".avi"]);
@@ -650,7 +1124,7 @@ async function buildIndex(vaultRoot2) {
650
1124
  await Promise.all(
651
1125
  noteEntries.map(async (entry) => {
652
1126
  try {
653
- const raw = await readFile4(entry.absPath, "utf8");
1127
+ const raw = await readFile6(entry.absPath, "utf8");
654
1128
  const fm = parseFrontmatter(raw);
655
1129
  const inlineTags = extractInlineTags(fm.body);
656
1130
  const fmTags = frontmatterTags(fm.data);
@@ -673,12 +1147,37 @@ async function buildIndex(vaultRoot2) {
673
1147
  })
674
1148
  );
675
1149
  index2.addAll(docs);
676
- return { index: index2, notes: notes2, buildMs: Date.now() - t0 };
1150
+ const backlinks2 = buildBacklinks(notes2);
1151
+ return { index: index2, notes: notes2, backlinks: backlinks2, buildMs: Date.now() - t0 };
1152
+ }
1153
+ function buildBacklinks(notes2) {
1154
+ const result = /* @__PURE__ */ new Map();
1155
+ for (const [srcPath, doc] of notes2) {
1156
+ const seen = /* @__PURE__ */ new Set();
1157
+ for (const link of extractLinksWithLines(doc.raw)) {
1158
+ const resolved = resolveLink(link.target, notes2);
1159
+ if (resolved === void 0) continue;
1160
+ const dedupeKey = `${srcPath}\0${resolved}`;
1161
+ if (seen.has(dedupeKey)) continue;
1162
+ seen.add(dedupeKey);
1163
+ let arr = result.get(resolved);
1164
+ if (arr === void 0) {
1165
+ arr = [];
1166
+ result.set(resolved, arr);
1167
+ }
1168
+ arr.push({ path: srcPath, line: link.line, linkType: link.linkType });
1169
+ }
1170
+ }
1171
+ for (const arr of result.values()) {
1172
+ arr.sort((a, b) => a.path.localeCompare(b.path));
1173
+ }
1174
+ return result;
677
1175
  }
678
1176
 
679
1177
  // src/init.ts
680
- import { access as access3, copyFile, mkdir as mkdir3, readFile as readFile5, readdir, stat as stat2, writeFile as writeFile4 } from "fs/promises";
681
- import path, { dirname as dirname3, join as join8 } from "path";
1178
+ import { execFileSync } from "child_process";
1179
+ import { access as access3, copyFile, mkdir as mkdir3, readFile as readFile7, readdir, stat as stat2, writeFile as writeFile5 } from "fs/promises";
1180
+ import path, { dirname as dirname3, join as join12 } from "path";
682
1181
  function parseInitArgs(argv) {
683
1182
  const opts = { write: false, client: "desktop" };
684
1183
  for (let i = 0; i < argv.length; i++) {
@@ -715,6 +1214,43 @@ function claudeDesktopConfigPath(platform, env) {
715
1214
  }
716
1215
  return j(home, ".config", "Claude", "claude_desktop_config.json");
717
1216
  }
1217
+ function obsidianRegistryPath(platform, env) {
1218
+ if (platform === "win32") {
1219
+ const j2 = path.win32.join;
1220
+ const base = env.appData ?? j2(env.home ?? "", "AppData", "Roaming");
1221
+ return j2(base, "obsidian", "obsidian.json");
1222
+ }
1223
+ const j = path.posix.join;
1224
+ const home = env.home ?? "";
1225
+ if (platform === "darwin") {
1226
+ return j(home, "Library", "Application Support", "obsidian", "obsidian.json");
1227
+ }
1228
+ return j(home, ".config", "obsidian", "obsidian.json");
1229
+ }
1230
+ function parseObsidianVaults(json) {
1231
+ let data;
1232
+ try {
1233
+ data = JSON.parse(json);
1234
+ } catch {
1235
+ return [];
1236
+ }
1237
+ if (!data || typeof data !== "object") return [];
1238
+ const raw = data.vaults;
1239
+ if (!raw || typeof raw !== "object") return [];
1240
+ const entries = [];
1241
+ for (const v of Object.values(raw)) {
1242
+ if (v && typeof v === "object") {
1243
+ const entry = v;
1244
+ if (typeof entry.path === "string") {
1245
+ entries.push({ path: entry.path, open: entry.open === true });
1246
+ }
1247
+ }
1248
+ }
1249
+ return entries.sort((a, b) => {
1250
+ if (a.open !== b.open) return a.open ? -1 : 1;
1251
+ return a.path.localeCompare(b.path);
1252
+ });
1253
+ }
718
1254
  function mergeSeekstoneConfig(existing, vaultPath) {
719
1255
  const base = existing ? structuredClone(existing) : {};
720
1256
  const servers = { ...base.mcpServers ?? {} };
@@ -729,7 +1265,7 @@ async function validateVault(vaultPath) {
729
1265
  return { ok: false, error: `Path does not exist: ${vaultPath}` };
730
1266
  }
731
1267
  try {
732
- await access3(join8(vaultPath, ".obsidian"));
1268
+ await access3(join12(vaultPath, ".obsidian"));
733
1269
  } catch {
734
1270
  return {
735
1271
  ok: false,
@@ -749,7 +1285,7 @@ async function countMarkdown(root) {
749
1285
  }
750
1286
  for (const e of entries) {
751
1287
  if (e.name.startsWith(".")) continue;
752
- const full = join8(dir, e.name);
1288
+ const full = join12(dir, e.name);
753
1289
  if (e.isDirectory()) await walk(full);
754
1290
  else if (e.name.endsWith(".md")) count++;
755
1291
  }
@@ -758,16 +1294,45 @@ async function countMarkdown(root) {
758
1294
  return count;
759
1295
  }
760
1296
  async function runInit(opts, deps) {
761
- const vaultPath = opts.vault ?? deps.env.SEEKSTONE_VAULT;
1297
+ const rf = deps.readFile ?? readFile7;
1298
+ let vaultPath = opts.vault ?? deps.env.SEEKSTONE_VAULT;
762
1299
  if (!vaultPath) {
763
- return {
764
- ok: false,
765
- exitCode: 1,
766
- output: [
767
- "No vault specified. Pass --vault <path> or set SEEKSTONE_VAULT.",
768
- 'Example: seekstone init --vault "/Users/you/Obsidian/My Vault"'
769
- ]
770
- };
1300
+ const registryPath = obsidianRegistryPath(deps.platform, {
1301
+ home: deps.env.HOME,
1302
+ appData: deps.env.APPDATA
1303
+ });
1304
+ let vaults = [];
1305
+ try {
1306
+ vaults = parseObsidianVaults(await rf(registryPath, "utf8"));
1307
+ } catch {
1308
+ }
1309
+ if (vaults.length === 0) {
1310
+ return {
1311
+ ok: false,
1312
+ exitCode: 1,
1313
+ output: [
1314
+ "No vault specified. Pass --vault <path> or set SEEKSTONE_VAULT.",
1315
+ 'Example: seekstone init --vault "/Users/you/Obsidian/My Vault"'
1316
+ ]
1317
+ };
1318
+ }
1319
+ if (vaults.length === 1) {
1320
+ const [first] = vaults;
1321
+ vaultPath = first?.path ?? "";
1322
+ } else {
1323
+ const list = vaults.map((v, i) => ` ${i + 1}. ${v.path}${v.open ? " (currently open)" : ""}`).join("\n");
1324
+ return {
1325
+ ok: false,
1326
+ exitCode: 1,
1327
+ output: [
1328
+ "Multiple Obsidian vaults found. Re-run with --vault to pick one:",
1329
+ "",
1330
+ list,
1331
+ "",
1332
+ 'Example: seekstone init --vault "/path/to/vault"'
1333
+ ]
1334
+ };
1335
+ }
771
1336
  }
772
1337
  const check = await validateVault(vaultPath);
773
1338
  if (!check.ok) {
@@ -779,6 +1344,42 @@ async function runInit(opts, deps) {
779
1344
  ""
780
1345
  ];
781
1346
  if (opts.client === "code") {
1347
+ if (opts.write) {
1348
+ const spawn = deps.spawnClaudeMcp ?? ((args) => {
1349
+ try {
1350
+ execFileSync("claude", args, { stdio: "inherit" });
1351
+ return { ok: true };
1352
+ } catch (err) {
1353
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1354
+ }
1355
+ });
1356
+ const mcpArgs = [
1357
+ "mcp",
1358
+ "add",
1359
+ "seekstone",
1360
+ "--env",
1361
+ `SEEKSTONE_VAULT=${vaultPath}`,
1362
+ "--",
1363
+ "npx",
1364
+ "-y",
1365
+ "seekstone"
1366
+ ];
1367
+ const result = spawn(mcpArgs);
1368
+ if (result.ok) {
1369
+ out.push("\u2713 seekstone added to Claude Code.", "", "Restart Claude Code to load seekstone.");
1370
+ return { ok: true, exitCode: 0, output: out, ranClaudeMcpAdd: true };
1371
+ }
1372
+ out.push(
1373
+ "\u26A0 Could not run `claude mcp add` automatically.",
1374
+ result.error ? ` ${result.error}` : "",
1375
+ "",
1376
+ "Run this command manually instead:",
1377
+ "",
1378
+ ` ${mcpAddCommand(vaultPath)}`,
1379
+ ""
1380
+ );
1381
+ return { ok: false, exitCode: 1, output: out };
1382
+ }
782
1383
  out.push("Add to Claude Code by running:", "", ` ${mcpAddCommand(vaultPath)}`, "");
783
1384
  return { ok: true, exitCode: 0, output: out };
784
1385
  }
@@ -805,7 +1406,7 @@ async function runInit(opts, deps) {
805
1406
  let existing = null;
806
1407
  let existingRaw = null;
807
1408
  try {
808
- existingRaw = await readFile5(configPath, "utf8");
1409
+ existingRaw = await rf(configPath, "utf8");
809
1410
  existing = JSON.parse(existingRaw);
810
1411
  } catch (err) {
811
1412
  if (existingRaw !== null) {
@@ -827,7 +1428,7 @@ async function runInit(opts, deps) {
827
1428
  backupPath = `${configPath}.backup-${deps.timestamp}`;
828
1429
  await copyFile(configPath, backupPath);
829
1430
  }
830
- await writeFile4(configPath, `${JSON.stringify(merged, null, 2)}
1431
+ await writeFile5(configPath, `${JSON.stringify(merged, null, 2)}
831
1432
  `, "utf8");
832
1433
  out.push(
833
1434
  `\u2713 Patched ${configPath}`,
@@ -899,7 +1500,7 @@ function createLogger(opts = {}) {
899
1500
  );
900
1501
  }
901
1502
  }
902
- function writeFile5(record) {
1503
+ function writeFile6(record) {
903
1504
  if (!fileOk || filePath === void 0) return;
904
1505
  const line = `${JSON.stringify(record)}
905
1506
  `;
@@ -921,7 +1522,7 @@ function createLogger(opts = {}) {
921
1522
  const f = fields ?? {};
922
1523
  stderr(`${lvl.toUpperCase().padEnd(5)} ${ts} ${msg}${fmtFields(f)}
923
1524
  `);
924
- writeFile5({ t: ts, level: lvl, msg, ...f });
1525
+ writeFile6({ t: ts, level: lvl, msg, ...f });
925
1526
  }
926
1527
  return {
927
1528
  level,
@@ -933,22 +1534,55 @@ function createLogger(opts = {}) {
933
1534
  }
934
1535
 
935
1536
  // src/watcher.ts
936
- import { readFile as readFile6 } from "fs/promises";
937
- import { join as join9, relative as relative2, sep as sep2 } from "path";
1537
+ import { readFile as readFile8 } from "fs/promises";
1538
+ import { join as join13, relative as relative2, sep as sep2 } from "path";
938
1539
  import chokidar from "chokidar";
1540
+ function removeNoteBacklinks(ctx2, relPath) {
1541
+ const oldDoc = ctx2.notes.get(relPath);
1542
+ if (oldDoc === void 0) return;
1543
+ for (const link of extractLinksWithLines(oldDoc.raw)) {
1544
+ const resolved = resolveLink(link.target, ctx2.notes);
1545
+ if (resolved === void 0) continue;
1546
+ const arr = ctx2.backlinks.get(resolved);
1547
+ if (arr === void 0) continue;
1548
+ const filtered = arr.filter((r) => r.path !== relPath);
1549
+ ctx2.backlinks.set(resolved, filtered);
1550
+ }
1551
+ }
1552
+ function addNoteBacklinks(ctx2, relPath, raw) {
1553
+ const seen = /* @__PURE__ */ new Set();
1554
+ for (const link of extractLinksWithLines(raw)) {
1555
+ const resolved = resolveLink(link.target, ctx2.notes);
1556
+ if (resolved === void 0) continue;
1557
+ const dedupeKey = `${relPath}\0${resolved}`;
1558
+ if (seen.has(dedupeKey)) continue;
1559
+ seen.add(dedupeKey);
1560
+ let arr = ctx2.backlinks.get(resolved);
1561
+ if (arr === void 0) {
1562
+ arr = [];
1563
+ ctx2.backlinks.set(resolved, arr);
1564
+ }
1565
+ const ref = { path: relPath, line: link.line, linkType: link.linkType };
1566
+ arr.push(ref);
1567
+ arr.sort((a, b) => a.path.localeCompare(b.path));
1568
+ }
1569
+ }
939
1570
  function startWatcher(ctx2, log2, opts) {
940
1571
  const usePolling = opts?.usePolling ?? process.env.SEEKSTONE_WATCH_POLL === "1";
941
1572
  const toRel = (abs) => relative2(ctx2.vaultRoot, abs).split(sep2).join("/");
942
1573
  async function upsert(relPath, op) {
943
1574
  try {
944
- const raw = await readFile6(join9(ctx2.vaultRoot, relPath), "utf8");
1575
+ const raw = await readFile8(join13(ctx2.vaultRoot, relPath), "utf8");
1576
+ removeNoteBacklinks(ctx2, relPath);
945
1577
  upsertDoc(ctx2, buildDoc(relPath, raw));
1578
+ addNoteBacklinks(ctx2, relPath, raw);
946
1579
  log2?.debug("index updated", { path: relPath, op });
947
1580
  } catch {
948
1581
  }
949
1582
  }
950
1583
  function remove(relPath) {
951
1584
  if (ctx2.notes.has(relPath)) {
1585
+ removeNoteBacklinks(ctx2, relPath);
952
1586
  ctx2.index.discard(relPath);
953
1587
  ctx2.notes.delete(relPath);
954
1588
  log2?.debug("index removed", { path: relPath, op: "delete" });
@@ -966,7 +1600,11 @@ function startWatcher(ctx2, log2, opts) {
966
1600
  binaryInterval: 100,
967
1601
  // Skip dot-dirs (.obsidian, .git, .trash, …) regardless of where the vault
968
1602
  // lives — matched on the path relative to the vault root.
969
- ignored: (p) => relative2(ctx2.vaultRoot, p).split(sep2).some((seg) => seg.startsWith("."))
1603
+ ignored: (p) => {
1604
+ const rel = relative2(ctx2.vaultRoot, p);
1605
+ if (!rel || rel === ".") return false;
1606
+ return rel.split(sep2).filter(Boolean).some((seg) => seg.startsWith("."));
1607
+ }
970
1608
  });
971
1609
  watcher2.on("add", onUpsert("add")).on("change", onUpsert("change")).on("unlink", (abs) => {
972
1610
  const relPath = toRel(abs);
@@ -982,7 +1620,7 @@ function startWatcher(ctx2, log2, opts) {
982
1620
  }
983
1621
 
984
1622
  // src/index.ts
985
- var VERSION = true ? "0.2.1" : "0.0.0-dev";
1623
+ var VERSION = true ? "0.4.0" : "0.0.0-dev";
986
1624
  var intent = parseCliIntent(process.argv.slice(2));
987
1625
  if (intent === "version") {
988
1626
  process.stdout.write(`${VERSION}
@@ -1011,9 +1649,9 @@ if (!vaultRoot) {
1011
1649
  process.exit(1);
1012
1650
  }
1013
1651
  log.info("building index", { vault: vaultRoot });
1014
- var { index, notes, buildMs } = await buildIndex(vaultRoot);
1652
+ var { index, notes, backlinks, buildMs } = await buildIndex(vaultRoot);
1015
1653
  log.info("index ready", { notes: notes.size, buildMs });
1016
- var ctx = { vaultRoot, index, notes };
1654
+ var ctx = { vaultRoot, index, notes, backlinks };
1017
1655
  var watcher = startWatcher(ctx, log);
1018
1656
  process.on("exit", watcher.stop);
1019
1657
  var server = new Server({ name: "seekstone", version: "0.1.0" }, { capabilities: { tools: {} } });
@@ -1035,13 +1673,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1035
1673
  },
1036
1674
  {
1037
1675
  name: "read_note",
1038
- description: "Read the full content of a specific note by its vault-relative path. Use search first to find the right path.",
1676
+ description: "Read a note or a span of it \u2014 by heading section, block reference, or line range. Returns structured JSON with the content, bytes returned, and total note size so payload savings are measurable. Use search or outline_note first to find the right path and section names.",
1039
1677
  inputSchema: {
1040
1678
  type: "object",
1041
1679
  properties: {
1042
1680
  path: {
1043
1681
  type: "string",
1044
1682
  description: 'Vault-relative path, e.g. "Daily Notes/2026-05-29.md".'
1683
+ },
1684
+ section: {
1685
+ type: "string",
1686
+ description: "Return only this heading section (heading text, # prefix optional). First match wins."
1687
+ },
1688
+ block: {
1689
+ type: "string",
1690
+ description: "Return only the line anchored by this block id (^ prefix optional)."
1691
+ },
1692
+ lines: {
1693
+ type: "object",
1694
+ description: "Return only this line range (1-indexed, inclusive).",
1695
+ properties: {
1696
+ from: { type: "number", description: "First line (1-indexed)." },
1697
+ to: {
1698
+ type: "number",
1699
+ description: "Last line (1-indexed, inclusive). Defaults to EOF."
1700
+ }
1701
+ },
1702
+ required: ["from"]
1703
+ },
1704
+ includeFrontmatter: {
1705
+ type: "boolean",
1706
+ description: "Prepend frontmatter to section/block span results. Default false for spans."
1045
1707
  }
1046
1708
  },
1047
1709
  required: ["path"]
@@ -1060,6 +1722,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1060
1722
  required: []
1061
1723
  }
1062
1724
  },
1725
+ {
1726
+ name: "list_tags",
1727
+ description: "List all tags in the vault with usage counts. Supports substring filtering, minimum count threshold, and sort order. Nested tags (e.g. area/work) include a parent field.",
1728
+ inputSchema: {
1729
+ type: "object",
1730
+ properties: {
1731
+ pattern: {
1732
+ type: "string",
1733
+ description: 'Substring filter on tag name. Example: "work" matches "work" and "area/work".'
1734
+ },
1735
+ minCount: {
1736
+ type: "number",
1737
+ description: "Only return tags used in at least this many notes."
1738
+ },
1739
+ sort: {
1740
+ type: "string",
1741
+ enum: ["count", "name"],
1742
+ description: "Sort by usage count descending (default) or alphabetically."
1743
+ }
1744
+ },
1745
+ required: []
1746
+ }
1747
+ },
1063
1748
  {
1064
1749
  name: "create_note",
1065
1750
  description: "Create a new note at a vault-relative path. Optionally sets frontmatter and body content. Parent directories are created automatically. Fails if the note already exists unless overwrite is true.",
@@ -1138,6 +1823,80 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1138
1823
  },
1139
1824
  required: ["path", "patch"]
1140
1825
  }
1826
+ },
1827
+ {
1828
+ name: "outline_note",
1829
+ description: "Return a note's structure \u2014 heading tree with offsets, block-reference anchors, and frontmatter key list \u2014 without returning any prose. Use this before section reads or patches to discover what sections exist at a fraction of the cost of reading the full note.",
1830
+ inputSchema: {
1831
+ type: "object",
1832
+ properties: {
1833
+ path: { type: "string", description: "Vault-relative path to the note." },
1834
+ includeBlocks: {
1835
+ type: "boolean",
1836
+ description: "Include block-reference anchors (^id). Default true."
1837
+ },
1838
+ includeSizes: {
1839
+ type: "boolean",
1840
+ description: "Include per-section character length in each heading entry. Default false."
1841
+ }
1842
+ },
1843
+ required: ["path"]
1844
+ }
1845
+ },
1846
+ {
1847
+ name: "patch_note",
1848
+ description: "Surgically edit a section of a note \u2014 targeted by heading or block reference \u2014 without rewriting the whole file. Operations: append (add after section), prepend (add after heading line), replace (swap section content). Frontmatter is never touched.",
1849
+ inputSchema: {
1850
+ type: "object",
1851
+ properties: {
1852
+ path: { type: "string", description: "Vault-relative path to the note." },
1853
+ target: {
1854
+ type: "object",
1855
+ description: 'Exactly one of: { heading: "Section Title" } or { block: "block-id" } (without the ^ prefix).'
1856
+ },
1857
+ operation: {
1858
+ type: "string",
1859
+ enum: ["append", "prepend", "replace"],
1860
+ description: "append: insert after section content. prepend: insert after heading line. replace: swap section content."
1861
+ },
1862
+ content: { type: "string", description: "Content to insert or replace with." },
1863
+ createIfMissing: {
1864
+ type: "boolean",
1865
+ description: "If the heading target is not found, append a new heading (level 2) + content. Only valid for heading targets. Default false."
1866
+ }
1867
+ },
1868
+ required: ["path", "target", "operation", "content"]
1869
+ }
1870
+ },
1871
+ {
1872
+ name: "get_backlinks",
1873
+ description: "Return every note that links to the target note, with the source line and an optional excerpt. Results come from the pre-built reverse-link index so this is a fast, pure index lookup. Sort order: source path ascending.",
1874
+ inputSchema: {
1875
+ type: "object",
1876
+ properties: {
1877
+ path: { type: "string", description: "Vault-relative path to the target note." },
1878
+ includeContext: {
1879
+ type: "boolean",
1880
+ description: "Include a short excerpt (~200 chars) from the linking line. Default true."
1881
+ },
1882
+ limit: {
1883
+ type: "number",
1884
+ description: "Max backlinks to return (1\u2013500). Default 50."
1885
+ }
1886
+ },
1887
+ required: ["path"]
1888
+ }
1889
+ },
1890
+ {
1891
+ name: "get_links",
1892
+ description: "Return all outgoing wikilinks and embeds from a note. Each link is marked resolved (with target path) or unresolved. Duplicate targets are de-duplicated; results sorted by line number.",
1893
+ inputSchema: {
1894
+ type: "object",
1895
+ properties: {
1896
+ path: { type: "string", description: "Vault-relative path to the note." }
1897
+ },
1898
+ required: ["path"]
1899
+ }
1141
1900
  }
1142
1901
  ]
1143
1902
  }));
@@ -1147,7 +1906,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1147
1906
  });
1148
1907
  var transport = new StdioServerTransport();
1149
1908
  await server.connect(transport);
1150
- log.info("ready", { tools: 8, transport: "stdio" });
1909
+ log.info("ready", { tools: 13, transport: "stdio" });
1151
1910
  process.stderr.write(
1152
1911
  `seekstone: add to Claude Desktop:
1153
1912
  ${JSON.stringify(