@magic-markdown/cli 0.3.0 → 0.3.5

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 (3) hide show
  1. package/README.md +6 -2
  2. package/dist/index.js +1430 -156
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -321,6 +321,17 @@ function fnv1a(value) {
321
321
  return hash;
322
322
  }
323
323
 
324
+ // ../core/src/wikilinks.ts
325
+ function parseWikilinkBody(value) {
326
+ const separatorIndex = value.indexOf("|");
327
+ const rawTarget = separatorIndex >= 0 ? value.slice(0, separatorIndex) : value;
328
+ const rawLabel = separatorIndex >= 0 ? value.slice(separatorIndex + 1) : void 0;
329
+ const target = rawTarget.trim();
330
+ if (!target) return void 0;
331
+ const label = rawLabel?.trim();
332
+ return label ? { target, label } : { target };
333
+ }
334
+
324
335
  // ../core/src/markdown.ts
325
336
  var POLLUTING_PATTERNS = [
326
337
  { name: "CriticMarkup addition", pattern: /\{\+\+[\s\S]*?\+\+\}/ },
@@ -381,9 +392,16 @@ function replaceLineRange(markdown, range, replacement) {
381
392
  const lines = getLines(markdown);
382
393
  const before = lines.slice(0, range.startLine - 1);
383
394
  const after = lines.slice(range.endLine);
384
- const replacementLines = replacement.length === 0 ? [] : replacement.split(/\r?\n/);
395
+ const replacementLines = replacementLinesForRange(replacement);
385
396
  return [...before, ...replacementLines, ...after].join("\n");
386
397
  }
398
+ function replacementLinesForRange(replacement) {
399
+ if (replacement.length === 0) return [];
400
+ const normalized = replacement.replace(/\r\n?/g, "\n");
401
+ const lines = normalized.split("\n");
402
+ if (normalized.endsWith("\n")) lines.pop();
403
+ return lines;
404
+ }
387
405
  function extractMarkdownImages(markdown, documentPath) {
388
406
  const masked = maskCode(markdown);
389
407
  const referenceDefinitions = collectReferenceDefinitions(markdown, masked);
@@ -469,6 +487,86 @@ function extractMarkdownImages(markdown, documentPath) {
469
487
  }
470
488
  return images;
471
489
  }
490
+ function extractMarkdownLinks(markdown) {
491
+ const codeMasked = maskCode(markdown);
492
+ const referenceDefinitions = collectReferenceDefinitions(markdown, codeMasked);
493
+ const masked = maskReferenceDefinitions(markdown, codeMasked);
494
+ const lineStarts = getLineStarts(markdown);
495
+ const links = [];
496
+ let index = 0;
497
+ while (index < masked.length) {
498
+ const start = masked.indexOf("[", index);
499
+ if (start === -1) break;
500
+ if (masked[start + 1] === "[" || markdown[start - 1] === "!" || isEscaped(markdown, start)) {
501
+ index = start + 1;
502
+ continue;
503
+ }
504
+ const labelClose = findClosingBracket(masked, start);
505
+ if (labelClose === -1) {
506
+ index = start + 1;
507
+ continue;
508
+ }
509
+ const label = markdown.slice(start + 1, labelClose);
510
+ const afterLabel = labelClose + 1;
511
+ const position = positionAt(lineStarts, start);
512
+ if (masked[afterLabel] === "(") {
513
+ const destinationClose = findClosingParen(markdown, afterLabel + 1);
514
+ if (destinationClose === -1) {
515
+ index = afterLabel + 1;
516
+ continue;
517
+ }
518
+ const parsed = parseDestinationAndTitle(markdown.slice(afterLabel + 1, destinationClose));
519
+ if (parsed) {
520
+ links.push(toLinkReference({
521
+ label,
522
+ target: parsed.source,
523
+ title: parsed.title,
524
+ line: position.line,
525
+ column: position.column,
526
+ syntax: "inline"
527
+ }));
528
+ }
529
+ index = destinationClose + 1;
530
+ continue;
531
+ }
532
+ if (masked[afterLabel] === "[") {
533
+ const referenceClose = findClosingBracket(masked, afterLabel);
534
+ if (referenceClose === -1) {
535
+ index = afterLabel + 1;
536
+ continue;
537
+ }
538
+ const explicitLabel = markdown.slice(afterLabel + 1, referenceClose);
539
+ const definitionLabel = explicitLabel.length > 0 ? explicitLabel : label;
540
+ const definition = referenceDefinitions.get(normalizeReferenceLabel(definitionLabel));
541
+ if (definition) {
542
+ links.push(toLinkReference({
543
+ label,
544
+ target: definition.source,
545
+ title: definition.title,
546
+ line: position.line,
547
+ column: position.column,
548
+ syntax: explicitLabel.length > 0 ? "reference" : "collapsed_reference"
549
+ }));
550
+ }
551
+ index = referenceClose + 1;
552
+ continue;
553
+ }
554
+ const shortcutDefinition = referenceDefinitions.get(normalizeReferenceLabel(label));
555
+ if (shortcutDefinition) {
556
+ links.push(toLinkReference({
557
+ label,
558
+ target: shortcutDefinition.source,
559
+ title: shortcutDefinition.title,
560
+ line: position.line,
561
+ column: position.column,
562
+ syntax: "shortcut_reference"
563
+ }));
564
+ }
565
+ index = labelClose + 1;
566
+ }
567
+ extractWikilinks(markdown, masked, lineStarts).forEach((link2) => links.push(link2));
568
+ return links.sort((left, right) => left.line - right.line || left.column - right.column);
569
+ }
472
570
  function toImageReference(image2) {
473
571
  const workspacePath = resolveWorkspaceImagePath(image2.source, image2.documentPath);
474
572
  return {
@@ -483,6 +581,54 @@ function toImageReference(image2) {
483
581
  ...workspacePath ? { workspacePath } : {}
484
582
  };
485
583
  }
584
+ function toLinkReference(link2) {
585
+ return {
586
+ label: unescapeMarkdown(link2.label),
587
+ target: link2.target,
588
+ ...link2.title ? { title: link2.title } : {},
589
+ line: link2.line,
590
+ column: link2.column,
591
+ syntax: link2.syntax
592
+ };
593
+ }
594
+ function extractWikilinks(markdown, masked, lineStarts) {
595
+ const links = [];
596
+ let index = 0;
597
+ while (index < masked.length) {
598
+ const start = masked.indexOf("[[", index);
599
+ if (start === -1) break;
600
+ if (isEscaped(markdown, start)) {
601
+ index = start + 2;
602
+ continue;
603
+ }
604
+ const close2 = masked.indexOf("]]", start + 2);
605
+ if (close2 === -1 || markdown.slice(start + 2, close2).includes("\n")) {
606
+ index = start + 2;
607
+ continue;
608
+ }
609
+ const parsed = parseWikilinkBody(markdown.slice(start + 2, close2));
610
+ if (parsed) {
611
+ const position = positionAt(lineStarts, start);
612
+ links.push({
613
+ label: parsed.label ?? parsed.target,
614
+ target: parsed.target,
615
+ line: position.line,
616
+ column: position.column,
617
+ syntax: "wikilink"
618
+ });
619
+ }
620
+ index = close2 + 2;
621
+ }
622
+ return links;
623
+ }
624
+ function maskReferenceDefinitions(markdown, masked) {
625
+ const markdownLines = markdown.split(/(?<=\n)/);
626
+ const maskedLines = masked.split(/(?<=\n)/);
627
+ return maskedLines.map((line, index) => {
628
+ const original = markdownLines[index] ?? "";
629
+ return parseReferenceDefinition(original.replace(/\r?\n$/, "")) ? maskLine(line) : line;
630
+ }).join("");
631
+ }
486
632
  function collectReferenceDefinitions(markdown, masked) {
487
633
  const definitions = /* @__PURE__ */ new Map();
488
634
  const lines = getLines(markdown);
@@ -818,8 +964,129 @@ function scoreContext(lines, startLine, endLine, selector) {
818
964
  return Math.min(1, score);
819
965
  }
820
966
 
967
+ // ../core/src/text-merge.ts
968
+ var MAX_DIFF_CELLS = 25e6;
969
+ function mergeText(base, ours, theirs) {
970
+ if (ours === theirs) return { ok: true, text: ours };
971
+ if (base === ours) return { ok: true, text: theirs };
972
+ if (base === theirs) return { ok: true, text: ours };
973
+ const baseLines = base.split("\n");
974
+ const ourLines = ours.split("\n");
975
+ const theirLines = theirs.split("\n");
976
+ let regionsA;
977
+ let regionsB;
978
+ try {
979
+ regionsA = diffTextRegions(baseLines, ourLines);
980
+ regionsB = diffTextRegions(baseLines, theirLines);
981
+ } catch {
982
+ return { ok: false, reason: "too_large" };
983
+ }
984
+ const clusters = clusterRegions(regionsA, regionsB);
985
+ const merged = [];
986
+ let baseCursor = 0;
987
+ for (const cluster of clusters) {
988
+ for (let line = baseCursor; line < cluster.lo; line += 1) merged.push(baseLines[line]);
989
+ const baseText = baseLines.slice(cluster.lo, cluster.hi);
990
+ const ourText = sideSlice(ourLines, regionsA, cluster.lo, cluster.hi);
991
+ const theirText = sideSlice(theirLines, regionsB, cluster.lo, cluster.hi);
992
+ const oursChanged = !linesEqual(ourText, baseText);
993
+ const theirsChanged = !linesEqual(theirText, baseText);
994
+ if (oursChanged && theirsChanged && !linesEqual(ourText, theirText)) {
995
+ return { ok: false, reason: "overlapping_changes" };
996
+ }
997
+ merged.push(...oursChanged ? ourText : theirText);
998
+ baseCursor = cluster.hi;
999
+ }
1000
+ for (let line = baseCursor; line < baseLines.length; line += 1) merged.push(baseLines[line]);
1001
+ return { ok: true, text: merged.join("\n") };
1002
+ }
1003
+ function diffTextRegions(base, side) {
1004
+ let prefix = 0;
1005
+ while (prefix < base.length && prefix < side.length && base[prefix] === side[prefix]) prefix += 1;
1006
+ let suffix = 0;
1007
+ while (suffix < base.length - prefix && suffix < side.length - prefix && base[base.length - 1 - suffix] === side[side.length - 1 - suffix]) {
1008
+ suffix += 1;
1009
+ }
1010
+ const baseCore = base.slice(prefix, base.length - suffix);
1011
+ const sideCore = side.slice(prefix, side.length - suffix);
1012
+ if (!baseCore.length && !sideCore.length) return [];
1013
+ if ((baseCore.length + 1) * (sideCore.length + 1) > MAX_DIFF_CELLS) throw new Error("diff too large");
1014
+ const rows = baseCore.length + 1;
1015
+ const cols = sideCore.length + 1;
1016
+ const lcs = new Uint32Array(rows * cols);
1017
+ for (let row2 = baseCore.length - 1; row2 >= 0; row2 -= 1) {
1018
+ for (let col2 = sideCore.length - 1; col2 >= 0; col2 -= 1) {
1019
+ lcs[row2 * cols + col2] = baseCore[row2] === sideCore[col2] ? lcs[(row2 + 1) * cols + col2 + 1] + 1 : Math.max(lcs[(row2 + 1) * cols + col2], lcs[row2 * cols + col2 + 1]);
1020
+ }
1021
+ }
1022
+ const regions = [];
1023
+ let row = 0;
1024
+ let col = 0;
1025
+ let pendingBase = -1;
1026
+ let pendingSide = -1;
1027
+ const flush = (baseEnd, sideEnd) => {
1028
+ if (pendingBase < 0) return;
1029
+ regions.push({
1030
+ baseStart: prefix + pendingBase,
1031
+ baseLength: baseEnd - pendingBase,
1032
+ sideStart: prefix + pendingSide,
1033
+ sideLength: sideEnd - pendingSide
1034
+ });
1035
+ pendingBase = -1;
1036
+ pendingSide = -1;
1037
+ };
1038
+ while (row < baseCore.length || col < sideCore.length) {
1039
+ if (row < baseCore.length && col < sideCore.length && baseCore[row] === sideCore[col]) {
1040
+ flush(row, col);
1041
+ row += 1;
1042
+ col += 1;
1043
+ } else {
1044
+ if (pendingBase < 0) {
1045
+ pendingBase = row;
1046
+ pendingSide = col;
1047
+ }
1048
+ if (col >= sideCore.length || row < baseCore.length && lcs[(row + 1) * cols + col] >= lcs[row * cols + col + 1]) {
1049
+ row += 1;
1050
+ } else {
1051
+ col += 1;
1052
+ }
1053
+ }
1054
+ }
1055
+ flush(row, col);
1056
+ return regions;
1057
+ }
1058
+ function clusterRegions(regionsA, regionsB) {
1059
+ const spans = [...regionsA, ...regionsB].map((region) => ({ lo: region.baseStart, hi: region.baseStart + region.baseLength })).sort((left, right) => left.lo - right.lo || left.hi - right.hi);
1060
+ const clusters = [];
1061
+ for (const span of spans) {
1062
+ const last = clusters.at(-1);
1063
+ if (last && span.lo <= last.hi) {
1064
+ last.hi = Math.max(last.hi, span.hi);
1065
+ } else {
1066
+ clusters.push({ lo: span.lo, hi: span.hi });
1067
+ }
1068
+ }
1069
+ return clusters;
1070
+ }
1071
+ function sideSlice(side, regions, lo, hi) {
1072
+ return side.slice(lo + sideDeltaBefore(regions, lo, false), hi + sideDeltaBefore(regions, hi, true));
1073
+ }
1074
+ function sideDeltaBefore(regions, basePos, includeInsertionAt) {
1075
+ let delta = 0;
1076
+ for (const region of regions) {
1077
+ const end = region.baseStart + region.baseLength;
1078
+ if (end > basePos) break;
1079
+ if (end === basePos && region.baseLength === 0 && !includeInsertionAt) break;
1080
+ delta += region.sideLength - region.baseLength;
1081
+ }
1082
+ return delta;
1083
+ }
1084
+ function linesEqual(left, right) {
1085
+ return left.length === right.length && left.every((line, index) => line === right[index]);
1086
+ }
1087
+
821
1088
  // ../core/src/patches.ts
822
- function applySuggestion(markdown, suggestion) {
1089
+ function applySuggestion(markdown, suggestion, input) {
823
1090
  if (suggestion.status !== "open") {
824
1091
  throw new MdocsError(
825
1092
  "validation_error",
@@ -827,18 +1094,308 @@ function applySuggestion(markdown, suggestion) {
827
1094
  "Only open suggestions can be applied. List open suggestions with the suggestions command."
828
1095
  );
829
1096
  }
830
- const current = extractLineRange(markdown, suggestion.patch.range);
831
- if (current !== suggestion.patch.before) {
1097
+ const context = applySuggestionContext(input);
1098
+ const baseResult = applySuggestionFromBase(markdown, suggestion, context.baseMarkdown);
1099
+ if (baseResult.kind === "applied") return baseResult.markdown;
1100
+ if (baseResult.kind === "conflict") {
1101
+ throw new MdocsError(
1102
+ "conflict",
1103
+ `Suggestion ${suggestion.id} no longer applies cleanly: the document changed inside the suggested edit.`,
1104
+ "Review the current text around the suggestion, then accept it manually or ask the agent for a fresh suggestion."
1105
+ );
1106
+ }
1107
+ const range = currentSuggestionRange(markdown, suggestion, context.anchor);
1108
+ if (!range) {
832
1109
  throw new MdocsError(
833
1110
  "conflict",
834
1111
  `Suggestion ${suggestion.id} no longer applies cleanly: the document changed since it was created.`,
835
1112
  "Re-read the document, then create a fresh suggestion against the current content."
836
1113
  );
837
1114
  }
838
- const next = replaceLineRange(markdown, suggestion.patch.range, suggestion.patch.after);
1115
+ const next = replaceLineRange(markdown, range, suggestion.patch.after);
1116
+ assertCleanMarkdown(next);
1117
+ return next;
1118
+ }
1119
+ function applySuggestionContext(input) {
1120
+ if (!input) return {};
1121
+ if ("selector" in input) return { anchor: input };
1122
+ return input;
1123
+ }
1124
+ function applySuggestionFromBase(markdown, suggestion, baseMarkdown) {
1125
+ if (!baseMarkdown || !suggestion.base?.contentHash) return { kind: "unavailable" };
1126
+ if (contentHashForText(baseMarkdown) !== suggestion.base.contentHash) return { kind: "unavailable" };
1127
+ if (!rangeIsWithin(baseMarkdown, suggestion.patch.range) || extractLineRange(baseMarkdown, suggestion.patch.range) !== suggestion.patch.before) {
1128
+ return { kind: "conflict" };
1129
+ }
1130
+ const rebased = rebaseSuggestionPatch(baseMarkdown, markdown, suggestion);
1131
+ if (rebased !== void 0) return { kind: "applied", markdown: rebased };
1132
+ const suggestedMarkdown = replaceLineRange(baseMarkdown, suggestion.patch.range, suggestion.patch.after);
1133
+ assertCleanMarkdown(suggestedMarkdown);
1134
+ const merged = mergeText(baseMarkdown, markdown, suggestedMarkdown);
1135
+ if (!merged.ok) return { kind: "conflict" };
1136
+ assertCleanMarkdown(merged.text);
1137
+ return { kind: "applied", markdown: merged.text };
1138
+ }
1139
+ function rebaseSuggestionPatch(baseMarkdown, markdown, suggestion) {
1140
+ const baseLines = getLines(baseMarkdown);
1141
+ const currentLines = getLines(markdown);
1142
+ let regions;
1143
+ try {
1144
+ regions = diffTextRegions(baseLines, currentLines);
1145
+ } catch {
1146
+ return void 0;
1147
+ }
1148
+ const baseStart = suggestion.patch.range.startLine - 1;
1149
+ const baseEnd = suggestion.patch.range.endLine;
1150
+ if (regions.some((region) => regionTouchesSuggestionRange(region, baseStart, baseEnd))) return void 0;
1151
+ const currentStart = baseStart + sideDeltaBefore(regions, baseStart, true);
1152
+ const currentEnd = baseEnd + sideDeltaBefore(regions, baseEnd, false);
1153
+ const currentRange = { startLine: currentStart + 1, endLine: currentEnd };
1154
+ if (!rangeIsWithin(markdown, currentRange)) return void 0;
1155
+ if (extractLineRange(markdown, currentRange) !== suggestion.patch.before) return void 0;
1156
+ const next = replaceLineRange(markdown, currentRange, suggestion.patch.after);
839
1157
  assertCleanMarkdown(next);
840
1158
  return next;
841
1159
  }
1160
+ function regionTouchesSuggestionRange(region, baseStart, baseEnd) {
1161
+ if (region.baseLength === 0) return region.baseStart > baseStart && region.baseStart < baseEnd;
1162
+ const regionEnd = region.baseStart + region.baseLength;
1163
+ return region.baseStart < baseEnd && regionEnd > baseStart;
1164
+ }
1165
+ function currentSuggestionRange(markdown, suggestion, anchor) {
1166
+ const hintedRange = suggestion.patch.range;
1167
+ if (rangeIsWithin(markdown, hintedRange) && extractLineRange(markdown, hintedRange) === suggestion.patch.before) return hintedRange;
1168
+ const candidates = findBeforeCandidates(markdown, suggestion.patch.before);
1169
+ if (candidates.length === 0) return void 0;
1170
+ if (candidates.length === 1) return candidates[0];
1171
+ if (!anchor) return void 0;
1172
+ const scored = candidates.map((range) => ({ range, score: scoreContext2(markdown, range, anchor) })).sort((left, right) => right.score - left.score);
1173
+ const best = scored[0];
1174
+ if (!best) return void 0;
1175
+ const tied = scored.filter((candidate) => candidate.score === best.score);
1176
+ if (tied.length > 1) return void 0;
1177
+ if (best.score < 0.7) return void 0;
1178
+ return best.range;
1179
+ }
1180
+ function findBeforeCandidates(markdown, before) {
1181
+ const lines = getLines(markdown);
1182
+ const beforeLines = linePattern(before);
1183
+ if (beforeLines.length === 0 || beforeLines.length > lines.length) return [];
1184
+ const ranges = [];
1185
+ const beforeText = beforeLines.join("\n");
1186
+ for (let index = 0; index <= lines.length - beforeLines.length; index += 1) {
1187
+ if (lines.slice(index, index + beforeLines.length).join("\n") === beforeText) {
1188
+ ranges.push({ startLine: index + 1, endLine: index + beforeLines.length });
1189
+ }
1190
+ }
1191
+ return ranges;
1192
+ }
1193
+ function rangeIsWithin(markdown, range) {
1194
+ const lineCount = getLines(markdown).length;
1195
+ return range.startLine >= 1 && range.endLine >= range.startLine && range.endLine <= lineCount;
1196
+ }
1197
+ function linePattern(value) {
1198
+ return value.replace(/\r\n?/g, "\n").split("\n");
1199
+ }
1200
+ function scoreContext2(markdown, range, anchor) {
1201
+ const lines = getLines(markdown);
1202
+ let score = anchor.selector.quote === extractLineRange(markdown, range) ? 0.7 : 0.65;
1203
+ const expectedPrefix = contextPrefix(anchor.selector.prefix);
1204
+ if (expectedPrefix) {
1205
+ const actualPrefix = contextPrefix(lines.slice(Math.max(0, range.startLine - 4), range.startLine - 1).join("\n"));
1206
+ if (actualPrefix?.endsWith(expectedPrefix)) score += 0.15;
1207
+ }
1208
+ const expectedSuffix = contextSuffix(anchor.selector.suffix);
1209
+ if (expectedSuffix) {
1210
+ const actualSuffix = contextSuffix(lines.slice(range.endLine, range.endLine + 3).join("\n"));
1211
+ if (actualSuffix?.startsWith(expectedSuffix)) score += 0.15;
1212
+ }
1213
+ return Math.min(1, score);
1214
+ }
1215
+ function contextPrefix(value) {
1216
+ return value?.split(/\r?\n/).filter(Boolean).slice(-3).join("\n") || void 0;
1217
+ }
1218
+ function contextSuffix(value) {
1219
+ return value?.split(/\r?\n/).filter(Boolean).slice(0, 3).join("\n") || void 0;
1220
+ }
1221
+
1222
+ // ../core/src/graph.ts
1223
+ function resolveMarkdownLinks(links, currentDocument, documents) {
1224
+ return links.map((link2) => resolveMarkdownLink(link2, currentDocument, documents));
1225
+ }
1226
+ function buildWorkspaceGraph(workspaceId, schemaVersion, documents) {
1227
+ const edges = documents.flatMap((document) => document.links.map((link2, index) => toGraphEdge(document, link2, index)));
1228
+ const incomingByDocId = /* @__PURE__ */ new Map();
1229
+ for (const edge of edges) {
1230
+ if (!edge.targetDocId) continue;
1231
+ incomingByDocId.set(edge.targetDocId, (incomingByDocId.get(edge.targetDocId) ?? 0) + 1);
1232
+ }
1233
+ const nodes = documents.map((document) => ({
1234
+ docId: document.docId,
1235
+ path: document.path,
1236
+ title: document.title,
1237
+ openComments: document.openComments,
1238
+ openSuggestions: document.openSuggestions,
1239
+ anchorsNeedingReview: document.anchorsNeedingReview,
1240
+ outgoingLinks: document.links.length,
1241
+ incomingLinks: incomingByDocId.get(document.docId) ?? 0,
1242
+ unresolvedLinks: document.unresolvedLinks,
1243
+ externalLinks: document.externalLinks
1244
+ }));
1245
+ return {
1246
+ workspaceId,
1247
+ schemaVersion,
1248
+ nodes,
1249
+ edges
1250
+ };
1251
+ }
1252
+ function resolveMarkdownLink(link2, currentDocument, documents) {
1253
+ const target = link2.target.trim();
1254
+ const parsed = parseInternalTarget(target);
1255
+ if (!target) return { ...link2, status: "unresolved" };
1256
+ if (isExternalTarget(target)) return { ...link2, status: "external" };
1257
+ if (!parsed) return { ...link2, status: "unresolved" };
1258
+ if (!parsed.path) {
1259
+ return parsed.fragment ? {
1260
+ ...link2,
1261
+ status: "anchor",
1262
+ fragment: parsed.fragment,
1263
+ targetDocId: currentDocument.docId,
1264
+ targetPath: currentDocument.path,
1265
+ targetTitle: currentDocument.title
1266
+ } : { ...link2, status: "unresolved" };
1267
+ }
1268
+ if (link2.syntax === "wikilink" && !isPathLikeWikilinkTarget(parsed.path)) {
1269
+ const candidates = findBasenameWikilinkCandidates(parsed.path, parsed.fragment, documents);
1270
+ if (candidates.length === 1) return resolvedLink(link2, candidates[0]);
1271
+ if (candidates.length > 1) return { ...link2, status: "ambiguous", fragment: parsed.fragment, candidates };
1272
+ return { ...link2, status: "unresolved", fragment: parsed.fragment };
1273
+ }
1274
+ const linked = link2.syntax === "wikilink" ? resolvePathLikeWikilink(target, currentDocument.path, documents) : resolveWorkspaceDocumentLink(target, currentDocument.path, documents);
1275
+ return linked ? resolvedLink(link2, linked) : { ...link2, status: "unresolved", fragment: parsed.fragment };
1276
+ }
1277
+ function resolvedLink(link2, target) {
1278
+ return {
1279
+ ...link2,
1280
+ status: "resolved",
1281
+ ...target.fragment ? { fragment: target.fragment } : {},
1282
+ targetDocId: target.docId,
1283
+ targetPath: target.path,
1284
+ targetTitle: target.title
1285
+ };
1286
+ }
1287
+ function toGraphEdge(document, link2, index) {
1288
+ return {
1289
+ id: `${document.docId}:${index}`,
1290
+ sourceDocId: document.docId,
1291
+ sourcePath: document.path,
1292
+ sourceTitle: document.title,
1293
+ label: link2.label,
1294
+ target: link2.target,
1295
+ ...link2.title ? { title: link2.title } : {},
1296
+ line: link2.line,
1297
+ column: link2.column,
1298
+ syntax: link2.syntax,
1299
+ status: link2.status,
1300
+ ...link2.fragment ? { fragment: link2.fragment } : {},
1301
+ ...link2.targetDocId ? { targetDocId: link2.targetDocId } : {},
1302
+ ...link2.targetPath ? { targetPath: link2.targetPath } : {},
1303
+ ...link2.targetTitle ? { targetTitle: link2.targetTitle } : {},
1304
+ ...link2.candidates ? { candidates: link2.candidates } : {}
1305
+ };
1306
+ }
1307
+ function resolveWorkspaceDocumentLink(target, currentPath, documents) {
1308
+ const parsed = parseInternalTarget(target);
1309
+ if (!parsed || !parsed.path) return void 0;
1310
+ const workspacePath = resolveWorkspacePath(parsed.path, currentPath);
1311
+ if (!workspacePath) return void 0;
1312
+ const document = findLinkedDocument(candidateDocumentPaths(workspacePath), documents);
1313
+ return document ? { ...document, ...parsed.fragment ? { fragment: parsed.fragment } : {} } : void 0;
1314
+ }
1315
+ function resolvePathLikeWikilink(target, currentPath, documents) {
1316
+ const linked = resolveWorkspaceDocumentLink(target, currentPath, documents);
1317
+ if (linked) return linked;
1318
+ if (/^(?:\/|\.\/|\.\.\/)/.test(target)) return void 0;
1319
+ return resolveWorkspaceDocumentLink(`/${target}`, currentPath, documents);
1320
+ }
1321
+ function findBasenameWikilinkCandidates(name, fragment, documents) {
1322
+ const normalizedName = normalizeWikilinkName(name);
1323
+ return documents.filter((doc) => normalizeWikilinkName(markdownStem(doc.path)) === normalizedName).map((doc) => ({ ...doc, ...fragment ? { fragment } : {} })).sort((left, right) => left.path.localeCompare(right.path, void 0, { numeric: true, sensitivity: "base" }));
1324
+ }
1325
+ function findLinkedDocument(paths, documents) {
1326
+ for (const path of paths) {
1327
+ const document = documents.find((candidate) => candidate.path === path);
1328
+ if (document) return document;
1329
+ }
1330
+ return void 0;
1331
+ }
1332
+ function parseInternalTarget(target) {
1333
+ const trimmed = target.trim();
1334
+ if (!trimmed) return void 0;
1335
+ const hashIndex = trimmed.indexOf("#");
1336
+ const withoutFragment = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
1337
+ const fragment = hashIndex >= 0 ? safeDecode(trimmed.slice(hashIndex + 1)) : void 0;
1338
+ const queryIndex = withoutFragment.indexOf("?");
1339
+ const path = safeDecode(queryIndex >= 0 ? withoutFragment.slice(0, queryIndex) : withoutFragment);
1340
+ return { path, ...fragment ? { fragment } : {} };
1341
+ }
1342
+ function resolveWorkspacePath(linkPath, currentPath) {
1343
+ const path = linkPath.replaceAll("\\", "/").trim();
1344
+ if (!path) return void 0;
1345
+ const source = path.startsWith("/") ? path.slice(1) : `${dirname2(currentPath)}/${path}`;
1346
+ return normalizeWorkspacePath2(source);
1347
+ }
1348
+ function normalizeWorkspacePath2(path) {
1349
+ const segments = [];
1350
+ for (const segment of path.split("/")) {
1351
+ if (!segment || segment === ".") continue;
1352
+ if (segment === "..") {
1353
+ if (segments.length === 0) return void 0;
1354
+ segments.pop();
1355
+ continue;
1356
+ }
1357
+ segments.push(segment);
1358
+ }
1359
+ return segments.join("/");
1360
+ }
1361
+ function candidateDocumentPaths(path) {
1362
+ const candidates = [path];
1363
+ if (path.endsWith("/")) {
1364
+ candidates.push(`${path}README.md`, `${path}index.md`);
1365
+ } else if (!/\.[^/.]+$/.test(path)) {
1366
+ candidates.push(`${path}.md`, `${path}.mdx`, `${path}/README.md`, `${path}/index.md`);
1367
+ }
1368
+ return [...new Set(candidates)];
1369
+ }
1370
+ function isPathLikeWikilinkTarget(path) {
1371
+ return /^(?:\/|\.\/|\.\.\/)/.test(path) || path.includes("/") || path.includes("\\") || /\.(md|mdx)$/i.test(path);
1372
+ }
1373
+ function isExternalTarget(target) {
1374
+ const trimmed = target.trim();
1375
+ if (!trimmed || trimmed.startsWith("#")) return false;
1376
+ return /^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith("//") || looksLikeBareDomain(trimmed);
1377
+ }
1378
+ function looksLikeBareDomain(value) {
1379
+ return /^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(value) && !/\.(md|mdx)(?:[?#]|$)/i.test(value);
1380
+ }
1381
+ function markdownStem(path) {
1382
+ const basename3 = path.split("/").pop() ?? path;
1383
+ return basename3.replace(/\.mdx?$/i, "");
1384
+ }
1385
+ function normalizeWikilinkName(value) {
1386
+ return value.trim().toLowerCase();
1387
+ }
1388
+ function dirname2(path) {
1389
+ const index = path.lastIndexOf("/");
1390
+ return index >= 0 ? path.slice(0, index) : "";
1391
+ }
1392
+ function safeDecode(value) {
1393
+ try {
1394
+ return decodeURIComponent(value);
1395
+ } catch {
1396
+ return value;
1397
+ }
1398
+ }
842
1399
 
843
1400
  // ../core/src/sidecar.ts
844
1401
  function defaultActor(name = "mdocs") {
@@ -916,9 +1473,12 @@ async function indexWorkspace(io) {
916
1473
  }
917
1474
  async function getWorkspaceMap(io) {
918
1475
  const manifest = await indexWorkspace(io);
1476
+ const graph = await workspaceGraphForManifest(io, manifest);
1477
+ const graphNodesByDocId = new Map(graph.nodes.map((node) => [node.docId, node]));
919
1478
  const docs = await Promise.all(
920
1479
  manifest.docs.map(async (entry) => {
921
1480
  const sidecar = await readSidecar(io, entry.docId);
1481
+ const graphNode = graphNodesByDocId.get(entry.docId);
922
1482
  return {
923
1483
  docId: entry.docId,
924
1484
  path: entry.path,
@@ -926,7 +1486,11 @@ async function getWorkspaceMap(io) {
926
1486
  sidecarPath: sidecarPath(entry.docId),
927
1487
  openComments: sidecar.comments.filter((comment2) => comment2.status === "open").length,
928
1488
  openSuggestions: sidecar.suggestions.filter((suggestion) => suggestion.status === "open").length,
929
- anchorsNeedingReview: sidecar.anchors.filter((anchor) => anchor.status !== "mapped").length
1489
+ anchorsNeedingReview: sidecar.anchors.filter((anchor) => anchor.status !== "mapped").length,
1490
+ outgoingLinks: graphNode?.outgoingLinks ?? 0,
1491
+ incomingLinks: graphNode?.incomingLinks ?? 0,
1492
+ unresolvedLinks: graphNode?.unresolvedLinks ?? 0,
1493
+ externalLinks: graphNode?.externalLinks ?? 0
930
1494
  };
931
1495
  })
932
1496
  );
@@ -936,6 +1500,9 @@ async function getWorkspaceMap(io) {
936
1500
  docs
937
1501
  };
938
1502
  }
1503
+ async function getWorkspaceGraph(io) {
1504
+ return workspaceGraphForManifest(io, await indexWorkspace(io));
1505
+ }
939
1506
  async function getDocumentState(io, pathOrDocId) {
940
1507
  const manifest = await indexWorkspace(io);
941
1508
  const entry = findDoc(manifest, pathOrDocId);
@@ -964,10 +1531,31 @@ async function getDocumentState(io, pathOrDocId) {
964
1531
  confidence: anchor.confidence
965
1532
  })),
966
1533
  images: extractMarkdownImages(markdown, entry.path),
1534
+ links: resolveMarkdownLinks(extractMarkdownLinks(markdown), entry, manifest.docs),
967
1535
  openComments: nextSidecar.comments.filter((comment2) => comment2.status === "open").length,
968
1536
  openSuggestions: nextSidecar.suggestions.filter((suggestion) => suggestion.status === "open").length
969
1537
  };
970
1538
  }
1539
+ async function workspaceGraphForManifest(io, manifest) {
1540
+ const documents = await Promise.all(
1541
+ manifest.docs.map(async (entry) => {
1542
+ const [markdown, sidecar] = await Promise.all([io.readText(entry.path), readSidecar(io, entry.docId)]);
1543
+ const links = resolveMarkdownLinks(extractMarkdownLinks(markdown), entry, manifest.docs);
1544
+ return {
1545
+ docId: entry.docId,
1546
+ path: entry.path,
1547
+ title: entry.title,
1548
+ openComments: sidecar.comments.filter((comment2) => comment2.status === "open").length,
1549
+ openSuggestions: sidecar.suggestions.filter((suggestion) => suggestion.status === "open").length,
1550
+ anchorsNeedingReview: sidecar.anchors.filter((anchor) => anchor.status !== "mapped").length,
1551
+ externalLinks: links.filter((link2) => link2.status === "external").length,
1552
+ unresolvedLinks: links.filter((link2) => link2.status === "unresolved" || link2.status === "ambiguous").length,
1553
+ links
1554
+ };
1555
+ })
1556
+ );
1557
+ return buildWorkspaceGraph(manifest.workspaceId, manifest.schemaVersion, documents);
1558
+ }
971
1559
  async function addComment(io, pathOrDocId, range, body, author = defaultActor()) {
972
1560
  const state = await getDocumentState(io, pathOrDocId);
973
1561
  assertLineRangeWithin(state.markdown, range);
@@ -1001,6 +1589,8 @@ async function addComment(io, pathOrDocId, range, body, author = defaultActor())
1001
1589
  }
1002
1590
  async function addSuggestion(io, pathOrDocId, range, replacement, message, author = { id: "agent_local", kind: "agent", name: "Local Agent" }, changeSetId) {
1003
1591
  const state = await getDocumentState(io, pathOrDocId);
1592
+ const manifest = await indexWorkspace(io);
1593
+ const entry = manifest.docs.find((doc) => doc.docId === state.docId);
1004
1594
  assertLineRangeWithin(state.markdown, range);
1005
1595
  assertCleanMarkdown(replacement);
1006
1596
  const now = nowIso();
@@ -1026,6 +1616,10 @@ async function addSuggestion(io, pathOrDocId, range, replacement, message, autho
1026
1616
  before,
1027
1617
  after: replacement
1028
1618
  },
1619
+ base: {
1620
+ contentHash: contentHashForText(state.markdown),
1621
+ ...entry?.currentSha ? { head: entry.currentSha } : {}
1622
+ },
1029
1623
  changeSetId,
1030
1624
  createdAt: now,
1031
1625
  updatedAt: now
@@ -1068,7 +1662,8 @@ async function acceptSuggestion(io, suggestionId, actor = defaultActor()) {
1068
1662
  const sidecar = await readSidecar(io, entry.docId);
1069
1663
  const suggestion = sidecar.suggestions.find((candidate) => candidate.id === suggestionId);
1070
1664
  if (!suggestion) continue;
1071
- const nextMarkdown = applySuggestion(markdown, suggestion);
1665
+ const anchor = sidecar.anchors.find((candidate) => candidate.id === suggestion.anchorId);
1666
+ const nextMarkdown = applySuggestion(markdown, suggestion, anchor);
1072
1667
  await io.writeText(entry.path, nextMarkdown);
1073
1668
  return updateSuggestionStatus(io, suggestionId, "accepted", actor);
1074
1669
  }
@@ -1192,6 +1787,105 @@ function sidecarMarkdownMatchScore(sidecar, markdown) {
1192
1787
  return score;
1193
1788
  }
1194
1789
 
1790
+ // ../core/src/context.ts
1791
+ function sliceMarkdown(markdown, startLine, endLine) {
1792
+ const lines = markdown.split("\n");
1793
+ const totalLines = lines.length;
1794
+ const sl = startLine ?? 1;
1795
+ const el = endLine ?? totalLines;
1796
+ if (sl < 1 || el > totalLines || sl > el) {
1797
+ throw new MdocsError(
1798
+ "invalid_range",
1799
+ `Line range ${sl}:${el} is out of bounds for a ${totalLines}-line document.`,
1800
+ `Use startLine/endLine or --start-line/--end-line with 1-based line numbers within 1:${totalLines}.`
1801
+ );
1802
+ }
1803
+ return { markdown: lines.slice(sl - 1, el).join("\n"), totalLines, startLine: sl, endLine: el };
1804
+ }
1805
+ function summarizeDocumentContext(document) {
1806
+ const comments = openComments(document);
1807
+ const suggestions = openSuggestions(document);
1808
+ const currentSha = currentDocumentSha(document);
1809
+ return {
1810
+ docId: document.docId,
1811
+ path: document.path,
1812
+ title: document.title,
1813
+ ...currentSha ? { currentSha } : {},
1814
+ totalLines: document.markdown.split("\n").length,
1815
+ byteLength: new TextEncoder().encode(document.markdown).byteLength,
1816
+ headings: extractMarkdownHeadings(document.markdown),
1817
+ reviewCounts: {
1818
+ openComments: comments.length,
1819
+ openSuggestions: suggestions.length,
1820
+ anchorsNeedingReview: document.anchors.filter((anchor) => anchor.status !== "mapped").length
1821
+ },
1822
+ imageCounts: imageCounts(document.images),
1823
+ linkCounts: linkCounts(document.links)
1824
+ };
1825
+ }
1826
+ function reviewStateForDocument(document) {
1827
+ const currentSha = currentDocumentSha(document);
1828
+ return {
1829
+ docId: document.docId,
1830
+ path: document.path,
1831
+ title: document.title,
1832
+ ...currentSha ? { currentSha } : {},
1833
+ comments: openComments(document),
1834
+ suggestions: openSuggestions(document),
1835
+ anchors: document.anchors
1836
+ };
1837
+ }
1838
+ function currentDocumentSha(document) {
1839
+ return "currentSha" in document ? document.currentSha : void 0;
1840
+ }
1841
+ function extractMarkdownHeadings(markdown) {
1842
+ const headings = [];
1843
+ let fence2;
1844
+ const lines = markdown.split("\n");
1845
+ lines.forEach((line, index) => {
1846
+ const fenceMatch = /^( {0,3})(`{3,}|~{3,})/.exec(line);
1847
+ if (fenceMatch) {
1848
+ const marker = fenceMatch[2]?.[0];
1849
+ if (!fence2) fence2 = marker;
1850
+ else if (marker === fence2) fence2 = void 0;
1851
+ return;
1852
+ }
1853
+ if (fence2) return;
1854
+ const match2 = /^(#{1,6})[ \t]+(.+?)\s*#*\s*$/.exec(line);
1855
+ if (!match2) return;
1856
+ headings.push({
1857
+ level: match2[1].length,
1858
+ text: match2[2].trim(),
1859
+ line: index + 1
1860
+ });
1861
+ });
1862
+ return headings;
1863
+ }
1864
+ function openComments(document) {
1865
+ return document.sidecar.comments.filter((comment2) => comment2.status === "open");
1866
+ }
1867
+ function openSuggestions(document) {
1868
+ return document.sidecar.suggestions.filter((suggestion) => suggestion.status === "open");
1869
+ }
1870
+ function imageCounts(images) {
1871
+ const local = images.filter((image2) => image2.isLocal).length;
1872
+ return {
1873
+ total: images.length,
1874
+ local,
1875
+ remote: images.length - local
1876
+ };
1877
+ }
1878
+ function linkCounts(links) {
1879
+ return {
1880
+ total: links.length,
1881
+ resolved: links.filter((link2) => link2.status === "resolved").length,
1882
+ ambiguous: links.filter((link2) => link2.status === "ambiguous").length,
1883
+ unresolved: links.filter((link2) => link2.status === "unresolved").length,
1884
+ external: links.filter((link2) => link2.status === "external").length,
1885
+ anchors: links.filter((link2) => link2.status === "anchor").length
1886
+ };
1887
+ }
1888
+
1195
1889
  // ../core/src/root-policy.ts
1196
1890
  var DEFAULT_ROOT_PATH_RULES = {
1197
1891
  include: ["**/*.md", "**/*.mdx", ".mdocs/**/*.json"],
@@ -1234,7 +1928,7 @@ var DEFAULT_ROOT_PATH_RULES = {
1234
1928
  maxFileSizeBytes: 1024 * 1024
1235
1929
  };
1236
1930
  var MARKDOWN_FILE_PATTERN = /\.mdx?$/i;
1237
- function normalizeWorkspacePath2(path) {
1931
+ function normalizeWorkspacePath3(path) {
1238
1932
  const normalized = path.replaceAll("\\", "/").replace(/\/+/g, "/").trim();
1239
1933
  if (!normalized) throw new Error("Workspace path cannot be empty.");
1240
1934
  if (normalized.startsWith("/") || /^[a-zA-Z]:\//.test(normalized)) {
@@ -1247,7 +1941,7 @@ function normalizeWorkspacePath2(path) {
1247
1941
  return parts.filter((part) => part && part !== ".").join("/");
1248
1942
  }
1249
1943
  function assertWorkspacePathAllowed(path, rules = DEFAULT_ROOT_PATH_RULES, sizeBytes, ignoreMatcher) {
1250
- const normalized = normalizeWorkspacePath2(path);
1944
+ const normalized = normalizeWorkspacePath3(path);
1251
1945
  if (isIgnoredWorkspacePath(normalized, rules)) {
1252
1946
  throw new Error(`Workspace path is ignored by root policy: ${normalized}`);
1253
1947
  }
@@ -1278,7 +1972,7 @@ function isWorkspaceMarkdownPathAllowed(path, rules = DEFAULT_ROOT_PATH_RULES, s
1278
1972
  function createWorkspaceIgnoreMatcher(ignoreFiles) {
1279
1973
  const rules = parseWorkspaceIgnoreRules(ignoreFiles);
1280
1974
  return (path) => {
1281
- const normalized = normalizeWorkspacePath2(path);
1975
+ const normalized = normalizeWorkspacePath3(path);
1282
1976
  let ignored = false;
1283
1977
  for (const rule of rules) {
1284
1978
  if (!matchesWorkspaceIgnoreRule(normalized, rule)) continue;
@@ -1288,7 +1982,7 @@ function createWorkspaceIgnoreMatcher(ignoreFiles) {
1288
1982
  };
1289
1983
  }
1290
1984
  function isIgnoredWorkspacePath(path, rules = DEFAULT_ROOT_PATH_RULES) {
1291
- const normalized = normalizeWorkspacePath2(path);
1985
+ const normalized = normalizeWorkspacePath3(path);
1292
1986
  const parts = normalized.split("/");
1293
1987
  return parts.some((part) => rules.ignoredDirectories.includes(part));
1294
1988
  }
@@ -1308,8 +2002,8 @@ function matchesAny(path, patterns) {
1308
2002
  return patterns.some((pattern) => globToRegExp(pattern).test(path));
1309
2003
  }
1310
2004
  function parseWorkspaceIgnoreRules(ignoreFiles) {
1311
- return [...ignoreFiles].sort((left, right) => normalizeWorkspacePath2(left.path).localeCompare(normalizeWorkspacePath2(right.path))).flatMap((file) => {
1312
- const normalizedPath = normalizeWorkspacePath2(file.path);
2005
+ return [...ignoreFiles].sort((left, right) => normalizeWorkspacePath3(left.path).localeCompare(normalizeWorkspacePath3(right.path))).flatMap((file) => {
2006
+ const normalizedPath = normalizeWorkspacePath3(file.path);
1313
2007
  const basePath = normalizedPath.endsWith("/.gitignore") ? normalizedPath.slice(0, -"/.gitignore".length) : "";
1314
2008
  return file.content.split(/\r?\n/).flatMap((line) => {
1315
2009
  const rule = parseWorkspaceIgnoreRule(basePath, line);
@@ -1416,14 +2110,14 @@ function createSourceMapping(input, updatedAt = (/* @__PURE__ */ new Date()).toI
1416
2110
  };
1417
2111
  }
1418
2112
  function canonicalPathToReplicaPath(path, mapping) {
1419
- if (path === ".mdocs" || path.startsWith(".mdocs/")) return normalizeWorkspacePath2(path);
1420
- const canonicalPath = normalizeWorkspacePath2(path);
2113
+ if (path === ".mdocs" || path.startsWith(".mdocs/")) return normalizeWorkspacePath3(path);
2114
+ const canonicalPath = normalizeWorkspacePath3(path);
1421
2115
  const relativePath = stripPrefix(canonicalPath, normalizeOptionalPrefix(mapping.canonicalPrefix), "canonical");
1422
2116
  return joinWorkspacePath(normalizeOptionalPrefix(mapping.replicaPrefix), relativePath);
1423
2117
  }
1424
2118
  function replicaPathToCanonicalPath(path, mapping) {
1425
- if (path === ".mdocs" || path.startsWith(".mdocs/")) return normalizeWorkspacePath2(path);
1426
- const replicaPath = normalizeWorkspacePath2(path);
2119
+ if (path === ".mdocs" || path.startsWith(".mdocs/")) return normalizeWorkspacePath3(path);
2120
+ const replicaPath = normalizeWorkspacePath3(path);
1427
2121
  const relativePath = stripPrefix(replicaPath, normalizeOptionalPrefix(mapping.replicaPrefix), "replica");
1428
2122
  return joinWorkspacePath(normalizeOptionalPrefix(mapping.canonicalPrefix), relativePath);
1429
2123
  }
@@ -1438,7 +2132,7 @@ function sourceMappingPathRules(mapping, base) {
1438
2132
  function normalizeOptionalPrefix(prefix) {
1439
2133
  const value = prefix?.trim();
1440
2134
  if (!value || value === ".") return "";
1441
- return normalizeWorkspacePath2(value).replace(/\/+$/, "");
2135
+ return normalizeWorkspacePath3(value).replace(/\/+$/, "");
1442
2136
  }
1443
2137
  function stripPrefix(path, prefix, label) {
1444
2138
  if (!prefix) return path;
@@ -1447,21 +2141,27 @@ function stripPrefix(path, prefix, label) {
1447
2141
  throw new Error(`Path ${path} is outside ${label} prefix ${prefix}`);
1448
2142
  }
1449
2143
  function joinWorkspacePath(prefix, path) {
1450
- if (!prefix) return normalizeWorkspacePath2(path);
2144
+ if (!prefix) return normalizeWorkspacePath3(path);
1451
2145
  if (!path) return prefix;
1452
- return normalizeWorkspacePath2(`${prefix}/${path}`);
2146
+ return normalizeWorkspacePath3(`${prefix}/${path}`);
1453
2147
  }
1454
2148
 
1455
2149
  // ../core/src/share-routes.ts
1456
2150
  function parseSharePath(pathname) {
1457
- const [, workspaceId, rootId, docId] = pathname.match(/^\/share\/([^/]+)\/([^/]+)(?:\/([^/]+))?\/?$/) ?? [];
1458
- if (!workspaceId || !rootId) return void 0;
2151
+ const [, workspaceId, rootId, docId] = pathname.match(/^\/share\/([^/]+)(?:\/([^/]+)(?:\/([^/]+))?)?\/?$/) ?? [];
2152
+ if (!workspaceId) return void 0;
1459
2153
  return {
1460
2154
  workspaceId: decodeURIComponent(workspaceId),
1461
- rootId: decodeURIComponent(rootId),
2155
+ rootId: rootId ? decodeURIComponent(rootId) : void 0,
1462
2156
  docId: docId ? decodeURIComponent(docId) : void 0
1463
2157
  };
1464
2158
  }
2159
+ function buildSharePath(workspaceId, rootId, docId) {
2160
+ const workspacePath = `/share/${encodeURIComponent(workspaceId)}`;
2161
+ if (!rootId) return workspacePath;
2162
+ const base = `${workspacePath}/${encodeURIComponent(rootId)}`;
2163
+ return docId ? `${base}/${encodeURIComponent(docId)}` : base;
2164
+ }
1465
2165
 
1466
2166
  // ../../node_modules/orderedmap/dist/index.js
1467
2167
  function OrderedMap(content) {
@@ -3901,7 +4601,10 @@ function gatherMarks(schema2, marks) {
3901
4601
  // ../core/src/pm-schema.ts
3902
4602
  var canonicalSchema = new Schema({
3903
4603
  nodes: {
3904
- doc: { content: "block+" },
4604
+ // The doc allows suggestion marks on its block children so pending
4605
+ // block-level suggestions (node marks) survive fromJSON and can be
4606
+ // structurally reverted before serialization.
4607
+ doc: { content: "block+", marks: "insertion deletion modification" },
3905
4608
  paragraph: { group: "block", content: "inline*" },
3906
4609
  heading: {
3907
4610
  group: "block",
@@ -3969,14 +4672,45 @@ var canonicalSchema = new Schema({
3969
4672
  },
3970
4673
  inclusive: false
3971
4674
  },
4675
+ wikilink: {
4676
+ attrs: {
4677
+ target: { default: null }
4678
+ },
4679
+ inclusive: false
4680
+ },
3972
4681
  bold: {},
3973
4682
  italic: {},
3974
4683
  strike: {},
3975
- code: {}
4684
+ code: {},
4685
+ // Pending-suggestion marks (mirroring the web editor's extensions). They
4686
+ // are never serialized — the canonical serializer reverts them first —
4687
+ // but the schema must know them so suggestion-marked docs load intact.
4688
+ insertion: {
4689
+ attrs: { id: { default: null } },
4690
+ inclusive: false,
4691
+ excludes: "deletion modification insertion"
4692
+ },
4693
+ deletion: {
4694
+ attrs: { id: { default: null } },
4695
+ inclusive: false,
4696
+ excludes: "insertion modification deletion"
4697
+ },
4698
+ modification: {
4699
+ attrs: {
4700
+ id: { default: null },
4701
+ type: { default: null },
4702
+ attrName: { default: null },
4703
+ previousValue: { default: null },
4704
+ newValue: { default: null }
4705
+ },
4706
+ inclusive: false,
4707
+ excludes: "deletion insertion"
4708
+ }
3976
4709
  }
3977
4710
  });
4711
+ var SUGGESTION_MARK_TYPES = /* @__PURE__ */ new Set(["insertion", "deletion", "modification"]);
3978
4712
  var CANONICAL_NODE_TYPES = new Set(Object.keys(canonicalSchema.nodes));
3979
- var CANONICAL_MARK_TYPES = new Set(Object.keys(canonicalSchema.marks));
4713
+ var CANONICAL_MARK_TYPES = new Set(Object.keys(canonicalSchema.marks).filter((name) => !SUGGESTION_MARK_TYPES.has(name)));
3980
4714
 
3981
4715
  // ../../node_modules/markdown-it/lib/common/utils.mjs
3982
4716
  var utils_exports = {};
@@ -10171,9 +10905,28 @@ var markdownSerializer = new MarkdownSerializer(
10171
10905
  return `](${href.replace(/[()"]/g, "\\$&")})`;
10172
10906
  },
10173
10907
  mixable: false
10908
+ },
10909
+ wikilink: {
10910
+ open(_state, mark, parent, index) {
10911
+ const target = typeof mark.attrs.target === "string" ? mark.attrs.target : "";
10912
+ const label = markedRangeText(parent, index, mark);
10913
+ if (!target || label === target) return "[[";
10914
+ return `[[${target}|`;
10915
+ },
10916
+ close: "]]",
10917
+ mixable: false
10174
10918
  }
10175
10919
  }
10176
10920
  );
10921
+ function markedRangeText(parent, index, mark) {
10922
+ let text2 = "";
10923
+ for (let childIndex = index; childIndex < parent.childCount; childIndex += 1) {
10924
+ const child = parent.child(childIndex);
10925
+ if (!mark.isInSet(child.marks)) break;
10926
+ text2 += child.textContent;
10927
+ }
10928
+ return text2;
10929
+ }
10177
10930
  function escapeImageAlt(value) {
10178
10931
  return value.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/]/g, "\\]").replace(/\n+/g, " ");
10179
10932
  }
@@ -10237,7 +10990,7 @@ var RemoteDocumentIO = class {
10237
10990
  };
10238
10991
 
10239
10992
  // src/agent.ts
10240
- var CLI_VERSION = "0.3.0";
10993
+ var CLI_VERSION = "0.3.5";
10241
10994
  var CLI_PACKAGE_NAME = "@magic-markdown/cli";
10242
10995
  var AGENT_COMMANDS = [
10243
10996
  {
@@ -10267,11 +11020,12 @@ var AGENT_COMMANDS = [
10267
11020
  {
10268
11021
  name: "join",
10269
11022
  summary: "Join a Magic Markdown share URL through the CLI, announce presence, and remember the session for later rejoin.",
10270
- usage: "mdocs join <share-url> --scope file|project --doc <docId> --name <agent-name> --json",
11023
+ usage: "mdocs join <share-url> --scope file|project|workspace --doc <docId> --name <agent-name> --json",
10271
11024
  output: "json",
10272
11025
  mutates: true,
10273
11026
  examples: [
10274
11027
  'mdocs join https://example.com/share/workspace/root/doc --scope file --doc doc_abc123 --name "Claude Code" --json',
11028
+ 'mdocs join https://example.com/share/workspace --scope workspace --name "Claude Code" --json',
10275
11029
  "mdocs join --json"
10276
11030
  ],
10277
11031
  notes: [
@@ -10288,26 +11042,40 @@ var AGENT_COMMANDS = [
10288
11042
  },
10289
11043
  {
10290
11044
  name: "remote",
10291
- summary: "Read, comment on, suggest edits to, monitor, and restore the active Magic Markdown remote join.",
10292
- usage: "mdocs remote map|context|comment|suggest|reject|events|history|restore --json",
11045
+ summary: "Read, organize, comment on, suggest edits to, monitor, and restore the active Magic Markdown remote join.",
11046
+ usage: "mdocs remote map|graph|context|review|create-file|move-file|comment|suggest|reject|events|history|restore|library|create-folder|update-folder|move-root|invite-folder --json",
10293
11047
  output: "json",
10294
11048
  mutates: true,
10295
11049
  examples: [
10296
- "mdocs remote context --json",
10297
- "mdocs remote context --start-line 1 --end-line 100 --json",
10298
- "mdocs remote context --start-line 101 --end-line 200 --json",
11050
+ "mdocs remote context --summary --json",
11051
+ "mdocs remote context --start-line 1 --end-line 100 --no-review --json",
11052
+ "mdocs remote context --start-line 101 --end-line 200 --no-review --json",
11053
+ "mdocs remote review --json",
10299
11054
  "mdocs remote map --json",
11055
+ "mdocs remote graph --json",
11056
+ "mdocs remote create-file docs/new-note.md --with-file /tmp/initial.md --json",
11057
+ "mdocs remote move-file docs/new-note.md --to archive/new-note.md --json",
10300
11058
  "mdocs remote suggest --range 4:9 --with-file /tmp/replacement.md --message-file /tmp/message.txt --json",
10301
11059
  "mdocs remote comment docs/example.md --range 3:5 --body-file /tmp/comment.txt --json",
10302
11060
  "mdocs remote reject suggestion_abc123 --json",
10303
11061
  "mdocs remote history --json",
10304
- "mdocs remote restore head_abc123 --json"
11062
+ "mdocs remote restore head_abc123 --json",
11063
+ "mdocs remote library --json",
11064
+ "mdocs remote create-folder Research --json",
11065
+ "mdocs remote move-root --folder fold_abc123 --json",
11066
+ "mdocs remote invite-folder fold_abc123 --email person@example.com --role edit --json"
10305
11067
  ],
10306
11068
  notes: [
11069
+ "Use remote context --summary --json first on file-scoped joins and before reading large documents. It returns metadata, heading line numbers, current head, review counts, and suggested next commands without dumping Markdown.",
10307
11070
  "remote context accepts --start-line and --end-line (1-based, inclusive) to page through large documents. The response always includes totalLines, startLine, and endLine \u2014 if totalLines > endLine, request the next page with --start-line <endLine+1>.",
11071
+ "Use remote context --no-review when reading document content only; use remote review to list open comments, open suggestions, and anchors separately.",
11072
+ "remote map works on project and workspace-scoped joins. Workspace-scoped map lists every shared Home root; target duplicate paths as <rootId>:<path-or-docId>.",
11073
+ "remote graph requires a project-scoped join and returns the shared project's Obsidian-style Markdown/wikilink graph.",
11074
+ "remote create-file and remote move-file require a project-scoped join and edit access. They write through the root sync API, so version history and conflict handling stay intact.",
11075
+ "remote library, create-folder, update-folder, move-root, and invite-folder use the organization suite on top of the joined project. Folder invites require admin access on the joined root and can only target folders containing that root.",
10308
11076
  "remote comment/suggest never clobber concurrent edits: the server merges review additions, and the CLI rebases and retries automatically if a conflict still occurs.",
10309
11077
  "remote reject <suggestionId> withdraws a suggestion you submitted (for example, a duplicate). You can always withdraw your own; rejecting another author's suggestion requires edit access, and resolving a human's review work needs the user's explicit ask.",
10310
- "remote events returns truncated: true when older events were dropped from the bounded log; refetch full state with remote context instead of trusting the gap.",
11078
+ "remote events returns truncated: true when older events were dropped from the bounded log; refetch with remote context --summary, then read needed content pages and remote review instead of trusting the gap.",
10311
11079
  "Agents have suggest-only access by default: comments and suggestions work, direct content rewrites are rejected with a forbidden_role error unless a human grants edit access.",
10312
11080
  "remote history lists content-addressed commits (every commit carries a full snapshot); remote restore <headId> restores the joined document to that snapshot as a new commit. Restore requires edit access \u2014 only run it when the user asked."
10313
11081
  ]
@@ -10322,28 +11090,51 @@ var AGENT_COMMANDS = [
10322
11090
  },
10323
11091
  {
10324
11092
  name: "map",
10325
- summary: "Return the indexed Markdown documents and review counts.",
11093
+ summary: "Return the indexed Markdown documents, review counts, and link counts.",
10326
11094
  usage: "mdocs map --json",
10327
11095
  output: "json",
10328
11096
  mutates: true,
10329
11097
  examples: ["mdocs map --json"]
10330
11098
  },
11099
+ {
11100
+ name: "graph",
11101
+ summary: "Return the workspace knowledge graph as document nodes and Markdown/wikilink edges.",
11102
+ usage: "mdocs graph --json",
11103
+ output: "json",
11104
+ mutates: true,
11105
+ examples: ["mdocs graph --json"],
11106
+ notes: [
11107
+ "Edges include resolved document links, ambiguous wikilinks, unresolved links, external links, and same-document anchor links.",
11108
+ "Use this before broad agent edits to understand the Obsidian-style document graph and avoid changing related notes in isolation."
11109
+ ]
11110
+ },
10331
11111
  {
10332
11112
  name: "context",
10333
- summary: "Return agent-ready document Markdown, open review state, anchors, and image references.",
10334
- usage: "mdocs context <path|docId> [--start-line N] [--end-line N] --json",
11113
+ summary: "Return agent-ready document Markdown, optional review state, image references, and resolved outgoing links.",
11114
+ usage: "mdocs context <path|docId> [--summary] [--no-review] [--start-line N] [--end-line N] --json",
10335
11115
  output: "json",
10336
11116
  mutates: true,
10337
11117
  examples: [
11118
+ "mdocs context docs/example.md --summary --json",
10338
11119
  "mdocs context docs/example.md --json",
10339
- "mdocs context docs/example.md --start-line 1 --end-line 100 --json",
10340
- "mdocs context docs/example.md --start-line 101 --end-line 200 --json"
11120
+ "mdocs context docs/example.md --start-line 1 --end-line 100 --no-review --json",
11121
+ "mdocs context docs/example.md --start-line 101 --end-line 200 --no-review --json"
10341
11122
  ],
10342
11123
  notes: [
11124
+ "--summary returns metadata, heading line numbers, review counts, image counts, and link counts without returning Markdown.",
10343
11125
  "The response always includes totalLines, startLine, and endLine. If totalLines > endLine, request the next page with --start-line <endLine+1>.",
10344
- "--start-line and --end-line are 1-based and inclusive. Omitting both returns the full document."
11126
+ "--start-line and --end-line are 1-based and inclusive. Omitting both returns the full document.",
11127
+ "--no-review omits comments, suggestions, and anchors from content reads; use review/comments/suggestions commands when you need review state."
10345
11128
  ]
10346
11129
  },
11130
+ {
11131
+ name: "review",
11132
+ summary: "Return open comments, open suggestions, and anchors for one document without returning Markdown.",
11133
+ usage: "mdocs review <path|docId> --json",
11134
+ output: "json",
11135
+ mutates: true,
11136
+ examples: ["mdocs review docs/example.md --json"]
11137
+ },
10347
11138
  {
10348
11139
  name: "state",
10349
11140
  summary: "Return full merged Markdown and sidecar state for one document.",
@@ -10376,7 +11167,8 @@ var AGENT_COMMANDS = [
10376
11167
  ],
10377
11168
  notes: [
10378
11169
  "--range is 1-based and inclusive, validated against the current document line count.",
10379
- "The replacement text replaces the whole range; re-read context first so the range matches current content."
11170
+ "The replacement text replaces the whole range; re-read context first so the range matches current content.",
11171
+ "Prefer the smallest coherent range for the intended revision. If only one sentence inside a line changes, keep the rest of that line identical; use longer ranges only when the broader rewrite is genuinely needed."
10380
11172
  ]
10381
11173
  },
10382
11174
  {
@@ -10493,7 +11285,7 @@ function getAgentGuidePayload() {
10493
11285
  function getAgentSkillMarkdown() {
10494
11286
  return `---
10495
11287
  name: magic-markdown
10496
- description: Use the Magic Markdown CLI or MCP server to read, review, comment on, suggest edits to, and sync clean Markdown workspaces without hand-editing .mdocs sidecars. Use when an agent needs to interact with Magic Markdown documents, comments, suggestions, image context, checkpoints, or local source sync.
11288
+ description: Use the Magic Markdown CLI or MCP server to read, organize, review, comment on, suggest edits to, and sync clean Markdown workspaces without hand-editing .mdocs sidecars. Use when an agent needs to interact with Magic Markdown documents, library folders, comments, suggestions, image context, checkpoints, or local source sync.
10497
11289
  metadata:
10498
11290
  short-description: Operate Magic Markdown via CLI/MCP
10499
11291
  ---
@@ -10520,14 +11312,18 @@ Run \`mdocs help <command>\` (or \`mdocs <command> --help\`) for usage, examples
10520
11312
 
10521
11313
  1. If given a Magic Markdown share URL, run \`mdocs join <share-url> --json\` first, and always include \`--name "<your agent name>"\` (for example \`--name "Claude Code"\`) so collaborators can see which agent is connected. Use \`mdocs remote ...\` commands after joining.
10522
11314
  2. If working in a local workspace, run \`mdocs doctor --json\` to validate the workspace and learn recommended next commands.
10523
- 3. Run \`mdocs map --json\` or \`mdocs remote map --json\` to list documents, paths, docIds, open comments, open suggestions, and anchor review counts. File-scoped joins (most share links) cover a single document, so \`mdocs remote map\` is unavailable for them \u2014 use \`mdocs remote context --json\` instead.
10524
- 4. For a document, run \`mdocs context <path|docId> --json\` locally or \`mdocs remote context <path|docId> --json\` for a joined share.
10525
- 5. Use \`mdocs comment\` / \`mdocs remote comment\` for review notes and \`mdocs suggest\` / \`mdocs remote suggest\` for proposed replacements. Do not insert comments, CriticMarkup, directives, or Magic markers into Markdown files.
11315
+ 3. Run \`mdocs map --json\` or \`mdocs remote map --json\` to list documents, paths, docIds, open comments, open suggestions, anchor review counts, and link counts. File-scoped joins (most share links) cover a single document, so \`mdocs remote map\` is unavailable for them \u2014 use \`mdocs remote context --summary --json\` instead. Project-scoped joins cover one root; workspace-scoped joins cover Home and can target duplicate paths as \`<rootId>:<path-or-docId>\`.
11316
+ 4. Run \`mdocs graph --json\` or project-scoped \`mdocs remote graph --json\` before broad edits to inspect the Obsidian-style document graph built from Markdown links and wikilinks.
11317
+ 5. For a document, run \`mdocs context <path|docId> --summary --json\` locally or \`mdocs remote context <path|docId> --summary --json\` for a joined share before reading full content. Then page Markdown with \`--start-line\` / \`--end-line\` and \`--no-review\` when you only need document text.
11318
+ 6. Pull review state separately with \`mdocs review <path|docId> --json\` locally or \`mdocs remote review <path|docId> --json\` for a joined share. Use \`mdocs comment\` / \`mdocs remote comment\` for review notes and \`mdocs suggest\` / \`mdocs remote suggest\` for proposed replacements. Do not insert comments, CriticMarkup, directives, or Magic markers into Markdown files.
10526
11319
 
10527
11320
  ## Editing Rules
10528
11321
 
10529
11322
  - Comments and suggestions are sidecar operations. They should not modify clean Markdown until a suggestion is accepted.
10530
11323
  - \`--range <start:end>\` is 1-based and inclusive, and validated against the current document: read the document first and quote line numbers from what you just read.
11324
+ - Prefer the smallest coherent suggestion range that contains the actual change. If one sentence, list item, table row, or short paragraph changes, suggest that unit rather than replacing surrounding unchanged paragraphs or sections.
11325
+ - Broader paragraph, section, or multi-section rewrites are appropriate when the edit genuinely changes structure, ordering, transitions, or multiple interdependent ideas. Do not avoid a long edit when it is the clearest correct revision.
11326
+ - When the narrowest Markdown line contains unchanged surrounding text, keep that unchanged text byte-for-byte identical in the replacement instead of rewording the whole paragraph.
10531
11327
  - Prefer \`--json\` for agent calls and parse stdout as JSON.
10532
11328
  - For multiline suggestion text, write the exact replacement to a temporary file and pass \`--with-file <file>\`.
10533
11329
  - For long comments or messages, use \`--body-file <file>\` or \`--message-file <file>\`.
@@ -10540,13 +11336,14 @@ Run \`mdocs help <command>\` (or \`mdocs <command> --help\`) for usage, examples
10540
11336
  - Errors go to stderr. With \`--json\` they are structured: \`{ "ok": false, "error": { "code", "message", "hint" }, "cliVersion" }\`. Follow the \`hint\`.
10541
11337
  - Exit codes: 0 ok, 1 internal, 2 usage/invalid range, 3 conflict, 4 not found, 5 network, 6 unauthorized (share link revoked or expired).
10542
11338
  - \`remote comment\` and \`remote suggest\` are concurrency-safe: the server merges review additions without clobbering concurrent edits, and the CLI rebases and retries automatically. A surviving \`conflict\` error means the document is changing rapidly \u2014 refetch context and retry.
10543
- - \`remote events --json\` may return \`"truncated": true\` when older events were dropped from the bounded event log. Do not assume nothing happened; refetch full state with \`mdocs remote context --json\`.
11339
+ - \`remote events --json\` may return \`"truncated": true\` when older events were dropped from the bounded event log. Do not assume nothing happened; refetch with \`mdocs remote context --summary --json\`, then read needed pages and review state.
10544
11340
  - Retries are safe: remote writes carry a changeId and the server deduplicates replays.
10545
11341
  - Stale reads are safe too: the server merges your change three-way against the version you read, so non-overlapping concurrent edits never conflict. Only true overlaps return \`conflict\`.
10546
11342
 
10547
11343
  ## Access Roles and Version History
10548
11344
 
10549
11345
  - Agents are **suggest-only by default**: comments and suggestions always work, but direct content rewrites are rejected with a \`forbidden_role\` error until a human grants edit access. Propose changes with \`mdocs remote suggest\` instead of rewriting content.
11346
+ - Project-scoped agents with edit access can create and move Markdown files and can move the joined root into library folders. Workspace-scoped agents can read/comment/suggest across Home roots; root organization commands still require a project-scoped join. Folder invites and folder hierarchy updates require admin access on the joined root.
10550
11347
  - Every accepted change is a content-addressed commit with a full snapshot. \`mdocs remote history --json\` lists commits with changed paths; \`mdocs remote restore <headId> --json\` restores the joined document to that snapshot (as a new commit, so it is reversible). Restore requires edit access \u2014 only run it when the user asked.
10551
11348
 
10552
11349
  ## Core Commands
@@ -10561,7 +11358,7 @@ Start the local stdio MCP server with:
10561
11358
  mdocs serve-mcp --cwd /path/to/workspace
10562
11359
  \`\`\`
10563
11360
 
10564
- The MCP server exposes document resources, image resources, a workspace map resource, an agent guide resource, typed tools for comments and suggestions, and the \`magic_markdown_agent_workflow\` prompt.
11361
+ The MCP server exposes document resources, image resources, workspace map and graph resources, an agent guide resource, typed tools for comments and suggestions, and the \`magic_markdown_agent_workflow\` prompt. Use \`mdocs_context\` / \`magic_context\` with \`summary: true\` before dumping Markdown; use \`includeReview: false\` for content-only reads and \`mdocs_review\` / \`magic_review\` for review state.
10565
11362
  `;
10566
11363
  }
10567
11364
  function formatCommandReference() {
@@ -10618,7 +11415,7 @@ function toCliError(error) {
10618
11415
 
10619
11416
  // src/checkpoints.ts
10620
11417
  import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
10621
- import { basename, dirname as dirname2, join } from "node:path";
11418
+ import { basename, dirname as dirname3, join } from "node:path";
10622
11419
  async function createCheckpoint(root, label) {
10623
11420
  const id = createId("checkpoint");
10624
11421
  const createdAt = nowIso();
@@ -10683,7 +11480,7 @@ async function restoreTree(snapshotRoot, root) {
10683
11480
  await mkdir(dest, { recursive: true });
10684
11481
  await restoreTree(source, dest);
10685
11482
  } else if (entry.isFile()) {
10686
- await mkdir(dirname2(dest), { recursive: true });
11483
+ await mkdir(dirname3(dest), { recursive: true });
10687
11484
  await cp(source, dest);
10688
11485
  }
10689
11486
  }
@@ -10739,7 +11536,7 @@ function nextCommands(map) {
10739
11536
 
10740
11537
  // src/fs-io.ts
10741
11538
  import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, realpath, rename, rm as rm2, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
10742
- import { dirname as dirname3, join as join2, relative, resolve } from "node:path";
11539
+ import { dirname as dirname4, join as join2, relative, resolve } from "node:path";
10743
11540
  var NodeWorkspaceIO = class {
10744
11541
  constructor(root, pathRules = DEFAULT_ROOT_PATH_RULES) {
10745
11542
  this.root = root;
@@ -10752,12 +11549,12 @@ var NodeWorkspaceIO = class {
10752
11549
  }
10753
11550
  async writeText(path, content) {
10754
11551
  const target = await this.resolveWritablePath(path, Buffer.byteLength(content, "utf8"));
10755
- await mkdir2(dirname3(target), { recursive: true });
11552
+ await mkdir2(dirname4(target), { recursive: true });
10756
11553
  await writeFile2(target, content, "utf8");
10757
11554
  }
10758
11555
  async writeTextAtomic(path, content) {
10759
11556
  const target = await this.resolveWritablePath(path, Buffer.byteLength(content, "utf8"));
10760
- await mkdir2(dirname3(target), { recursive: true });
11557
+ await mkdir2(dirname4(target), { recursive: true });
10761
11558
  const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
10762
11559
  await writeFile2(temporary, content, "utf8");
10763
11560
  await rename(temporary, target);
@@ -10794,12 +11591,12 @@ var NodeWorkspaceIO = class {
10794
11591
  const normalized = assertWorkspacePathAllowed(path, this.pathRules, sizeBytes);
10795
11592
  const root = await realpath(this.root);
10796
11593
  const target = resolve(root, normalized);
10797
- await mkdir2(dirname3(target), { recursive: true });
10798
- assertPathInsideRoot(root, await realpath(dirname3(target)));
11594
+ await mkdir2(dirname4(target), { recursive: true });
11595
+ assertPathInsideRoot(root, await realpath(dirname4(target)));
10799
11596
  return target;
10800
11597
  }
10801
11598
  async resolvePath(path) {
10802
- const normalized = normalizeWorkspacePath2(path);
11599
+ const normalized = normalizeWorkspacePath3(path);
10803
11600
  const root = await realpath(this.root);
10804
11601
  const target = resolve(root, normalized);
10805
11602
  assertPathInsideRoot(root, target);
@@ -10836,7 +11633,7 @@ var PathMappedWorkspaceIO = class {
10836
11633
  return files.map((path) => replicaPathToCanonicalPath(path, this.mapping)).sort();
10837
11634
  }
10838
11635
  toReplicaPath(path) {
10839
- if (path === ".mdocs" || path.startsWith(".mdocs/")) return normalizeWorkspacePath2(path);
11636
+ if (path === ".mdocs" || path.startsWith(".mdocs/")) return normalizeWorkspacePath3(path);
10840
11637
  return canonicalPathToReplicaPath(path, this.mapping);
10841
11638
  }
10842
11639
  };
@@ -10857,14 +11654,14 @@ async function walk(root, output, base, rules, ignoreFiles) {
10857
11654
  }
10858
11655
  if (entry.isFile()) {
10859
11656
  const stats = await stat2(absolute);
10860
- if (isWorkspaceMarkdownPathAllowed(relativePath, rules, stats.size, ignoreMatcher)) output.push(normalizeWorkspacePath2(relativePath));
11657
+ if (isWorkspaceMarkdownPathAllowed(relativePath, rules, stats.size, ignoreMatcher)) output.push(normalizeWorkspacePath3(relativePath));
10861
11658
  }
10862
11659
  }
10863
11660
  }
10864
11661
  async function withDirectoryIgnoreFile(root, base, ignoreFiles) {
10865
11662
  try {
10866
11663
  const path = join2(relative(base, root).replaceAll("\\", "/"), ".gitignore").replace(/^\.\//, "");
10867
- const normalizedPath = path === ".gitignore" ? path : normalizeWorkspacePath2(path);
11664
+ const normalizedPath = path === ".gitignore" ? path : normalizeWorkspacePath3(path);
10868
11665
  const content = await readFile2(join2(root, ".gitignore"), "utf8");
10869
11666
  return [...ignoreFiles, { path: normalizedPath, content }];
10870
11667
  } catch {
@@ -10985,8 +11782,9 @@ function withMcpImageResources(io, state) {
10985
11782
  }))
10986
11783
  };
10987
11784
  }
10988
- async function contextForDocument(io, path, startLine, endLine) {
11785
+ async function contextForDocument(io, path, startLine, endLine, options = {}) {
10989
11786
  const state = await getDocumentState(io, path);
11787
+ if (options.summary) return summarizeDocumentContext(state);
10990
11788
  const { markdown, totalLines, startLine: sl, endLine: el } = sliceMarkdown(state.markdown, startLine, endLine);
10991
11789
  return {
10992
11790
  path: state.path,
@@ -10997,21 +11795,20 @@ async function contextForDocument(io, path, startLine, endLine) {
10997
11795
  endLine: el,
10998
11796
  markdown,
10999
11797
  images: withMcpImageResources(io, state).images,
11798
+ links: state.links,
11799
+ ...options.includeReview === false ? {} : reviewFields(state)
11800
+ };
11801
+ }
11802
+ async function reviewForDocument(io, path) {
11803
+ return reviewStateForDocument(await getDocumentState(io, path));
11804
+ }
11805
+ function reviewFields(state) {
11806
+ return {
11000
11807
  comments: state.sidecar.comments.filter((comment2) => comment2.status === "open"),
11001
11808
  suggestions: state.sidecar.suggestions.filter((suggestion) => suggestion.status === "open"),
11002
11809
  anchors: state.anchors
11003
11810
  };
11004
11811
  }
11005
- function sliceMarkdown(markdown, startLine, endLine) {
11006
- const lines = markdown.split("\n");
11007
- const totalLines = lines.length;
11008
- const sl = startLine ?? 1;
11009
- const el = endLine ?? totalLines;
11010
- if (sl < 1 || el > totalLines || sl > el) {
11011
- throw new Error(`Line range ${sl}:${el} is out of bounds for a ${totalLines}-line document.`);
11012
- }
11013
- return { markdown: lines.slice(sl - 1, el).join("\n"), totalLines, startLine: sl, endLine: el };
11014
- }
11015
11812
  function imageResourceUri(docId, index) {
11016
11813
  return `mdocs://documents/${docId}/images/${index}`;
11017
11814
  }
@@ -11074,7 +11871,12 @@ var actorProperties = {
11074
11871
  var tools = [
11075
11872
  {
11076
11873
  name: "mdocs_map",
11077
- description: "Return the workspace document map.",
11874
+ description: "Return the workspace document map with review and link counts.",
11875
+ inputSchema: noArgsSchema
11876
+ },
11877
+ {
11878
+ name: "mdocs_graph",
11879
+ description: "Return the workspace knowledge graph as document nodes and Markdown/wikilink edges. Edges include resolved, ambiguous, unresolved, external, and same-document anchor links.",
11078
11880
  inputSchema: noArgsSchema
11079
11881
  },
11080
11882
  {
@@ -11084,7 +11886,7 @@ var tools = [
11084
11886
  },
11085
11887
  {
11086
11888
  name: "mdocs_context",
11087
- description: "Return agent-ready document Markdown, open review state, anchors, and image references. Use startLine/endLine to read a specific line range when the full document is too large for one context window. The response always includes totalLines so you know whether to page.",
11889
+ description: "Return agent-ready document Markdown and resolved outgoing links. Use summary=true first to inspect title, line count, headings, current head, and review/link/image counts without dumping the full document. Use includeReview=false when reading content only, and mdocs_review for comments, suggestions, and anchors.",
11088
11890
  inputSchema: {
11089
11891
  type: "object",
11090
11892
  properties: {
@@ -11096,12 +11898,25 @@ var tools = [
11096
11898
  endLine: {
11097
11899
  type: "number",
11098
11900
  description: "1-based last line to include (default: last line of document)."
11901
+ },
11902
+ summary: {
11903
+ type: "boolean",
11904
+ description: "Return metadata, heading outline, current head, and counts instead of Markdown content."
11905
+ },
11906
+ includeReview: {
11907
+ type: "boolean",
11908
+ description: "Whether to include open comments, suggestions, and anchors in the content response (default: true). Use false to keep document reading separate from review state."
11099
11909
  }
11100
11910
  },
11101
11911
  required: ["path"],
11102
11912
  additionalProperties: false
11103
11913
  }
11104
11914
  },
11915
+ {
11916
+ name: "mdocs_review",
11917
+ description: "Return open comments, open suggestions, and mapped anchors for one document without returning document Markdown.",
11918
+ inputSchema: documentPathSchema
11919
+ },
11105
11920
  {
11106
11921
  name: "mdocs_state",
11107
11922
  description: "Return full merged Markdown and sidecar state for one document.",
@@ -11125,14 +11940,14 @@ var tools = [
11125
11940
  },
11126
11941
  {
11127
11942
  name: "mdocs_suggest",
11128
- description: "Create a sidecar replacement suggestion without modifying clean Markdown.",
11943
+ description: "Create a sidecar replacement suggestion without modifying clean Markdown. Prefer the smallest coherent range; use longer ranges only when the broader rewrite is genuinely needed.",
11129
11944
  inputSchema: {
11130
11945
  type: "object",
11131
11946
  properties: {
11132
11947
  path: documentPathSchema.properties.path,
11133
11948
  startLine: { type: "number", description: "1-based start line." },
11134
11949
  endLine: { type: "number", description: "1-based end line." },
11135
- replacement: { type: "string", description: "Exact replacement Markdown for the line range." },
11950
+ replacement: { type: "string", description: "Exact replacement Markdown for the line range; keep unchanged surrounding text identical for narrow edits." },
11136
11951
  message: { type: "string", description: "Short explanation for the suggestion." },
11137
11952
  ...actorProperties
11138
11953
  },
@@ -11223,6 +12038,11 @@ async function dispatch(io, method, params) {
11223
12038
  uri: "mdocs://workspace/map",
11224
12039
  name: "Workspace Map",
11225
12040
  mimeType: "application/vnd.mdocs.workspace-map+json"
12041
+ },
12042
+ {
12043
+ uri: "mdocs://workspace/graph",
12044
+ name: "Workspace Knowledge Graph",
12045
+ mimeType: "application/vnd.mdocs.workspace-graph+json"
11226
12046
  }
11227
12047
  ])
11228
12048
  };
@@ -11315,6 +12135,17 @@ async function readResource(io, path) {
11315
12135
  ]
11316
12136
  };
11317
12137
  }
12138
+ if (path === "mdocs://workspace/graph") {
12139
+ return {
12140
+ contents: [
12141
+ {
12142
+ uri: path,
12143
+ mimeType: "application/vnd.mdocs.workspace-graph+json",
12144
+ text: JSON.stringify(await getWorkspaceGraph(io), null, 2)
12145
+ }
12146
+ ]
12147
+ };
12148
+ }
11318
12149
  const imageMatch = /^mdocs:\/\/documents\/([^/]+)\/images\/(\d+)$/.exec(path);
11319
12150
  if (imageMatch) {
11320
12151
  return readImageResource(io, path, imageMatch[1] ?? "", Number(imageMatch[2]));
@@ -11333,12 +12164,17 @@ async function readResource(io, path) {
11333
12164
  }
11334
12165
  async function callTool(io, name, args) {
11335
12166
  if (name === "mdocs_map") return getWorkspaceMap(io);
12167
+ if (name === "mdocs_graph") return getWorkspaceGraph(io);
11336
12168
  if (name === "mdocs_doctor") return runDoctor(io, io.root);
11337
12169
  if (name === "mdocs_context") {
11338
12170
  const startLine = typeof args.startLine === "number" ? args.startLine : void 0;
11339
12171
  const endLine = typeof args.endLine === "number" ? args.endLine : void 0;
11340
- return contextForDocument(io, requiredString(args, "path"), startLine, endLine);
12172
+ return contextForDocument(io, requiredString(args, "path"), startLine, endLine, {
12173
+ summary: args.summary === true,
12174
+ includeReview: args.includeReview !== false
12175
+ });
11341
12176
  }
12177
+ if (name === "mdocs_review") return reviewForDocument(io, requiredString(args, "path"));
11342
12178
  if (name === "mdocs_state") return withMcpImageResources(io, await getDocumentState(io, requiredString(args, "path")));
11343
12179
  if (name === "mdocs_comment") {
11344
12180
  return addComment(
@@ -11445,12 +12281,22 @@ async function postPresenceAndReadState(share, shareUrl, docId, agentId, agentNa
11445
12281
  });
11446
12282
  }
11447
12283
  async function fetchState(record) {
11448
- if (!record.docId) {
12284
+ if (!record.rootId || !record.docId) {
11449
12285
  throw new CliError("usage_error", "This join has no current document.", {
11450
12286
  hint: "Pass a document path or docId, or list documents with mdocs remote map --json."
11451
12287
  });
11452
12288
  }
11453
- return fetchJson(agentUrl(record, record.docId, "state"), {
12289
+ return fetchJson(agentUrl({ ...record, rootId: record.rootId }, record.docId, "state"), {
12290
+ headers: shareHeaders(record.shareUrl)
12291
+ });
12292
+ }
12293
+ async function fetchWorkspaceRoots(record) {
12294
+ return fetchJson(`${record.origin}/api/workspaces/${encodeURIComponent(record.workspaceId)}/roots`, {
12295
+ headers: shareHeaders(record.shareUrl)
12296
+ });
12297
+ }
12298
+ async function fetchWorkspaceLibrary(record) {
12299
+ return fetchJson(`${record.origin}/api/workspaces/${encodeURIComponent(record.workspaceId)}/library`, {
11454
12300
  headers: shareHeaders(record.shareUrl)
11455
12301
  });
11456
12302
  }
@@ -11459,9 +12305,47 @@ async function fetchTree(record) {
11459
12305
  headers: shareHeaders(record.shareUrl)
11460
12306
  });
11461
12307
  }
12308
+ async function fetchLibrary(record) {
12309
+ return fetchJson(libraryUrl(record), {
12310
+ headers: shareHeaders(record.shareUrl)
12311
+ });
12312
+ }
12313
+ async function postLibraryFolder(record, input, actor) {
12314
+ return fetchJson(`${libraryUrl(record)}/folders`, {
12315
+ method: "POST",
12316
+ headers: { ...shareHeaders(record.shareUrl), "Content-Type": "application/json" },
12317
+ body: JSON.stringify({ ...input, actor })
12318
+ });
12319
+ }
12320
+ async function patchLibraryFolder(record, folderId, input, actor) {
12321
+ return fetchJson(`${libraryUrl(record)}/folders/${encodeURIComponent(folderId)}`, {
12322
+ method: "PATCH",
12323
+ headers: { ...shareHeaders(record.shareUrl), "Content-Type": "application/json" },
12324
+ body: JSON.stringify({ ...input, actor })
12325
+ });
12326
+ }
12327
+ async function postMoveRoot(record, folderId, actor) {
12328
+ return fetchJson(`${libraryUrl(record)}/move-root`, {
12329
+ method: "POST",
12330
+ headers: { ...shareHeaders(record.shareUrl), "Content-Type": "application/json" },
12331
+ body: JSON.stringify({ folderId, actor })
12332
+ });
12333
+ }
12334
+ async function postFolderInvite(record, folderId, input, actor) {
12335
+ return fetchJson(
12336
+ `${libraryUrl(record)}/folders/${encodeURIComponent(folderId)}/invites`,
12337
+ {
12338
+ method: "POST",
12339
+ headers: { ...shareHeaders(record.shareUrl), "Content-Type": "application/json" },
12340
+ body: JSON.stringify({ ...input, actor })
12341
+ }
12342
+ );
12343
+ }
11462
12344
  async function fetchDocument(record, pathOrDocId) {
12345
+ if (record.scope === "workspace") return fetchWorkspaceDocument(record, pathOrDocId);
12346
+ const rootRecord = requireRootRecord(record);
11463
12347
  if (record.scope === "file" && record.docId) {
11464
- const document = await fetchDocumentById(record, record.docId);
12348
+ const document = await fetchDocumentById(rootRecord, record.docId);
11465
12349
  if (pathOrDocId === document.docId || pathOrDocId === document.path) return document;
11466
12350
  throw new CliError("usage_error", `This file-scoped join only covers ${document.path} (${document.docId}).`, {
11467
12351
  hint: `Join a share link for ${pathOrDocId} to work on it.`
@@ -11469,19 +12353,19 @@ async function fetchDocument(record, pathOrDocId) {
11469
12353
  }
11470
12354
  if (pathOrDocId.startsWith("doc_") || pathOrDocId === record.docId) {
11471
12355
  try {
11472
- return await fetchDocumentById(record, pathOrDocId);
12356
+ return await fetchDocumentById(rootRecord, pathOrDocId);
11473
12357
  } catch (error) {
11474
12358
  if (!(error instanceof CliError) || error.code !== "not_found") throw error;
11475
12359
  }
11476
12360
  }
11477
- const tree = await fetchTree(record);
12361
+ const tree = await fetchTree(rootRecord);
11478
12362
  const docId = tree.docs.find((doc) => doc.docId === pathOrDocId || doc.path === pathOrDocId)?.docId;
11479
12363
  if (!docId) {
11480
12364
  throw new CliError("not_found", `No document matches "${pathOrDocId}" in the joined workspace.`, {
11481
12365
  hint: "List documents and their paths/docIds with mdocs remote map --json."
11482
12366
  });
11483
12367
  }
11484
- return fetchDocumentById(record, docId);
12368
+ return fetchDocumentById(rootRecord, docId);
11485
12369
  }
11486
12370
  function fetchDocumentById(record, docId) {
11487
12371
  return fetchJson(
@@ -11489,6 +12373,51 @@ function fetchDocumentById(record, docId) {
11489
12373
  { headers: shareHeaders(record.shareUrl) }
11490
12374
  );
11491
12375
  }
12376
+ async function fetchWorkspaceDocument(record, pathOrDocId) {
12377
+ if (!pathOrDocId) {
12378
+ throw new CliError("usage_error", "Workspace-scoped context requires a document path or docId.", {
12379
+ hint: "Run mdocs remote map --json, then pass a path, docId, or <rootId>:<path-or-docId>."
12380
+ });
12381
+ }
12382
+ const roots = await fetchWorkspaceRoots(record);
12383
+ const rootIds = new Set(roots.map((root) => root.rootId));
12384
+ const prefixed = splitWorkspaceDocumentTarget(pathOrDocId, rootIds);
12385
+ if (prefixed) {
12386
+ const rootRecord = { ...record, rootId: prefixed.rootId };
12387
+ const tree = await fetchTree(rootRecord);
12388
+ const match3 = tree.docs.find((doc) => doc.docId === prefixed.target || doc.path === prefixed.target);
12389
+ if (!match3) {
12390
+ throw new CliError("not_found", `No document matches "${prefixed.target}" in ${prefixed.rootId}.`, {
12391
+ hint: "Run mdocs remote map --json to verify the root id and document path."
12392
+ });
12393
+ }
12394
+ return fetchDocumentById(rootRecord, match3.docId);
12395
+ }
12396
+ const trees = await Promise.all(roots.map((root) => fetchTree({ ...record, rootId: root.rootId })));
12397
+ const matches = trees.flatMap(
12398
+ (tree) => tree.docs.filter((doc) => doc.docId === pathOrDocId || doc.path === pathOrDocId).map((doc) => ({ rootId: tree.root.rootId, doc }))
12399
+ );
12400
+ if (matches.length === 0) {
12401
+ throw new CliError("not_found", `No document matches "${pathOrDocId}" in the joined Home workspace.`, {
12402
+ hint: "Run mdocs remote map --json to list roots, paths, and docIds."
12403
+ });
12404
+ }
12405
+ if (matches.length > 1) {
12406
+ throw new CliError("usage_error", `Document target "${pathOrDocId}" is ambiguous across ${matches.length} roots.`, {
12407
+ hint: `Prefix the target with a root id, for example ${matches[0].rootId}:${pathOrDocId}.`
12408
+ });
12409
+ }
12410
+ const match2 = matches[0];
12411
+ return fetchDocumentById({ ...record, rootId: match2.rootId }, match2.doc.docId);
12412
+ }
12413
+ function splitWorkspaceDocumentTarget(value, rootIds) {
12414
+ const separator = value.indexOf(":");
12415
+ if (separator <= 0) return void 0;
12416
+ const rootId = value.slice(0, separator);
12417
+ if (!rootIds.has(rootId)) return void 0;
12418
+ const target = value.slice(separator + 1);
12419
+ return target ? { rootId, target } : void 0;
12420
+ }
11492
12421
  async function postReview(record, docId, additions, actor) {
11493
12422
  const response = await fetchJson(
11494
12423
  `${record.origin}/api/workspaces/${encodeURIComponent(record.workspaceId)}/roots/${encodeURIComponent(record.rootId)}/documents/${encodeURIComponent(docId)}/review`,
@@ -11571,7 +12500,7 @@ function conflictError(eventPayload) {
11571
12500
  const conflict = eventPayload ?? {};
11572
12501
  const head = typeof conflict.canonicalHead === "string" ? ` Current head: ${conflict.canonicalHead}.` : "";
11573
12502
  return new CliError("conflict", `Remote change was not accepted: the document changed since it was read.${head}`, {
11574
- hint: "Refetch with mdocs remote context --json, re-apply your change against the fresh content, and retry.",
12503
+ hint: "Refetch with mdocs remote context --summary --json, re-apply your change against the needed fresh content range, and retry.",
11575
12504
  details: eventPayload
11576
12505
  });
11577
12506
  }
@@ -11579,6 +12508,17 @@ function agentUrl(record, docId, action, query) {
11579
12508
  const base = `${record.origin}/api/agent/${encodeURIComponent(record.workspaceId)}/${encodeURIComponent(record.rootId)}/${encodeURIComponent(docId)}/${action}`;
11580
12509
  return query ? `${base}?${query}` : base;
11581
12510
  }
12511
+ function libraryUrl(record) {
12512
+ return `${record.origin}/api/workspaces/${encodeURIComponent(record.workspaceId)}/roots/${encodeURIComponent(record.rootId)}/library`;
12513
+ }
12514
+ function requireRootRecord(record) {
12515
+ if (!record.rootId) {
12516
+ throw new CliError("usage_error", "This command needs a root-scoped join.", {
12517
+ hint: "For Home joins, pass a document target from `mdocs remote map --json`, or use a project share for root organization commands."
12518
+ });
12519
+ }
12520
+ return { origin: record.origin, workspaceId: record.workspaceId, rootId: record.rootId, shareUrl: record.shareUrl };
12521
+ }
11582
12522
  function shareHeaders(shareUrl) {
11583
12523
  return { Referer: shareUrl, Authorization: `Bearer ${shareUrl}` };
11584
12524
  }
@@ -11616,7 +12556,7 @@ function remoteHttpError(status, value, text2) {
11616
12556
  }
11617
12557
  if (status === 409) {
11618
12558
  return new CliError("conflict", message, {
11619
- hint: "Refetch with mdocs remote context --json and retry.",
12559
+ hint: "Refetch with mdocs remote context --summary --json and retry.",
11620
12560
  details: value
11621
12561
  });
11622
12562
  }
@@ -11757,13 +12697,15 @@ async function runJoinCommand(root, parsed) {
11757
12697
  const parsedShare = parseShareUrl(target, parsed.flags);
11758
12698
  const agentName = resolveAgentName(parsed.flags);
11759
12699
  const agentId = normalizeAgentId(String(parsed.flags["agent-id"] ?? parsed.flags.actor ?? agentName));
11760
- const docId = parsedShare.docId ?? await firstDocIdForProject(parsedShare);
11761
- const state = await postPresenceAndReadState(parsedShare, target, docId, agentId, agentName);
12700
+ const rootScopedShare = rootScopedRecord(parsedShare);
12701
+ const docId = parsedShare.docId ?? (rootScopedShare ? await firstDocIdForProject(rootScopedShare) : void 0);
12702
+ const shareUrl = shareUrlForScope(target, { ...parsedShare, docId });
12703
+ const state = rootScopedShare && docId ? await postPresenceAndReadState(rootScopedShare, shareUrl, docId, agentId, agentName) : void 0;
11762
12704
  const now = (/* @__PURE__ */ new Date()).toISOString();
11763
12705
  const record = {
11764
12706
  joinId: joinIdFor(target),
11765
12707
  scope: parsedShare.scope,
11766
- shareUrl: target,
12708
+ shareUrl,
11767
12709
  origin: parsedShare.origin,
11768
12710
  workspaceId: parsedShare.workspaceId,
11769
12711
  rootId: parsedShare.rootId,
@@ -11773,28 +12715,39 @@ async function runJoinCommand(root, parsed) {
11773
12715
  joinedAt: now,
11774
12716
  updatedAt: now,
11775
12717
  lastSeenEventId: 0,
11776
- currentHead: state.document.currentSha
12718
+ currentHead: state?.document.currentSha
11777
12719
  };
11778
12720
  await writeJoinRecord(root, record);
11779
- return joinSummary(record, state.document);
12721
+ return joinSummary(record, state?.document);
11780
12722
  }
11781
12723
  async function runJoinsCommand(root) {
11782
12724
  return listJoinRecords(root);
11783
12725
  }
11784
12726
  async function runRemoteCommand(root, subcommand, parsed) {
11785
- const record = await readSelectedJoin(root, parsed.flags);
12727
+ const record = await normalizedSelectedJoin(root, parsed.flags);
11786
12728
  switch (subcommand) {
11787
12729
  case "map":
11788
- case void 0:
12730
+ case void 0: {
11789
12731
  if (record.scope === "file") {
11790
12732
  throw new CliError("usage_error", "This join is file-scoped, so the project tree is not accessible.", {
11791
- hint: "Use `mdocs remote context --json` to read the joined document."
12733
+ hint: "Use `mdocs remote context --summary --json` to inspect the joined document."
11792
12734
  });
11793
12735
  }
11794
- await refreshPresence(record, record.docId);
11795
- return fetchTree(record);
12736
+ if (record.scope === "workspace") return remoteWorkspaceMap(record);
12737
+ const rootRecord = assertRootScoped(record, "map");
12738
+ await refreshPresence(rootRecord, record.docId);
12739
+ return fetchTree(rootRecord);
12740
+ }
12741
+ case "graph":
12742
+ return remoteGraph(record);
11796
12743
  case "context":
11797
12744
  return remoteContext(record, parsed.command[2], parsed.flags);
12745
+ case "review":
12746
+ return remoteReview(record, parsed.command[2]);
12747
+ case "create-file":
12748
+ return remoteCreateFile(root, record, parsed);
12749
+ case "move-file":
12750
+ return remoteMoveFile(root, record, parsed);
11798
12751
  case "comment":
11799
12752
  return remoteComment(root, record, parsed);
11800
12753
  case "suggest":
@@ -11807,18 +12760,83 @@ async function runRemoteCommand(root, subcommand, parsed) {
11807
12760
  return remoteHistory(record, parsed.flags);
11808
12761
  case "restore":
11809
12762
  return remoteRestore(record, parsed);
12763
+ case "library": {
12764
+ if (record.scope === "workspace") return fetchWorkspaceLibrary(record);
12765
+ assertProjectScope(record, "library");
12766
+ const rootRecord = assertRootScoped(record, "library");
12767
+ await refreshPresence(rootRecord, record.docId);
12768
+ return fetchLibrary(rootRecord);
12769
+ }
12770
+ case "create-folder":
12771
+ return remoteCreateFolder(record, parsed);
12772
+ case "update-folder":
12773
+ return remoteUpdateFolder(record, parsed);
12774
+ case "move-root":
12775
+ return remoteMoveRoot(record, parsed);
12776
+ case "invite-folder":
12777
+ return remoteInviteFolder(record, parsed);
11810
12778
  case "rejoin":
11811
12779
  return rejoin(root, record.joinId, parsed.flags);
11812
12780
  default:
11813
12781
  throw new CliError("usage_error", `Unknown remote subcommand: ${subcommand}`, {
11814
- hint: "Use mdocs remote map|context|comment|suggest|reject|events|history|restore|rejoin."
12782
+ hint: "Use mdocs remote map|graph|context|review|create-file|move-file|comment|suggest|reject|events|history|restore|library|create-folder|update-folder|move-root|invite-folder|rejoin."
11815
12783
  });
11816
12784
  }
11817
12785
  }
12786
+ async function remoteGraph(record) {
12787
+ assertProjectScope(record, "graph");
12788
+ const rootRecord = assertRootScoped(record, "graph");
12789
+ await refreshPresence(rootRecord, record.docId);
12790
+ const tree = await fetchTree(rootRecord);
12791
+ const documents = await Promise.all(tree.docs.map((doc) => fetchDocument(rootScopedRecordFor(record, rootRecord.rootId), doc.docId)));
12792
+ const lookup = documents.map((doc) => ({ docId: doc.docId, path: doc.path, title: doc.title }));
12793
+ const graphDocuments = documents.map((doc) => {
12794
+ const links = doc.links ?? resolveMarkdownLinks(extractMarkdownLinks(doc.markdown), doc, lookup);
12795
+ return {
12796
+ docId: doc.docId,
12797
+ path: doc.path,
12798
+ title: doc.title,
12799
+ openComments: doc.openComments,
12800
+ openSuggestions: doc.openSuggestions,
12801
+ anchorsNeedingReview: doc.anchors.filter((anchor) => anchor.status !== "mapped").length,
12802
+ externalLinks: links.filter((link2) => link2.status === "external").length,
12803
+ unresolvedLinks: links.filter((link2) => link2.status === "unresolved" || link2.status === "ambiguous").length,
12804
+ links
12805
+ };
12806
+ });
12807
+ return buildWorkspaceGraph(record.workspaceId, SIDECAR_SCHEMA_VERSION, graphDocuments);
12808
+ }
12809
+ async function remoteWorkspaceMap(record) {
12810
+ const roots = await fetchWorkspaceRoots(record);
12811
+ const trees = await Promise.all(roots.map((root) => fetchTree(rootScopedRecordFor(record, root.rootId))));
12812
+ const library = await fetchWorkspaceLibrary(record).catch(() => void 0);
12813
+ return {
12814
+ workspaceId: record.workspaceId,
12815
+ scope: "workspace",
12816
+ roots: trees.map((tree) => ({
12817
+ root: tree.root,
12818
+ docs: tree.docs.map((doc) => ({
12819
+ rootId: tree.root.rootId,
12820
+ docId: doc.docId,
12821
+ path: doc.path,
12822
+ title: doc.title,
12823
+ openComments: doc.openComments,
12824
+ openSuggestions: doc.openSuggestions,
12825
+ anchorsNeedingReview: doc.anchorsNeedingReview,
12826
+ outgoingLinks: doc.outgoingLinks,
12827
+ incomingLinks: doc.incomingLinks,
12828
+ unresolvedLinks: doc.unresolvedLinks,
12829
+ externalLinks: doc.externalLinks
12830
+ }))
12831
+ })),
12832
+ ...library ? { folders: library.folders } : {}
12833
+ };
12834
+ }
11818
12835
  async function remoteHistory(record, flags) {
11819
- await refreshPresence(record, record.docId);
12836
+ const rootRecord = assertRootScoped(record, "history");
12837
+ await refreshPresence(rootRecord, record.docId);
11820
12838
  const limit = typeof flags.limit === "string" ? Number(flags.limit) || 50 : 50;
11821
- return fetchCommits(record, limit);
12839
+ return fetchCommits(rootRecord, limit);
11822
12840
  }
11823
12841
  async function remoteRestore(record, parsed) {
11824
12842
  const headId = parsed.command[2];
@@ -11827,9 +12845,10 @@ async function remoteRestore(record, parsed) {
11827
12845
  hint: "List restorable commits with mdocs remote history --json, then run mdocs remote restore <headId>."
11828
12846
  });
11829
12847
  }
11830
- await refreshPresence(record, record.docId);
11831
12848
  const restoreAll = parsed.flags.all === true || parsed.flags.all === "true";
11832
12849
  let docId;
12850
+ const rootRecord = assertRootScoped(record, "restore");
12851
+ await refreshPresence(rootRecord, record.docId);
11833
12852
  if (!restoreAll) {
11834
12853
  const target = typeof parsed.flags.doc === "string" ? parsed.flags.doc : record.docId;
11835
12854
  if (!target) {
@@ -11839,20 +12858,32 @@ async function remoteRestore(record, parsed) {
11839
12858
  }
11840
12859
  docId = (await fetchDocument(record, target)).docId;
11841
12860
  }
11842
- return postRestore(record, headId, docId);
12861
+ return postRestore(rootRecord, headId, docId);
11843
12862
  }
11844
12863
  async function rejoin(root, joinId, flags) {
11845
12864
  const record = await readSelectedJoin(root, { ...flags, ...joinId ? { join: joinId } : {} });
11846
- const docId = record.docId ?? await firstDocIdForProject(record);
11847
- const state = await postPresenceAndReadState(record, record.shareUrl, docId, record.agentId, record.agentName);
12865
+ const rootRecord = rootScopedRecord(record);
12866
+ const docId = record.docId ?? (rootRecord ? await firstDocIdForProject(rootRecord) : void 0);
12867
+ const shareUrl = shareUrlForScope(record.shareUrl, { ...record, docId });
12868
+ const state = rootRecord && docId ? await postPresenceAndReadState(rootRecord, shareUrl, docId, record.agentId, record.agentName) : void 0;
11848
12869
  const next = {
11849
12870
  ...record,
12871
+ shareUrl,
11850
12872
  docId,
11851
12873
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
11852
- currentHead: state.document.currentSha
12874
+ currentHead: state?.document.currentSha
11853
12875
  };
11854
12876
  await writeJoinRecord(root, next);
11855
- return joinSummary(next, state.document);
12877
+ return joinSummary(next, state?.document);
12878
+ }
12879
+ async function normalizedSelectedJoin(root, flags) {
12880
+ const record = await readSelectedJoin(root, flags);
12881
+ if (!record.docId && record.scope === "file") return record;
12882
+ const shareUrl = shareUrlForScope(record.shareUrl, record);
12883
+ if (shareUrl === record.shareUrl) return record;
12884
+ const next = { ...record, shareUrl, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
12885
+ await writeJoinRecord(root, next);
12886
+ return next;
11856
12887
  }
11857
12888
  async function refreshPresence(record, docId) {
11858
12889
  if (!docId) return;
@@ -11860,19 +12891,13 @@ async function refreshPresence(record, docId) {
11860
12891
  }
11861
12892
  async function remoteContext(record, pathOrDocId, flags = {}) {
11862
12893
  const document = pathOrDocId ? await fetchDocument(record, pathOrDocId) : (await fetchState(record)).document;
11863
- await refreshPresence(record, document.docId);
12894
+ await refreshPresence(rootScopedRecordFor(record, document.rootId), document.docId);
12895
+ if (flags.summary || flags["metadata-only"]) {
12896
+ return withRemoteSummaryNextCommands(summarizeDocumentContext(document), record, document, pathOrDocId);
12897
+ }
11864
12898
  const startLine = typeof flags["start-line"] === "string" ? Number(flags["start-line"]) : void 0;
11865
12899
  const endLine = typeof flags["end-line"] === "string" ? Number(flags["end-line"]) : void 0;
11866
- const lines = document.markdown.split("\n");
11867
- const totalLines = lines.length;
11868
- const sl = startLine ?? 1;
11869
- const el = endLine ?? totalLines;
11870
- if (sl < 1 || el > totalLines || sl > el) {
11871
- throw new CliError("invalid_range", `Line range ${sl}:${el} is out of bounds for a ${totalLines}-line document.`, {
11872
- hint: `Use --start-line and --end-line with 1-based line numbers within 1:${totalLines}.`
11873
- });
11874
- }
11875
- const markdown = startLine !== void 0 || endLine !== void 0 ? lines.slice(sl - 1, el).join("\n") : document.markdown;
12900
+ const { markdown, totalLines, startLine: sl, endLine: el } = sliceMarkdown(document.markdown, startLine, endLine);
11876
12901
  return {
11877
12902
  joinId: record.joinId,
11878
12903
  scope: record.scope,
@@ -11884,11 +12909,107 @@ async function remoteContext(record, pathOrDocId, flags = {}) {
11884
12909
  startLine: sl,
11885
12910
  endLine: el,
11886
12911
  markdown,
11887
- comments: document.sidecar.comments.filter((comment2) => comment2.status === "open"),
11888
- suggestions: document.sidecar.suggestions.filter((suggestion) => suggestion.status === "open"),
11889
- anchors: document.anchors,
11890
- images: document.images
12912
+ ...flags["no-review"] ? {} : {
12913
+ comments: document.sidecar.comments.filter((comment2) => comment2.status === "open"),
12914
+ suggestions: document.sidecar.suggestions.filter((suggestion) => suggestion.status === "open"),
12915
+ anchors: document.anchors
12916
+ },
12917
+ images: document.images,
12918
+ links: document.links
12919
+ };
12920
+ }
12921
+ async function remoteReview(record, pathOrDocId) {
12922
+ const document = pathOrDocId ? await fetchDocument(record, pathOrDocId) : (await fetchState(record)).document;
12923
+ await refreshPresence(rootScopedRecordFor(record, document.rootId), document.docId);
12924
+ return {
12925
+ joinId: record.joinId,
12926
+ scope: record.scope,
12927
+ ...reviewStateForDocument(document)
12928
+ };
12929
+ }
12930
+ function withRemoteSummaryNextCommands(summary, record, document, requestedTarget) {
12931
+ const target = remoteCommandTarget(record, document, requestedTarget);
12932
+ const firstEnd = Math.min(summary.totalLines, 120);
12933
+ return {
12934
+ ...summary,
12935
+ nextCommands: [
12936
+ `mdocs remote context${target} --start-line 1 --end-line ${firstEnd} --no-review --json`,
12937
+ summary.totalLines > firstEnd ? `mdocs remote context${target} --start-line ${firstEnd + 1} --end-line ${Math.min(summary.totalLines, firstEnd + 120)} --no-review --json` : void 0,
12938
+ summary.reviewCounts.openComments || summary.reviewCounts.openSuggestions || summary.reviewCounts.anchorsNeedingReview ? `mdocs remote review${target} --json` : void 0
12939
+ ].filter((command) => Boolean(command))
12940
+ };
12941
+ }
12942
+ function remoteCommandTarget(record, document, requestedTarget) {
12943
+ if (record.scope === "file" && (!requestedTarget || requestedTarget === document.docId || requestedTarget === document.path)) return "";
12944
+ if (record.scope === "workspace") return ` ${document.rootId}:${document.docId}`;
12945
+ return ` ${document.docId}`;
12946
+ }
12947
+ async function remoteCreateFile(root, record, parsed) {
12948
+ assertProjectScope(record, "create-file");
12949
+ const rootRecord = assertRootScoped(record, "create-file");
12950
+ const path = parsed.command[2];
12951
+ if (!path) {
12952
+ throw new CliError("usage_error", "Missing file path.", {
12953
+ hint: "Run mdocs remote create-file <path> --with-file /tmp/initial.md --json."
12954
+ });
12955
+ }
12956
+ const tree = await fetchTree(rootRecord);
12957
+ const markdownInput = await readOptionalTextFlag(parsed.flags, root, ["with", "markdown", "body"]);
12958
+ const markdown = markdownInput ?? defaultMarkdown(path);
12959
+ const normalizedPath = normalizeRemoteWorkspacePath(path, tree.root.pathRules, Buffer.byteLength(markdown, "utf8"));
12960
+ if (tree.docs.some((doc) => doc.path === normalizedPath)) {
12961
+ throw new CliError("validation_error", `Document already exists: ${normalizedPath}`, {
12962
+ hint: "Choose a new path or use mdocs remote context to inspect the existing document."
12963
+ });
12964
+ }
12965
+ const docId = createDocId();
12966
+ const title = titleFromMarkdown(normalizedPath, markdown);
12967
+ const sidecar = emptySidecar(docId, normalizedPath, title);
12968
+ const baseDocument = {
12969
+ workspaceId: rootRecord.workspaceId,
12970
+ rootId: rootRecord.rootId,
12971
+ docId,
12972
+ path: normalizedPath,
12973
+ title,
12974
+ markdown,
12975
+ sidecar,
12976
+ anchors: [],
12977
+ images: [],
12978
+ links: [],
12979
+ openComments: 0,
12980
+ openSuggestions: 0,
12981
+ currentSha: tree.root.canonical.head
11891
12982
  };
12983
+ const document = await pushDocument(rootRecord, baseDocument, markdown, sidecar);
12984
+ await recordHead(root, record, document);
12985
+ return { document };
12986
+ }
12987
+ async function remoteMoveFile(root, record, parsed) {
12988
+ assertProjectScope(record, "move-file");
12989
+ const rootRecord = assertRootScoped(record, "move-file");
12990
+ const pathOrDocId = parsed.command[2];
12991
+ if (!pathOrDocId) {
12992
+ throw new CliError("usage_error", "Missing source document path or docId.", {
12993
+ hint: "Run mdocs remote move-file <path|docId> --to <new-path> --json."
12994
+ });
12995
+ }
12996
+ const nextPathFlag = requiredFlag(parsed.flags, "to");
12997
+ const document = await fetchDocument(record, pathOrDocId);
12998
+ const tree = await fetchTree(rootRecord);
12999
+ const nextPath = normalizeRemoteWorkspacePath(nextPathFlag, tree.root.pathRules, Buffer.byteLength(document.markdown, "utf8"));
13000
+ if (tree.docs.some((doc) => doc.docId !== document.docId && doc.path === nextPath)) {
13001
+ throw new CliError("validation_error", `Document already exists: ${nextPath}`, {
13002
+ hint: "Choose a path that is not already in use."
13003
+ });
13004
+ }
13005
+ const moved = await pushDocument(
13006
+ rootScopedRecordFor(record, document.rootId),
13007
+ { ...document, path: nextPath, title: titleFromMarkdown(nextPath, document.markdown) },
13008
+ document.markdown,
13009
+ document.sidecar
13010
+ );
13011
+ await recordHead(root, record, moved);
13012
+ return { document: moved };
11892
13013
  }
11893
13014
  async function remoteComment(root, record, parsed) {
11894
13015
  const body = await readRequiredTextFlag(parsed.flags, root, ["body", "message"]);
@@ -11913,11 +13034,71 @@ async function remoteSuggest(root, record, parsed) {
11913
13034
  );
11914
13035
  return { suggestion: result.created, document: result.document };
11915
13036
  }
13037
+ async function remoteCreateFolder(record, parsed) {
13038
+ assertProjectScope(record, "create-folder");
13039
+ const rootRecord = assertRootScoped(record, "create-folder");
13040
+ const name = folderNameFromArgs(parsed);
13041
+ const parentId = optionalFolderTarget(parsed.flags.parent);
13042
+ await refreshPresence(rootRecord, record.docId);
13043
+ return postLibraryFolder(rootRecord, { name, ...parentId ? { parentId } : {} }, actorForRecord(record));
13044
+ }
13045
+ async function remoteUpdateFolder(record, parsed) {
13046
+ assertProjectScope(record, "update-folder");
13047
+ const rootRecord = assertRootScoped(record, "update-folder");
13048
+ const folderId = parsed.command[2];
13049
+ if (!folderId) {
13050
+ throw new CliError("usage_error", "Missing folder id.", {
13051
+ hint: "Run mdocs remote update-folder <folderId> --name <name> --json."
13052
+ });
13053
+ }
13054
+ const input = {};
13055
+ if (typeof parsed.flags.name === "string") input.name = parsed.flags.name;
13056
+ if (parsed.flags.home === true || parsed.flags.home === "true") input.parentId = null;
13057
+ else if (parsed.flags.parent !== void 0) input.parentId = optionalFolderTarget(parsed.flags.parent);
13058
+ if (input.name === void 0 && input.parentId === void 0) {
13059
+ throw new CliError("usage_error", "Nothing to update.", {
13060
+ hint: "Pass --name <name>, --parent <folderId>, or --home."
13061
+ });
13062
+ }
13063
+ await refreshPresence(rootRecord, record.docId);
13064
+ return patchLibraryFolder(rootRecord, folderId, input, actorForRecord(record));
13065
+ }
13066
+ async function remoteMoveRoot(record, parsed) {
13067
+ assertProjectScope(record, "move-root");
13068
+ const rootRecord = assertRootScoped(record, "move-root");
13069
+ const folderId = parsed.flags.home === true || parsed.flags.home === "true" ? null : optionalFolderTarget(parsed.flags.folder);
13070
+ if (folderId === void 0) {
13071
+ throw new CliError("usage_error", "Missing target folder.", {
13072
+ hint: "Pass --folder <folderId> to move into a folder, or --home to move to the top level."
13073
+ });
13074
+ }
13075
+ await refreshPresence(rootRecord, record.docId);
13076
+ return postMoveRoot(rootRecord, folderId, actorForRecord(record));
13077
+ }
13078
+ async function remoteInviteFolder(record, parsed) {
13079
+ assertProjectScope(record, "invite-folder");
13080
+ const rootRecord = assertRootScoped(record, "invite-folder");
13081
+ const folderId = parsed.command[2];
13082
+ if (!folderId) {
13083
+ throw new CliError("usage_error", "Missing folder id.", {
13084
+ hint: "Run mdocs remote invite-folder <folderId> --email person@example.com --role edit --json."
13085
+ });
13086
+ }
13087
+ const email = requiredFlag(parsed.flags, "email");
13088
+ const role = typeof parsed.flags.role === "string" ? parsed.flags.role : "edit";
13089
+ if (!["read", "suggest", "edit", "admin"].includes(role)) {
13090
+ throw new CliError("usage_error", "Invalid --role.", {
13091
+ hint: "Use --role read, suggest, edit, or admin."
13092
+ });
13093
+ }
13094
+ await refreshPresence(rootRecord, record.docId);
13095
+ return postFolderInvite(rootRecord, folderId, { email, role }, actorForRecord(record));
13096
+ }
11916
13097
  async function remoteReject(root, record, parsed) {
11917
13098
  const suggestionId = parsed.command[2];
11918
13099
  if (!suggestionId) {
11919
13100
  throw new CliError("usage_error", "Missing suggestion id.", {
11920
- hint: "List open suggestions with mdocs remote context --json, then run mdocs remote reject <suggestionId>."
13101
+ hint: "List open suggestions with mdocs remote review --json, then run mdocs remote reject <suggestionId>."
11921
13102
  });
11922
13103
  }
11923
13104
  const pathOrDocId = parsed.command[3] ?? record.docId;
@@ -11927,12 +13108,13 @@ async function remoteReject(root, record, parsed) {
11927
13108
  });
11928
13109
  }
11929
13110
  let document = await fetchDocument(record, pathOrDocId);
11930
- await refreshPresence(record, document.docId);
13111
+ let documentRecord = rootScopedRecordFor(record, document.rootId);
13112
+ await refreshPresence(documentRecord, document.docId);
11931
13113
  const actor = actorForRecord(record);
11932
13114
  const suggestion = document.sidecar.suggestions.find((candidate) => candidate.id === suggestionId);
11933
13115
  if (!suggestion) {
11934
13116
  throw new CliError("not_found", `Unknown suggestion: ${suggestionId}`, {
11935
- hint: "List suggestion ids with mdocs remote context --json."
13117
+ hint: "List suggestion ids with mdocs remote review --json."
11936
13118
  });
11937
13119
  }
11938
13120
  if (suggestion.status !== "open") {
@@ -11942,7 +13124,7 @@ async function remoteReject(root, record, parsed) {
11942
13124
  }
11943
13125
  try {
11944
13126
  const additions = { anchors: [], comments: [], suggestions: [], withdrawSuggestionIds: [suggestionId] };
11945
- const pushed = await postReview(record, document.docId, additions, actor);
13127
+ const pushed = await postReview(documentRecord, document.docId, additions, actor);
11946
13128
  await recordHead(root, record, pushed);
11947
13129
  const updated = pushed.sidecar.suggestions.find((candidate) => candidate.id === suggestionId);
11948
13130
  return { suggestion: updated ?? suggestion, document: pushed };
@@ -11956,11 +13138,12 @@ async function remoteReject(root, record, parsed) {
11956
13138
  }
11957
13139
  for (let index = 0; index < PUSH_REBASE_ATTEMPTS; index += 1) {
11958
13140
  if (index > 0) document = await fetchDocument(record, document.docId);
13141
+ documentRecord = rootScopedRecordFor(record, document.rootId);
11959
13142
  const io = new RemoteDocumentIO(document);
11960
13143
  const rejected = await rejectSuggestion(io, suggestionId, actor);
11961
13144
  const state = await getDocumentState(io, document.docId);
11962
13145
  try {
11963
- const pushed = await pushDocument(record, document, state.markdown, state.sidecar);
13146
+ const pushed = await pushDocument(documentRecord, document, state.markdown, state.sidecar);
11964
13147
  await recordHead(root, record, pushed);
11965
13148
  return { suggestion: rejected, document: pushed };
11966
13149
  } catch (error) {
@@ -11969,7 +13152,7 @@ async function remoteReject(root, record, parsed) {
11969
13152
  }
11970
13153
  }
11971
13154
  throw new CliError("conflict", "Remote change was not accepted after retries.", {
11972
- hint: "The document is changing rapidly. Refetch with mdocs remote context --json and retry."
13155
+ hint: "The document is changing rapidly. Refetch with mdocs remote context --summary --json, then retry against the needed content range."
11973
13156
  });
11974
13157
  }
11975
13158
  function isWithdrawalUnsupported(error) {
@@ -11985,7 +13168,8 @@ async function submitReview(root, record, pathOrDocId, mutate) {
11985
13168
  });
11986
13169
  }
11987
13170
  let document = await fetchDocument(record, pathOrDocId);
11988
- await refreshPresence(record, document.docId);
13171
+ let documentRecord = rootScopedRecordFor(record, document.rootId);
13172
+ await refreshPresence(documentRecord, document.docId);
11989
13173
  const build = async (base) => {
11990
13174
  const io = new RemoteDocumentIO(base);
11991
13175
  const created = await mutate(io, base.docId);
@@ -12002,7 +13186,7 @@ async function submitReview(root, record, pathOrDocId, mutate) {
12002
13186
  };
12003
13187
  let attempt = await build(document);
12004
13188
  try {
12005
- const pushed = await postReview(record, document.docId, attempt.additions, actorForRecord(record));
13189
+ const pushed = await postReview(documentRecord, document.docId, attempt.additions, actorForRecord(record));
12006
13190
  await recordHead(root, record, pushed);
12007
13191
  return { created: attempt.created, document: pushed };
12008
13192
  } catch (error) {
@@ -12011,10 +13195,11 @@ async function submitReview(root, record, pathOrDocId, mutate) {
12011
13195
  for (let index = 0; index < PUSH_REBASE_ATTEMPTS; index += 1) {
12012
13196
  if (index > 0) {
12013
13197
  document = await fetchDocument(record, document.docId);
13198
+ documentRecord = rootScopedRecordFor(record, document.rootId);
12014
13199
  attempt = await build(document);
12015
13200
  }
12016
13201
  try {
12017
- const pushed = await pushDocument(record, document, attempt.state.markdown, attempt.state.sidecar);
13202
+ const pushed = await pushDocument(documentRecord, document, attempt.state.markdown, attempt.state.sidecar);
12018
13203
  await recordHead(root, record, pushed);
12019
13204
  return { created: attempt.created, document: pushed };
12020
13205
  } catch (error) {
@@ -12023,7 +13208,7 @@ async function submitReview(root, record, pathOrDocId, mutate) {
12023
13208
  }
12024
13209
  }
12025
13210
  throw new CliError("conflict", "Remote change was not accepted after retries.", {
12026
- hint: "The document is changing rapidly. Refetch with mdocs remote context --json and retry."
13211
+ hint: "The document is changing rapidly. Refetch with mdocs remote context --summary --json, then retry against the needed content range."
12027
13212
  });
12028
13213
  }
12029
13214
  function isReviewEndpointUnsupported(error) {
@@ -12038,8 +13223,9 @@ async function recordHead(root, record, document) {
12038
13223
  }
12039
13224
  async function remoteEvents(root, record, flags) {
12040
13225
  const after = typeof flags.after === "string" ? Number(flags.after) : record.lastSeenEventId;
12041
- await refreshPresence(record, record.docId);
12042
- const response = await fetchPendingEvents(record, after);
13226
+ const rootRecord = assertRootScoped(record, "events");
13227
+ await refreshPresence(rootRecord, record.docId);
13228
+ const response = await fetchPendingEvents(rootRecord, after);
12043
13229
  await writeJoinRecord(root, {
12044
13230
  ...record,
12045
13231
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -12048,7 +13234,7 @@ async function remoteEvents(root, record, flags) {
12048
13234
  if (response.truncated) {
12049
13235
  return {
12050
13236
  ...response,
12051
- hint: "Older events were dropped from the bounded event log. Refetch full state with mdocs remote context --json instead of relying on the event stream."
13237
+ hint: "Older events were dropped from the bounded event log. Refetch with mdocs remote context --summary --json, then read needed content pages and review state."
12052
13238
  };
12053
13239
  }
12054
13240
  return response;
@@ -12057,11 +13243,11 @@ function parseShareUrl(value, flags) {
12057
13243
  const url = new URL(value);
12058
13244
  const route = parseSharePath(url.pathname);
12059
13245
  if (!route) {
12060
- throw new CliError("usage_error", "Share URL must be a Magic Markdown /share/<workspace>/<root>[/doc] URL.", {
13246
+ throw new CliError("usage_error", "Share URL must be a Magic Markdown /share/<workspace>[/<root>[/doc]] URL.", {
12061
13247
  hint: "Copy the share link from the Magic Markdown share dialog."
12062
13248
  });
12063
13249
  }
12064
- const scope = flags.scope === "project" || !route.docId && flags.scope !== "file" ? "project" : "file";
13250
+ const scope = flags.scope === "workspace" || !route.rootId ? "workspace" : flags.scope === "project" || !route.docId && flags.scope !== "file" ? "project" : "file";
12065
13251
  const explicitDoc = typeof flags.doc === "string" ? flags.doc : typeof flags["doc-id"] === "string" ? flags["doc-id"] : void 0;
12066
13252
  return {
12067
13253
  joinId: joinIdFor(value),
@@ -12069,8 +13255,8 @@ function parseShareUrl(value, flags) {
12069
13255
  shareUrl: value,
12070
13256
  origin: url.origin,
12071
13257
  workspaceId: route.workspaceId,
12072
- rootId: route.rootId,
12073
- docId: explicitDoc ?? route.docId,
13258
+ rootId: scope === "workspace" ? void 0 : route.rootId,
13259
+ docId: scope === "workspace" ? void 0 : explicitDoc ?? route.docId,
12074
13260
  agentId: "",
12075
13261
  agentName: "",
12076
13262
  joinedAt: "",
@@ -12078,6 +13264,15 @@ function parseShareUrl(value, flags) {
12078
13264
  lastSeenEventId: 0
12079
13265
  };
12080
13266
  }
13267
+ function shareUrlForScope(value, record) {
13268
+ const url = new URL(value);
13269
+ url.pathname = buildSharePath(
13270
+ record.workspaceId,
13271
+ record.scope === "workspace" ? void 0 : record.rootId,
13272
+ record.scope === "file" ? record.docId : void 0
13273
+ );
13274
+ return url.toString();
13275
+ }
12081
13276
  function joinSummary(record, document) {
12082
13277
  return {
12083
13278
  connected: true,
@@ -12086,19 +13281,84 @@ function joinSummary(record, document) {
12086
13281
  workspaceId: record.workspaceId,
12087
13282
  rootId: record.rootId,
12088
13283
  docId: record.docId,
12089
- currentHead: document.currentSha,
12090
- document: {
12091
- docId: document.docId,
12092
- path: document.path,
12093
- title: document.title
12094
- },
13284
+ currentHead: document?.currentSha,
13285
+ ...document ? {
13286
+ document: {
13287
+ docId: document.docId,
13288
+ path: document.path,
13289
+ title: document.title
13290
+ }
13291
+ } : {},
12095
13292
  nextCommands: [
12096
- "mdocs remote context --json",
13293
+ record.scope === "workspace" ? "mdocs remote map --json" : "mdocs remote context --summary --json",
12097
13294
  record.scope === "project" ? "mdocs remote map --json" : void 0,
12098
- "mdocs remote events --json"
13295
+ record.scope === "workspace" ? "mdocs remote library --json" : void 0,
13296
+ record.scope === "workspace" ? void 0 : "mdocs remote events --json"
12099
13297
  ].filter(Boolean)
12100
13298
  };
12101
13299
  }
13300
+ function assertProjectScope(record, command) {
13301
+ if (record.scope === "project") return;
13302
+ throw new CliError("usage_error", `remote ${command} requires a project-scoped join.`, {
13303
+ hint: "Rejoin the share with `mdocs join <share-url> --scope project --json`, then retry."
13304
+ });
13305
+ }
13306
+ function assertRootScoped(record, command) {
13307
+ if (record.rootId) return rootScopedRecordFor(record, record.rootId);
13308
+ throw new CliError("usage_error", `remote ${command} needs a specific project root.`, {
13309
+ hint: "For Home workspace joins, run `mdocs remote map --json` and use a document target, or join a project share for root organization commands."
13310
+ });
13311
+ }
13312
+ function rootScopedRecord(record) {
13313
+ return record.rootId ? rootScopedRecordFor(record, record.rootId) : void 0;
13314
+ }
13315
+ function rootScopedRecordFor(record, rootId) {
13316
+ return {
13317
+ ...record,
13318
+ rootId,
13319
+ scope: record.scope === "file" ? "file" : "project"
13320
+ };
13321
+ }
13322
+ function emptySidecar(docId, path, title) {
13323
+ return {
13324
+ schemaVersion: SIDECAR_SCHEMA_VERSION,
13325
+ docId,
13326
+ path,
13327
+ title,
13328
+ anchors: [],
13329
+ comments: [],
13330
+ suggestions: [],
13331
+ changeSets: [],
13332
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
13333
+ };
13334
+ }
13335
+ function defaultMarkdown(path) {
13336
+ return `# ${titleFromMarkdown(path, "")}
13337
+ `;
13338
+ }
13339
+ function normalizeRemoteWorkspacePath(path, rules, sizeBytes) {
13340
+ try {
13341
+ return assertWorkspacePathAllowed(path, rules, sizeBytes);
13342
+ } catch (error) {
13343
+ throw new CliError("validation_error", error instanceof Error ? error.message : String(error));
13344
+ }
13345
+ }
13346
+ function folderNameFromArgs(parsed) {
13347
+ const name = parsed.command[2] ?? (typeof parsed.flags.name === "string" ? parsed.flags.name : void 0);
13348
+ if (!name?.trim()) {
13349
+ throw new CliError("usage_error", "Missing folder name.", {
13350
+ hint: "Run mdocs remote create-folder <name> --json."
13351
+ });
13352
+ }
13353
+ return name;
13354
+ }
13355
+ function optionalFolderTarget(value) {
13356
+ if (value === void 0) return void 0;
13357
+ if (typeof value !== "string") return void 0;
13358
+ const trimmed = value.trim();
13359
+ if (!trimmed || trimmed === "home" || trimmed === "null" || trimmed === "top") return null;
13360
+ return trimmed;
13361
+ }
12102
13362
 
12103
13363
  // src/bridge.ts
12104
13364
  import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
@@ -12529,24 +13789,23 @@ async function main() {
12529
13789
  print(await getWorkspaceMap(io), parsed.flags);
12530
13790
  return;
12531
13791
  }
13792
+ case "graph": {
13793
+ print(await getWorkspaceGraph(io), parsed.flags);
13794
+ return;
13795
+ }
12532
13796
  case "state": {
12533
13797
  print(await getDocumentState(io, requiredArg(parsed.command[1], "path")), parsed.flags);
12534
13798
  return;
12535
13799
  }
12536
13800
  case "context": {
12537
13801
  const state = await getDocumentState(io, requiredArg(parsed.command[1], "path"));
13802
+ if (parsed.flags.summary || parsed.flags["metadata-only"]) {
13803
+ print(summarizeDocumentContext(state), parsed.flags);
13804
+ return;
13805
+ }
12538
13806
  const startLine = parsed.flags["start-line"] !== void 0 ? Number(parsed.flags["start-line"]) : void 0;
12539
13807
  const endLine = parsed.flags["end-line"] !== void 0 ? Number(parsed.flags["end-line"]) : void 0;
12540
- const lines = state.markdown.split("\n");
12541
- const totalLines = lines.length;
12542
- const sl = startLine ?? 1;
12543
- const el = endLine ?? totalLines;
12544
- if (sl < 1 || el > totalLines || sl > el) {
12545
- throw new CliError("invalid_range", `Line range ${sl}:${el} is out of bounds for a ${totalLines}-line document.`, {
12546
- hint: `Use --start-line and --end-line with 1-based line numbers within 1:${totalLines}.`
12547
- });
12548
- }
12549
- const markdown = startLine !== void 0 || endLine !== void 0 ? lines.slice(sl - 1, el).join("\n") : state.markdown;
13808
+ const { markdown, totalLines, startLine: sl, endLine: el } = sliceMarkdown(state.markdown, startLine, endLine);
12550
13809
  print({
12551
13810
  docId: state.docId,
12552
13811
  path: state.path,
@@ -12559,12 +13818,20 @@ async function main() {
12559
13818
  ...image2,
12560
13819
  ...image2.workspacePath ? { absolutePath: resolve5(cwd, image2.workspacePath) } : {}
12561
13820
  })),
12562
- comments: state.sidecar.comments.filter((comment2) => comment2.status === "open"),
12563
- suggestions: state.sidecar.suggestions.filter((suggestion) => suggestion.status === "open"),
12564
- anchors: state.anchors
13821
+ links: state.links,
13822
+ ...parsed.flags["no-review"] ? {} : {
13823
+ comments: state.sidecar.comments.filter((comment2) => comment2.status === "open"),
13824
+ suggestions: state.sidecar.suggestions.filter((suggestion) => suggestion.status === "open"),
13825
+ anchors: state.anchors
13826
+ }
12565
13827
  }, parsed.flags);
12566
13828
  return;
12567
13829
  }
13830
+ case "review": {
13831
+ const state = await getDocumentState(io, requiredArg(parsed.command[1], "path"));
13832
+ print(reviewStateForDocument(state), parsed.flags);
13833
+ return;
13834
+ }
12568
13835
  case "comments": {
12569
13836
  const state = await getDocumentState(io, requiredArg(parsed.command[1], "path"));
12570
13837
  print(state.sidecar.comments, parsed.flags);
@@ -12822,8 +14089,11 @@ Commands:
12822
14089
  agent commands --json Print machine-readable command metadata
12823
14090
  init [path] Initialize .mdocs and index Markdown files
12824
14091
  map --json Print workspace map
14092
+ graph --json Print workspace knowledge graph nodes and edges
12825
14093
  state <path> --json Print merged Markdown and sidecar state
12826
14094
  context <path> --json Print agent-ready context
14095
+ context <path> --summary --json Print document metadata and headings
14096
+ review <path> --json Print open comments, suggestions, anchors
12827
14097
  comments <path> --json List comments
12828
14098
  comment <path> --range 3:5 --body Add a sidecar comment
12829
14099
  suggestions <path> --json List suggestions
@@ -12841,9 +14111,13 @@ Commands:
12841
14111
  checkpoint create|list|restore Manage local reversible checkpoints
12842
14112
  join <share-url> --json Join a Magic Markdown share through the CLI
12843
14113
  joins --json List saved Magic Markdown remote joins
12844
- remote map|context|comment|suggest Work with the active remote join
14114
+ remote map|graph|context|review|create-file|move-file
14115
+ Work with documents in the active remote join
14116
+ remote comment|suggest Add remote review comments and suggestions
12845
14117
  remote reject <suggestionId> Withdraw a suggestion you submitted remotely
12846
14118
  remote events|history|restore Poll events, list commits, restore snapshots
14119
+ remote library|create-folder|update-folder|move-root|invite-folder
14120
+ Organize the joined project library
12847
14121
  bridge --workspace <id> --root . --url <base-url> --token <bridge-token>
12848
14122
  Sync an approved local root with the workspace
12849
14123
  (--token from the web "Bind agent filesystem"