seekstone 0.12.2 → 0.13.1

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 CHANGED
@@ -104,7 +104,7 @@ Other MCP clients (Windsurf, Cline, …) take the Option 3 JSON block in their o
104
104
 
105
105
  ## Tools
106
106
 
107
- 18 tools: 10 read, 8 write.
107
+ 19 tools: 10 read, 9 write.
108
108
 
109
109
  ### Read
110
110
 
@@ -128,6 +128,7 @@ Other MCP clients (Windsurf, Cline, …) take the Option 3 JSON block in their o
128
128
  | `create_note` | Create a note (optional frontmatter + body); parent dirs created automatically. Never clobbers an existing note unless you pass `overwrite: true`. |
129
129
  | `delete_note` | Move a note to the vault's `.trash/` folder (Obsidian-compatible, restorable). Pass `permanent: true` for an unrecoverable delete. |
130
130
  | `move_note` | Move/rename a note — wikilinks and markdown links in other notes that point at it are rewritten so nothing breaks; destination dirs created automatically. |
131
+ | `rename_heading` | Rename a heading in a note — every `[[note#heading]]` wikilink and embed across the vault is rewritten so references keep working (aliases preserved, fenced code blocks left alone). |
131
132
  | `append_note` | Append to a note body without touching frontmatter. |
132
133
  | `patch_frontmatter` | Set/update/delete frontmatter keys without reordering existing keys or changing quote style. |
133
134
  | `patch_note` | Insert text immediately after a heading without touching frontmatter. |
@@ -147,7 +148,7 @@ The content-editing tools (`append_note`, `patch_note`, `patch_frontmatter`, `re
147
148
  | `SEEKSTONE_LOG_FILE` | no | Absolute path; when set, JSON-line logs are appended here (size-rotated). |
148
149
  | `SEEKSTONE_WATCH_POLL` | no | Set to `1` to stat-poll for changes instead of native OS events — reliable on network drives, WSL, containers. |
149
150
  | `SEEKSTONE_LOG_MAX_SIZE` | no | Log-rotation threshold for `SEEKSTONE_LOG_FILE` (e.g. `10mb`; default 5 MB). |
150
- | `SEEKSTONE_READ_ONLY` | no | Set to `1` to run read-only: the 8 write tools are unregistered entirely (and rejected if called anyway), so the session provably cannot modify your vault. |
151
+ | `SEEKSTONE_READ_ONLY` | no | Set to `1` to run read-only: the 9 write tools are unregistered entirely (and rejected if called anyway), so the session provably cannot modify your vault. |
151
152
  | `SEEKSTONE_WRITE_PATHS` | no | Comma-separated vault-relative globs (e.g. `journal/**,inbox/*.md`). Writes are permitted only under matching paths; the rest of the vault stays read-only. |
152
153
 
153
154
  ---
package/dist/index.js CHANGED
@@ -246,25 +246,64 @@ async function appendNote(ctx2, input) {
246
246
  }
247
247
 
248
248
  // ../core/src/extract.ts
249
+ import { parseDocument, visit } from "yaml";
249
250
  var LINK_WITH_LINES_RE = /(!?\[\[)([^\]|#\n]{1,512})(#[^\]|\n]{1,512})?(\|[^\]\n]{1,512})?(\]\])/g;
250
251
  function extractLinksWithLines(raw) {
252
+ const fm = parseFrontmatter(raw);
253
+ if (!fm.present || fm.bodyStart === 0) return linksFromLines(raw, 1);
254
+ const fmRegion = raw.slice(0, fm.bodyStart);
255
+ const out = frontmatterLinks(fmRegion, fm.malformed);
256
+ const bodyFirstLine = countNewlines(fmRegion) + 1;
257
+ linksFromLinesInto(out, fm.body, bodyFirstLine);
258
+ return out;
259
+ }
260
+ function linksFromLines(text, firstLine) {
251
261
  const out = [];
252
- const lines = raw.split("\n");
262
+ linksFromLinesInto(out, text, firstLine);
263
+ return out;
264
+ }
265
+ function linksFromLinesInto(out, text, firstLine) {
266
+ const lines = text.split("\n");
253
267
  for (const [lineIdx, lineText] of lines.entries()) {
254
- for (const m of lineText.matchAll(LINK_WITH_LINES_RE)) {
255
- const prefix = m[1] ?? "";
256
- out.push({
257
- raw: m[0] ?? "",
258
- target: (m[2] ?? "").trim(),
259
- fragment: m[3] ? m[3].slice(1).trim() : null,
260
- alias: m[4] ? m[4].slice(1).trim() : null,
261
- linkType: prefix.startsWith("!") ? "embed" : "wikilink",
262
- line: lineIdx + 1
263
- });
264
- }
268
+ matchLinksInto(out, lineText, firstLine + lineIdx);
269
+ }
270
+ }
271
+ function matchLinksInto(out, text, line) {
272
+ for (const m of text.matchAll(LINK_WITH_LINES_RE)) {
273
+ const prefix = m[1] ?? "";
274
+ out.push({
275
+ raw: m[0] ?? "",
276
+ target: (m[2] ?? "").trim(),
277
+ fragment: m[3] ? m[3].slice(1).trim() : null,
278
+ alias: m[4] ? m[4].slice(1).trim() : null,
279
+ linkType: prefix.startsWith("!") ? "embed" : "wikilink",
280
+ line
281
+ });
265
282
  }
283
+ }
284
+ function frontmatterLinks(fmRegion, malformed) {
285
+ if (malformed) return linksFromLines(fmRegion, 1);
286
+ const yamlStart = fmRegion.indexOf("\n") + 1;
287
+ const closeLen = fmRegion.endsWith("---\r\n") ? 5 : 4;
288
+ const yamlText = fmRegion.slice(yamlStart, fmRegion.length - closeLen);
289
+ const doc = parseDocument(yamlText);
290
+ if (doc.errors.length > 0) return linksFromLines(fmRegion, 1);
291
+ const out = [];
292
+ visit(doc, {
293
+ Scalar(key, node) {
294
+ if (key === "key") return;
295
+ if (typeof node.value !== "string" || node.range == null) return;
296
+ const line = 2 + countNewlines(yamlText.slice(0, node.range[0]));
297
+ matchLinksInto(out, node.value, line);
298
+ }
299
+ });
266
300
  return out;
267
301
  }
302
+ function countNewlines(text) {
303
+ let n = 0;
304
+ for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) n++;
305
+ return n;
306
+ }
268
307
  var INLINE_TAG_RE = /(?:^|[^\w\]/])#([A-Za-z_][\w/-]*)/g;
269
308
  function extractInlineTags(body) {
270
309
  const out = [];
@@ -873,6 +912,16 @@ import { posix } from "path";
873
912
  var WIKI_RE = /(!?)\[\[([^\]|#\n]{1,512})(#[^\]|\n]{1,512})?(\|[^\]\n]{1,512})?\]\]/g;
874
913
  var MD_RE = /(!?)\[([^\]\n]*)\]\(([^)\n]+)\)/g;
875
914
  var FENCE_RE = /^\s*(```|~~~)/;
915
+ function mapUnfencedLines(raw, fn) {
916
+ let inFence = false;
917
+ return raw.split("\n").map((line) => {
918
+ if (FENCE_RE.test(line)) {
919
+ inFence = !inFence;
920
+ return line;
921
+ }
922
+ return inFence ? line : fn(line);
923
+ }).join("\n");
924
+ }
876
925
  function wikiTargetFor(newPath, postNotes) {
877
926
  const noExt = newPath.replace(/\.md$/i, "");
878
927
  const base = noExt.split("/").pop() ?? noExt;
@@ -932,16 +981,8 @@ function rewriteNoteLinks(raw, sourcePath, oldPath, newPath, preNotes, postNotes
932
981
  const target = needsWrap ? `<${next}${fragment}>` : `${next}${fragment}`;
933
982
  return `${bang}[${text}](${target})`;
934
983
  });
935
- let inFence = false;
936
- const lines = raw.split("\n").map((line) => {
937
- if (FENCE_RE.test(line)) {
938
- inFence = !inFence;
939
- return line;
940
- }
941
- if (inFence) return line;
942
- return rewriteMdLine(rewriteWikiLine(line));
943
- });
944
- return { content: lines.join("\n"), count };
984
+ const content = mapUnfencedLines(raw, (line) => rewriteMdLine(rewriteWikiLine(line)));
985
+ return { content, count };
945
986
  }
946
987
  function hasMarkdownLinkTo(raw, sourcePath, targetPath) {
947
988
  if (!raw.includes("](")) return false;
@@ -1111,7 +1152,7 @@ async function outlineNote(ctx2, input) {
1111
1152
 
1112
1153
  // src/tools/patch_frontmatter.ts
1113
1154
  import { readFile as readFile4 } from "fs/promises";
1114
- import { parseDocument } from "yaml";
1155
+ import { parseDocument as parseDocument2 } from "yaml";
1115
1156
  import { z as z12 } from "zod";
1116
1157
  var PatchFrontmatterInput = z12.object({
1117
1158
  path: z12.string().describe("Vault-relative path to the note."),
@@ -1133,7 +1174,7 @@ async function patchFrontmatter(ctx2, input) {
1133
1174
  const keysRemoved = [];
1134
1175
  let newContent;
1135
1176
  if (!fm.present) {
1136
- const doc = parseDocument("");
1177
+ const doc = parseDocument2("");
1137
1178
  for (const [k, v] of Object.entries(input.patch)) {
1138
1179
  if (v !== null) {
1139
1180
  doc.set(k, v);
@@ -1148,7 +1189,7 @@ ${original}`;
1148
1189
  const openLen = opensWithCRLF ? 5 : 4;
1149
1190
  const closeLen = opensWithCRLF ? 7 : 5;
1150
1191
  const yamlText = original.slice(openLen, fm.bodyStart - closeLen);
1151
- const doc = parseDocument(yamlText, { keepSourceTokens: true });
1192
+ const doc = parseDocument2(yamlText, { keepSourceTokens: true });
1152
1193
  for (const [k, v] of Object.entries(input.patch)) {
1153
1194
  const existing = doc.has(k);
1154
1195
  if (v === null) {
@@ -1735,19 +1776,142 @@ async function readNote(ctx2, input) {
1735
1776
  };
1736
1777
  }
1737
1778
 
1738
- // src/tools/replace_in_note.ts
1779
+ // src/tools/rename_heading.ts
1739
1780
  import { readFile as readFile8 } from "fs/promises";
1781
+ import { join as join4 } from "path";
1740
1782
  import { z as z17 } from "zod";
1741
- var ReplaceInNoteInput = z17.object({
1742
- path: z17.string().describe("Vault-relative path to the note."),
1743
- find: z17.string().min(1).max(1e3).describe("Text or pattern to find (max 1000 chars; bounds regex-mode pattern size)."),
1744
- replace: z17.string().describe("Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."),
1745
- regex: z17.boolean().default(false).describe("Treat find as a regular expression."),
1746
- caseSensitive: z17.boolean().default(false).describe("Case-sensitive matching."),
1747
- wholeWord: z17.boolean().default(false).describe("Match whole words only (\\b word-boundary)."),
1748
- limit: z17.number().int().min(1).optional().describe("Maximum number of replacements. Omit to replace all."),
1749
- dryRun: z17.boolean().default(false).describe("If true, report matches without writing anything."),
1783
+ var RenameHeadingInput = z17.object({
1784
+ path: z17.string().describe("Vault-relative path of the note containing the heading."),
1785
+ oldHeading: z17.string().min(1).describe(
1786
+ "Current heading text, without # markers. Matched case-insensitively; if the note has duplicate headings the first one wins, mirroring Obsidian link resolution."
1787
+ ),
1788
+ newHeading: z17.string().min(1).refine((s) => !/[\n[\]|#]/.test(s), {
1789
+ message: 'newHeading must not contain newlines, "[", "]", "|", or "#" \u2014 those break [[note#heading]] link syntax.'
1790
+ }).describe("New heading text, without # markers."),
1750
1791
  prevHash: z17.string().optional().describe(
1792
+ "Optional compare-and-swap guard: the contentHash from a prior read of this note. Fails with hash_conflict if the note changed since."
1793
+ )
1794
+ });
1795
+ var HEADING_LINK_RE = /(!?)\[\[([^\]|#\n]{0,512})(#[^\]|\n]{1,512})(\|[^\]\n]{1,512})?\]\]/g;
1796
+ function rewriteHeadingLinks(raw, oldHeading, newHeading, matchesTarget) {
1797
+ const oldLower = oldHeading.toLowerCase();
1798
+ let count = 0;
1799
+ const content = mapUnfencedLines(
1800
+ raw,
1801
+ (line) => line.replace(
1802
+ HEADING_LINK_RE,
1803
+ (full, bang, target, frag, alias) => {
1804
+ const fragText = frag.slice(1);
1805
+ if (fragText.startsWith("^") || fragText.includes("#")) return full;
1806
+ if (fragText.trim().toLowerCase() !== oldLower) return full;
1807
+ if (!matchesTarget(target.trim())) return full;
1808
+ count++;
1809
+ return `${bang}[[${target}#${newHeading}${alias ?? ""}]]`;
1810
+ }
1811
+ )
1812
+ );
1813
+ return { content, count };
1814
+ }
1815
+ async function renameHeading(ctx2, rawInput) {
1816
+ const input = RenameHeadingInput.parse(rawInput);
1817
+ const absPath = resolveVaultPath(ctx2.vaultRoot, input.path);
1818
+ assertWritable(ctx2.policy, input.path);
1819
+ let raw;
1820
+ try {
1821
+ raw = await readFile8(absPath, "utf8");
1822
+ } catch {
1823
+ throw new Error(`Note not found: ${input.path}`);
1824
+ }
1825
+ if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.path);
1826
+ const outline = buildOutline(raw, { includeBlocks: false });
1827
+ const heading = outline.headings.find(
1828
+ (h) => h.text.toLowerCase() === input.oldHeading.toLowerCase()
1829
+ );
1830
+ if (!heading) {
1831
+ throw new Error(
1832
+ JSON.stringify({
1833
+ error: "heading_not_found",
1834
+ target: input.oldHeading,
1835
+ available: outline.headings.map((h) => h.text)
1836
+ })
1837
+ );
1838
+ }
1839
+ const nl = raw.indexOf("\n", heading.byteOffset);
1840
+ const lineEnd2 = nl === -1 ? raw.length : nl;
1841
+ const lineText = raw.slice(heading.byteOffset, lineEnd2);
1842
+ const markerPrefix = /^#{1,6}\s+/.exec(lineText)?.[0] ?? "";
1843
+ const trailing = /\s*$/.exec(lineText)?.[0] ?? "";
1844
+ const newLineText = `${markerPrefix}${input.newHeading}${trailing}`;
1845
+ let newRaw = raw.slice(0, heading.byteOffset) + newLineText + raw.slice(lineEnd2);
1846
+ const fm = parseFrontmatter(newRaw);
1847
+ const bodyRegion = newRaw.slice(fm.bodyStart);
1848
+ const selfRewrite = rewriteHeadingLinks(
1849
+ bodyRegion,
1850
+ input.oldHeading,
1851
+ input.newHeading,
1852
+ (target) => target === "" || resolveLink(target, ctx2.notes) === input.path
1853
+ );
1854
+ newRaw = newRaw.slice(0, fm.bodyStart) + selfRewrite.content;
1855
+ const originalFmRegion = raw.slice(0, fm.bodyStart);
1856
+ await atomicWrite(absPath, newRaw);
1857
+ const written = await readFile8(absPath, "utf8");
1858
+ if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
1859
+ throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
1860
+ }
1861
+ removeNoteBacklinks(ctx2, input.path);
1862
+ upsertDoc(ctx2, buildDoc(input.path, newRaw));
1863
+ addNoteBacklinks(ctx2, input.path, newRaw);
1864
+ let notesRewritten = selfRewrite.count > 0 ? 1 : 0;
1865
+ let linksRewritten = selfRewrite.count;
1866
+ const skipped = [];
1867
+ const candidates = /* @__PURE__ */ new Set();
1868
+ for (const ref of ctx2.backlinks.get(input.path) ?? []) candidates.add(ref.path);
1869
+ candidates.delete(input.path);
1870
+ for (const path2 of [...candidates].sort()) {
1871
+ const doc = ctx2.notes.get(path2);
1872
+ if (doc === void 0) continue;
1873
+ const { content, count } = rewriteHeadingLinks(
1874
+ doc.raw,
1875
+ input.oldHeading,
1876
+ input.newHeading,
1877
+ (target) => target !== "" && resolveLink(target, ctx2.notes) === input.path
1878
+ );
1879
+ if (count === 0) continue;
1880
+ if (!isWritable(ctx2.policy, path2)) {
1881
+ skipped.push(path2);
1882
+ continue;
1883
+ }
1884
+ await atomicWrite(join4(ctx2.vaultRoot, path2), content);
1885
+ upsertDoc(ctx2, buildDoc(path2, content));
1886
+ notesRewritten++;
1887
+ linksRewritten += count;
1888
+ }
1889
+ const result = {
1890
+ path: input.path,
1891
+ oldHeading: input.oldHeading,
1892
+ newHeading: input.newHeading,
1893
+ line: heading.line,
1894
+ notesRewritten,
1895
+ linksRewritten,
1896
+ contentHash: contentHash(newRaw)
1897
+ };
1898
+ if (skipped.length > 0) result.skipped = skipped;
1899
+ return result;
1900
+ }
1901
+
1902
+ // src/tools/replace_in_note.ts
1903
+ import { readFile as readFile9 } from "fs/promises";
1904
+ import { z as z18 } from "zod";
1905
+ var ReplaceInNoteInput = z18.object({
1906
+ path: z18.string().describe("Vault-relative path to the note."),
1907
+ find: z18.string().min(1).max(1e3).describe("Text or pattern to find (max 1000 chars; bounds regex-mode pattern size)."),
1908
+ replace: z18.string().describe("Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."),
1909
+ regex: z18.boolean().default(false).describe("Treat find as a regular expression."),
1910
+ caseSensitive: z18.boolean().default(false).describe("Case-sensitive matching."),
1911
+ wholeWord: z18.boolean().default(false).describe("Match whole words only (\\b word-boundary)."),
1912
+ limit: z18.number().int().min(1).optional().describe("Maximum number of replacements. Omit to replace all."),
1913
+ dryRun: z18.boolean().default(false).describe("If true, report matches without writing anything."),
1914
+ prevHash: z18.string().optional().describe(
1751
1915
  "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
1752
1916
  )
1753
1917
  });
@@ -1773,7 +1937,7 @@ async function replaceInNote(ctx2, input) {
1773
1937
  } catch (err) {
1774
1938
  throw new Error(`Invalid regex: ${err instanceof Error ? err.message : String(err)}`);
1775
1939
  }
1776
- const raw = await readFile8(absPath, "utf8");
1940
+ const raw = await readFile9(absPath, "utf8");
1777
1941
  if (input.prevHash !== void 0) assertHashMatch(raw, input.prevHash, input.path);
1778
1942
  const fm = parseFrontmatter(raw);
1779
1943
  const originalFmRegion = raw.slice(0, fm.bodyStart);
@@ -1805,7 +1969,7 @@ async function replaceInNote(ctx2, input) {
1805
1969
  newBody += body.slice(lastIndex);
1806
1970
  const newContent = `${originalFmRegion}${newBody}`;
1807
1971
  await atomicWrite(absPath, newContent);
1808
- const written = await readFile8(absPath, "utf8");
1972
+ const written = await readFile9(absPath, "utf8");
1809
1973
  if (written.slice(0, originalFmRegion.length) !== originalFmRegion) {
1810
1974
  throw new Error("Write-safety violation: frontmatter region changed unexpectedly");
1811
1975
  }
@@ -1830,6 +1994,7 @@ var WRITE_TOOLS = /* @__PURE__ */ new Set([
1830
1994
  "create_note",
1831
1995
  "delete_note",
1832
1996
  "move_note",
1997
+ "rename_heading",
1833
1998
  "append_note",
1834
1999
  "patch_frontmatter",
1835
2000
  "patch_note",
@@ -1973,6 +2138,15 @@ async function run(ctx2, name, args) {
1973
2138
  }
1974
2139
  return { content: [{ type: "text", text }] };
1975
2140
  }
2141
+ case "rename_heading": {
2142
+ const input = RenameHeadingInput.parse(args);
2143
+ const result = await renameHeading(ctx2, input);
2144
+ let text = `Renamed heading "${result.oldHeading}" \u2192 "${result.newHeading}" in ${result.path} (line ${result.line}). Rewrote ${result.linksRewritten} link${result.linksRewritten === 1 ? "" : "s"} in ${result.notesRewritten} note${result.notesRewritten === 1 ? "" : "s"}. contentHash: ${result.contentHash}`;
2145
+ if (result.skipped?.length) {
2146
+ text += ` Skipped (outside SEEKSTONE_WRITE_PATHS): ${result.skipped.join(", ")}.`;
2147
+ }
2148
+ return { content: [{ type: "text", text }] };
2149
+ }
1976
2150
  case "append_note": {
1977
2151
  const input = AppendNoteInput.parse(args);
1978
2152
  const result = await appendNote(ctx2, input);
@@ -2038,12 +2212,12 @@ async function run(ctx2, name, args) {
2038
2212
  }
2039
2213
 
2040
2214
  // src/index/build.ts
2041
- import { readFile as readFile9 } from "fs/promises";
2215
+ import { readFile as readFile10 } from "fs/promises";
2042
2216
  import { basename as basename3, extname as extname4 } from "path";
2043
2217
 
2044
2218
  // ../core/src/walk.ts
2045
2219
  import { stat } from "fs/promises";
2046
- import { extname as extname3, join as join4, relative, sep as sep2 } from "path";
2220
+ import { extname as extname3, join as join5, relative, sep as sep2 } from "path";
2047
2221
  import fg from "fast-glob";
2048
2222
  var IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]);
2049
2223
  var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".mkv", ".avi"]);
@@ -2120,7 +2294,7 @@ async function buildIndex(vaultRoot2) {
2120
2294
  await Promise.all(
2121
2295
  noteEntries.map(async (entry) => {
2122
2296
  try {
2123
- const raw = await readFile9(entry.absPath, "utf8");
2297
+ const raw = await readFile10(entry.absPath, "utf8");
2124
2298
  const fm = parseFrontmatter(raw);
2125
2299
  const inlineTags = extractInlineTags(fm.body);
2126
2300
  const fmTags = frontmatterTags(fm.data);
@@ -2173,8 +2347,8 @@ function buildBacklinks(notes2) {
2173
2347
 
2174
2348
  // src/init.ts
2175
2349
  import { execFileSync } from "child_process";
2176
- import { access as access5, copyFile, mkdir as mkdir5, readdir, readFile as readFile10, stat as stat2, writeFile as writeFile2 } from "fs/promises";
2177
- import path, { dirname as dirname4, join as join5 } from "path";
2350
+ import { access as access5, copyFile, mkdir as mkdir5, readdir, readFile as readFile11, stat as stat2, writeFile as writeFile2 } from "fs/promises";
2351
+ import path, { dirname as dirname4, join as join6 } from "path";
2178
2352
  function parseInitArgs(argv) {
2179
2353
  const opts = { write: false, client: "desktop" };
2180
2354
  for (let i = 0; i < argv.length; i++) {
@@ -2217,7 +2391,7 @@ function cursorConfigPath(platform, env) {
2217
2391
  return j(env.home ?? "", ".cursor", "mcp.json");
2218
2392
  }
2219
2393
  function vscodeMcpConfigPath(cwd) {
2220
- return join5(cwd, ".vscode", "mcp.json");
2394
+ return join6(cwd, ".vscode", "mcp.json");
2221
2395
  }
2222
2396
  function obsidianRegistryPath(platform, env) {
2223
2397
  if (platform === "win32") {
@@ -2279,7 +2453,7 @@ async function validateVault(vaultPath) {
2279
2453
  return { ok: false, error: `Path does not exist: ${vaultPath}` };
2280
2454
  }
2281
2455
  try {
2282
- await access5(join5(vaultPath, ".obsidian"));
2456
+ await access5(join6(vaultPath, ".obsidian"));
2283
2457
  } catch {
2284
2458
  return {
2285
2459
  ok: false,
@@ -2299,7 +2473,7 @@ async function countMarkdown(root) {
2299
2473
  }
2300
2474
  for (const e of entries) {
2301
2475
  if (e.name.startsWith(".")) continue;
2302
- const full = join5(dir, e.name);
2476
+ const full = join6(dir, e.name);
2303
2477
  if (e.isDirectory()) await walk(full);
2304
2478
  else if (e.name.endsWith(".md")) count++;
2305
2479
  }
@@ -2317,7 +2491,7 @@ async function runInit(opts, deps) {
2317
2491
  ]
2318
2492
  };
2319
2493
  }
2320
- const rf = deps.readFile ?? readFile10;
2494
+ const rf = deps.readFile ?? readFile11;
2321
2495
  let vaultPath = opts.vault ?? deps.env.SEEKSTONE_VAULT;
2322
2496
  if (!vaultPath) {
2323
2497
  const registryPath = obsidianRegistryPath(deps.platform, {
@@ -2578,9 +2752,32 @@ function installProcessGuards(log2) {
2578
2752
  }
2579
2753
 
2580
2754
  // src/tool-list.ts
2755
+ var READ_ONLY_ANNOTATIONS = {
2756
+ readOnlyHint: true,
2757
+ openWorldHint: false
2758
+ };
2759
+ var ADDITIVE_WRITE_ANNOTATIONS = {
2760
+ readOnlyHint: false,
2761
+ destructiveHint: false,
2762
+ idempotentHint: false,
2763
+ openWorldHint: false
2764
+ };
2765
+ var DESTRUCTIVE_WRITE_ANNOTATIONS = {
2766
+ readOnlyHint: false,
2767
+ destructiveHint: true,
2768
+ idempotentHint: false,
2769
+ openWorldHint: false
2770
+ };
2771
+ var IDEMPOTENT_DESTRUCTIVE_WRITE_ANNOTATIONS = {
2772
+ readOnlyHint: false,
2773
+ destructiveHint: true,
2774
+ idempotentHint: true,
2775
+ openWorldHint: false
2776
+ };
2581
2777
  var ALL_TOOLS = [
2582
2778
  {
2583
2779
  name: "search",
2780
+ annotations: READ_ONLY_ANNOTATIONS,
2584
2781
  description: "Full-text search across the vault. Returns ranked excerpts (~200 chars) \u2014 not full notes \u2014 to minimise context usage. Supports fuzzy matching and prefix search.",
2585
2782
  inputSchema: {
2586
2783
  type: "object",
@@ -2595,6 +2792,7 @@ var ALL_TOOLS = [
2595
2792
  },
2596
2793
  {
2597
2794
  name: "query_notes",
2795
+ annotations: READ_ONLY_ANNOTATIONS,
2598
2796
  description: "Structured metadata query \u2014 filter notes by frontmatter key/value predicates, tag, folder, modified time, and size. Returns compact rows (path + title by default; opt into more via select), not note content. Use this instead of search when filtering by properties rather than text.",
2599
2797
  inputSchema: {
2600
2798
  type: "object",
@@ -2652,6 +2850,7 @@ var ALL_TOOLS = [
2652
2850
  },
2653
2851
  {
2654
2852
  name: "context_pack",
2853
+ annotations: READ_ONLY_ANNOTATIONS,
2655
2854
  description: 'Assemble everything needed to ANSWER a natural-language question in one call, under a strict byte budget (default 2048): ranked excerpts, linked neighbor notes (backlinks/outlinks) with one-line summaries, and follow-up source paths. Use search to locate notes and query_notes for metadata filters; use context_pack when you want answer-ready context without multiple round-trips. Empty excerpts with confidence "none" or "low" means the vault lacks coverage \u2014 do not infer content.',
2656
2855
  inputSchema: {
2657
2856
  type: "object",
@@ -2667,6 +2866,7 @@ var ALL_TOOLS = [
2667
2866
  },
2668
2867
  {
2669
2868
  name: "read_note",
2869
+ annotations: READ_ONLY_ANNOTATIONS,
2670
2870
  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, total note size, and a contentHash to pass as prevHash to edit tools for compare-and-swap. Use search or outline_note first to find the right path and section names.",
2671
2871
  inputSchema: {
2672
2872
  type: "object",
@@ -2705,6 +2905,7 @@ var ALL_TOOLS = [
2705
2905
  },
2706
2906
  {
2707
2907
  name: "list_notes",
2908
+ annotations: READ_ONLY_ANNOTATIONS,
2708
2909
  description: "List notes, optionally filtered by folder prefix or tag.",
2709
2910
  inputSchema: {
2710
2911
  type: "object",
@@ -2718,6 +2919,7 @@ var ALL_TOOLS = [
2718
2919
  },
2719
2920
  {
2720
2921
  name: "list_tags",
2922
+ annotations: READ_ONLY_ANNOTATIONS,
2721
2923
  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.",
2722
2924
  inputSchema: {
2723
2925
  type: "object",
@@ -2741,6 +2943,7 @@ var ALL_TOOLS = [
2741
2943
  },
2742
2944
  {
2743
2945
  name: "create_note",
2946
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
2744
2947
  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 (prevHash may guard the overwrite).",
2745
2948
  inputSchema: {
2746
2949
  type: "object",
@@ -2769,6 +2972,7 @@ var ALL_TOOLS = [
2769
2972
  },
2770
2973
  {
2771
2974
  name: "delete_note",
2975
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
2772
2976
  description: "Delete a note. By default it is moved to the vault .trash/ folder (recoverable by moving it back); pass permanent: true to remove it outright.",
2773
2977
  inputSchema: {
2774
2978
  type: "object",
@@ -2784,6 +2988,7 @@ var ALL_TOOLS = [
2784
2988
  },
2785
2989
  {
2786
2990
  name: "move_note",
2991
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
2787
2992
  description: "Move or rename a note to a new vault-relative path, rewriting wikilinks and markdown links in other notes that point at it so nothing breaks (links inside fenced code blocks are left alone). Parent directories at the destination are created automatically. Fails if the destination already exists unless overwrite is true.",
2788
2993
  inputSchema: {
2789
2994
  type: "object",
@@ -2802,8 +3007,36 @@ var ALL_TOOLS = [
2802
3007
  required: ["from", "to"]
2803
3008
  }
2804
3009
  },
3010
+ {
3011
+ name: "rename_heading",
3012
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
3013
+ description: "Rename a heading in a note and rewrite every [[note#heading]] wikilink and embed across the vault so references keep working \u2014 aliases preserved, fenced code blocks left alone. Served from the warm backlink index, no vault scan. Heading matching is case-insensitive; with duplicate headings the first match wins, mirroring Obsidian link resolution.",
3014
+ inputSchema: {
3015
+ type: "object",
3016
+ properties: {
3017
+ path: {
3018
+ type: "string",
3019
+ description: "Vault-relative path of the note containing the heading."
3020
+ },
3021
+ oldHeading: {
3022
+ type: "string",
3023
+ description: "Current heading text, without # markers. Case-insensitive; first match wins."
3024
+ },
3025
+ newHeading: {
3026
+ type: "string",
3027
+ description: 'New heading text, without # markers. Must not contain newlines, "[", "]", "|", or "#".'
3028
+ },
3029
+ prevHash: {
3030
+ type: "string",
3031
+ description: "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
3032
+ }
3033
+ },
3034
+ required: ["path", "oldHeading", "newHeading"]
3035
+ }
3036
+ },
2805
3037
  {
2806
3038
  name: "append_note",
3039
+ annotations: ADDITIVE_WRITE_ANNOTATIONS,
2807
3040
  description: "Append text to a note body without touching the frontmatter. Safe for meeting notes, daily logs, and append-only workflows.",
2808
3041
  inputSchema: {
2809
3042
  type: "object",
@@ -2820,6 +3053,7 @@ var ALL_TOOLS = [
2820
3053
  },
2821
3054
  {
2822
3055
  name: "patch_frontmatter",
3056
+ annotations: IDEMPOTENT_DESTRUCTIVE_WRITE_ANNOTATIONS,
2823
3057
  description: "Set, update, or delete frontmatter keys without reordering existing keys or changing quote style. Pass null as a value to delete a key.",
2824
3058
  inputSchema: {
2825
3059
  type: "object",
@@ -2840,6 +3074,7 @@ var ALL_TOOLS = [
2840
3074
  },
2841
3075
  {
2842
3076
  name: "outline_note",
3077
+ annotations: READ_ONLY_ANNOTATIONS,
2843
3078
  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.",
2844
3079
  inputSchema: {
2845
3080
  type: "object",
@@ -2859,6 +3094,7 @@ var ALL_TOOLS = [
2859
3094
  },
2860
3095
  {
2861
3096
  name: "patch_note",
3097
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
2862
3098
  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.",
2863
3099
  inputSchema: {
2864
3100
  type: "object",
@@ -2888,6 +3124,7 @@ var ALL_TOOLS = [
2888
3124
  },
2889
3125
  {
2890
3126
  name: "get_backlinks",
3127
+ annotations: READ_ONLY_ANNOTATIONS,
2891
3128
  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.",
2892
3129
  inputSchema: {
2893
3130
  type: "object",
@@ -2907,6 +3144,7 @@ var ALL_TOOLS = [
2907
3144
  },
2908
3145
  {
2909
3146
  name: "get_links",
3147
+ annotations: READ_ONLY_ANNOTATIONS,
2910
3148
  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.",
2911
3149
  inputSchema: {
2912
3150
  type: "object",
@@ -2918,6 +3156,7 @@ var ALL_TOOLS = [
2918
3156
  },
2919
3157
  {
2920
3158
  name: "replace_in_note",
3159
+ annotations: DESTRUCTIVE_WRITE_ANNOTATIONS,
2921
3160
  description: "Find and replace text within a note body. Supports literal and regex search, case sensitivity, whole-word matching, and a replacement limit. Frontmatter is never touched. Use dryRun to preview matches before writing.",
2922
3161
  inputSchema: {
2923
3162
  type: "object",
@@ -2958,6 +3197,7 @@ var ALL_TOOLS = [
2958
3197
  },
2959
3198
  {
2960
3199
  name: "get_periodic_note",
3200
+ annotations: ADDITIVE_WRITE_ANNOTATIONS,
2961
3201
  description: "Get the path and existence status of a periodic note (daily, weekly, monthly, quarterly, or yearly) for a given date. Reads folder/format config from .obsidian/daily-notes.json (daily) or the periodic-notes plugin data.json. Optionally creates the note from the configured template if it is missing.",
2962
3202
  inputSchema: {
2963
3203
  type: "object",
@@ -2981,6 +3221,7 @@ var ALL_TOOLS = [
2981
3221
  },
2982
3222
  {
2983
3223
  name: "append_periodic_note",
3224
+ annotations: ADDITIVE_WRITE_ANNOTATIONS,
2984
3225
  description: "Append text to a periodic note (daily, weekly, monthly, quarterly, or yearly). Preserves existing frontmatter exactly. Creates the note first (from template if configured) when createIfMissing is true (default).",
2985
3226
  inputSchema: {
2986
3227
  type: "object",
@@ -3018,8 +3259,8 @@ function visibleTools(policy2) {
3018
3259
 
3019
3260
  // src/watcher.ts
3020
3261
  import { realpathSync } from "fs";
3021
- import { readFile as readFile11 } from "fs/promises";
3022
- import { join as join6, relative as relative2, sep as sep3 } from "path";
3262
+ import { readFile as readFile12 } from "fs/promises";
3263
+ import { join as join7, relative as relative2, sep as sep3 } from "path";
3023
3264
  import chokidar from "chokidar";
3024
3265
  function startWatcher(ctx2, log2, opts) {
3025
3266
  const usePolling = opts?.usePolling ?? process.env.SEEKSTONE_WATCH_POLL === "1";
@@ -3031,7 +3272,7 @@ function startWatcher(ctx2, log2, opts) {
3031
3272
  const toRel = (abs) => relative2(vaultRoot2, abs).split(sep3).join("/");
3032
3273
  async function upsert(relPath, op) {
3033
3274
  try {
3034
- const raw = await readFile11(join6(vaultRoot2, relPath), "utf8");
3275
+ const raw = await readFile12(join7(vaultRoot2, relPath), "utf8");
3035
3276
  removeNoteBacklinks(ctx2, relPath);
3036
3277
  upsertDoc(ctx2, buildDoc(relPath, raw));
3037
3278
  addNoteBacklinks(ctx2, relPath, raw);
@@ -3083,7 +3324,7 @@ function startWatcher(ctx2, log2, opts) {
3083
3324
  }
3084
3325
 
3085
3326
  // src/index.ts
3086
- var VERSION = true ? "0.12.2" : "0.0.0-dev";
3327
+ var VERSION = true ? "0.13.1" : "0.0.0-dev";
3087
3328
  var intent = parseCliIntent(process.argv.slice(2));
3088
3329
  if (intent === "version") {
3089
3330
  process.stdout.write(`${VERSION}