seekstone 0.3.0 → 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
@@ -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++) {
@@ -766,7 +1265,7 @@ async function validateVault(vaultPath) {
766
1265
  return { ok: false, error: `Path does not exist: ${vaultPath}` };
767
1266
  }
768
1267
  try {
769
- await access3(join8(vaultPath, ".obsidian"));
1268
+ await access3(join12(vaultPath, ".obsidian"));
770
1269
  } catch {
771
1270
  return {
772
1271
  ok: false,
@@ -786,7 +1285,7 @@ async function countMarkdown(root) {
786
1285
  }
787
1286
  for (const e of entries) {
788
1287
  if (e.name.startsWith(".")) continue;
789
- const full = join8(dir, e.name);
1288
+ const full = join12(dir, e.name);
790
1289
  if (e.isDirectory()) await walk(full);
791
1290
  else if (e.name.endsWith(".md")) count++;
792
1291
  }
@@ -795,7 +1294,7 @@ async function countMarkdown(root) {
795
1294
  return count;
796
1295
  }
797
1296
  async function runInit(opts, deps) {
798
- const rf = deps.readFile ?? readFile5;
1297
+ const rf = deps.readFile ?? readFile7;
799
1298
  let vaultPath = opts.vault ?? deps.env.SEEKSTONE_VAULT;
800
1299
  if (!vaultPath) {
801
1300
  const registryPath = obsidianRegistryPath(deps.platform, {
@@ -845,6 +1344,42 @@ async function runInit(opts, deps) {
845
1344
  ""
846
1345
  ];
847
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
+ }
848
1383
  out.push("Add to Claude Code by running:", "", ` ${mcpAddCommand(vaultPath)}`, "");
849
1384
  return { ok: true, exitCode: 0, output: out };
850
1385
  }
@@ -893,7 +1428,7 @@ async function runInit(opts, deps) {
893
1428
  backupPath = `${configPath}.backup-${deps.timestamp}`;
894
1429
  await copyFile(configPath, backupPath);
895
1430
  }
896
- await writeFile4(configPath, `${JSON.stringify(merged, null, 2)}
1431
+ await writeFile5(configPath, `${JSON.stringify(merged, null, 2)}
897
1432
  `, "utf8");
898
1433
  out.push(
899
1434
  `\u2713 Patched ${configPath}`,
@@ -965,7 +1500,7 @@ function createLogger(opts = {}) {
965
1500
  );
966
1501
  }
967
1502
  }
968
- function writeFile5(record) {
1503
+ function writeFile6(record) {
969
1504
  if (!fileOk || filePath === void 0) return;
970
1505
  const line = `${JSON.stringify(record)}
971
1506
  `;
@@ -987,7 +1522,7 @@ function createLogger(opts = {}) {
987
1522
  const f = fields ?? {};
988
1523
  stderr(`${lvl.toUpperCase().padEnd(5)} ${ts} ${msg}${fmtFields(f)}
989
1524
  `);
990
- writeFile5({ t: ts, level: lvl, msg, ...f });
1525
+ writeFile6({ t: ts, level: lvl, msg, ...f });
991
1526
  }
992
1527
  return {
993
1528
  level,
@@ -999,22 +1534,55 @@ function createLogger(opts = {}) {
999
1534
  }
1000
1535
 
1001
1536
  // src/watcher.ts
1002
- import { readFile as readFile6 } from "fs/promises";
1003
- 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";
1004
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
+ }
1005
1570
  function startWatcher(ctx2, log2, opts) {
1006
1571
  const usePolling = opts?.usePolling ?? process.env.SEEKSTONE_WATCH_POLL === "1";
1007
1572
  const toRel = (abs) => relative2(ctx2.vaultRoot, abs).split(sep2).join("/");
1008
1573
  async function upsert(relPath, op) {
1009
1574
  try {
1010
- const raw = await readFile6(join9(ctx2.vaultRoot, relPath), "utf8");
1575
+ const raw = await readFile8(join13(ctx2.vaultRoot, relPath), "utf8");
1576
+ removeNoteBacklinks(ctx2, relPath);
1011
1577
  upsertDoc(ctx2, buildDoc(relPath, raw));
1578
+ addNoteBacklinks(ctx2, relPath, raw);
1012
1579
  log2?.debug("index updated", { path: relPath, op });
1013
1580
  } catch {
1014
1581
  }
1015
1582
  }
1016
1583
  function remove(relPath) {
1017
1584
  if (ctx2.notes.has(relPath)) {
1585
+ removeNoteBacklinks(ctx2, relPath);
1018
1586
  ctx2.index.discard(relPath);
1019
1587
  ctx2.notes.delete(relPath);
1020
1588
  log2?.debug("index removed", { path: relPath, op: "delete" });
@@ -1032,7 +1600,11 @@ function startWatcher(ctx2, log2, opts) {
1032
1600
  binaryInterval: 100,
1033
1601
  // Skip dot-dirs (.obsidian, .git, .trash, …) regardless of where the vault
1034
1602
  // lives — matched on the path relative to the vault root.
1035
- 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
+ }
1036
1608
  });
1037
1609
  watcher2.on("add", onUpsert("add")).on("change", onUpsert("change")).on("unlink", (abs) => {
1038
1610
  const relPath = toRel(abs);
@@ -1048,7 +1620,7 @@ function startWatcher(ctx2, log2, opts) {
1048
1620
  }
1049
1621
 
1050
1622
  // src/index.ts
1051
- var VERSION = true ? "0.3.0" : "0.0.0-dev";
1623
+ var VERSION = true ? "0.4.0" : "0.0.0-dev";
1052
1624
  var intent = parseCliIntent(process.argv.slice(2));
1053
1625
  if (intent === "version") {
1054
1626
  process.stdout.write(`${VERSION}
@@ -1077,9 +1649,9 @@ if (!vaultRoot) {
1077
1649
  process.exit(1);
1078
1650
  }
1079
1651
  log.info("building index", { vault: vaultRoot });
1080
- var { index, notes, buildMs } = await buildIndex(vaultRoot);
1652
+ var { index, notes, backlinks, buildMs } = await buildIndex(vaultRoot);
1081
1653
  log.info("index ready", { notes: notes.size, buildMs });
1082
- var ctx = { vaultRoot, index, notes };
1654
+ var ctx = { vaultRoot, index, notes, backlinks };
1083
1655
  var watcher = startWatcher(ctx, log);
1084
1656
  process.on("exit", watcher.stop);
1085
1657
  var server = new Server({ name: "seekstone", version: "0.1.0" }, { capabilities: { tools: {} } });
@@ -1101,13 +1673,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1101
1673
  },
1102
1674
  {
1103
1675
  name: "read_note",
1104
- 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.",
1105
1677
  inputSchema: {
1106
1678
  type: "object",
1107
1679
  properties: {
1108
1680
  path: {
1109
1681
  type: "string",
1110
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."
1111
1707
  }
1112
1708
  },
1113
1709
  required: ["path"]
@@ -1126,6 +1722,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1126
1722
  required: []
1127
1723
  }
1128
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
+ },
1129
1748
  {
1130
1749
  name: "create_note",
1131
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.",
@@ -1204,6 +1823,80 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1204
1823
  },
1205
1824
  required: ["path", "patch"]
1206
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
+ }
1207
1900
  }
1208
1901
  ]
1209
1902
  }));
@@ -1213,7 +1906,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1213
1906
  });
1214
1907
  var transport = new StdioServerTransport();
1215
1908
  await server.connect(transport);
1216
- log.info("ready", { tools: 8, transport: "stdio" });
1909
+ log.info("ready", { tools: 13, transport: "stdio" });
1217
1910
  process.stderr.write(
1218
1911
  `seekstone: add to Claude Desktop:
1219
1912
  ${JSON.stringify(