@json-to-office/mcp-server 1.4.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
7
7
  import { McpServer } from "@modelcontextprotocol/server";
8
8
 
9
9
  // src/lib/version.ts
10
- var SERVER_VERSION = true ? "1.4.0" : "dev-mode";
10
+ var SERVER_VERSION = true ? "1.8.0" : "dev-mode";
11
11
  var SERVER_NAME = "json-to-office";
12
12
  var PACKAGE_NAME = "@json-to-office/mcp-server";
13
13
 
@@ -221,6 +221,8 @@ var ERROR_CODES = {
221
221
  HOST_NOTE: "W_HOST_NOTE",
222
222
  /** A generation warning the core raised without a code of its own. */
223
223
  GENERATION: "W_GENERATION",
224
+ /** A design-quality rule threw, so its whole class of findings is missing. */
225
+ QUALITY_RULE_ERROR: "W_QUALITY_RULE_ERROR",
224
226
  /** A required host binary (LibreOffice, poppler) is absent. */
225
227
  DEPENDENCY_MISSING: "E_DEPENDENCY_MISSING",
226
228
  /** The client cancelled the request. */
@@ -317,6 +319,25 @@ function toolResult(payload) {
317
319
  };
318
320
  }
319
321
  var HOST_DEPENDENCY_ERRORS = /* @__PURE__ */ new Set([RENDERER_DEPENDENCY_MISSING]);
322
+ function qualityOptionDiagnostic(error) {
323
+ const code = qualityCallerCode(error);
324
+ if (code === void 0 || code === ERROR_CODES.INVALID_DOCUMENT) {
325
+ return void 0;
326
+ }
327
+ return diagnostic(
328
+ code,
329
+ error instanceof Error ? error.message : String(error)
330
+ );
331
+ }
332
+ function qualityCallerCode(error) {
333
+ const code = error?.code;
334
+ if (code === "QUALITY_PROFILE_INCOMPATIBLE")
335
+ return OPTION_ERROR_CODES.INVALID_QUALITY_PROFILE;
336
+ if (code === "QUALITY_POLICY_INVALID")
337
+ return OPTION_ERROR_CODES.INVALID_QUALITY_POLICY;
338
+ if (code === "QUALITY_GATE_FAILED") return ERROR_CODES.INVALID_DOCUMENT;
339
+ return void 0;
340
+ }
320
341
  function stackAllowed() {
321
342
  const flag = process.env.JTO_MCP_DEBUG_STACKS;
322
343
  return flag === "1" || flag === "true";
@@ -367,7 +388,7 @@ async function guarded(body) {
367
388
  return withHostNotes(result, notes);
368
389
  } catch (error) {
369
390
  const message2 = error instanceof Error ? error.message : String(error);
370
- const code = error instanceof Error && HOST_DEPENDENCY_ERRORS.has(error.name) ? ERROR_CODES.DEPENDENCY_MISSING : ERROR_CODES.INTERNAL;
391
+ const code = qualityCallerCode(error) ?? (error instanceof Error && HOST_DEPENDENCY_ERRORS.has(error.name) ? ERROR_CODES.DEPENDENCY_MISSING : ERROR_CODES.INTERNAL);
371
392
  return withHostNotes(
372
393
  failure(code, message2, {
373
394
  context: {
@@ -386,7 +407,11 @@ var OPTION_ERROR_CODES = {
386
407
  /** `themePath` is not a data-only JSON theme path. */
387
408
  INVALID_THEME_PATH: "E_INVALID_THEME_PATH",
388
409
  /** The tool does not support the requested format. */
389
- UNSUPPORTED_FORMAT: "E_UNSUPPORTED_FORMAT"
410
+ UNSUPPORTED_FORMAT: "E_UNSUPPORTED_FORMAT",
411
+ /** `quality.profile` does not cover the format or renderer of this run. */
412
+ INVALID_QUALITY_PROFILE: "E_INVALID_QUALITY_PROFILE",
413
+ /** `quality.policy` sets a gate, severity or budget that is not a legal value. */
414
+ INVALID_QUALITY_POLICY: "E_INVALID_QUALITY_POLICY"
390
415
  };
391
416
  var DEFERRED_TO_COMPILER = /* @__PURE__ */ new Set([
392
417
  ERROR_CODES.UNSUPPORTED_RENDERER_FEATURE
@@ -395,9 +420,9 @@ var ROOT_SENTINELS = /* @__PURE__ */ new Set(["", "/", "#", "root"]);
395
420
  function escapePointerSegment(segment) {
396
421
  return segment.replace(/~/g, "~0").replace(/\//g, "~1");
397
422
  }
398
- function toJsonPointer(path6) {
399
- if (path6 === void 0) return void 0;
400
- const trimmed = path6.trim();
423
+ function toJsonPointer(path7) {
424
+ if (path7 === void 0) return void 0;
425
+ const trimmed = path7.trim();
401
426
  if (ROOT_SENTINELS.has(trimmed)) return "";
402
427
  const segments = trimmed.startsWith("/") ? trimmed.slice(1).split("/") : trimmed.replace(/\[(\d+)\]/g, ".$1").split(".").filter((segment) => segment !== "");
403
428
  if (segments.length === 0) return "";
@@ -426,6 +451,34 @@ function validationDiagnostics(errors) {
426
451
  })
427
452
  );
428
453
  }
454
+ function qualityAnalysisDiagnostics(analysis) {
455
+ return analysis.diagnostics.map((diagnostic2) => ({
456
+ source: diagnostic2.source,
457
+ ruleId: diagnostic2.ruleId,
458
+ category: diagnostic2.category,
459
+ certainty: diagnostic2.certainty,
460
+ severity: diagnostic2.severity,
461
+ code: diagnostic2.code,
462
+ message: diagnostic2.message,
463
+ path: diagnostic2.path,
464
+ blocking: diagnostic2.blocking,
465
+ ...diagnostic2.suggestion !== void 0 && {
466
+ suggestion: diagnostic2.suggestion
467
+ },
468
+ ...diagnostic2.context !== void 0 && {
469
+ context: { ...diagnostic2.context }
470
+ },
471
+ ...diagnostic2.relatedPaths !== void 0 && {
472
+ relatedPaths: diagnostic2.relatedPaths
473
+ },
474
+ ...diagnostic2.evidence !== void 0 && {
475
+ evidence: { ...diagnostic2.evidence }
476
+ },
477
+ ...diagnostic2.fixes !== void 0 && {
478
+ fixes: diagnostic2.fixes
479
+ }
480
+ }));
481
+ }
429
482
  function looksLikeValidationErrors(value) {
430
483
  return Array.isArray(value) && value.length > 0 && value.every(
431
484
  (entry) => typeof entry === "object" && entry !== null && typeof entry.message === "string"
@@ -570,11 +623,11 @@ async function probeService(rawUrl, envVar) {
570
623
  }
571
624
  const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
572
625
  const net = await import("net");
573
- return new Promise((resolve2) => {
626
+ return new Promise((resolve3) => {
574
627
  const socket = net.connect({ host: target.hostname, port });
575
628
  const settle = (detail) => {
576
629
  socket.destroy();
577
- resolve2({
630
+ resolve3({
578
631
  available: detail === void 0,
579
632
  url: target.origin,
580
633
  envVar,
@@ -614,7 +667,7 @@ function register(server, deps) {
614
667
  "jto_info",
615
668
  {
616
669
  title: "Server info",
617
- description: "Versions, supported formats with each renderer and whether its backend loads here, workspace availability, output-root and size limits, and whether the optional host dependencies (LibreOffice and poppler for jto_preview, a Highcharts export server for the DOCX `highcharts` component) are present on this host. Call this first.",
670
+ description: "Versions, supported formats with each renderer and whether its backend loads here, workspace availability and whether workspaces survive a lost connection, output-root and size limits, and whether the optional host dependencies (LibreOffice and poppler for jto_preview, a Highcharts export server for the DOCX `highcharts` component) are present on this host. Call this first.",
618
671
  annotations: { readOnlyHint: true, openWorldHint: false },
619
672
  inputSchema: S({
620
673
  type: "object",
@@ -699,9 +752,17 @@ function register(server, deps) {
699
752
  type: "object",
700
753
  properties: {
701
754
  available: { type: "boolean" },
702
- open: { type: "integer" }
755
+ open: { type: "integer" },
756
+ persistent: {
757
+ type: "boolean",
758
+ description: "True when revisions are mirrored to disk and survive a lost connection; false when handles are memory-only, which is the default."
759
+ },
760
+ root: {
761
+ type: "string",
762
+ description: "Directory the revisions are mirrored to, when persistent."
763
+ }
703
764
  },
704
- required: ["available", "open"],
765
+ required: ["available", "open", "persistent"],
705
766
  additionalProperties: false
706
767
  },
707
768
  output: {
@@ -862,7 +923,11 @@ function register(server, deps) {
862
923
  formats,
863
924
  workspaces: {
864
925
  available: store.available,
865
- open: listed.ok ? listed.records.length : 0
926
+ open: listed.ok ? listed.records.length : 0,
927
+ persistent: deps.workspacePersistence !== void 0,
928
+ ...deps.workspacePersistence && {
929
+ root: deps.workspacePersistence.root
930
+ }
866
931
  },
867
932
  output: {
868
933
  root: deps.outputRoot.path,
@@ -1122,10 +1187,10 @@ var STARTERS = [
1122
1187
  id: "pptx-minimal",
1123
1188
  format: "pptx",
1124
1189
  title: "Minimal presentation",
1125
- description: "The smallest valid .pptx: root, one slide, one title text.",
1190
+ description: "The smallest well-formed .pptx: root with a declared 16:9 canvas, one slide, one title text. The canvas stays: without it the renderer silently falls back to 4:3.",
1126
1191
  document: {
1127
1192
  name: "pptx",
1128
- props: { title: "Untitled deck" },
1193
+ props: { title: "Untitled deck", slideWidth: 13.333, slideHeight: 7.5 },
1129
1194
  children: [
1130
1195
  {
1131
1196
  name: "slide",
@@ -1982,17 +2047,26 @@ function capDiagnostics(diagnostics, limit) {
1982
2047
  if (diagnostics.length <= limit)
1983
2048
  return { kept: diagnostics, truncated: false };
1984
2049
  const ordered = [...diagnostics].sort(
1985
- (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
2050
+ (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || Number(b.blocking === true) - Number(a.blocking === true)
1986
2051
  );
1987
2052
  return { kept: ordered.slice(0, limit), truncated: true };
1988
2053
  }
2054
+ function ruleErrorDiagnostics(analysis) {
2055
+ return analysis.ruleErrors.map(
2056
+ (entry) => diagnostic(
2057
+ ERROR_CODES.QUALITY_RULE_ERROR,
2058
+ `Quality rule "${entry.ruleId}" failed: ${entry.message}`,
2059
+ { severity: "warning", source: "quality", ruleId: entry.ruleId }
2060
+ )
2061
+ );
2062
+ }
1989
2063
  var DEFAULT_MAX_DIAGNOSTICS = 100;
1990
2064
  function register4(server, deps) {
1991
2065
  server.registerTool(
1992
2066
  "jto_validate",
1993
2067
  {
1994
2068
  title: "Validate a document",
1995
- description: "Check a document against its format schema and report every defect as a path-addressed diagnostic. Paths are RFC 6901 JSON Pointers into the document you passed, so they can be used directly as patch targets; codes are the stable `E_`/`W_` vocabulary, e.g. `E_REQUIRED_PROPERTY`, `E_UNEXPECTED_PROPERTY`, `E_TYPE_MISMATCH`, `E_UNKNOWN_COMPONENT`. `ok` mirrors the gate generation applies: schema and semantic errors block it \u2014 the semantic rules the published JSON Schema cannot state, such as a text component needing one of `text`/`runs`, are checked here and only here \u2014 while renderer-profile findings (code `W_UNSUPPORTED_RENDERER_FEATURE`) come back as warnings because the renderer, not the schema, has the last word on those. A broken document is a normal result with `ok: false`, never an error. A renderer whose backend is not installed on this host is reported here too, as a `E_DEPENDENCY_MISSING` warning \u2014 the document may be fine and the host merely incomplete.",
2069
+ description: "Check a document against its format schema and report every defect as a path-addressed diagnostic. Paths are RFC 6901 JSON Pointers into the document you passed, so they can be used directly as patch targets; codes are the stable `E_`/`W_` vocabulary. `ok` mirrors generation: schema and semantic errors block; design-quality `W_QUALITY_*` findings advise by default and block only when `quality.policy.gate` requests it. A broken document is a normal result with `ok: false`, never a protocol error.",
1996
2070
  annotations: { readOnlyHint: true, openWorldHint: false },
1997
2071
  inputSchema: S({
1998
2072
  type: "object",
@@ -2008,6 +2082,29 @@ function register4(server, deps) {
2008
2082
  minimum: 1,
2009
2083
  maximum: 1e3,
2010
2084
  description: `Cap on returned diagnostics (default ${DEFAULT_MAX_DIAGNOSTICS}). Errors are kept ahead of warnings when the cap bites.`
2085
+ },
2086
+ quality: {
2087
+ type: "object",
2088
+ description: "Optional design profile plus per-run enforcement policy.",
2089
+ properties: {
2090
+ profile: {
2091
+ type: "object",
2092
+ properties: { id: { type: "string", minLength: 1 } },
2093
+ required: ["id"],
2094
+ additionalProperties: true
2095
+ },
2096
+ policy: {
2097
+ type: "object",
2098
+ properties: {
2099
+ gate: {
2100
+ type: "string",
2101
+ enum: ["none", "error", "warning", "info"]
2102
+ }
2103
+ },
2104
+ additionalProperties: true
2105
+ }
2106
+ },
2107
+ additionalProperties: false
2011
2108
  }
2012
2109
  },
2013
2110
  required: ["format"],
@@ -2038,7 +2135,11 @@ function register4(server, deps) {
2038
2135
  },
2039
2136
  truncated: {
2040
2137
  type: "boolean",
2041
- description: "`diagnostics` was capped by `maxDiagnostics`."
2138
+ description: "`diagnostics` was capped, by `maxDiagnostics` or by the budget the quality policy set."
2139
+ },
2140
+ profileId: {
2141
+ type: "string",
2142
+ description: "The quality profile the design analysis ran under, when one applied."
2042
2143
  }
2043
2144
  })
2044
2145
  )
@@ -2058,24 +2159,52 @@ function register4(server, deps) {
2058
2159
  resolved.document,
2059
2160
  args.renderer
2060
2161
  );
2061
- const all = [
2162
+ let analysis;
2163
+ let qualityOption;
2164
+ if (adapter.analyzeQuality) {
2165
+ try {
2166
+ analysis = await adapter.analyzeQuality(resolved.document, {
2167
+ renderer: args.renderer,
2168
+ quality: args.quality
2169
+ });
2170
+ } catch (error) {
2171
+ const option = result.valid ? void 0 : qualityOptionDiagnostic(error);
2172
+ if (!option) throw error;
2173
+ qualityOption = option;
2174
+ }
2175
+ }
2176
+ const structural = [
2062
2177
  ...validationDiagnostics(result.errors),
2063
- ...unavailable2 ? [unavailable2] : []
2178
+ ...unavailable2 ? [unavailable2] : [],
2179
+ ...qualityOption ? [qualityOption] : []
2180
+ ];
2181
+ const all = [
2182
+ ...structural,
2183
+ ...analysis ? [
2184
+ ...qualityAnalysisDiagnostics(analysis),
2185
+ ...ruleErrorDiagnostics(analysis)
2186
+ ] : []
2064
2187
  ];
2065
2188
  const counts = countDiagnostics(all);
2189
+ const blocked = countDiagnostics(structural).error > 0 || analysis?.blocked === true;
2066
2190
  const { kept, truncated } = capDiagnostics(
2067
2191
  all,
2068
2192
  args.maxDiagnostics ?? DEFAULT_MAX_DIAGNOSTICS
2069
2193
  );
2070
2194
  return {
2071
- ok: counts.error === 0,
2195
+ ok: !blocked,
2072
2196
  diagnostics: kept,
2073
- valid: counts.error === 0,
2197
+ valid: !blocked,
2074
2198
  format: args.format,
2075
2199
  ...args.renderer !== void 0 && { renderer: args.renderer },
2076
2200
  source: sourceSummary(resolved),
2077
2201
  counts,
2078
- truncated
2202
+ // The engine caps its own list under a policy budget, so a report
2203
+ // shortened there would otherwise come back reading complete.
2204
+ truncated: truncated || analysis?.truncated === true,
2205
+ ...analysis?.profileId !== void 0 && {
2206
+ profileId: analysis.profileId
2207
+ }
2079
2208
  };
2080
2209
  })
2081
2210
  )
@@ -3019,9 +3148,9 @@ async function digestAssets(document, baseDir) {
3019
3148
  [...references].map(async (reference) => {
3020
3149
  const resolved = path4.resolve(root, reference);
3021
3150
  try {
3022
- const stat = await fs4.stat(resolved);
3023
- if (!stat.isFile()) return;
3024
- entries.push(`${resolved}|${stat.size}|${stat.mtimeMs}`);
3151
+ const stat2 = await fs4.stat(resolved);
3152
+ if (!stat2.isFile()) return;
3153
+ entries.push(`${resolved}|${stat2.size}|${stat2.mtimeMs}`);
3025
3154
  } catch {
3026
3155
  }
3027
3156
  })
@@ -3051,8 +3180,8 @@ function collectAssetReferences(value, into) {
3051
3180
  async function digestThemeFile(themePath) {
3052
3181
  if (!themePath) return "none";
3053
3182
  try {
3054
- const stat = await fs4.stat(themePath);
3055
- return `${path4.resolve(themePath)}|${stat.size}|${stat.mtimeMs}`;
3183
+ const stat2 = await fs4.stat(themePath);
3184
+ return `${path4.resolve(themePath)}|${stat2.size}|${stat2.mtimeMs}`;
3056
3185
  } catch {
3057
3186
  return `${path4.resolve(themePath)}|missing`;
3058
3187
  }
@@ -3124,18 +3253,18 @@ function missingDependencyFailure(dependencies) {
3124
3253
  );
3125
3254
  }
3126
3255
  async function binaryIdentity(binary, args, signal) {
3127
- const [version, stat] = await Promise.all([
3256
+ const [version, stat2] = await Promise.all([
3128
3257
  readVersion(binary, args, signal),
3129
3258
  fs5.stat(binary).catch(() => void 0)
3130
3259
  ]);
3131
3260
  const identity = [
3132
3261
  version ?? "unknown",
3133
- stat ? `${stat.size}:${stat.mtimeMs}` : "nostat"
3262
+ stat2 ? `${stat2.size}:${stat2.mtimeMs}` : "nostat"
3134
3263
  ].join("|");
3135
3264
  return { identity, ...version !== void 0 && { version } };
3136
3265
  }
3137
3266
  function readVersion(binary, args, signal) {
3138
- return new Promise((resolve2) => {
3267
+ return new Promise((resolve3) => {
3139
3268
  execFile(
3140
3269
  binary,
3141
3270
  args,
@@ -3149,8 +3278,8 @@ function readVersion(binary, args, signal) {
3149
3278
  const text = `${stdout ?? ""}
3150
3279
  ${stderr ?? ""}`.trim();
3151
3280
  const line = text.split("\n")[0]?.trim();
3152
- if (!line && error) return resolve2(void 0);
3153
- resolve2(line || void 0);
3281
+ if (!line && error) return resolve3(void 0);
3282
+ resolve3(line || void 0);
3154
3283
  }
3155
3284
  );
3156
3285
  });
@@ -3195,12 +3324,12 @@ function defaultPreviewCacheDir() {
3195
3324
  }
3196
3325
  var CACHE_ENTRY_NAME = /^[a-f0-9]{64}\.(?:png|meta\.json)(?:\.tmp-\d+-\d+)?$/;
3197
3326
  async function inspectPrivateCacheDir(cacheDir) {
3198
- const stat = await fs6.lstat(cacheDir).catch(() => void 0);
3199
- if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) return false;
3200
- if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
3327
+ const stat2 = await fs6.lstat(cacheDir).catch(() => void 0);
3328
+ if (!stat2 || stat2.isSymbolicLink() || !stat2.isDirectory()) return false;
3329
+ if (typeof process.getuid === "function" && stat2.uid !== process.getuid()) {
3201
3330
  return false;
3202
3331
  }
3203
- if (process.platform !== "win32" && (stat.mode & 63) !== 0) {
3332
+ if (process.platform !== "win32" && (stat2.mode & 63) !== 0) {
3204
3333
  try {
3205
3334
  await fs6.chmod(cacheDir, 448);
3206
3335
  } catch {
@@ -3230,9 +3359,9 @@ async function sweepPreviewCache(cacheDir, options = {}) {
3230
3359
  for (const name of names) {
3231
3360
  if (!CACHE_ENTRY_NAME.test(name)) continue;
3232
3361
  const file = path5.join(cacheDir, name);
3233
- const stat = await fs6.lstat(file).catch(() => void 0);
3234
- if (!stat?.isFile()) continue;
3235
- entries.push({ file, mtimeMs: stat.mtimeMs, size: stat.size });
3362
+ const stat2 = await fs6.lstat(file).catch(() => void 0);
3363
+ if (!stat2?.isFile()) continue;
3364
+ entries.push({ file, mtimeMs: stat2.mtimeMs, size: stat2.size });
3236
3365
  }
3237
3366
  const doomed = entries.filter((entry) => now - entry.mtimeMs > maxAgeMs);
3238
3367
  const kept = entries.filter((entry) => now - entry.mtimeMs <= maxAgeMs).sort((a, b) => a.mtimeMs - b.mtimeMs);
@@ -3319,7 +3448,7 @@ function countPdfPages(pdf) {
3319
3448
  return matches ? matches.length : 0;
3320
3449
  }
3321
3450
  function exec(binary, args, timeoutMs, options = {}) {
3322
- return new Promise((resolve2, reject) => {
3451
+ return new Promise((resolve3, reject) => {
3323
3452
  execFile2(
3324
3453
  binary,
3325
3454
  args,
@@ -3330,7 +3459,7 @@ function exec(binary, args, timeoutMs, options = {}) {
3330
3459
  env: options.env ? { ...process.env, ...options.env } : process.env,
3331
3460
  ...options.signal && { signal: options.signal }
3332
3461
  },
3333
- (error) => error ? reject(error) : resolve2()
3462
+ (error) => error ? reject(error) : resolve3()
3334
3463
  );
3335
3464
  });
3336
3465
  }
@@ -3424,7 +3553,7 @@ async function resolvePdfinfo(pdftoppmPath) {
3424
3553
  return status.available ? status.path : void 0;
3425
3554
  }
3426
3555
  async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
3427
- return new Promise((resolve2) => {
3556
+ return new Promise((resolve3) => {
3428
3557
  execFile2(
3429
3558
  pdfinfo,
3430
3559
  [pdfPath],
@@ -3435,9 +3564,9 @@ async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
3435
3564
  ...signal && { signal }
3436
3565
  },
3437
3566
  (error, stdout) => {
3438
- if (error && !stdout) return resolve2(void 0);
3567
+ if (error && !stdout) return resolve3(void 0);
3439
3568
  const match = /^Pages:\s+(\d+)/m.exec(stdout ?? "");
3440
- resolve2(match ? Number(match[1]) : void 0);
3569
+ resolve3(match ? Number(match[1]) : void 0);
3441
3570
  }
3442
3571
  );
3443
3572
  });
@@ -4550,9 +4679,9 @@ function compilePatch(operations) {
4550
4679
  }
4551
4680
  );
4552
4681
  }
4553
- const path6 = parsePointer(raw.path);
4554
- if (!path6.ok) {
4555
- return problem(PATCH_ERROR_CODES.INVALID_POINTER, path6.message, index, {
4682
+ const path7 = parsePointer(raw.path);
4683
+ if (!path7.ok) {
4684
+ return problem(PATCH_ERROR_CODES.INVALID_POINTER, path7.message, index, {
4556
4685
  context: { op, pointer: raw.path },
4557
4686
  suggestion: 'Pointers are RFC 6901: "/children/0/props/text", with "~0" for "~" and "~1" for "/".'
4558
4687
  });
@@ -4560,7 +4689,7 @@ function compilePatch(operations) {
4560
4689
  const entry = {
4561
4690
  op,
4562
4691
  path: raw.path,
4563
- tokens: path6.tokens
4692
+ tokens: path7.tokens
4564
4693
  };
4565
4694
  if (VALUE_OPS.has(op)) {
4566
4695
  if (raw.value === void 0) {
@@ -4910,12 +5039,338 @@ function jsonEqualTokens(left, right) {
4910
5039
  }
4911
5040
 
4912
5041
  // src/workspace/store.ts
5042
+ import { randomBytes as randomBytes3 } from "crypto";
5043
+
5044
+ // src/workspace/persistence.ts
5045
+ import * as fs7 from "fs/promises";
5046
+ import * as path6 from "path";
4913
5047
  import { randomBytes as randomBytes2 } from "crypto";
5048
+ var WORKSPACE_DIR_ENV = "JTO_MCP_WORKSPACE_DIR";
5049
+ var SCHEMA_VERSION = 1;
5050
+ var META_FILE = "meta.json";
5051
+ var REVISION_PREFIX = "rev-";
5052
+ var REVISION_SUFFIX = ".json";
5053
+ var TEMP_SUFFIX = ".tmp";
5054
+ var STALE_TEMP_MS = 60 * 60 * 1e3;
5055
+ var SAFE_HANDLE = /^[A-Za-z0-9_-]{1,128}$/;
5056
+ function isPersistableHandle(handle) {
5057
+ return SAFE_HANDLE.test(handle);
5058
+ }
5059
+ var DEFAULT_PERSISTENCE_LIMITS = {
5060
+ maxWorkspaces: 32,
5061
+ maxRevisionsPerWorkspace: 9,
5062
+ maxEntryBytes: 16 * 1024 * 1024
5063
+ };
5064
+ function createWorkspacePersistence(options = {}) {
5065
+ const env = options.env ?? process.env;
5066
+ const configured = options.flagDir?.trim() || env[WORKSPACE_DIR_ENV]?.trim();
5067
+ if (!configured) return void 0;
5068
+ return createWorkspacePersistenceAt(path6.resolve(configured), options);
5069
+ }
5070
+ function createWorkspacePersistenceAt(root, limits = {}) {
5071
+ const bounds = {
5072
+ maxWorkspaces: limits.maxWorkspaces ?? DEFAULT_PERSISTENCE_LIMITS.maxWorkspaces,
5073
+ maxRevisionsPerWorkspace: limits.maxRevisionsPerWorkspace ?? DEFAULT_PERSISTENCE_LIMITS.maxRevisionsPerWorkspace,
5074
+ maxEntryBytes: limits.maxEntryBytes ?? DEFAULT_PERSISTENCE_LIMITS.maxEntryBytes
5075
+ };
5076
+ let ensured;
5077
+ const authored = /* @__PURE__ */ new Map();
5078
+ function claim(handle, revision) {
5079
+ const owned = authored.get(handle) ?? /* @__PURE__ */ new Set();
5080
+ owned.add(revision);
5081
+ authored.set(handle, owned);
5082
+ }
5083
+ async function ensureRoot() {
5084
+ if (ensured === void 0) {
5085
+ ensured = fs7.mkdir(root, { recursive: true, mode: 448 }).then(
5086
+ () => void 0,
5087
+ (error) => {
5088
+ ensured = void 0;
5089
+ throw error;
5090
+ }
5091
+ );
5092
+ }
5093
+ return ensured;
5094
+ }
5095
+ function dirFor(handle) {
5096
+ return path6.join(root, handle);
5097
+ }
5098
+ async function writeAtomic2(target, contents) {
5099
+ const temp = `${target}.${randomBytes2(6).toString("hex")}${TEMP_SUFFIX}`;
5100
+ try {
5101
+ await fs7.writeFile(temp, contents, { encoding: "utf8", mode: 384 });
5102
+ await fs7.rename(temp, target);
5103
+ } catch (error) {
5104
+ await fs7.rm(temp, { force: true }).catch(() => void 0);
5105
+ throw error;
5106
+ }
5107
+ }
5108
+ function revisionFile(handle, revision) {
5109
+ return path6.join(
5110
+ dirFor(handle),
5111
+ `${REVISION_PREFIX}${revision}${REVISION_SUFFIX}`
5112
+ );
5113
+ }
5114
+ async function readMeta(handle) {
5115
+ let raw;
5116
+ try {
5117
+ raw = await fs7.readFile(path6.join(dirFor(handle), META_FILE), "utf8");
5118
+ } catch {
5119
+ return void 0;
5120
+ }
5121
+ return parseMeta(raw, handle);
5122
+ }
5123
+ async function readRevision(handle, revision) {
5124
+ let text;
5125
+ try {
5126
+ text = await fs7.readFile(revisionFile(handle, revision), "utf8");
5127
+ } catch {
5128
+ return void 0;
5129
+ }
5130
+ try {
5131
+ const parsed = JSON.parse(text);
5132
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
5133
+ return void 0;
5134
+ } catch {
5135
+ return void 0;
5136
+ }
5137
+ return { revision, text, bytes: Buffer.byteLength(text, "utf8") };
5138
+ }
5139
+ async function handleDirs() {
5140
+ try {
5141
+ const entries = await fs7.readdir(root, { withFileTypes: true });
5142
+ return entries.filter(
5143
+ (entry) => entry.isDirectory() && isPersistableHandle(entry.name)
5144
+ ).map((entry) => entry.name);
5145
+ } catch {
5146
+ return [];
5147
+ }
5148
+ }
5149
+ async function pruneWorkspaces(incoming) {
5150
+ const handles = (await handleDirs()).filter(
5151
+ (handle) => handle !== incoming
5152
+ );
5153
+ const excess = handles.length + 1 - bounds.maxWorkspaces;
5154
+ if (excess <= 0) return;
5155
+ const metas = await Promise.all(
5156
+ handles.map(async (handle) => ({
5157
+ handle,
5158
+ // An unreadable entry sorts first: it is the one worth losing.
5159
+ updatedAt: (await readMeta(handle))?.updatedAt ?? 0
5160
+ }))
5161
+ );
5162
+ metas.sort((a, b) => a.updatedAt - b.updatedAt);
5163
+ for (const victim of metas.slice(0, excess)) {
5164
+ await fs7.rm(dirFor(victim.handle), { recursive: true, force: true }).catch(() => void 0);
5165
+ authored.delete(victim.handle);
5166
+ }
5167
+ }
5168
+ async function pruneRevisions(handle, retained) {
5169
+ let names;
5170
+ try {
5171
+ names = await fs7.readdir(dirFor(handle));
5172
+ } catch {
5173
+ return;
5174
+ }
5175
+ const owned = authored.get(handle);
5176
+ for (const name of names) {
5177
+ const target = path6.join(dirFor(handle), name);
5178
+ if (name.endsWith(TEMP_SUFFIX)) {
5179
+ if (await isStale(target)) {
5180
+ await fs7.rm(target, { force: true }).catch(() => void 0);
5181
+ }
5182
+ continue;
5183
+ }
5184
+ const revision = revisionOf(name);
5185
+ if (revision === void 0 || retained.has(revision)) continue;
5186
+ await fs7.rm(target, { force: true }).catch(() => void 0);
5187
+ owned?.delete(revision);
5188
+ }
5189
+ }
5190
+ return {
5191
+ root,
5192
+ limits: bounds,
5193
+ async save(snapshot) {
5194
+ if (!isPersistableHandle(snapshot.handle)) {
5195
+ throw new Error(
5196
+ `Workspace handle ${JSON.stringify(
5197
+ snapshot.handle
5198
+ )} cannot be used as a directory name.`
5199
+ );
5200
+ }
5201
+ if (snapshot.head.bytes > bounds.maxEntryBytes) {
5202
+ throw new Error(
5203
+ `Revision ${snapshot.head.revision} is ${snapshot.head.bytes} bytes, over the ${bounds.maxEntryBytes}-byte persistence limit.`
5204
+ );
5205
+ }
5206
+ await ensureRoot();
5207
+ const dir = dirFor(snapshot.handle);
5208
+ const fresh = !await exists(dir);
5209
+ if (fresh) await pruneWorkspaces(snapshot.handle);
5210
+ await fs7.mkdir(dir, { recursive: true, mode: 448 });
5211
+ const files = [snapshot.head];
5212
+ const retained = /* @__PURE__ */ new Set([snapshot.head.revision]);
5213
+ const pinned = [];
5214
+ for (const pin of [...snapshot.pins].sort(
5215
+ (a, b) => b.revision - a.revision
5216
+ )) {
5217
+ if (!retained.has(pin.revision)) {
5218
+ if (files.length >= bounds.maxRevisionsPerWorkspace) continue;
5219
+ files.push(pin);
5220
+ retained.add(pin.revision);
5221
+ }
5222
+ pinned.push(pin.revision);
5223
+ }
5224
+ const owned = authored.get(snapshot.handle);
5225
+ for (const document of files) {
5226
+ if (owned?.has(document.revision)) continue;
5227
+ await writeAtomic2(
5228
+ revisionFile(snapshot.handle, document.revision),
5229
+ document.text
5230
+ );
5231
+ claim(snapshot.handle, document.revision);
5232
+ }
5233
+ const meta = {
5234
+ schema: SCHEMA_VERSION,
5235
+ handle: snapshot.handle,
5236
+ format: snapshot.format,
5237
+ revision: snapshot.head.revision,
5238
+ bytes: snapshot.head.bytes,
5239
+ createdAt: snapshot.createdAt,
5240
+ updatedAt: snapshot.updatedAt,
5241
+ ...snapshot.title !== void 0 && { title: snapshot.title },
5242
+ pinnedRevisions: pinned.sort((a, b) => a - b)
5243
+ };
5244
+ await writeAtomic2(path6.join(dir, META_FILE), JSON.stringify(meta));
5245
+ await pruneRevisions(snapshot.handle, retained);
5246
+ },
5247
+ async list() {
5248
+ const handles = await handleDirs();
5249
+ const metas = await Promise.all(
5250
+ handles.map((handle) => readMeta(handle))
5251
+ );
5252
+ return metas.filter((meta) => meta !== void 0).sort((a, b) => b.updatedAt - a.updatedAt);
5253
+ },
5254
+ async load(handle) {
5255
+ if (!isPersistableHandle(handle)) return void 0;
5256
+ const meta = await readMeta(handle);
5257
+ if (!meta) return void 0;
5258
+ const head = await readRevision(handle, meta.revision);
5259
+ if (!head) {
5260
+ await fs7.rm(dirFor(handle), { recursive: true, force: true }).catch(() => void 0);
5261
+ return void 0;
5262
+ }
5263
+ const pins = [];
5264
+ for (const revision of meta.pinnedRevisions) {
5265
+ const pin = await readRevision(handle, revision);
5266
+ if (pin) pins.push(pin);
5267
+ }
5268
+ return {
5269
+ ...meta,
5270
+ // A pin whose file went missing is dropped rather than advertised: the
5271
+ // record has to describe what can actually be read back.
5272
+ pinnedRevisions: pins.map((pin) => pin.revision).sort((a, b) => a - b),
5273
+ head,
5274
+ pins
5275
+ };
5276
+ },
5277
+ async remove(handle) {
5278
+ if (!isPersistableHandle(handle)) return false;
5279
+ const dir = dirFor(handle);
5280
+ if (!await exists(dir)) {
5281
+ authored.delete(handle);
5282
+ return false;
5283
+ }
5284
+ await fs7.rm(dir, { recursive: true, force: true });
5285
+ authored.delete(handle);
5286
+ return true;
5287
+ }
5288
+ };
5289
+ }
5290
+ async function exists(target) {
5291
+ try {
5292
+ await fs7.stat(target);
5293
+ return true;
5294
+ } catch {
5295
+ return false;
5296
+ }
5297
+ }
5298
+ async function isStale(target) {
5299
+ try {
5300
+ const { mtimeMs } = await fs7.stat(target);
5301
+ return Date.now() - mtimeMs > STALE_TEMP_MS;
5302
+ } catch {
5303
+ return false;
5304
+ }
5305
+ }
5306
+ function revisionOf(name) {
5307
+ if (!name.startsWith(REVISION_PREFIX) || !name.endsWith(REVISION_SUFFIX)) {
5308
+ return void 0;
5309
+ }
5310
+ const digits = name.slice(
5311
+ REVISION_PREFIX.length,
5312
+ name.length - REVISION_SUFFIX.length
5313
+ );
5314
+ if (!/^[0-9]+$/.test(digits)) return void 0;
5315
+ return Number(digits);
5316
+ }
5317
+ function parseMeta(raw, handle) {
5318
+ let parsed;
5319
+ try {
5320
+ parsed = JSON.parse(raw);
5321
+ } catch {
5322
+ return void 0;
5323
+ }
5324
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
5325
+ return void 0;
5326
+ }
5327
+ const meta = parsed;
5328
+ if (meta.schema !== SCHEMA_VERSION) return void 0;
5329
+ if (meta.handle !== handle) return void 0;
5330
+ if (!FORMAT_NAMES.includes(meta.format)) return void 0;
5331
+ if (!isPositiveInteger(meta.revision)) return void 0;
5332
+ if (!isNonNegativeInteger(meta.bytes)) return void 0;
5333
+ if (!isNonNegativeInteger(meta.createdAt)) return void 0;
5334
+ if (!isNonNegativeInteger(meta.updatedAt)) return void 0;
5335
+ if (meta.title !== void 0 && typeof meta.title !== "string")
5336
+ return void 0;
5337
+ const pinnedRevisions = Array.isArray(meta.pinnedRevisions) ? meta.pinnedRevisions.filter(isPositiveInteger) : [];
5338
+ return {
5339
+ handle,
5340
+ format: meta.format,
5341
+ revision: meta.revision,
5342
+ bytes: meta.bytes,
5343
+ createdAt: meta.createdAt,
5344
+ updatedAt: meta.updatedAt,
5345
+ ...typeof meta.title === "string" && { title: meta.title },
5346
+ pinnedRevisions: [...pinnedRevisions].sort((a, b) => a - b)
5347
+ };
5348
+ }
5349
+ function isPositiveInteger(value) {
5350
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
5351
+ }
5352
+ function isNonNegativeInteger(value) {
5353
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
5354
+ }
5355
+
5356
+ // src/workspace/store.ts
4914
5357
  var WORKSPACE_ERROR_CODES = {
4915
5358
  EVICTED: "E_WORKSPACE_EVICTED",
4916
5359
  LIMIT: "E_WORKSPACE_LIMIT",
4917
5360
  DOCUMENT_TOO_LARGE: "E_DOCUMENT_TOO_LARGE",
4918
- INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT"
5361
+ INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT",
5362
+ /**
5363
+ * A revision could not be mirrored to disk (#290). A warning, never a
5364
+ * failure: the edit landed and the workspace is usable, but this connection
5365
+ * has stopped being survivable and saying so is the whole point.
5366
+ */
5367
+ NOT_PERSISTED: "W_WORKSPACE_NOT_PERSISTED",
5368
+ /**
5369
+ * A close could not delete the durable copy, so nothing was released (#290).
5370
+ * An error rather than a warning: the agent asked for the document to be
5371
+ * destroyed, and it is still there to be read by the next connection.
5372
+ */
5373
+ NOT_CLOSED: "E_WORKSPACE_NOT_CLOSED"
4919
5374
  };
4920
5375
  var DEFAULT_WORKSPACE_LIMITS = {
4921
5376
  maxWorkspaces: 16,
@@ -4935,6 +5390,7 @@ function createMemoryWorkspaceStore(options = {}) {
4935
5390
  };
4936
5391
  const now = options.now ?? Date.now;
4937
5392
  const newHandle = options.newHandle ?? defaultHandle;
5393
+ const persistence = options.persistence;
4938
5394
  const entries = /* @__PURE__ */ new Map();
4939
5395
  const tombstones = /* @__PURE__ */ new Map();
4940
5396
  function footprint(entry) {
@@ -4947,12 +5403,8 @@ function createMemoryWorkspaceStore(options = {}) {
4947
5403
  for (const entry of entries.values()) total += footprint(entry);
4948
5404
  return total;
4949
5405
  }
4950
- function tombstone(entry, reason) {
4951
- tombstones.set(entry.handle, {
4952
- reason,
4953
- at: now(),
4954
- revision: entry.revision
4955
- });
5406
+ function tombstone(handle, reason, revision) {
5407
+ tombstones.set(handle, { reason, at: now(), revision });
4956
5408
  while (tombstones.size > MAX_TOMBSTONES) {
4957
5409
  const oldest = tombstones.keys().next();
4958
5410
  if (oldest.done) break;
@@ -4962,7 +5414,7 @@ function createMemoryWorkspaceStore(options = {}) {
4962
5414
  function evict(entry, reason) {
4963
5415
  entries.delete(entry.handle);
4964
5416
  tombstones.delete(entry.handle);
4965
- tombstone(entry, reason);
5417
+ tombstone(entry.handle, reason, entry.revision);
4966
5418
  }
4967
5419
  function sweep() {
4968
5420
  const at = now();
@@ -4970,6 +5422,108 @@ function createMemoryWorkspaceStore(options = {}) {
4970
5422
  if (at - entry.touchedAt > limits.idleTtlMs) evict(entry, "ttl");
4971
5423
  }
4972
5424
  }
5425
+ async function persist(entry) {
5426
+ if (!persistence) return void 0;
5427
+ try {
5428
+ await persistence.save({
5429
+ handle: entry.handle,
5430
+ format: entry.format,
5431
+ createdAt: entry.createdAt,
5432
+ updatedAt: entry.updatedAt,
5433
+ ...entry.title !== void 0 && { title: entry.title },
5434
+ head: {
5435
+ revision: entry.revision,
5436
+ text: entry.text,
5437
+ bytes: entry.bytes
5438
+ },
5439
+ pins: [...entry.pins.entries()].map(([revision, pin]) => ({
5440
+ revision,
5441
+ text: pin.text,
5442
+ bytes: pin.bytes
5443
+ }))
5444
+ });
5445
+ entry.persisted = true;
5446
+ return void 0;
5447
+ } catch (error) {
5448
+ entry.persisted = false;
5449
+ return diagnostic(
5450
+ WORKSPACE_ERROR_CODES.NOT_PERSISTED,
5451
+ `Revision ${entry.revision} of ${entry.handle} is held in memory but was not written to ${persistence.root}: ${error instanceof Error ? error.message : String(error)}`,
5452
+ {
5453
+ severity: "warning",
5454
+ suggestion: "Export the JSON with jto_workspace_snapshot and keep it yourself; this handle will not survive a lost connection.",
5455
+ context: {
5456
+ handle: entry.handle,
5457
+ revision: entry.revision,
5458
+ workspaceRoot: persistence.root
5459
+ }
5460
+ }
5461
+ );
5462
+ }
5463
+ }
5464
+ function committed(record, warning) {
5465
+ return { ok: true, record, ...warning && { warnings: [warning] } };
5466
+ }
5467
+ async function rehydrate(handle) {
5468
+ if (!persistence || !isPersistableHandle(handle)) return void 0;
5469
+ let loaded;
5470
+ try {
5471
+ loaded = await persistence.load(handle);
5472
+ } catch {
5473
+ return void 0;
5474
+ }
5475
+ if (!loaded) return void 0;
5476
+ const restored = loaded;
5477
+ const pins = [...restored.pins].sort((a, b) => b.revision - a.revision).slice(0, limits.maxPinnedRevisions);
5478
+ const needed = pins.reduce(
5479
+ (total, pin) => total + pin.bytes,
5480
+ restored.head.bytes
5481
+ );
5482
+ if (restored.head.bytes > limits.maxDocumentBytes) {
5483
+ return tooLarge(restored.head.bytes, limits.maxDocumentBytes);
5484
+ }
5485
+ if (!hasRoom(needed, true)) {
5486
+ return failure(
5487
+ WORKSPACE_ERROR_CODES.LIMIT,
5488
+ `Workspace ${handle} is on disk at revision ${restored.revision} but does not fit in this connection's ${limits.maxTotalBytes}-byte budget.`,
5489
+ {
5490
+ suggestion: "Close a workspace you have finished with, then use this handle again.",
5491
+ context: {
5492
+ handle,
5493
+ bytes: needed,
5494
+ maxTotalBytes: limits.maxTotalBytes,
5495
+ maxWorkspaces: limits.maxWorkspaces
5496
+ }
5497
+ }
5498
+ );
5499
+ }
5500
+ const entry = {
5501
+ handle,
5502
+ format: restored.format,
5503
+ revision: restored.head.revision,
5504
+ text: restored.head.text,
5505
+ bytes: restored.head.bytes,
5506
+ createdAt: restored.createdAt,
5507
+ updatedAt: restored.updatedAt,
5508
+ touchedAt: now(),
5509
+ ...restored.title !== void 0 && { title: restored.title },
5510
+ pins: new Map(
5511
+ pins.map((pin) => [pin.revision, { text: pin.text, bytes: pin.bytes }])
5512
+ ),
5513
+ persisted: true
5514
+ };
5515
+ entries.set(handle, entry);
5516
+ tombstones.delete(handle);
5517
+ return { ok: true, entry };
5518
+ }
5519
+ async function resolve3(handle) {
5520
+ const entry = entries.get(handle);
5521
+ if (entry) return { entry };
5522
+ const restored = await rehydrate(handle);
5523
+ if (restored === void 0) return missing(handle);
5524
+ if (!restored.ok) return restored;
5525
+ return { entry: restored.entry };
5526
+ }
4973
5527
  function missing(handle) {
4974
5528
  const grave = tombstones.get(handle);
4975
5529
  if (grave?.reason === "ttl") {
@@ -5005,12 +5559,29 @@ function createMemoryWorkspaceStore(options = {}) {
5005
5559
  createdAt: new Date(entry.createdAt).toISOString(),
5006
5560
  updatedAt: new Date(entry.updatedAt).toISOString(),
5007
5561
  ...entry.title !== void 0 && { title: entry.title },
5008
- pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b)
5562
+ pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b),
5563
+ ...persistence && { persisted: entry.persisted }
5564
+ };
5565
+ }
5566
+ function fromMeta(meta) {
5567
+ return {
5568
+ handle: meta.handle,
5569
+ format: meta.format,
5570
+ revision: meta.revision,
5571
+ bytes: meta.bytes,
5572
+ createdAt: new Date(meta.createdAt).toISOString(),
5573
+ updatedAt: new Date(meta.updatedAt).toISOString(),
5574
+ ...meta.title !== void 0 && { title: meta.title },
5575
+ pinnedRevisions: [...meta.pinnedRevisions],
5576
+ persisted: true
5009
5577
  };
5010
5578
  }
5011
5579
  return {
5012
5580
  available: true,
5013
5581
  limits,
5582
+ ...persistence && {
5583
+ persistence: { root: persistence.root, limits: persistence.limits }
5584
+ },
5014
5585
  usage() {
5015
5586
  sweep();
5016
5587
  return { workspaces: entries.size, bytes: totalBytes() };
@@ -5051,15 +5622,18 @@ function createMemoryWorkspaceStore(options = {}) {
5051
5622
  updatedAt: at,
5052
5623
  touchedAt: at,
5053
5624
  ...input.title !== void 0 && { title: input.title },
5054
- pins: /* @__PURE__ */ new Map()
5625
+ pins: /* @__PURE__ */ new Map(),
5626
+ persisted: false
5055
5627
  };
5056
5628
  entries.set(entry.handle, entry);
5057
- return { ok: true, record: toRecord(entry) };
5629
+ const warning = await persist(entry);
5630
+ return committed(toRecord(entry), warning);
5058
5631
  },
5059
5632
  async get(handle, readOptions) {
5060
5633
  sweep();
5061
- const entry = entries.get(handle);
5062
- if (!entry) return missing(handle);
5634
+ const found = await resolve3(handle);
5635
+ if (!("entry" in found)) return found;
5636
+ const entry = found.entry;
5063
5637
  entry.touchedAt = now();
5064
5638
  let text = entry.text;
5065
5639
  let bytes = entry.bytes;
@@ -5084,15 +5658,16 @@ function createMemoryWorkspaceStore(options = {}) {
5084
5658
  context: { handle, pointer }
5085
5659
  });
5086
5660
  }
5087
- const found = resolvePointer(document, parsed.tokens);
5088
- if (found.found) projection[pointer] = found.value;
5661
+ const found2 = resolvePointer(document, parsed.tokens);
5662
+ if (found2.found) projection[pointer] = found2.value;
5089
5663
  }
5090
5664
  return { ok: true, record, document, projection };
5091
5665
  },
5092
5666
  async patch(input) {
5093
5667
  sweep();
5094
- const entry = entries.get(input.handle);
5095
- if (!entry) return missing(input.handle);
5668
+ const found = await resolve3(input.handle);
5669
+ if (!("entry" in found)) return found;
5670
+ const entry = found.entry;
5096
5671
  entry.touchedAt = now();
5097
5672
  if (input.baseRevision !== void 0 && input.baseRevision !== entry.revision) {
5098
5673
  return stale(
@@ -5158,12 +5733,14 @@ function createMemoryWorkspaceStore(options = {}) {
5158
5733
  entry.revision += 1;
5159
5734
  entry.updatedAt = now();
5160
5735
  entry.touchedAt = entry.updatedAt;
5161
- return { ok: true, record: toRecord(entry) };
5736
+ const warning = await persist(entry);
5737
+ return committed(toRecord(entry), warning);
5162
5738
  },
5163
5739
  async snapshot(handle) {
5164
5740
  sweep();
5165
- const entry = entries.get(handle);
5166
- if (!entry) return missing(handle);
5741
+ const found = await resolve3(handle);
5742
+ if (!("entry" in found)) return found;
5743
+ const entry = found.entry;
5167
5744
  entry.touchedAt = now();
5168
5745
  const revision = entry.revision;
5169
5746
  const document = JSON.parse(entry.text);
@@ -5172,24 +5749,70 @@ function createMemoryWorkspaceStore(options = {}) {
5172
5749
  entry.pins.set(revision, { text: entry.text, bytes: entry.bytes });
5173
5750
  }
5174
5751
  }
5175
- return { ok: true, record: toRecord(entry), document };
5176
- },
5177
- async list() {
5178
- sweep();
5752
+ const warning = entry.pins.has(revision) ? await persist(entry) : void 0;
5179
5753
  return {
5180
5754
  ok: true,
5181
- records: [...entries.values()].map((entry) => toRecord(entry))
5755
+ record: toRecord(entry),
5756
+ document,
5757
+ ...warning && { warnings: [warning] }
5182
5758
  };
5183
5759
  },
5760
+ async list() {
5761
+ sweep();
5762
+ const records = [...entries.values()].map((entry) => toRecord(entry));
5763
+ if (!persistence) return { ok: true, records };
5764
+ const resident = new Set(records.map((record) => record.handle));
5765
+ let stored = [];
5766
+ try {
5767
+ stored = await persistence.list();
5768
+ } catch {
5769
+ }
5770
+ for (const meta of stored) {
5771
+ if (resident.has(meta.handle)) continue;
5772
+ if (tombstones.get(meta.handle)?.reason === "closed") continue;
5773
+ records.push(fromMeta(meta));
5774
+ }
5775
+ return { ok: true, records };
5776
+ },
5184
5777
  async close(handle) {
5185
5778
  sweep();
5186
5779
  const entry = entries.get(handle);
5187
- if (!entry) return { ok: true, handle, closed: false };
5188
- evict(entry, "closed");
5189
- return { ok: true, handle, closed: true };
5780
+ let durable = false;
5781
+ if (persistence) {
5782
+ try {
5783
+ durable = await persistence.remove(handle);
5784
+ } catch (error) {
5785
+ return failure(
5786
+ WORKSPACE_ERROR_CODES.NOT_CLOSED,
5787
+ `Workspace ${handle} could not be removed from ${persistence.root}: ${error instanceof Error ? error.message : String(error)}. It is still open and still on disk.`,
5788
+ {
5789
+ suggestion: "Check the workspace directory is writable, then close again.",
5790
+ context: { handle, workspaceRoot: persistence.root }
5791
+ }
5792
+ );
5793
+ }
5794
+ }
5795
+ if (entry) evict(entry, "closed");
5796
+ else if (durable) tombstone(handle, "closed", 0);
5797
+ return { ok: true, handle, closed: entry !== void 0 || durable };
5190
5798
  },
5799
+ /**
5800
+ * Release memory, keep the disk.
5801
+ *
5802
+ * The asymmetry with `close` is the point: this exists for a host
5803
+ * reclaiming memory at a moment of its own choosing, and #290 is precisely
5804
+ * about an event that ends a connection not being allowed to destroy
5805
+ * revisions. The handles come back from disk on next use.
5806
+ */
5191
5807
  async closeAll() {
5192
- for (const entry of [...entries.values()]) evict(entry, "closed");
5808
+ for (const entry of [...entries.values()]) {
5809
+ if (!persistence) {
5810
+ evict(entry, "closed");
5811
+ continue;
5812
+ }
5813
+ entries.delete(entry.handle);
5814
+ tombstones.delete(entry.handle);
5815
+ }
5193
5816
  }
5194
5817
  };
5195
5818
  function stale(entry, wanted, pinned, mutation = false) {
@@ -5209,7 +5832,7 @@ function createMemoryWorkspaceStore(options = {}) {
5209
5832
  }
5210
5833
  }
5211
5834
  function defaultHandle() {
5212
- return `ws_${randomBytes2(9).toString("base64url")}`;
5835
+ return `ws_${randomBytes3(9).toString("base64url")}`;
5213
5836
  }
5214
5837
  function serialize(document) {
5215
5838
  let text;
@@ -5262,7 +5885,7 @@ var workspaceSchema = {
5262
5885
  properties: {
5263
5886
  handle: {
5264
5887
  type: "string",
5265
- description: "Opaque, valid only on this connection."
5888
+ description: "Opaque and server-generated. Valid only on this connection, unless the server has a workspace directory \u2014 then it survives a reconnect and jto_workspace_list hands it back."
5266
5889
  },
5267
5890
  format: { type: "string", enum: [...FORMAT_NAMES] },
5268
5891
  revision: {
@@ -5280,6 +5903,10 @@ var workspaceSchema = {
5280
5903
  type: "array",
5281
5904
  items: { type: "integer" },
5282
5905
  description: "Revisions kept retrievable by jto_workspace_snapshot; read one back with `revision`."
5906
+ },
5907
+ persisted: {
5908
+ type: "boolean",
5909
+ description: "True when this revision is mirrored to disk and so survives a lost connection. Absent when this connection has no workspace directory configured."
5283
5910
  }
5284
5911
  },
5285
5912
  required: [
@@ -5293,6 +5920,26 @@ var workspaceSchema = {
5293
5920
  ],
5294
5921
  additionalProperties: false
5295
5922
  };
5923
+ var persistenceSchema = {
5924
+ type: "object",
5925
+ description: "Where revisions survive the connection. Absent when workspaces are memory-only, which is the default.",
5926
+ properties: {
5927
+ root: { type: "string" },
5928
+ maxWorkspaces: { type: "integer" },
5929
+ maxRevisionsPerWorkspace: {
5930
+ type: "integer",
5931
+ description: "Revision files kept per handle: the head plus its pins."
5932
+ },
5933
+ maxEntryBytes: { type: "integer" }
5934
+ },
5935
+ required: [
5936
+ "root",
5937
+ "maxWorkspaces",
5938
+ "maxRevisionsPerWorkspace",
5939
+ "maxEntryBytes"
5940
+ ],
5941
+ additionalProperties: false
5942
+ };
5296
5943
  var limitsSchema = {
5297
5944
  type: "object",
5298
5945
  description: "What this connection allows. Bounded on purpose; see the codes above.",
@@ -5324,7 +5971,9 @@ function isMemoryStore(store) {
5324
5971
  }
5325
5972
  function ensureStore(deps) {
5326
5973
  if (deps.workspaces().available || hasWorkspaceStore()) return;
5327
- const owned = createMemoryWorkspaceStore();
5974
+ const owned = createMemoryWorkspaceStore(
5975
+ deps.workspacePersistence ? { persistence: deps.workspacePersistence } : {}
5976
+ );
5328
5977
  deps.workspaces = () => owned;
5329
5978
  }
5330
5979
  function register8(server, deps) {
@@ -5367,9 +6016,9 @@ function register8(server, deps) {
5367
6016
  ...args.title !== void 0 && { title: args.title }
5368
6017
  });
5369
6018
  if (!created.ok) return created;
5370
- return success(
5371
- { workspace: created.record },
5372
- seeded ? [
6019
+ return success({ workspace: created.record }, [
6020
+ ...created.warnings ?? [],
6021
+ ...seeded ? [
5373
6022
  diagnostic(
5374
6023
  "W_BLANK_DOCUMENT",
5375
6024
  `Opened an empty ${args.format} skeleton; it has no content until you patch some in.`,
@@ -5379,7 +6028,7 @@ function register8(server, deps) {
5379
6028
  }
5380
6029
  )
5381
6030
  ] : []
5382
- );
6031
+ ]);
5383
6032
  })
5384
6033
  )
5385
6034
  );
@@ -5445,17 +6094,20 @@ function register8(server, deps) {
5445
6094
  const missingPaths = projecting ? args.paths.filter(
5446
6095
  (pointer) => !(pointer in projection)
5447
6096
  ) : [];
5448
- const diagnostics = missingPaths.map(
5449
- (pointer) => diagnostic(
5450
- "W_PATH_NOT_FOUND",
5451
- `${pointer} does not resolve in revision ${read.record.revision}.`,
5452
- {
5453
- severity: "warning",
5454
- path: pointer,
5455
- suggestion: "Read a shorter prefix of the pointer to see what is actually there."
5456
- }
6097
+ const diagnostics = [
6098
+ ...read.warnings ?? [],
6099
+ ...missingPaths.map(
6100
+ (pointer) => diagnostic(
6101
+ "W_PATH_NOT_FOUND",
6102
+ `${pointer} does not resolve in revision ${read.record.revision}.`,
6103
+ {
6104
+ severity: "warning",
6105
+ path: pointer,
6106
+ suggestion: "Read a shorter prefix of the pointer to see what is actually there."
6107
+ }
6108
+ )
5457
6109
  )
5458
- );
6110
+ ];
5459
6111
  return success(
5460
6112
  {
5461
6113
  workspace: read.record,
@@ -5524,7 +6176,7 @@ function register8(server, deps) {
5524
6176
  }
5525
6177
  });
5526
6178
  if (!patched.ok) return patched;
5527
- return success({ workspace: patched.record });
6179
+ return success({ workspace: patched.record }, patched.warnings ?? []);
5528
6180
  })
5529
6181
  )
5530
6182
  );
@@ -5570,7 +6222,7 @@ function register8(server, deps) {
5570
6222
  await guarded(async () => {
5571
6223
  const snapshot = await deps.workspaces().snapshot(args.handle);
5572
6224
  if (!snapshot.ok) return snapshot;
5573
- const diagnostics = [];
6225
+ const diagnostics = [...snapshot.warnings ?? []];
5574
6226
  if (!snapshot.record.pinnedRevisions.includes(snapshot.record.revision)) {
5575
6227
  diagnostics.push(
5576
6228
  diagnostic(
@@ -5613,7 +6265,7 @@ function register8(server, deps) {
5613
6265
  "jto_workspace_list",
5614
6266
  {
5615
6267
  title: "List open workspaces",
5616
- description: "Every document open on this connection, with its revision and size. Call this to recover handles you no longer have \u2014 it is the cheapest way back after losing track of what you opened. An empty list means nothing is open, not that anything failed.",
6268
+ description: "Every document open on this connection, with its revision and size. Call this to recover handles you no longer have \u2014 it is the cheapest way back after losing track of what you opened, including after a reconnect when the server has a workspace directory. An empty list means nothing is open, not that anything failed.",
5617
6269
  annotations: { readOnlyHint: true, openWorldHint: false },
5618
6270
  inputSchema: S({
5619
6271
  type: "object",
@@ -5628,6 +6280,7 @@ function register8(server, deps) {
5628
6280
  description: "False when this connection has workspaces switched off."
5629
6281
  },
5630
6282
  limits: limitsSchema,
6283
+ persistence: persistenceSchema,
5631
6284
  usage: {
5632
6285
  type: "object",
5633
6286
  properties: {
@@ -5645,12 +6298,24 @@ function register8(server, deps) {
5645
6298
  const store = deps.workspaces();
5646
6299
  const listed = await store.list();
5647
6300
  if (!listed.ok) return listed;
5648
- const budget = isMemoryStore(store) ? { limits: store.limits, usage: store.usage() } : {};
5649
- return success({
5650
- workspaces: listed.records,
5651
- available: store.available,
5652
- ...budget
5653
- });
6301
+ const budget = isMemoryStore(store) ? {
6302
+ limits: store.limits,
6303
+ usage: store.usage(),
6304
+ ...store.persistence && {
6305
+ persistence: {
6306
+ root: store.persistence.root,
6307
+ ...store.persistence.limits
6308
+ }
6309
+ }
6310
+ } : {};
6311
+ return success(
6312
+ {
6313
+ workspaces: listed.records,
6314
+ available: store.available,
6315
+ ...budget
6316
+ },
6317
+ listed.warnings ?? []
6318
+ );
5654
6319
  })
5655
6320
  )
5656
6321
  );
@@ -5658,7 +6323,7 @@ function register8(server, deps) {
5658
6323
  "jto_workspace_close",
5659
6324
  {
5660
6325
  title: "Close a workspace",
5661
- description: "Release a handle and the memory behind it, including its pinned snapshots. Idempotent: closing a handle that is already gone reports `closed: false` rather than failing. Snapshot anything you still want first \u2014 closing is not recoverable.",
6326
+ description: "Release a handle and everything behind it \u2014 the document, its pinned snapshots, and the durable copy when this connection keeps one. Idempotent: closing a handle that is already gone reports `closed: false` rather than failing. Snapshot anything you still want first \u2014 closing is not recoverable.",
5662
6327
  annotations: {
5663
6328
  readOnlyHint: false,
5664
6329
  destructiveHint: true,
@@ -5705,6 +6370,7 @@ var RESOURCE_URIS = {
5705
6370
  catalog: "jto://catalog",
5706
6371
  renderers: "jto://renderers",
5707
6372
  themes: "jto://themes",
6373
+ themeValues: "jto://themes/values",
5708
6374
  templates: "jto://templates",
5709
6375
  documentSchema: (format) => `jto://schema/${format}/document`,
5710
6376
  themeSchema: (format) => `jto://schema/${format}/theme`
@@ -5756,7 +6422,7 @@ function register9(server, deps) {
5756
6422
  RESOURCE_URIS.themes,
5757
6423
  {
5758
6424
  title: "Built-in themes",
5759
- description: "Theme names shipped with each format, usable as a document\u2019s props.theme or as the tools\u2019 theme option.",
6425
+ description: "Theme names shipped with each format, usable as a document\u2019s props.theme or as the tools\u2019 theme option. jto://themes/values carries what each name actually looks like.",
5760
6426
  mimeType: JSON_MIME
5761
6427
  },
5762
6428
  async (uri) => {
@@ -5769,6 +6435,33 @@ function register9(server, deps) {
5769
6435
  });
5770
6436
  }
5771
6437
  );
6438
+ server.registerResource(
6439
+ "theme-values",
6440
+ RESOURCE_URIS.themeValues,
6441
+ {
6442
+ title: "Built-in theme values",
6443
+ description: "The palette, fonts, style tables and component defaults behind every built-in theme name \u2014 what a document actually opts into with props.theme. A name alone cannot tell you whether a theme fits the brief; this can.",
6444
+ mimeType: JSON_MIME
6445
+ },
6446
+ async (uri) => {
6447
+ const formats = await Promise.all(
6448
+ FORMAT_NAMES.map(async (format) => {
6449
+ const adapter = deps.getAdapter(format);
6450
+ let themes;
6451
+ try {
6452
+ themes = adapter.getBuiltinThemeValues ? await adapter.getBuiltinThemeValues() : adapter.getBuiltinThemes();
6453
+ } catch {
6454
+ themes = adapter.getBuiltinThemes();
6455
+ }
6456
+ return {
6457
+ format,
6458
+ themes
6459
+ };
6460
+ })
6461
+ );
6462
+ return jsonContents(uri, { formats });
6463
+ }
6464
+ );
5772
6465
  server.registerResource(
5773
6466
  "templates",
5774
6467
  RESOURCE_URIS.templates,
@@ -5817,6 +6510,7 @@ Working rules:
5817
6510
  - Discover before authoring. Call jto_info first, then jto_discover and jto_describe_component (or read the jto:// resources) for the components and renderer ids a format actually supports.
5818
6511
  - Make small edits. With a workspace handle, patch precisely (RFC 6902 over RFC 6901 paths) instead of resending the whole document; without one, change one region at a time.
5819
6512
  - Validate often. Run jto_validate after each edit rather than once at the end; diagnostics are path-addressed, so they map straight back onto the JSON you just changed.
6513
+ - Treat design findings as defects. Schema-valid is not well-designed: jto_validate also lints layout and legibility (W_QUALITY_* \u2014 undeclared slide canvas, text overflowing its box, overcrowded slides, table widths exceeding their section). These never block generation, but they almost always show in the rendered result \u2014 repair them like errors.
5820
6514
  - Preview when the answer is visual. jto_preview renders pages to PNG; use it whenever layout, overflow or fit is in question, not only before finishing.
5821
6515
  - Snapshot before risky changes. jto_workspace_snapshot pins the current revision so a restructuring you cannot cleanly undo is still recoverable.
5822
6516
 
@@ -5846,6 +6540,12 @@ function createServerFactory(deps) {
5846
6540
 
5847
6541
  // src/lib/deps.ts
5848
6542
  function createToolDeps(options = {}) {
6543
+ const workspacePersistence = options.workspacePersistence ?? createWorkspacePersistence({
6544
+ ...options.workspaceDir !== void 0 && {
6545
+ flagDir: options.workspaceDir
6546
+ },
6547
+ ...options.env !== void 0 && { env: options.env }
6548
+ });
5849
6549
  return {
5850
6550
  serverVersion: options.serverVersion ?? SERVER_VERSION,
5851
6551
  outputRoot: options.outputRoot ?? createOutputRoot({
@@ -5854,23 +6554,45 @@ function createToolDeps(options = {}) {
5854
6554
  }),
5855
6555
  getAdapter: options.getAdapter ?? getAdapter,
5856
6556
  workspaces: options.workspaces ?? getWorkspaceStore,
6557
+ ...workspacePersistence !== void 0 && { workspacePersistence },
5857
6558
  maxInlineArtifactBytes: options.maxInlineArtifactBytes ?? MAX_INLINE_ARTIFACT_BYTES
5858
6559
  };
5859
6560
  }
5860
6561
 
5861
6562
  // src/cli.ts
6563
+ function takeValue(argv, index) {
6564
+ const next = argv[index];
6565
+ if (next === void 0 || next.startsWith("-") || next.trim() === "") {
6566
+ return void 0;
6567
+ }
6568
+ return { value: next };
6569
+ }
5862
6570
  function parseArgs(argv) {
5863
- const parsed = { version: false, help: false, unknown: [] };
6571
+ const parsed = {
6572
+ version: false,
6573
+ help: false,
6574
+ unknown: [],
6575
+ missingValue: []
6576
+ };
5864
6577
  for (let index = 0; index < argv.length; index += 1) {
5865
6578
  const arg = argv[index];
5866
6579
  if (arg === "--version" || arg === "-v") {
5867
6580
  parsed.version = true;
5868
6581
  } else if (arg === "--help" || arg === "-h") {
5869
6582
  parsed.help = true;
5870
- } else if (arg === "--output-dir") {
5871
- parsed.outputDir = argv[++index];
6583
+ } else if (arg === "--output-dir" || arg === "--workspace-dir") {
6584
+ const taken = takeValue(argv, index + 1);
6585
+ if (!taken) {
6586
+ parsed.missingValue.push(arg);
6587
+ continue;
6588
+ }
6589
+ index += 1;
6590
+ if (arg === "--output-dir") parsed.outputDir = taken.value;
6591
+ else parsed.workspaceDir = taken.value;
5872
6592
  } else if (arg.startsWith("--output-dir=")) {
5873
6593
  parsed.outputDir = arg.slice("--output-dir=".length);
6594
+ } else if (arg.startsWith("--workspace-dir=")) {
6595
+ parsed.workspaceDir = arg.slice("--workspace-dir=".length);
5874
6596
  } else {
5875
6597
  parsed.unknown.push(arg);
5876
6598
  }
@@ -5888,11 +6610,16 @@ Options:
5888
6610
  --output-dir <path> Directory generated files are written to. Overrides
5889
6611
  ${OUTPUT_DIR_ENV}. Defaults to a per-connection
5890
6612
  directory under the system temp dir.
6613
+ --workspace-dir <p> Directory workspace revisions are mirrored to, so a lost
6614
+ client session does not destroy them. Overrides
6615
+ ${WORKSPACE_DIR_ENV}. Off by default:
6616
+ without it, handles live only in memory.
5891
6617
  -v, --version Print the version and exit.
5892
6618
  -h, --help Print this help and exit.
5893
6619
 
5894
6620
  Environment:
5895
6621
  ${OUTPUT_DIR_ENV} Output root, when --output-dir is absent.
6622
+ ${WORKSPACE_DIR_ENV} Workspace root, when --workspace-dir is absent.
5896
6623
  LIBREOFFICE_PATH LibreOffice binary, for preview.
5897
6624
  PDFTOPPM_PATH poppler pdftoppm binary, for preview.
5898
6625
 
@@ -5915,13 +6642,23 @@ function main(argv) {
5915
6642
  process.stderr.write(
5916
6643
  `jto-mcp: unknown argument ${args.unknown[0]}
5917
6644
  Run jto-mcp --help.
6645
+ `
6646
+ );
6647
+ process.exitCode = 1;
6648
+ return;
6649
+ }
6650
+ if (args.missingValue.length > 0) {
6651
+ process.stderr.write(
6652
+ `jto-mcp: ${args.missingValue[0]} needs a directory path.
6653
+ Run jto-mcp --help.
5918
6654
  `
5919
6655
  );
5920
6656
  process.exitCode = 1;
5921
6657
  return;
5922
6658
  }
5923
6659
  const deps = createToolDeps({
5924
- ...args.outputDir !== void 0 && { outputDir: args.outputDir }
6660
+ ...args.outputDir !== void 0 && { outputDir: args.outputDir },
6661
+ ...args.workspaceDir !== void 0 && { workspaceDir: args.workspaceDir }
5925
6662
  });
5926
6663
  const handle = serveStdio(createServerFactory(deps), {
5927
6664
  legacy: "serve",