@gmickel/gno 1.31.0 → 1.33.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.
Files changed (77) hide show
  1. package/README.md +15 -5
  2. package/assets/skill/SKILL.md +34 -0
  3. package/assets/skill/cli-reference.md +10 -2
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.31.0.zip → gno-browser-clipper-v1.33.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.33.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +5 -1
  8. package/spec/cli.md +35 -1
  9. package/spec/db/schema.sql +55 -0
  10. package/spec/mcp.md +114 -11
  11. package/spec/output-schemas/file-refactor-apply-result.schema.json +305 -0
  12. package/spec/output-schemas/file-refactor-preview.schema.json +393 -0
  13. package/spec/output-schemas/publish-artifact.schema.json +76 -1
  14. package/src/cli/commands/publish.ts +43 -7
  15. package/src/core/document-capabilities.ts +13 -0
  16. package/src/core/file-ops.ts +129 -1
  17. package/src/core/file-refactor-adapter.ts +329 -0
  18. package/src/core/file-refactor-apply-edits.ts +61 -0
  19. package/src/core/file-refactor-apply-fs.ts +512 -0
  20. package/src/core/file-refactor-apply-safety.ts +340 -0
  21. package/src/core/file-refactor-apply-validate.ts +401 -0
  22. package/src/core/file-refactor-contract.ts +486 -0
  23. package/src/core/file-refactor-destination.ts +123 -0
  24. package/src/core/file-refactor-from-snapshot.ts +148 -0
  25. package/src/core/file-refactor-journal-port.ts +150 -0
  26. package/src/core/file-refactor-journal.ts +347 -0
  27. package/src/core/file-refactor-paths.ts +60 -0
  28. package/src/core/file-refactor-plan-classify.ts +208 -0
  29. package/src/core/file-refactor-plan-validate.ts +169 -0
  30. package/src/core/file-refactor-planner-types.ts +62 -0
  31. package/src/core/file-refactor-planner.ts +423 -0
  32. package/src/core/file-refactor-resolve.ts +280 -0
  33. package/src/core/file-refactor-service.ts +468 -0
  34. package/src/core/file-refactors.ts +84 -56
  35. package/src/core/link-destination-parse.ts +275 -0
  36. package/src/core/link-inventory-markdown.ts +454 -0
  37. package/src/core/link-inventory-opaque.ts +244 -0
  38. package/src/core/link-inventory-types.ts +47 -0
  39. package/src/core/link-inventory.ts +182 -0
  40. package/src/core/link-relevance.ts +150 -0
  41. package/src/ingestion/strip.ts +152 -26
  42. package/src/mcp/tools/index.ts +18 -17
  43. package/src/mcp/tools/workspace-write.ts +215 -97
  44. package/src/publish/artifact-asset-codec.ts +75 -0
  45. package/src/publish/artifact-asset-contract.ts +152 -0
  46. package/src/publish/artifact-asset-parse.ts +401 -0
  47. package/src/publish/artifact-asset-sniff.ts +108 -0
  48. package/src/publish/artifact-asset-validate.ts +209 -0
  49. package/src/publish/artifact-assets.ts +58 -0
  50. package/src/publish/artifact-validation.ts +32 -6
  51. package/src/publish/artifact.ts +50 -3
  52. package/src/publish/attachment-bundle.ts +145 -0
  53. package/src/publish/attachment-discover.ts +203 -0
  54. package/src/publish/attachment-load.ts +133 -0
  55. package/src/publish/attachment-obsidian.ts +45 -0
  56. package/src/publish/attachment-path.ts +334 -0
  57. package/src/publish/attachment-raster.ts +852 -0
  58. package/src/publish/attachment-resolver.ts +280 -0
  59. package/src/publish/attachment-types.ts +54 -0
  60. package/src/publish/encrypted-export.ts +121 -44
  61. package/src/publish/export-attachments.ts +224 -0
  62. package/src/publish/export-service.ts +142 -80
  63. package/src/publish/obsidian-sanitize.ts +121 -13
  64. package/src/sdk/client.ts +167 -115
  65. package/src/sdk/index.ts +7 -0
  66. package/src/sdk/types.ts +32 -2
  67. package/src/serve/file-refactor-http.ts +239 -0
  68. package/src/serve/public/components/RefactorImpactPreview.tsx +227 -0
  69. package/src/serve/public/globals.built.css +1 -1
  70. package/src/serve/public/pages/DocView.tsx +176 -41
  71. package/src/serve/routes/api.ts +193 -105
  72. package/src/store/migrations/026-file-refactor-recovery-journal.ts +72 -0
  73. package/src/store/migrations/index.ts +2 -0
  74. package/src/store/sqlite/adapter.ts +452 -0
  75. package/src/store/sqlite/file-refactor-journal-store.ts +275 -0
  76. package/src/store/types.ts +84 -0
  77. package/browser-extension/artifacts/gno-browser-clipper-v1.31.0.zip.sha256 +0 -1
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared inventory token types and caps for reference-safe refactors.
3
+ *
4
+ * @module src/core/link-inventory-types
5
+ */
6
+
7
+ import type {
8
+ FileRefactorReasonCode,
9
+ FileRefactorReferenceClassification,
10
+ FileRefactorReferenceKind,
11
+ } from "./file-refactor-contract";
12
+ import type { LinkEncodingStyle } from "./link-destination-parse";
13
+
14
+ /** Hard caps — callers must fail closed when truncated. */
15
+ export const LINK_INVENTORY_CAPS = {
16
+ maxContentChars: 1_000_000,
17
+ maxTokensPerDocument: 2_000,
18
+ } as const;
19
+
20
+ export interface LinkInventoryToken {
21
+ kind: FileRefactorReferenceKind;
22
+ classification?: FileRefactorReferenceClassification;
23
+ reasonCode?: FileRefactorReasonCode;
24
+ raw: string;
25
+ originalDestination: string;
26
+ destinationStart: number;
27
+ destinationEnd: number;
28
+ startOffset: number;
29
+ endOffset: number;
30
+ startLine: number;
31
+ startCol: number;
32
+ endLine: number;
33
+ endCol: number;
34
+ targetRef: string;
35
+ targetAnchor?: string;
36
+ targetCollection?: string;
37
+ targetQuery?: string;
38
+ hadLeadingDotSlash: boolean;
39
+ encodingStyle: LinkEncodingStyle;
40
+ }
41
+
42
+ export interface LinkInventoryResult {
43
+ tokens: LinkInventoryToken[];
44
+ truncated: boolean;
45
+ /** True when destination spans overlap after inventory. */
46
+ overlapping: boolean;
47
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Parser-backed link inventory for reference-safe file refactors.
3
+ *
4
+ * Produces exact UTF-16 destination token spans plus conservative opaque /
5
+ * malformed detections. Does not mutate disk or change parseLinks behavior.
6
+ *
7
+ * @module src/core/link-inventory
8
+ */
9
+
10
+ import type { ExcludedRange } from "../ingestion/strip";
11
+ import type {
12
+ LinkInventoryResult,
13
+ LinkInventoryToken,
14
+ } from "./link-inventory-types";
15
+
16
+ import { getExcludedRanges } from "../ingestion/strip";
17
+ import {
18
+ inventoryInlineMarkdown,
19
+ inventoryReferenceDefinitions,
20
+ inventoryWikiLinks,
21
+ } from "./link-inventory-markdown";
22
+ import {
23
+ buildLineOffsets,
24
+ inventoryEmbeds,
25
+ inventoryHtmlHrefs,
26
+ inventoryMalformedWiki,
27
+ } from "./link-inventory-opaque";
28
+ import { LINK_INVENTORY_CAPS } from "./link-inventory-types";
29
+
30
+ export {
31
+ LINK_INVENTORY_CAPS,
32
+ type LinkInventoryResult,
33
+ type LinkInventoryToken,
34
+ } from "./link-inventory-types";
35
+ export {
36
+ buildContentPrefilterNeedles,
37
+ buildSourceRelevanceKeys,
38
+ isRelevantDestination,
39
+ } from "./link-relevance";
40
+
41
+ /**
42
+ * Destination identity for inventory dedupe.
43
+ * Kind is intentionally omitted so identical spans from overlapping scanners
44
+ * collapse to the first (scanner-precedence) token.
45
+ */
46
+ export function inventoryDestinationKey(token: {
47
+ destinationStart: number;
48
+ destinationEnd: number;
49
+ originalDestination: string;
50
+ }): string {
51
+ return `${token.destinationStart}:${token.destinationEnd}:${token.originalDestination}`;
52
+ }
53
+
54
+ /**
55
+ * Dedupe identical destination spans (regardless of scanner kind) and detect
56
+ * true partial overlaps. First token wins — callers should push in scanner order.
57
+ */
58
+ export function dedupeInventoryDestinationTokens(
59
+ tokens: LinkInventoryToken[]
60
+ ): {
61
+ tokens: LinkInventoryToken[];
62
+ overlapping: boolean;
63
+ } {
64
+ const seen = new Set<string>();
65
+ const unique: LinkInventoryToken[] = [];
66
+ for (const token of tokens) {
67
+ const key = inventoryDestinationKey(token);
68
+ if (seen.has(key)) continue;
69
+ seen.add(key);
70
+ unique.push(token);
71
+ }
72
+ unique.sort((a, b) => {
73
+ if (a.destinationStart !== b.destinationStart) {
74
+ return a.destinationStart - b.destinationStart;
75
+ }
76
+ return a.destinationEnd - b.destinationEnd;
77
+ });
78
+ let overlapping = false;
79
+ for (let i = 1; i < unique.length; i += 1) {
80
+ const prev = unique[i - 1]!;
81
+ const cur = unique[i]!;
82
+ if (cur.destinationStart < prev.destinationEnd) {
83
+ overlapping = true;
84
+ break;
85
+ }
86
+ }
87
+ unique.sort((a, b) => {
88
+ if (a.startLine !== b.startLine) return a.startLine - b.startLine;
89
+ if (a.startCol !== b.startCol) return a.startCol - b.startCol;
90
+ return a.destinationStart - b.destinationStart;
91
+ });
92
+ return { tokens: unique, overlapping };
93
+ }
94
+
95
+ /**
96
+ * Inventory all relevant rewrite / opaque reference tokens in one document.
97
+ */
98
+ export function inventoryDocumentLinks(
99
+ markdown: string,
100
+ options: {
101
+ sourceKeys: ReadonlySet<string>;
102
+ excludedRanges?: ExcludedRange[];
103
+ }
104
+ ): LinkInventoryResult {
105
+ const truncated = { value: false };
106
+ if (markdown.length > LINK_INVENTORY_CAPS.maxContentChars) {
107
+ return { tokens: [], truncated: true, overlapping: false };
108
+ }
109
+
110
+ const excluded = options.excludedRanges ?? getExcludedRanges(markdown);
111
+ const lineOffsets = buildLineOffsets(markdown);
112
+ const tokens: LinkInventoryToken[] = [];
113
+ const consumed = new Set<number>();
114
+ const sourceKeys = options.sourceKeys;
115
+
116
+ const ok =
117
+ inventoryEmbeds(
118
+ markdown,
119
+ lineOffsets,
120
+ sourceKeys,
121
+ excluded,
122
+ tokens,
123
+ truncated,
124
+ consumed
125
+ ) &&
126
+ inventoryHtmlHrefs(
127
+ markdown,
128
+ lineOffsets,
129
+ sourceKeys,
130
+ excluded,
131
+ tokens,
132
+ truncated,
133
+ consumed
134
+ ) &&
135
+ inventoryMalformedWiki(
136
+ markdown,
137
+ lineOffsets,
138
+ sourceKeys,
139
+ excluded,
140
+ tokens,
141
+ truncated,
142
+ consumed
143
+ ) &&
144
+ inventoryWikiLinks(
145
+ markdown,
146
+ lineOffsets,
147
+ sourceKeys,
148
+ excluded,
149
+ tokens,
150
+ truncated,
151
+ consumed
152
+ ) &&
153
+ inventoryReferenceDefinitions(
154
+ markdown,
155
+ lineOffsets,
156
+ sourceKeys,
157
+ excluded,
158
+ tokens,
159
+ truncated,
160
+ consumed
161
+ ) &&
162
+ inventoryInlineMarkdown(
163
+ markdown,
164
+ lineOffsets,
165
+ sourceKeys,
166
+ excluded,
167
+ tokens,
168
+ truncated,
169
+ consumed
170
+ );
171
+
172
+ if (!ok) {
173
+ return { tokens, truncated: true, overlapping: false };
174
+ }
175
+
176
+ const finalized = dedupeInventoryDestinationTokens(tokens);
177
+ return {
178
+ tokens: finalized.tokens,
179
+ truncated: truncated.value,
180
+ overlapping: finalized.overlapping,
181
+ };
182
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Conservative destination relevance for reference-safe file refactors.
3
+ *
4
+ * Uses exact token/path/basename equality after stripping query/fragment,
5
+ * safe decoding, NFC normalization, and syntax delimiters. No fuzzy
6
+ * substring matching — unrelated `other-note.md` must not match `note.md`.
7
+ *
8
+ * @module src/core/link-relevance
9
+ */
10
+
11
+ // node:path/posix — no Bun path utils
12
+ import { posix as pathPosix } from "node:path";
13
+
14
+ import {
15
+ stripAngleBracketDestination,
16
+ unescapeCommonMarkDestination,
17
+ } from "./link-destination-parse";
18
+ import { normalizeWikiName, stripWikiMdExt } from "./links";
19
+
20
+ function safeDecodeForRelevance(value: string): string {
21
+ return value
22
+ .replaceAll("%20", " ")
23
+ .replaceAll("%28", "(")
24
+ .replaceAll("%29", ")");
25
+ }
26
+
27
+ function stripQueryAndFragment(value: string): string {
28
+ const hash = value.indexOf("#");
29
+ const withoutHash = hash >= 0 ? value.slice(0, hash) : value;
30
+ const query = withoutHash.indexOf("?");
31
+ return query >= 0 ? withoutHash.slice(0, query) : withoutHash;
32
+ }
33
+
34
+ function addKey(keys: Set<string>, value: string): void {
35
+ const trimmed = value.trim();
36
+ if (!trimmed) return;
37
+ keys.add(normalizeWikiName(trimmed));
38
+ keys.add(normalizeWikiName(stripWikiMdExt(trimmed)));
39
+ keys.add(trimmed.toLowerCase());
40
+ keys.add(stripWikiMdExt(trimmed).toLowerCase());
41
+ const base = pathPosix.basename(trimmed);
42
+ keys.add(normalizeWikiName(base));
43
+ keys.add(normalizeWikiName(stripWikiMdExt(base)));
44
+ keys.add(base.toLowerCase());
45
+ keys.add(stripWikiMdExt(base).toLowerCase());
46
+ }
47
+
48
+ /** Build lookup keys used to decide whether a destination is about the source. */
49
+ export function buildSourceRelevanceKeys(input: {
50
+ relPath: string;
51
+ title: string | null | undefined;
52
+ }): Set<string> {
53
+ const keys = new Set<string>();
54
+ addKey(keys, input.relPath);
55
+ addKey(keys, input.relPath.normalize("NFC"));
56
+ addKey(keys, input.relPath.normalize("NFD"));
57
+ if (input.title) {
58
+ addKey(keys, input.title);
59
+ addKey(keys, input.title.normalize("NFC"));
60
+ addKey(keys, input.title.normalize("NFD"));
61
+ }
62
+ return keys;
63
+ }
64
+
65
+ /**
66
+ * Normalize a destination token into candidate equality keys (exact only).
67
+ */
68
+ export function destinationRelevanceCandidates(destination: string): string[] {
69
+ const trimmed = destination.trim();
70
+ if (!trimmed) return [];
71
+
72
+ const { path: withoutAngles } = stripAngleBracketDestination(trimmed);
73
+ const unescaped = unescapeCommonMarkDestination(withoutAngles);
74
+ const withoutQueryFrag = stripQueryAndFragment(unescaped);
75
+ const decoded = safeDecodeForRelevance(withoutQueryFrag);
76
+
77
+ const forms = [
78
+ trimmed,
79
+ withoutAngles,
80
+ unescaped,
81
+ withoutQueryFrag,
82
+ decoded,
83
+ withoutQueryFrag.normalize("NFC"),
84
+ withoutQueryFrag.normalize("NFD"),
85
+ decoded.normalize("NFC"),
86
+ decoded.normalize("NFD"),
87
+ ];
88
+
89
+ const keys = new Set<string>();
90
+ for (const form of forms) {
91
+ addKey(keys, form);
92
+ }
93
+ return [...keys];
94
+ }
95
+
96
+ /**
97
+ * Exact-token relevance: true only when a normalized destination key equals a
98
+ * source key. Malformed prefixes may still match when the prefix token itself
99
+ * equals a source key (bounded exact equality, not substring).
100
+ */
101
+ export function isRelevantDestination(
102
+ destination: string,
103
+ sourceKeys: ReadonlySet<string>
104
+ ): boolean {
105
+ const candidates = destinationRelevanceCandidates(destination);
106
+ return candidates.some((key) => sourceKeys.has(key));
107
+ }
108
+
109
+ /**
110
+ * Content-prefilter needle strings for SQL LIKE (caller escapes wildcards).
111
+ * Conservative: source path/title identities and common escape encodings.
112
+ */
113
+ export function buildContentPrefilterNeedles(input: {
114
+ relPath: string;
115
+ title: string | null | undefined;
116
+ }): string[] {
117
+ const needles = new Set<string>();
118
+ const add = (value: string): void => {
119
+ const trimmed = value.trim();
120
+ if (!trimmed || trimmed.length < 2) return;
121
+ needles.add(trimmed);
122
+ needles.add(trimmed.normalize("NFC"));
123
+ needles.add(trimmed.normalize("NFD"));
124
+ needles.add(trimmed.replaceAll(" ", "%20"));
125
+ needles.add(
126
+ trimmed
127
+ .replaceAll(" ", "%20")
128
+ .replaceAll("(", "%28")
129
+ .replaceAll(")", "%29")
130
+ );
131
+ needles.add(trimmed.replaceAll("(", "%28").replaceAll(")", "%29"));
132
+ needles.add(
133
+ trimmed
134
+ .replaceAll(" ", "\\ ")
135
+ .replaceAll("(", "\\(")
136
+ .replaceAll(")", "\\)")
137
+ );
138
+ needles.add(trimmed.replaceAll("(", "\\(").replaceAll(")", "\\)"));
139
+ };
140
+
141
+ add(input.relPath);
142
+ add(pathPosix.basename(input.relPath));
143
+ add(stripWikiMdExt(pathPosix.basename(input.relPath)));
144
+ add(stripWikiMdExt(input.relPath));
145
+ if (input.title) {
146
+ add(input.title);
147
+ add(`${input.title}.md`);
148
+ }
149
+ return [...needles].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
150
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Identifies regions to exclude from link/tag extraction:
5
5
  * - YAML frontmatter
6
- * - Fenced code blocks
6
+ * - Fenced code blocks (CommonMark backtick and tilde fences)
7
7
  * - Inline code
8
8
  * - HTML comments
9
9
  *
@@ -39,15 +39,153 @@ export interface ExcludedRange {
39
39
  /** Frontmatter at start of file (YAML between --- delimiters) */
40
40
  const FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?(?:\r?\n)?---(?:\r?\n|$)/;
41
41
 
42
- /** Fenced code blocks (``` with optional language) */
43
- const FENCED_CODE_REGEX = /^```[^\n]*\n[\s\S]*?^```/gm;
42
+ /** CommonMark fence opener: 0–3 spaces, then 3+ backticks or tildes + info. */
43
+ const FENCE_OPEN_REGEX = /^ {0,3}(`{3,}|~{3,})(.*)$/u;
44
44
 
45
- /** Inline code (backticks, non-greedy) */
46
- const INLINE_CODE_REGEX = /`[^`\n]+`/g;
45
+ /** CommonMark fence closer: matching character, length ≥ opener, trailing space/tabs only. */
46
+ const FENCE_CLOSE_REGEX = /^ {0,3}(`{3,}|~{3,})[\t ]*$/u;
47
47
 
48
48
  /** HTML comments */
49
49
  const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
50
50
 
51
+ interface OpenFence {
52
+ marker: "`" | "~";
53
+ length: number;
54
+ start: number;
55
+ }
56
+
57
+ /**
58
+ * Collect CommonMark fenced code ranges (backtick and tilde). A closer must
59
+ * use the same character and be at least as long as the opener; when omitted,
60
+ * CommonMark extends the fenced block through end of input.
61
+ */
62
+ const collectFencedCodeRanges = (markdown: string): ExcludedRange[] => {
63
+ const ranges: ExcludedRange[] = [];
64
+ let offset = 0;
65
+ let open: OpenFence | null = null;
66
+
67
+ while (offset <= markdown.length) {
68
+ const nextNl = markdown.indexOf("\n", offset);
69
+ const lineEnd = nextNl === -1 ? markdown.length : nextNl;
70
+ const rawLine = markdown.slice(offset, lineEnd);
71
+ const logical = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
72
+
73
+ if (open) {
74
+ const closeRun = FENCE_CLOSE_REGEX.exec(logical)?.[1];
75
+ if (
76
+ closeRun &&
77
+ closeRun[0] === open.marker &&
78
+ closeRun.length >= open.length
79
+ ) {
80
+ const end = nextNl === -1 ? markdown.length : nextNl + 1;
81
+ ranges.push({ start: open.start, end, kind: "fenced_code" });
82
+ open = null;
83
+ }
84
+ } else {
85
+ const openMatch = FENCE_OPEN_REGEX.exec(logical);
86
+ const run = openMatch?.[1];
87
+ const suffix = openMatch?.[2] ?? "";
88
+ // Backtick info strings cannot contain backticks (CommonMark).
89
+ if (run && !(run[0] === "`" && suffix.includes("`"))) {
90
+ open = {
91
+ marker: run[0] as OpenFence["marker"],
92
+ length: run.length,
93
+ start: offset,
94
+ };
95
+ }
96
+ }
97
+
98
+ if (nextNl === -1) break;
99
+ offset = nextNl + 1;
100
+ }
101
+
102
+ if (open) {
103
+ ranges.push({
104
+ start: open.start,
105
+ end: markdown.length,
106
+ kind: "fenced_code",
107
+ });
108
+ }
109
+
110
+ return ranges;
111
+ };
112
+
113
+ interface BacktickRun {
114
+ end: number;
115
+ length: number;
116
+ start: number;
117
+ }
118
+
119
+ /** CommonMark code spans close only on a backtick run of equal length. */
120
+ const collectInlineCodeRanges = (
121
+ markdown: string,
122
+ excludedRanges: ExcludedRange[]
123
+ ): ExcludedRange[] => {
124
+ const runs: BacktickRun[] = [];
125
+ let cursor = 0;
126
+ let excludedIndex = 0;
127
+ while (cursor < markdown.length) {
128
+ while (
129
+ excludedRanges[excludedIndex] &&
130
+ excludedRanges[excludedIndex]!.end <= cursor
131
+ ) {
132
+ excludedIndex += 1;
133
+ }
134
+ const excluded = excludedRanges[excludedIndex];
135
+ if (excluded && cursor >= excluded.start && cursor < excluded.end) {
136
+ cursor = excluded.end;
137
+ continue;
138
+ }
139
+ if (markdown[cursor] !== "`") {
140
+ cursor += 1;
141
+ continue;
142
+ }
143
+ const start = cursor;
144
+ while (markdown[cursor] === "`") cursor += 1;
145
+ let backslashes = 0;
146
+ for (let i = start - 1; i >= 0 && markdown[i] === "\\"; i -= 1) {
147
+ backslashes += 1;
148
+ }
149
+ if (backslashes % 2 === 0) {
150
+ runs.push({ start, end: cursor, length: cursor - start });
151
+ }
152
+ }
153
+
154
+ const nextMatchingRun = Array.from<number | undefined>({
155
+ length: runs.length,
156
+ });
157
+ const latestByLength = new Map<number, number>();
158
+ for (let index = runs.length - 1; index >= 0; index -= 1) {
159
+ const run = runs[index];
160
+ if (!run) continue;
161
+ nextMatchingRun[index] = latestByLength.get(run.length);
162
+ latestByLength.set(run.length, index);
163
+ }
164
+
165
+ const ranges: ExcludedRange[] = [];
166
+ let index = 0;
167
+ while (index < runs.length) {
168
+ const closeIndex = nextMatchingRun[index];
169
+ const opener = runs[index];
170
+ if (closeIndex === undefined || !opener) {
171
+ index += 1;
172
+ continue;
173
+ }
174
+ const closer = runs[closeIndex];
175
+ if (!closer) {
176
+ index += 1;
177
+ continue;
178
+ }
179
+ ranges.push({
180
+ start: opener.start,
181
+ end: closer.end,
182
+ kind: "inline_code",
183
+ });
184
+ index = closeIndex + 1;
185
+ }
186
+ return ranges;
187
+ };
188
+
51
189
  // ─────────────────────────────────────────────────────────────────────────────
52
190
  // Main Functions
53
191
  // ─────────────────────────────────────────────────────────────────────────────
@@ -70,29 +208,12 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
70
208
  });
71
209
  }
72
210
 
73
- // 2. Fenced code blocks
74
- FENCED_CODE_REGEX.lastIndex = 0;
75
- let match: RegExpExecArray | null;
76
- while ((match = FENCED_CODE_REGEX.exec(markdown)) !== null) {
77
- ranges.push({
78
- start: match.index,
79
- end: match.index + match[0].length,
80
- kind: "fenced_code",
81
- });
82
- }
83
-
84
- // 3. Inline code
85
- INLINE_CODE_REGEX.lastIndex = 0;
86
- while ((match = INLINE_CODE_REGEX.exec(markdown)) !== null) {
87
- ranges.push({
88
- start: match.index,
89
- end: match.index + match[0].length,
90
- kind: "inline_code",
91
- });
92
- }
211
+ // 2. Fenced code blocks (backtick + tilde, CommonMark matching rules)
212
+ ranges.push(...collectFencedCodeRanges(markdown));
93
213
 
94
- // 4. HTML comments
214
+ // 3. HTML comments
95
215
  HTML_COMMENT_REGEX.lastIndex = 0;
216
+ let match: RegExpExecArray | null;
96
217
  while ((match = HTML_COMMENT_REGEX.exec(markdown)) !== null) {
97
218
  ranges.push({
98
219
  start: match.index,
@@ -101,6 +222,11 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
101
222
  });
102
223
  }
103
224
 
225
+ // 4. Inline code. Pair delimiters only in visible prose so unmatched
226
+ // backticks inside already-excluded blocks cannot consume later content.
227
+ ranges.sort((a, b) => a.start - b.start);
228
+ ranges.push(...collectInlineCodeRanges(markdown, ranges));
229
+
104
230
  // Sort by start position for efficient lookup
105
231
  ranges.sort((a, b) => a.start - b.start);
106
232
 
@@ -95,6 +95,10 @@ import {
95
95
  handleDuplicateNote,
96
96
  handleMoveNote,
97
97
  handleRenameNote,
98
+ MOVE_NOTE_MCP_ANNOTATIONS,
99
+ moveNoteInputSchema,
100
+ RENAME_NOTE_MCP_ANNOTATIONS,
101
+ renameNoteInputSchema,
98
102
  } from "./workspace-write";
99
103
 
100
104
  // ─────────────────────────────────────────────────────────────────────────────
@@ -398,17 +402,6 @@ const createFolderInputSchema = z.object({
398
402
  parentPath: z.string().optional(),
399
403
  });
400
404
 
401
- const renameNoteInputSchema = z.object({
402
- ref: z.string().min(1, "ref cannot be empty"),
403
- name: z.string().min(1, "name cannot be empty"),
404
- });
405
-
406
- const moveNoteInputSchema = z.object({
407
- ref: z.string().min(1, "ref cannot be empty"),
408
- folderPath: z.string().min(1, "folderPath cannot be empty"),
409
- name: z.string().optional(),
410
- });
411
-
412
405
  const duplicateNoteInputSchema = z.object({
413
406
  ref: z.string().min(1, "ref cannot be empty"),
414
407
  folderPath: z.string().optional(),
@@ -1351,17 +1344,25 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
1351
1344
  (args) => handleCreateFolder(args, ctx)
1352
1345
  );
1353
1346
 
1354
- server.tool(
1347
+ server.registerTool(
1355
1348
  "gno_rename_note",
1356
- "Rename an editable note in place.",
1357
- renameNoteInputSchema.shape,
1349
+ {
1350
+ description:
1351
+ 'Preview or apply a reference-safe rename of an editable note. Use action=preview first, then action=apply with the exact planDigest, confirmation="apply", confirm=true, and schemaVersion="1.0". Annotations are hints only; --enable-write remains authoritative.',
1352
+ inputSchema: renameNoteInputSchema,
1353
+ annotations: RENAME_NOTE_MCP_ANNOTATIONS,
1354
+ },
1358
1355
  (args) => handleRenameNote(args, ctx)
1359
1356
  );
1360
1357
 
1361
- server.tool(
1358
+ server.registerTool(
1362
1359
  "gno_move_note",
1363
- "Move an editable note to another folder in the same collection.",
1364
- moveNoteInputSchema.shape,
1360
+ {
1361
+ description:
1362
+ 'Preview or apply a reference-safe same-collection move of an editable note. Use action=preview first, then action=apply with the exact planDigest, confirmation="apply", confirm=true, and schemaVersion="1.0". Annotations are hints only; --enable-write remains authoritative.',
1363
+ inputSchema: moveNoteInputSchema,
1364
+ annotations: MOVE_NOTE_MCP_ANNOTATIONS,
1365
+ },
1365
1366
  (args) => handleMoveNote(args, ctx)
1366
1367
  );
1367
1368