seekstone 0.12.2 → 0.13.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 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, {
@@ -2802,6 +2976,32 @@ var ALL_TOOLS = [
2802
2976
  required: ["from", "to"]
2803
2977
  }
2804
2978
  },
2979
+ {
2980
+ name: "rename_heading",
2981
+ 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.",
2982
+ inputSchema: {
2983
+ type: "object",
2984
+ properties: {
2985
+ path: {
2986
+ type: "string",
2987
+ description: "Vault-relative path of the note containing the heading."
2988
+ },
2989
+ oldHeading: {
2990
+ type: "string",
2991
+ description: "Current heading text, without # markers. Case-insensitive; first match wins."
2992
+ },
2993
+ newHeading: {
2994
+ type: "string",
2995
+ description: 'New heading text, without # markers. Must not contain newlines, "[", "]", "|", or "#".'
2996
+ },
2997
+ prevHash: {
2998
+ type: "string",
2999
+ description: "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
3000
+ }
3001
+ },
3002
+ required: ["path", "oldHeading", "newHeading"]
3003
+ }
3004
+ },
2805
3005
  {
2806
3006
  name: "append_note",
2807
3007
  description: "Append text to a note body without touching the frontmatter. Safe for meeting notes, daily logs, and append-only workflows.",
@@ -3018,8 +3218,8 @@ function visibleTools(policy2) {
3018
3218
 
3019
3219
  // src/watcher.ts
3020
3220
  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";
3221
+ import { readFile as readFile12 } from "fs/promises";
3222
+ import { join as join7, relative as relative2, sep as sep3 } from "path";
3023
3223
  import chokidar from "chokidar";
3024
3224
  function startWatcher(ctx2, log2, opts) {
3025
3225
  const usePolling = opts?.usePolling ?? process.env.SEEKSTONE_WATCH_POLL === "1";
@@ -3031,7 +3231,7 @@ function startWatcher(ctx2, log2, opts) {
3031
3231
  const toRel = (abs) => relative2(vaultRoot2, abs).split(sep3).join("/");
3032
3232
  async function upsert(relPath, op) {
3033
3233
  try {
3034
- const raw = await readFile11(join6(vaultRoot2, relPath), "utf8");
3234
+ const raw = await readFile12(join7(vaultRoot2, relPath), "utf8");
3035
3235
  removeNoteBacklinks(ctx2, relPath);
3036
3236
  upsertDoc(ctx2, buildDoc(relPath, raw));
3037
3237
  addNoteBacklinks(ctx2, relPath, raw);
@@ -3083,7 +3283,7 @@ function startWatcher(ctx2, log2, opts) {
3083
3283
  }
3084
3284
 
3085
3285
  // src/index.ts
3086
- var VERSION = true ? "0.12.2" : "0.0.0-dev";
3286
+ var VERSION = true ? "0.13.0" : "0.0.0-dev";
3087
3287
  var intent = parseCliIntent(process.argv.slice(2));
3088
3288
  if (intent === "version") {
3089
3289
  process.stdout.write(`${VERSION}