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/README.md +95 -33
- package/dist/index.js +827 -68
- package/dist/index.js.map +1 -1
- package/package.json +12 -3
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 (
|
|
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/
|
|
275
|
+
// src/tools/get_backlinks.ts
|
|
276
|
+
import { join as join4 } from "path";
|
|
257
277
|
import { z as z4 } from "zod";
|
|
258
|
-
var
|
|
259
|
-
|
|
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:
|
|
263
|
-
limit:
|
|
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
|
|
288
|
-
import { z as
|
|
289
|
-
var MoveNoteInput =
|
|
290
|
-
from:
|
|
291
|
-
to:
|
|
292
|
-
overwrite:
|
|
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 =
|
|
296
|
-
const absTo =
|
|
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
|
|
327
|
-
import { join as
|
|
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
|
|
330
|
-
var PatchFrontmatterInput =
|
|
331
|
-
path:
|
|
332
|
-
patch:
|
|
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 =
|
|
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
|
|
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
|
|
395
|
-
import { join as
|
|
396
|
-
import { z as
|
|
397
|
-
var ReadNoteInput =
|
|
398
|
-
path:
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
442
|
-
query:
|
|
443
|
-
limit:
|
|
444
|
-
folder:
|
|
445
|
-
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 = [
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
681
|
-
import
|
|
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(
|
|
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 =
|
|
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
|
|
1297
|
+
const rf = deps.readFile ?? readFile7;
|
|
1298
|
+
let vaultPath = opts.vault ?? deps.env.SEEKSTONE_VAULT;
|
|
762
1299
|
if (!vaultPath) {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
937
|
-
import { join as
|
|
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
|
|
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) =>
|
|
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.
|
|
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
|
|
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:
|
|
1909
|
+
log.info("ready", { tools: 13, transport: "stdio" });
|
|
1151
1910
|
process.stderr.write(
|
|
1152
1911
|
`seekstone: add to Claude Desktop:
|
|
1153
1912
|
${JSON.stringify(
|