@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/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { McpServer } from "@modelcontextprotocol/server";
3
3
 
4
4
  // src/lib/version.ts
5
- var SERVER_VERSION = true ? "1.4.0" : "dev-mode";
5
+ var SERVER_VERSION = true ? "1.8.0" : "dev-mode";
6
6
  var SERVER_NAME = "json-to-office";
7
7
  var PACKAGE_NAME = "@json-to-office/mcp-server";
8
8
 
@@ -216,6 +216,8 @@ var ERROR_CODES = {
216
216
  HOST_NOTE: "W_HOST_NOTE",
217
217
  /** A generation warning the core raised without a code of its own. */
218
218
  GENERATION: "W_GENERATION",
219
+ /** A design-quality rule threw, so its whole class of findings is missing. */
220
+ QUALITY_RULE_ERROR: "W_QUALITY_RULE_ERROR",
219
221
  /** A required host binary (LibreOffice, poppler) is absent. */
220
222
  DEPENDENCY_MISSING: "E_DEPENDENCY_MISSING",
221
223
  /** The client cancelled the request. */
@@ -312,6 +314,25 @@ function toolResult(payload) {
312
314
  };
313
315
  }
314
316
  var HOST_DEPENDENCY_ERRORS = /* @__PURE__ */ new Set([RENDERER_DEPENDENCY_MISSING]);
317
+ function qualityOptionDiagnostic(error) {
318
+ const code = qualityCallerCode(error);
319
+ if (code === void 0 || code === ERROR_CODES.INVALID_DOCUMENT) {
320
+ return void 0;
321
+ }
322
+ return diagnostic(
323
+ code,
324
+ error instanceof Error ? error.message : String(error)
325
+ );
326
+ }
327
+ function qualityCallerCode(error) {
328
+ const code = error?.code;
329
+ if (code === "QUALITY_PROFILE_INCOMPATIBLE")
330
+ return OPTION_ERROR_CODES.INVALID_QUALITY_PROFILE;
331
+ if (code === "QUALITY_POLICY_INVALID")
332
+ return OPTION_ERROR_CODES.INVALID_QUALITY_POLICY;
333
+ if (code === "QUALITY_GATE_FAILED") return ERROR_CODES.INVALID_DOCUMENT;
334
+ return void 0;
335
+ }
315
336
  function stackAllowed() {
316
337
  const flag = process.env.JTO_MCP_DEBUG_STACKS;
317
338
  return flag === "1" || flag === "true";
@@ -362,7 +383,7 @@ async function guarded(body) {
362
383
  return withHostNotes(result, notes);
363
384
  } catch (error) {
364
385
  const message2 = error instanceof Error ? error.message : String(error);
365
- const code = error instanceof Error && HOST_DEPENDENCY_ERRORS.has(error.name) ? ERROR_CODES.DEPENDENCY_MISSING : ERROR_CODES.INTERNAL;
386
+ const code = qualityCallerCode(error) ?? (error instanceof Error && HOST_DEPENDENCY_ERRORS.has(error.name) ? ERROR_CODES.DEPENDENCY_MISSING : ERROR_CODES.INTERNAL);
366
387
  return withHostNotes(
367
388
  failure(code, message2, {
368
389
  context: {
@@ -381,7 +402,11 @@ var OPTION_ERROR_CODES = {
381
402
  /** `themePath` is not a data-only JSON theme path. */
382
403
  INVALID_THEME_PATH: "E_INVALID_THEME_PATH",
383
404
  /** The tool does not support the requested format. */
384
- UNSUPPORTED_FORMAT: "E_UNSUPPORTED_FORMAT"
405
+ UNSUPPORTED_FORMAT: "E_UNSUPPORTED_FORMAT",
406
+ /** `quality.profile` does not cover the format or renderer of this run. */
407
+ INVALID_QUALITY_PROFILE: "E_INVALID_QUALITY_PROFILE",
408
+ /** `quality.policy` sets a gate, severity or budget that is not a legal value. */
409
+ INVALID_QUALITY_POLICY: "E_INVALID_QUALITY_POLICY"
385
410
  };
386
411
  var DEFERRED_TO_COMPILER = /* @__PURE__ */ new Set([
387
412
  ERROR_CODES.UNSUPPORTED_RENDERER_FEATURE
@@ -390,9 +415,9 @@ var ROOT_SENTINELS = /* @__PURE__ */ new Set(["", "/", "#", "root"]);
390
415
  function escapePointerSegment(segment) {
391
416
  return segment.replace(/~/g, "~0").replace(/\//g, "~1");
392
417
  }
393
- function toJsonPointer(path6) {
394
- if (path6 === void 0) return void 0;
395
- const trimmed = path6.trim();
418
+ function toJsonPointer(path7) {
419
+ if (path7 === void 0) return void 0;
420
+ const trimmed = path7.trim();
396
421
  if (ROOT_SENTINELS.has(trimmed)) return "";
397
422
  const segments = trimmed.startsWith("/") ? trimmed.slice(1).split("/") : trimmed.replace(/\[(\d+)\]/g, ".$1").split(".").filter((segment) => segment !== "");
398
423
  if (segments.length === 0) return "";
@@ -421,6 +446,34 @@ function validationDiagnostics(errors) {
421
446
  })
422
447
  );
423
448
  }
449
+ function qualityAnalysisDiagnostics(analysis) {
450
+ return analysis.diagnostics.map((diagnostic2) => ({
451
+ source: diagnostic2.source,
452
+ ruleId: diagnostic2.ruleId,
453
+ category: diagnostic2.category,
454
+ certainty: diagnostic2.certainty,
455
+ severity: diagnostic2.severity,
456
+ code: diagnostic2.code,
457
+ message: diagnostic2.message,
458
+ path: diagnostic2.path,
459
+ blocking: diagnostic2.blocking,
460
+ ...diagnostic2.suggestion !== void 0 && {
461
+ suggestion: diagnostic2.suggestion
462
+ },
463
+ ...diagnostic2.context !== void 0 && {
464
+ context: { ...diagnostic2.context }
465
+ },
466
+ ...diagnostic2.relatedPaths !== void 0 && {
467
+ relatedPaths: diagnostic2.relatedPaths
468
+ },
469
+ ...diagnostic2.evidence !== void 0 && {
470
+ evidence: { ...diagnostic2.evidence }
471
+ },
472
+ ...diagnostic2.fixes !== void 0 && {
473
+ fixes: diagnostic2.fixes
474
+ }
475
+ }));
476
+ }
424
477
  function looksLikeValidationErrors(value) {
425
478
  return Array.isArray(value) && value.length > 0 && value.every(
426
479
  (entry) => typeof entry === "object" && entry !== null && typeof entry.message === "string"
@@ -565,11 +618,11 @@ async function probeService(rawUrl, envVar) {
565
618
  }
566
619
  const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
567
620
  const net = await import("net");
568
- return new Promise((resolve2) => {
621
+ return new Promise((resolve3) => {
569
622
  const socket = net.connect({ host: target.hostname, port });
570
623
  const settle = (detail) => {
571
624
  socket.destroy();
572
- resolve2({
625
+ resolve3({
573
626
  available: detail === void 0,
574
627
  url: target.origin,
575
628
  envVar,
@@ -609,7 +662,7 @@ function register(server, deps) {
609
662
  "jto_info",
610
663
  {
611
664
  title: "Server info",
612
- 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.",
665
+ 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.",
613
666
  annotations: { readOnlyHint: true, openWorldHint: false },
614
667
  inputSchema: S({
615
668
  type: "object",
@@ -694,9 +747,17 @@ function register(server, deps) {
694
747
  type: "object",
695
748
  properties: {
696
749
  available: { type: "boolean" },
697
- open: { type: "integer" }
750
+ open: { type: "integer" },
751
+ persistent: {
752
+ type: "boolean",
753
+ description: "True when revisions are mirrored to disk and survive a lost connection; false when handles are memory-only, which is the default."
754
+ },
755
+ root: {
756
+ type: "string",
757
+ description: "Directory the revisions are mirrored to, when persistent."
758
+ }
698
759
  },
699
- required: ["available", "open"],
760
+ required: ["available", "open", "persistent"],
700
761
  additionalProperties: false
701
762
  },
702
763
  output: {
@@ -857,7 +918,11 @@ function register(server, deps) {
857
918
  formats,
858
919
  workspaces: {
859
920
  available: store.available,
860
- open: listed.ok ? listed.records.length : 0
921
+ open: listed.ok ? listed.records.length : 0,
922
+ persistent: deps.workspacePersistence !== void 0,
923
+ ...deps.workspacePersistence && {
924
+ root: deps.workspacePersistence.root
925
+ }
861
926
  },
862
927
  output: {
863
928
  root: deps.outputRoot.path,
@@ -1117,10 +1182,10 @@ var STARTERS = [
1117
1182
  id: "pptx-minimal",
1118
1183
  format: "pptx",
1119
1184
  title: "Minimal presentation",
1120
- description: "The smallest valid .pptx: root, one slide, one title text.",
1185
+ 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.",
1121
1186
  document: {
1122
1187
  name: "pptx",
1123
- props: { title: "Untitled deck" },
1188
+ props: { title: "Untitled deck", slideWidth: 13.333, slideHeight: 7.5 },
1124
1189
  children: [
1125
1190
  {
1126
1191
  name: "slide",
@@ -1972,14 +2037,14 @@ function sourceSummary(resolved) {
1972
2037
  revision: resolved.revision
1973
2038
  } : { origin: "inline" };
1974
2039
  }
1975
- function parseDocumentJson(text, path6) {
2040
+ function parseDocumentJson(text, path7) {
1976
2041
  try {
1977
2042
  return { ok: true, document: JSON.parse(text) };
1978
2043
  } catch (error) {
1979
2044
  return failure(
1980
2045
  ERROR_CODES.INVALID_JSON,
1981
2046
  error instanceof Error ? error.message : String(error),
1982
- { ...path6 !== void 0 && { path: path6 } }
2047
+ { ...path7 !== void 0 && { path: path7 } }
1983
2048
  );
1984
2049
  }
1985
2050
  }
@@ -1994,17 +2059,26 @@ function capDiagnostics(diagnostics, limit) {
1994
2059
  if (diagnostics.length <= limit)
1995
2060
  return { kept: diagnostics, truncated: false };
1996
2061
  const ordered = [...diagnostics].sort(
1997
- (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
2062
+ (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || Number(b.blocking === true) - Number(a.blocking === true)
1998
2063
  );
1999
2064
  return { kept: ordered.slice(0, limit), truncated: true };
2000
2065
  }
2066
+ function ruleErrorDiagnostics(analysis) {
2067
+ return analysis.ruleErrors.map(
2068
+ (entry) => diagnostic(
2069
+ ERROR_CODES.QUALITY_RULE_ERROR,
2070
+ `Quality rule "${entry.ruleId}" failed: ${entry.message}`,
2071
+ { severity: "warning", source: "quality", ruleId: entry.ruleId }
2072
+ )
2073
+ );
2074
+ }
2001
2075
  var DEFAULT_MAX_DIAGNOSTICS = 100;
2002
2076
  function register4(server, deps) {
2003
2077
  server.registerTool(
2004
2078
  "jto_validate",
2005
2079
  {
2006
2080
  title: "Validate a document",
2007
- 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.",
2081
+ 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.",
2008
2082
  annotations: { readOnlyHint: true, openWorldHint: false },
2009
2083
  inputSchema: S({
2010
2084
  type: "object",
@@ -2020,6 +2094,29 @@ function register4(server, deps) {
2020
2094
  minimum: 1,
2021
2095
  maximum: 1e3,
2022
2096
  description: `Cap on returned diagnostics (default ${DEFAULT_MAX_DIAGNOSTICS}). Errors are kept ahead of warnings when the cap bites.`
2097
+ },
2098
+ quality: {
2099
+ type: "object",
2100
+ description: "Optional design profile plus per-run enforcement policy.",
2101
+ properties: {
2102
+ profile: {
2103
+ type: "object",
2104
+ properties: { id: { type: "string", minLength: 1 } },
2105
+ required: ["id"],
2106
+ additionalProperties: true
2107
+ },
2108
+ policy: {
2109
+ type: "object",
2110
+ properties: {
2111
+ gate: {
2112
+ type: "string",
2113
+ enum: ["none", "error", "warning", "info"]
2114
+ }
2115
+ },
2116
+ additionalProperties: true
2117
+ }
2118
+ },
2119
+ additionalProperties: false
2023
2120
  }
2024
2121
  },
2025
2122
  required: ["format"],
@@ -2050,7 +2147,11 @@ function register4(server, deps) {
2050
2147
  },
2051
2148
  truncated: {
2052
2149
  type: "boolean",
2053
- description: "`diagnostics` was capped by `maxDiagnostics`."
2150
+ description: "`diagnostics` was capped, by `maxDiagnostics` or by the budget the quality policy set."
2151
+ },
2152
+ profileId: {
2153
+ type: "string",
2154
+ description: "The quality profile the design analysis ran under, when one applied."
2054
2155
  }
2055
2156
  })
2056
2157
  )
@@ -2070,24 +2171,52 @@ function register4(server, deps) {
2070
2171
  resolved.document,
2071
2172
  args.renderer
2072
2173
  );
2073
- const all = [
2174
+ let analysis;
2175
+ let qualityOption;
2176
+ if (adapter.analyzeQuality) {
2177
+ try {
2178
+ analysis = await adapter.analyzeQuality(resolved.document, {
2179
+ renderer: args.renderer,
2180
+ quality: args.quality
2181
+ });
2182
+ } catch (error) {
2183
+ const option = result.valid ? void 0 : qualityOptionDiagnostic(error);
2184
+ if (!option) throw error;
2185
+ qualityOption = option;
2186
+ }
2187
+ }
2188
+ const structural = [
2074
2189
  ...validationDiagnostics(result.errors),
2075
- ...unavailable2 ? [unavailable2] : []
2190
+ ...unavailable2 ? [unavailable2] : [],
2191
+ ...qualityOption ? [qualityOption] : []
2192
+ ];
2193
+ const all = [
2194
+ ...structural,
2195
+ ...analysis ? [
2196
+ ...qualityAnalysisDiagnostics(analysis),
2197
+ ...ruleErrorDiagnostics(analysis)
2198
+ ] : []
2076
2199
  ];
2077
2200
  const counts = countDiagnostics(all);
2201
+ const blocked = countDiagnostics(structural).error > 0 || analysis?.blocked === true;
2078
2202
  const { kept, truncated } = capDiagnostics(
2079
2203
  all,
2080
2204
  args.maxDiagnostics ?? DEFAULT_MAX_DIAGNOSTICS
2081
2205
  );
2082
2206
  return {
2083
- ok: counts.error === 0,
2207
+ ok: !blocked,
2084
2208
  diagnostics: kept,
2085
- valid: counts.error === 0,
2209
+ valid: !blocked,
2086
2210
  format: args.format,
2087
2211
  ...args.renderer !== void 0 && { renderer: args.renderer },
2088
2212
  source: sourceSummary(resolved),
2089
2213
  counts,
2090
- truncated
2214
+ // The engine caps its own list under a policy budget, so a report
2215
+ // shortened there would otherwise come back reading complete.
2216
+ truncated: truncated || analysis?.truncated === true,
2217
+ ...analysis?.profileId !== void 0 && {
2218
+ profileId: analysis.profileId
2219
+ }
2091
2220
  };
2092
2221
  })
2093
2222
  )
@@ -3031,9 +3160,9 @@ async function digestAssets(document, baseDir) {
3031
3160
  [...references].map(async (reference) => {
3032
3161
  const resolved = path4.resolve(root, reference);
3033
3162
  try {
3034
- const stat = await fs4.stat(resolved);
3035
- if (!stat.isFile()) return;
3036
- entries.push(`${resolved}|${stat.size}|${stat.mtimeMs}`);
3163
+ const stat2 = await fs4.stat(resolved);
3164
+ if (!stat2.isFile()) return;
3165
+ entries.push(`${resolved}|${stat2.size}|${stat2.mtimeMs}`);
3037
3166
  } catch {
3038
3167
  }
3039
3168
  })
@@ -3063,8 +3192,8 @@ function collectAssetReferences(value, into) {
3063
3192
  async function digestThemeFile(themePath) {
3064
3193
  if (!themePath) return "none";
3065
3194
  try {
3066
- const stat = await fs4.stat(themePath);
3067
- return `${path4.resolve(themePath)}|${stat.size}|${stat.mtimeMs}`;
3195
+ const stat2 = await fs4.stat(themePath);
3196
+ return `${path4.resolve(themePath)}|${stat2.size}|${stat2.mtimeMs}`;
3068
3197
  } catch {
3069
3198
  return `${path4.resolve(themePath)}|missing`;
3070
3199
  }
@@ -3136,18 +3265,18 @@ function missingDependencyFailure(dependencies) {
3136
3265
  );
3137
3266
  }
3138
3267
  async function binaryIdentity(binary, args, signal) {
3139
- const [version, stat] = await Promise.all([
3268
+ const [version, stat2] = await Promise.all([
3140
3269
  readVersion(binary, args, signal),
3141
3270
  fs5.stat(binary).catch(() => void 0)
3142
3271
  ]);
3143
3272
  const identity = [
3144
3273
  version ?? "unknown",
3145
- stat ? `${stat.size}:${stat.mtimeMs}` : "nostat"
3274
+ stat2 ? `${stat2.size}:${stat2.mtimeMs}` : "nostat"
3146
3275
  ].join("|");
3147
3276
  return { identity, ...version !== void 0 && { version } };
3148
3277
  }
3149
3278
  function readVersion(binary, args, signal) {
3150
- return new Promise((resolve2) => {
3279
+ return new Promise((resolve3) => {
3151
3280
  execFile(
3152
3281
  binary,
3153
3282
  args,
@@ -3161,8 +3290,8 @@ function readVersion(binary, args, signal) {
3161
3290
  const text = `${stdout ?? ""}
3162
3291
  ${stderr ?? ""}`.trim();
3163
3292
  const line = text.split("\n")[0]?.trim();
3164
- if (!line && error) return resolve2(void 0);
3165
- resolve2(line || void 0);
3293
+ if (!line && error) return resolve3(void 0);
3294
+ resolve3(line || void 0);
3166
3295
  }
3167
3296
  );
3168
3297
  });
@@ -3207,12 +3336,12 @@ function defaultPreviewCacheDir() {
3207
3336
  }
3208
3337
  var CACHE_ENTRY_NAME = /^[a-f0-9]{64}\.(?:png|meta\.json)(?:\.tmp-\d+-\d+)?$/;
3209
3338
  async function inspectPrivateCacheDir(cacheDir) {
3210
- const stat = await fs6.lstat(cacheDir).catch(() => void 0);
3211
- if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) return false;
3212
- if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
3339
+ const stat2 = await fs6.lstat(cacheDir).catch(() => void 0);
3340
+ if (!stat2 || stat2.isSymbolicLink() || !stat2.isDirectory()) return false;
3341
+ if (typeof process.getuid === "function" && stat2.uid !== process.getuid()) {
3213
3342
  return false;
3214
3343
  }
3215
- if (process.platform !== "win32" && (stat.mode & 63) !== 0) {
3344
+ if (process.platform !== "win32" && (stat2.mode & 63) !== 0) {
3216
3345
  try {
3217
3346
  await fs6.chmod(cacheDir, 448);
3218
3347
  } catch {
@@ -3242,9 +3371,9 @@ async function sweepPreviewCache(cacheDir, options = {}) {
3242
3371
  for (const name of names) {
3243
3372
  if (!CACHE_ENTRY_NAME.test(name)) continue;
3244
3373
  const file = path5.join(cacheDir, name);
3245
- const stat = await fs6.lstat(file).catch(() => void 0);
3246
- if (!stat?.isFile()) continue;
3247
- entries.push({ file, mtimeMs: stat.mtimeMs, size: stat.size });
3374
+ const stat2 = await fs6.lstat(file).catch(() => void 0);
3375
+ if (!stat2?.isFile()) continue;
3376
+ entries.push({ file, mtimeMs: stat2.mtimeMs, size: stat2.size });
3248
3377
  }
3249
3378
  const doomed = entries.filter((entry) => now - entry.mtimeMs > maxAgeMs);
3250
3379
  const kept = entries.filter((entry) => now - entry.mtimeMs <= maxAgeMs).sort((a, b) => a.mtimeMs - b.mtimeMs);
@@ -3331,7 +3460,7 @@ function countPdfPages(pdf) {
3331
3460
  return matches ? matches.length : 0;
3332
3461
  }
3333
3462
  function exec(binary, args, timeoutMs, options = {}) {
3334
- return new Promise((resolve2, reject) => {
3463
+ return new Promise((resolve3, reject) => {
3335
3464
  execFile2(
3336
3465
  binary,
3337
3466
  args,
@@ -3342,7 +3471,7 @@ function exec(binary, args, timeoutMs, options = {}) {
3342
3471
  env: options.env ? { ...process.env, ...options.env } : process.env,
3343
3472
  ...options.signal && { signal: options.signal }
3344
3473
  },
3345
- (error) => error ? reject(error) : resolve2()
3474
+ (error) => error ? reject(error) : resolve3()
3346
3475
  );
3347
3476
  });
3348
3477
  }
@@ -3436,7 +3565,7 @@ async function resolvePdfinfo(pdftoppmPath) {
3436
3565
  return status.available ? status.path : void 0;
3437
3566
  }
3438
3567
  async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
3439
- return new Promise((resolve2) => {
3568
+ return new Promise((resolve3) => {
3440
3569
  execFile2(
3441
3570
  pdfinfo,
3442
3571
  [pdfPath],
@@ -3447,9 +3576,9 @@ async function pdfinfoPageCount(pdfinfo, pdfPath, signal) {
3447
3576
  ...signal && { signal }
3448
3577
  },
3449
3578
  (error, stdout) => {
3450
- if (error && !stdout) return resolve2(void 0);
3579
+ if (error && !stdout) return resolve3(void 0);
3451
3580
  const match = /^Pages:\s+(\d+)/m.exec(stdout ?? "");
3452
- resolve2(match ? Number(match[1]) : void 0);
3581
+ resolve3(match ? Number(match[1]) : void 0);
3453
3582
  }
3454
3583
  );
3455
3584
  });
@@ -4562,9 +4691,9 @@ function compilePatch(operations) {
4562
4691
  }
4563
4692
  );
4564
4693
  }
4565
- const path6 = parsePointer(raw.path);
4566
- if (!path6.ok) {
4567
- return problem(PATCH_ERROR_CODES.INVALID_POINTER, path6.message, index, {
4694
+ const path7 = parsePointer(raw.path);
4695
+ if (!path7.ok) {
4696
+ return problem(PATCH_ERROR_CODES.INVALID_POINTER, path7.message, index, {
4568
4697
  context: { op, pointer: raw.path },
4569
4698
  suggestion: 'Pointers are RFC 6901: "/children/0/props/text", with "~0" for "~" and "~1" for "/".'
4570
4699
  });
@@ -4572,7 +4701,7 @@ function compilePatch(operations) {
4572
4701
  const entry = {
4573
4702
  op,
4574
4703
  path: raw.path,
4575
- tokens: path6.tokens
4704
+ tokens: path7.tokens
4576
4705
  };
4577
4706
  if (VALUE_OPS.has(op)) {
4578
4707
  if (raw.value === void 0) {
@@ -4922,12 +5051,338 @@ function jsonEqualTokens(left, right) {
4922
5051
  }
4923
5052
 
4924
5053
  // src/workspace/store.ts
5054
+ import { randomBytes as randomBytes3 } from "crypto";
5055
+
5056
+ // src/workspace/persistence.ts
5057
+ import * as fs7 from "fs/promises";
5058
+ import * as path6 from "path";
4925
5059
  import { randomBytes as randomBytes2 } from "crypto";
5060
+ var WORKSPACE_DIR_ENV = "JTO_MCP_WORKSPACE_DIR";
5061
+ var SCHEMA_VERSION = 1;
5062
+ var META_FILE = "meta.json";
5063
+ var REVISION_PREFIX = "rev-";
5064
+ var REVISION_SUFFIX = ".json";
5065
+ var TEMP_SUFFIX = ".tmp";
5066
+ var STALE_TEMP_MS = 60 * 60 * 1e3;
5067
+ var SAFE_HANDLE = /^[A-Za-z0-9_-]{1,128}$/;
5068
+ function isPersistableHandle(handle) {
5069
+ return SAFE_HANDLE.test(handle);
5070
+ }
5071
+ var DEFAULT_PERSISTENCE_LIMITS = {
5072
+ maxWorkspaces: 32,
5073
+ maxRevisionsPerWorkspace: 9,
5074
+ maxEntryBytes: 16 * 1024 * 1024
5075
+ };
5076
+ function createWorkspacePersistence(options = {}) {
5077
+ const env = options.env ?? process.env;
5078
+ const configured = options.flagDir?.trim() || env[WORKSPACE_DIR_ENV]?.trim();
5079
+ if (!configured) return void 0;
5080
+ return createWorkspacePersistenceAt(path6.resolve(configured), options);
5081
+ }
5082
+ function createWorkspacePersistenceAt(root, limits = {}) {
5083
+ const bounds = {
5084
+ maxWorkspaces: limits.maxWorkspaces ?? DEFAULT_PERSISTENCE_LIMITS.maxWorkspaces,
5085
+ maxRevisionsPerWorkspace: limits.maxRevisionsPerWorkspace ?? DEFAULT_PERSISTENCE_LIMITS.maxRevisionsPerWorkspace,
5086
+ maxEntryBytes: limits.maxEntryBytes ?? DEFAULT_PERSISTENCE_LIMITS.maxEntryBytes
5087
+ };
5088
+ let ensured;
5089
+ const authored = /* @__PURE__ */ new Map();
5090
+ function claim(handle, revision) {
5091
+ const owned = authored.get(handle) ?? /* @__PURE__ */ new Set();
5092
+ owned.add(revision);
5093
+ authored.set(handle, owned);
5094
+ }
5095
+ async function ensureRoot() {
5096
+ if (ensured === void 0) {
5097
+ ensured = fs7.mkdir(root, { recursive: true, mode: 448 }).then(
5098
+ () => void 0,
5099
+ (error) => {
5100
+ ensured = void 0;
5101
+ throw error;
5102
+ }
5103
+ );
5104
+ }
5105
+ return ensured;
5106
+ }
5107
+ function dirFor(handle) {
5108
+ return path6.join(root, handle);
5109
+ }
5110
+ async function writeAtomic2(target, contents) {
5111
+ const temp = `${target}.${randomBytes2(6).toString("hex")}${TEMP_SUFFIX}`;
5112
+ try {
5113
+ await fs7.writeFile(temp, contents, { encoding: "utf8", mode: 384 });
5114
+ await fs7.rename(temp, target);
5115
+ } catch (error) {
5116
+ await fs7.rm(temp, { force: true }).catch(() => void 0);
5117
+ throw error;
5118
+ }
5119
+ }
5120
+ function revisionFile(handle, revision) {
5121
+ return path6.join(
5122
+ dirFor(handle),
5123
+ `${REVISION_PREFIX}${revision}${REVISION_SUFFIX}`
5124
+ );
5125
+ }
5126
+ async function readMeta(handle) {
5127
+ let raw;
5128
+ try {
5129
+ raw = await fs7.readFile(path6.join(dirFor(handle), META_FILE), "utf8");
5130
+ } catch {
5131
+ return void 0;
5132
+ }
5133
+ return parseMeta(raw, handle);
5134
+ }
5135
+ async function readRevision(handle, revision) {
5136
+ let text;
5137
+ try {
5138
+ text = await fs7.readFile(revisionFile(handle, revision), "utf8");
5139
+ } catch {
5140
+ return void 0;
5141
+ }
5142
+ try {
5143
+ const parsed = JSON.parse(text);
5144
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
5145
+ return void 0;
5146
+ } catch {
5147
+ return void 0;
5148
+ }
5149
+ return { revision, text, bytes: Buffer.byteLength(text, "utf8") };
5150
+ }
5151
+ async function handleDirs() {
5152
+ try {
5153
+ const entries = await fs7.readdir(root, { withFileTypes: true });
5154
+ return entries.filter(
5155
+ (entry) => entry.isDirectory() && isPersistableHandle(entry.name)
5156
+ ).map((entry) => entry.name);
5157
+ } catch {
5158
+ return [];
5159
+ }
5160
+ }
5161
+ async function pruneWorkspaces(incoming) {
5162
+ const handles = (await handleDirs()).filter(
5163
+ (handle) => handle !== incoming
5164
+ );
5165
+ const excess = handles.length + 1 - bounds.maxWorkspaces;
5166
+ if (excess <= 0) return;
5167
+ const metas = await Promise.all(
5168
+ handles.map(async (handle) => ({
5169
+ handle,
5170
+ // An unreadable entry sorts first: it is the one worth losing.
5171
+ updatedAt: (await readMeta(handle))?.updatedAt ?? 0
5172
+ }))
5173
+ );
5174
+ metas.sort((a, b) => a.updatedAt - b.updatedAt);
5175
+ for (const victim of metas.slice(0, excess)) {
5176
+ await fs7.rm(dirFor(victim.handle), { recursive: true, force: true }).catch(() => void 0);
5177
+ authored.delete(victim.handle);
5178
+ }
5179
+ }
5180
+ async function pruneRevisions(handle, retained) {
5181
+ let names;
5182
+ try {
5183
+ names = await fs7.readdir(dirFor(handle));
5184
+ } catch {
5185
+ return;
5186
+ }
5187
+ const owned = authored.get(handle);
5188
+ for (const name of names) {
5189
+ const target = path6.join(dirFor(handle), name);
5190
+ if (name.endsWith(TEMP_SUFFIX)) {
5191
+ if (await isStale(target)) {
5192
+ await fs7.rm(target, { force: true }).catch(() => void 0);
5193
+ }
5194
+ continue;
5195
+ }
5196
+ const revision = revisionOf(name);
5197
+ if (revision === void 0 || retained.has(revision)) continue;
5198
+ await fs7.rm(target, { force: true }).catch(() => void 0);
5199
+ owned?.delete(revision);
5200
+ }
5201
+ }
5202
+ return {
5203
+ root,
5204
+ limits: bounds,
5205
+ async save(snapshot) {
5206
+ if (!isPersistableHandle(snapshot.handle)) {
5207
+ throw new Error(
5208
+ `Workspace handle ${JSON.stringify(
5209
+ snapshot.handle
5210
+ )} cannot be used as a directory name.`
5211
+ );
5212
+ }
5213
+ if (snapshot.head.bytes > bounds.maxEntryBytes) {
5214
+ throw new Error(
5215
+ `Revision ${snapshot.head.revision} is ${snapshot.head.bytes} bytes, over the ${bounds.maxEntryBytes}-byte persistence limit.`
5216
+ );
5217
+ }
5218
+ await ensureRoot();
5219
+ const dir = dirFor(snapshot.handle);
5220
+ const fresh = !await exists(dir);
5221
+ if (fresh) await pruneWorkspaces(snapshot.handle);
5222
+ await fs7.mkdir(dir, { recursive: true, mode: 448 });
5223
+ const files = [snapshot.head];
5224
+ const retained = /* @__PURE__ */ new Set([snapshot.head.revision]);
5225
+ const pinned = [];
5226
+ for (const pin of [...snapshot.pins].sort(
5227
+ (a, b) => b.revision - a.revision
5228
+ )) {
5229
+ if (!retained.has(pin.revision)) {
5230
+ if (files.length >= bounds.maxRevisionsPerWorkspace) continue;
5231
+ files.push(pin);
5232
+ retained.add(pin.revision);
5233
+ }
5234
+ pinned.push(pin.revision);
5235
+ }
5236
+ const owned = authored.get(snapshot.handle);
5237
+ for (const document of files) {
5238
+ if (owned?.has(document.revision)) continue;
5239
+ await writeAtomic2(
5240
+ revisionFile(snapshot.handle, document.revision),
5241
+ document.text
5242
+ );
5243
+ claim(snapshot.handle, document.revision);
5244
+ }
5245
+ const meta = {
5246
+ schema: SCHEMA_VERSION,
5247
+ handle: snapshot.handle,
5248
+ format: snapshot.format,
5249
+ revision: snapshot.head.revision,
5250
+ bytes: snapshot.head.bytes,
5251
+ createdAt: snapshot.createdAt,
5252
+ updatedAt: snapshot.updatedAt,
5253
+ ...snapshot.title !== void 0 && { title: snapshot.title },
5254
+ pinnedRevisions: pinned.sort((a, b) => a - b)
5255
+ };
5256
+ await writeAtomic2(path6.join(dir, META_FILE), JSON.stringify(meta));
5257
+ await pruneRevisions(snapshot.handle, retained);
5258
+ },
5259
+ async list() {
5260
+ const handles = await handleDirs();
5261
+ const metas = await Promise.all(
5262
+ handles.map((handle) => readMeta(handle))
5263
+ );
5264
+ return metas.filter((meta) => meta !== void 0).sort((a, b) => b.updatedAt - a.updatedAt);
5265
+ },
5266
+ async load(handle) {
5267
+ if (!isPersistableHandle(handle)) return void 0;
5268
+ const meta = await readMeta(handle);
5269
+ if (!meta) return void 0;
5270
+ const head = await readRevision(handle, meta.revision);
5271
+ if (!head) {
5272
+ await fs7.rm(dirFor(handle), { recursive: true, force: true }).catch(() => void 0);
5273
+ return void 0;
5274
+ }
5275
+ const pins = [];
5276
+ for (const revision of meta.pinnedRevisions) {
5277
+ const pin = await readRevision(handle, revision);
5278
+ if (pin) pins.push(pin);
5279
+ }
5280
+ return {
5281
+ ...meta,
5282
+ // A pin whose file went missing is dropped rather than advertised: the
5283
+ // record has to describe what can actually be read back.
5284
+ pinnedRevisions: pins.map((pin) => pin.revision).sort((a, b) => a - b),
5285
+ head,
5286
+ pins
5287
+ };
5288
+ },
5289
+ async remove(handle) {
5290
+ if (!isPersistableHandle(handle)) return false;
5291
+ const dir = dirFor(handle);
5292
+ if (!await exists(dir)) {
5293
+ authored.delete(handle);
5294
+ return false;
5295
+ }
5296
+ await fs7.rm(dir, { recursive: true, force: true });
5297
+ authored.delete(handle);
5298
+ return true;
5299
+ }
5300
+ };
5301
+ }
5302
+ async function exists(target) {
5303
+ try {
5304
+ await fs7.stat(target);
5305
+ return true;
5306
+ } catch {
5307
+ return false;
5308
+ }
5309
+ }
5310
+ async function isStale(target) {
5311
+ try {
5312
+ const { mtimeMs } = await fs7.stat(target);
5313
+ return Date.now() - mtimeMs > STALE_TEMP_MS;
5314
+ } catch {
5315
+ return false;
5316
+ }
5317
+ }
5318
+ function revisionOf(name) {
5319
+ if (!name.startsWith(REVISION_PREFIX) || !name.endsWith(REVISION_SUFFIX)) {
5320
+ return void 0;
5321
+ }
5322
+ const digits = name.slice(
5323
+ REVISION_PREFIX.length,
5324
+ name.length - REVISION_SUFFIX.length
5325
+ );
5326
+ if (!/^[0-9]+$/.test(digits)) return void 0;
5327
+ return Number(digits);
5328
+ }
5329
+ function parseMeta(raw, handle) {
5330
+ let parsed;
5331
+ try {
5332
+ parsed = JSON.parse(raw);
5333
+ } catch {
5334
+ return void 0;
5335
+ }
5336
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
5337
+ return void 0;
5338
+ }
5339
+ const meta = parsed;
5340
+ if (meta.schema !== SCHEMA_VERSION) return void 0;
5341
+ if (meta.handle !== handle) return void 0;
5342
+ if (!FORMAT_NAMES.includes(meta.format)) return void 0;
5343
+ if (!isPositiveInteger(meta.revision)) return void 0;
5344
+ if (!isNonNegativeInteger(meta.bytes)) return void 0;
5345
+ if (!isNonNegativeInteger(meta.createdAt)) return void 0;
5346
+ if (!isNonNegativeInteger(meta.updatedAt)) return void 0;
5347
+ if (meta.title !== void 0 && typeof meta.title !== "string")
5348
+ return void 0;
5349
+ const pinnedRevisions = Array.isArray(meta.pinnedRevisions) ? meta.pinnedRevisions.filter(isPositiveInteger) : [];
5350
+ return {
5351
+ handle,
5352
+ format: meta.format,
5353
+ revision: meta.revision,
5354
+ bytes: meta.bytes,
5355
+ createdAt: meta.createdAt,
5356
+ updatedAt: meta.updatedAt,
5357
+ ...typeof meta.title === "string" && { title: meta.title },
5358
+ pinnedRevisions: [...pinnedRevisions].sort((a, b) => a - b)
5359
+ };
5360
+ }
5361
+ function isPositiveInteger(value) {
5362
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
5363
+ }
5364
+ function isNonNegativeInteger(value) {
5365
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
5366
+ }
5367
+
5368
+ // src/workspace/store.ts
4926
5369
  var WORKSPACE_ERROR_CODES = {
4927
5370
  EVICTED: "E_WORKSPACE_EVICTED",
4928
5371
  LIMIT: "E_WORKSPACE_LIMIT",
4929
5372
  DOCUMENT_TOO_LARGE: "E_DOCUMENT_TOO_LARGE",
4930
- INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT"
5373
+ INVALID_ROOT: "E_INVALID_DOCUMENT_ROOT",
5374
+ /**
5375
+ * A revision could not be mirrored to disk (#290). A warning, never a
5376
+ * failure: the edit landed and the workspace is usable, but this connection
5377
+ * has stopped being survivable and saying so is the whole point.
5378
+ */
5379
+ NOT_PERSISTED: "W_WORKSPACE_NOT_PERSISTED",
5380
+ /**
5381
+ * A close could not delete the durable copy, so nothing was released (#290).
5382
+ * An error rather than a warning: the agent asked for the document to be
5383
+ * destroyed, and it is still there to be read by the next connection.
5384
+ */
5385
+ NOT_CLOSED: "E_WORKSPACE_NOT_CLOSED"
4931
5386
  };
4932
5387
  var DEFAULT_WORKSPACE_LIMITS = {
4933
5388
  maxWorkspaces: 16,
@@ -4947,6 +5402,7 @@ function createMemoryWorkspaceStore(options = {}) {
4947
5402
  };
4948
5403
  const now = options.now ?? Date.now;
4949
5404
  const newHandle = options.newHandle ?? defaultHandle;
5405
+ const persistence = options.persistence;
4950
5406
  const entries = /* @__PURE__ */ new Map();
4951
5407
  const tombstones = /* @__PURE__ */ new Map();
4952
5408
  function footprint(entry) {
@@ -4959,12 +5415,8 @@ function createMemoryWorkspaceStore(options = {}) {
4959
5415
  for (const entry of entries.values()) total += footprint(entry);
4960
5416
  return total;
4961
5417
  }
4962
- function tombstone(entry, reason) {
4963
- tombstones.set(entry.handle, {
4964
- reason,
4965
- at: now(),
4966
- revision: entry.revision
4967
- });
5418
+ function tombstone(handle, reason, revision) {
5419
+ tombstones.set(handle, { reason, at: now(), revision });
4968
5420
  while (tombstones.size > MAX_TOMBSTONES) {
4969
5421
  const oldest = tombstones.keys().next();
4970
5422
  if (oldest.done) break;
@@ -4974,7 +5426,7 @@ function createMemoryWorkspaceStore(options = {}) {
4974
5426
  function evict(entry, reason) {
4975
5427
  entries.delete(entry.handle);
4976
5428
  tombstones.delete(entry.handle);
4977
- tombstone(entry, reason);
5429
+ tombstone(entry.handle, reason, entry.revision);
4978
5430
  }
4979
5431
  function sweep() {
4980
5432
  const at = now();
@@ -4982,6 +5434,108 @@ function createMemoryWorkspaceStore(options = {}) {
4982
5434
  if (at - entry.touchedAt > limits.idleTtlMs) evict(entry, "ttl");
4983
5435
  }
4984
5436
  }
5437
+ async function persist(entry) {
5438
+ if (!persistence) return void 0;
5439
+ try {
5440
+ await persistence.save({
5441
+ handle: entry.handle,
5442
+ format: entry.format,
5443
+ createdAt: entry.createdAt,
5444
+ updatedAt: entry.updatedAt,
5445
+ ...entry.title !== void 0 && { title: entry.title },
5446
+ head: {
5447
+ revision: entry.revision,
5448
+ text: entry.text,
5449
+ bytes: entry.bytes
5450
+ },
5451
+ pins: [...entry.pins.entries()].map(([revision, pin]) => ({
5452
+ revision,
5453
+ text: pin.text,
5454
+ bytes: pin.bytes
5455
+ }))
5456
+ });
5457
+ entry.persisted = true;
5458
+ return void 0;
5459
+ } catch (error) {
5460
+ entry.persisted = false;
5461
+ return diagnostic(
5462
+ WORKSPACE_ERROR_CODES.NOT_PERSISTED,
5463
+ `Revision ${entry.revision} of ${entry.handle} is held in memory but was not written to ${persistence.root}: ${error instanceof Error ? error.message : String(error)}`,
5464
+ {
5465
+ severity: "warning",
5466
+ suggestion: "Export the JSON with jto_workspace_snapshot and keep it yourself; this handle will not survive a lost connection.",
5467
+ context: {
5468
+ handle: entry.handle,
5469
+ revision: entry.revision,
5470
+ workspaceRoot: persistence.root
5471
+ }
5472
+ }
5473
+ );
5474
+ }
5475
+ }
5476
+ function committed(record, warning) {
5477
+ return { ok: true, record, ...warning && { warnings: [warning] } };
5478
+ }
5479
+ async function rehydrate(handle) {
5480
+ if (!persistence || !isPersistableHandle(handle)) return void 0;
5481
+ let loaded;
5482
+ try {
5483
+ loaded = await persistence.load(handle);
5484
+ } catch {
5485
+ return void 0;
5486
+ }
5487
+ if (!loaded) return void 0;
5488
+ const restored = loaded;
5489
+ const pins = [...restored.pins].sort((a, b) => b.revision - a.revision).slice(0, limits.maxPinnedRevisions);
5490
+ const needed = pins.reduce(
5491
+ (total, pin) => total + pin.bytes,
5492
+ restored.head.bytes
5493
+ );
5494
+ if (restored.head.bytes > limits.maxDocumentBytes) {
5495
+ return tooLarge(restored.head.bytes, limits.maxDocumentBytes);
5496
+ }
5497
+ if (!hasRoom(needed, true)) {
5498
+ return failure(
5499
+ WORKSPACE_ERROR_CODES.LIMIT,
5500
+ `Workspace ${handle} is on disk at revision ${restored.revision} but does not fit in this connection's ${limits.maxTotalBytes}-byte budget.`,
5501
+ {
5502
+ suggestion: "Close a workspace you have finished with, then use this handle again.",
5503
+ context: {
5504
+ handle,
5505
+ bytes: needed,
5506
+ maxTotalBytes: limits.maxTotalBytes,
5507
+ maxWorkspaces: limits.maxWorkspaces
5508
+ }
5509
+ }
5510
+ );
5511
+ }
5512
+ const entry = {
5513
+ handle,
5514
+ format: restored.format,
5515
+ revision: restored.head.revision,
5516
+ text: restored.head.text,
5517
+ bytes: restored.head.bytes,
5518
+ createdAt: restored.createdAt,
5519
+ updatedAt: restored.updatedAt,
5520
+ touchedAt: now(),
5521
+ ...restored.title !== void 0 && { title: restored.title },
5522
+ pins: new Map(
5523
+ pins.map((pin) => [pin.revision, { text: pin.text, bytes: pin.bytes }])
5524
+ ),
5525
+ persisted: true
5526
+ };
5527
+ entries.set(handle, entry);
5528
+ tombstones.delete(handle);
5529
+ return { ok: true, entry };
5530
+ }
5531
+ async function resolve3(handle) {
5532
+ const entry = entries.get(handle);
5533
+ if (entry) return { entry };
5534
+ const restored = await rehydrate(handle);
5535
+ if (restored === void 0) return missing(handle);
5536
+ if (!restored.ok) return restored;
5537
+ return { entry: restored.entry };
5538
+ }
4985
5539
  function missing(handle) {
4986
5540
  const grave = tombstones.get(handle);
4987
5541
  if (grave?.reason === "ttl") {
@@ -5017,12 +5571,29 @@ function createMemoryWorkspaceStore(options = {}) {
5017
5571
  createdAt: new Date(entry.createdAt).toISOString(),
5018
5572
  updatedAt: new Date(entry.updatedAt).toISOString(),
5019
5573
  ...entry.title !== void 0 && { title: entry.title },
5020
- pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b)
5574
+ pinnedRevisions: [...entry.pins.keys()].sort((a, b) => a - b),
5575
+ ...persistence && { persisted: entry.persisted }
5576
+ };
5577
+ }
5578
+ function fromMeta(meta) {
5579
+ return {
5580
+ handle: meta.handle,
5581
+ format: meta.format,
5582
+ revision: meta.revision,
5583
+ bytes: meta.bytes,
5584
+ createdAt: new Date(meta.createdAt).toISOString(),
5585
+ updatedAt: new Date(meta.updatedAt).toISOString(),
5586
+ ...meta.title !== void 0 && { title: meta.title },
5587
+ pinnedRevisions: [...meta.pinnedRevisions],
5588
+ persisted: true
5021
5589
  };
5022
5590
  }
5023
5591
  return {
5024
5592
  available: true,
5025
5593
  limits,
5594
+ ...persistence && {
5595
+ persistence: { root: persistence.root, limits: persistence.limits }
5596
+ },
5026
5597
  usage() {
5027
5598
  sweep();
5028
5599
  return { workspaces: entries.size, bytes: totalBytes() };
@@ -5063,15 +5634,18 @@ function createMemoryWorkspaceStore(options = {}) {
5063
5634
  updatedAt: at,
5064
5635
  touchedAt: at,
5065
5636
  ...input.title !== void 0 && { title: input.title },
5066
- pins: /* @__PURE__ */ new Map()
5637
+ pins: /* @__PURE__ */ new Map(),
5638
+ persisted: false
5067
5639
  };
5068
5640
  entries.set(entry.handle, entry);
5069
- return { ok: true, record: toRecord(entry) };
5641
+ const warning = await persist(entry);
5642
+ return committed(toRecord(entry), warning);
5070
5643
  },
5071
5644
  async get(handle, readOptions) {
5072
5645
  sweep();
5073
- const entry = entries.get(handle);
5074
- if (!entry) return missing(handle);
5646
+ const found = await resolve3(handle);
5647
+ if (!("entry" in found)) return found;
5648
+ const entry = found.entry;
5075
5649
  entry.touchedAt = now();
5076
5650
  let text = entry.text;
5077
5651
  let bytes = entry.bytes;
@@ -5096,15 +5670,16 @@ function createMemoryWorkspaceStore(options = {}) {
5096
5670
  context: { handle, pointer }
5097
5671
  });
5098
5672
  }
5099
- const found = resolvePointer(document, parsed.tokens);
5100
- if (found.found) projection[pointer] = found.value;
5673
+ const found2 = resolvePointer(document, parsed.tokens);
5674
+ if (found2.found) projection[pointer] = found2.value;
5101
5675
  }
5102
5676
  return { ok: true, record, document, projection };
5103
5677
  },
5104
5678
  async patch(input) {
5105
5679
  sweep();
5106
- const entry = entries.get(input.handle);
5107
- if (!entry) return missing(input.handle);
5680
+ const found = await resolve3(input.handle);
5681
+ if (!("entry" in found)) return found;
5682
+ const entry = found.entry;
5108
5683
  entry.touchedAt = now();
5109
5684
  if (input.baseRevision !== void 0 && input.baseRevision !== entry.revision) {
5110
5685
  return stale(
@@ -5170,12 +5745,14 @@ function createMemoryWorkspaceStore(options = {}) {
5170
5745
  entry.revision += 1;
5171
5746
  entry.updatedAt = now();
5172
5747
  entry.touchedAt = entry.updatedAt;
5173
- return { ok: true, record: toRecord(entry) };
5748
+ const warning = await persist(entry);
5749
+ return committed(toRecord(entry), warning);
5174
5750
  },
5175
5751
  async snapshot(handle) {
5176
5752
  sweep();
5177
- const entry = entries.get(handle);
5178
- if (!entry) return missing(handle);
5753
+ const found = await resolve3(handle);
5754
+ if (!("entry" in found)) return found;
5755
+ const entry = found.entry;
5179
5756
  entry.touchedAt = now();
5180
5757
  const revision = entry.revision;
5181
5758
  const document = JSON.parse(entry.text);
@@ -5184,24 +5761,70 @@ function createMemoryWorkspaceStore(options = {}) {
5184
5761
  entry.pins.set(revision, { text: entry.text, bytes: entry.bytes });
5185
5762
  }
5186
5763
  }
5187
- return { ok: true, record: toRecord(entry), document };
5188
- },
5189
- async list() {
5190
- sweep();
5764
+ const warning = entry.pins.has(revision) ? await persist(entry) : void 0;
5191
5765
  return {
5192
5766
  ok: true,
5193
- records: [...entries.values()].map((entry) => toRecord(entry))
5767
+ record: toRecord(entry),
5768
+ document,
5769
+ ...warning && { warnings: [warning] }
5194
5770
  };
5195
5771
  },
5772
+ async list() {
5773
+ sweep();
5774
+ const records = [...entries.values()].map((entry) => toRecord(entry));
5775
+ if (!persistence) return { ok: true, records };
5776
+ const resident = new Set(records.map((record) => record.handle));
5777
+ let stored = [];
5778
+ try {
5779
+ stored = await persistence.list();
5780
+ } catch {
5781
+ }
5782
+ for (const meta of stored) {
5783
+ if (resident.has(meta.handle)) continue;
5784
+ if (tombstones.get(meta.handle)?.reason === "closed") continue;
5785
+ records.push(fromMeta(meta));
5786
+ }
5787
+ return { ok: true, records };
5788
+ },
5196
5789
  async close(handle) {
5197
5790
  sweep();
5198
5791
  const entry = entries.get(handle);
5199
- if (!entry) return { ok: true, handle, closed: false };
5200
- evict(entry, "closed");
5201
- return { ok: true, handle, closed: true };
5792
+ let durable = false;
5793
+ if (persistence) {
5794
+ try {
5795
+ durable = await persistence.remove(handle);
5796
+ } catch (error) {
5797
+ return failure(
5798
+ WORKSPACE_ERROR_CODES.NOT_CLOSED,
5799
+ `Workspace ${handle} could not be removed from ${persistence.root}: ${error instanceof Error ? error.message : String(error)}. It is still open and still on disk.`,
5800
+ {
5801
+ suggestion: "Check the workspace directory is writable, then close again.",
5802
+ context: { handle, workspaceRoot: persistence.root }
5803
+ }
5804
+ );
5805
+ }
5806
+ }
5807
+ if (entry) evict(entry, "closed");
5808
+ else if (durable) tombstone(handle, "closed", 0);
5809
+ return { ok: true, handle, closed: entry !== void 0 || durable };
5202
5810
  },
5811
+ /**
5812
+ * Release memory, keep the disk.
5813
+ *
5814
+ * The asymmetry with `close` is the point: this exists for a host
5815
+ * reclaiming memory at a moment of its own choosing, and #290 is precisely
5816
+ * about an event that ends a connection not being allowed to destroy
5817
+ * revisions. The handles come back from disk on next use.
5818
+ */
5203
5819
  async closeAll() {
5204
- for (const entry of [...entries.values()]) evict(entry, "closed");
5820
+ for (const entry of [...entries.values()]) {
5821
+ if (!persistence) {
5822
+ evict(entry, "closed");
5823
+ continue;
5824
+ }
5825
+ entries.delete(entry.handle);
5826
+ tombstones.delete(entry.handle);
5827
+ }
5205
5828
  }
5206
5829
  };
5207
5830
  function stale(entry, wanted, pinned, mutation = false) {
@@ -5221,7 +5844,7 @@ function createMemoryWorkspaceStore(options = {}) {
5221
5844
  }
5222
5845
  }
5223
5846
  function defaultHandle() {
5224
- return `ws_${randomBytes2(9).toString("base64url")}`;
5847
+ return `ws_${randomBytes3(9).toString("base64url")}`;
5225
5848
  }
5226
5849
  function serialize(document) {
5227
5850
  let text;
@@ -5274,7 +5897,7 @@ var workspaceSchema = {
5274
5897
  properties: {
5275
5898
  handle: {
5276
5899
  type: "string",
5277
- description: "Opaque, valid only on this connection."
5900
+ 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."
5278
5901
  },
5279
5902
  format: { type: "string", enum: [...FORMAT_NAMES] },
5280
5903
  revision: {
@@ -5292,6 +5915,10 @@ var workspaceSchema = {
5292
5915
  type: "array",
5293
5916
  items: { type: "integer" },
5294
5917
  description: "Revisions kept retrievable by jto_workspace_snapshot; read one back with `revision`."
5918
+ },
5919
+ persisted: {
5920
+ type: "boolean",
5921
+ description: "True when this revision is mirrored to disk and so survives a lost connection. Absent when this connection has no workspace directory configured."
5295
5922
  }
5296
5923
  },
5297
5924
  required: [
@@ -5305,6 +5932,26 @@ var workspaceSchema = {
5305
5932
  ],
5306
5933
  additionalProperties: false
5307
5934
  };
5935
+ var persistenceSchema = {
5936
+ type: "object",
5937
+ description: "Where revisions survive the connection. Absent when workspaces are memory-only, which is the default.",
5938
+ properties: {
5939
+ root: { type: "string" },
5940
+ maxWorkspaces: { type: "integer" },
5941
+ maxRevisionsPerWorkspace: {
5942
+ type: "integer",
5943
+ description: "Revision files kept per handle: the head plus its pins."
5944
+ },
5945
+ maxEntryBytes: { type: "integer" }
5946
+ },
5947
+ required: [
5948
+ "root",
5949
+ "maxWorkspaces",
5950
+ "maxRevisionsPerWorkspace",
5951
+ "maxEntryBytes"
5952
+ ],
5953
+ additionalProperties: false
5954
+ };
5308
5955
  var limitsSchema = {
5309
5956
  type: "object",
5310
5957
  description: "What this connection allows. Bounded on purpose; see the codes above.",
@@ -5336,7 +5983,9 @@ function isMemoryStore(store) {
5336
5983
  }
5337
5984
  function ensureStore(deps) {
5338
5985
  if (deps.workspaces().available || hasWorkspaceStore()) return;
5339
- const owned = createMemoryWorkspaceStore();
5986
+ const owned = createMemoryWorkspaceStore(
5987
+ deps.workspacePersistence ? { persistence: deps.workspacePersistence } : {}
5988
+ );
5340
5989
  deps.workspaces = () => owned;
5341
5990
  }
5342
5991
  function register8(server, deps) {
@@ -5379,9 +6028,9 @@ function register8(server, deps) {
5379
6028
  ...args.title !== void 0 && { title: args.title }
5380
6029
  });
5381
6030
  if (!created.ok) return created;
5382
- return success(
5383
- { workspace: created.record },
5384
- seeded ? [
6031
+ return success({ workspace: created.record }, [
6032
+ ...created.warnings ?? [],
6033
+ ...seeded ? [
5385
6034
  diagnostic(
5386
6035
  "W_BLANK_DOCUMENT",
5387
6036
  `Opened an empty ${args.format} skeleton; it has no content until you patch some in.`,
@@ -5391,7 +6040,7 @@ function register8(server, deps) {
5391
6040
  }
5392
6041
  )
5393
6042
  ] : []
5394
- );
6043
+ ]);
5395
6044
  })
5396
6045
  )
5397
6046
  );
@@ -5457,17 +6106,20 @@ function register8(server, deps) {
5457
6106
  const missingPaths = projecting ? args.paths.filter(
5458
6107
  (pointer) => !(pointer in projection)
5459
6108
  ) : [];
5460
- const diagnostics = missingPaths.map(
5461
- (pointer) => diagnostic(
5462
- "W_PATH_NOT_FOUND",
5463
- `${pointer} does not resolve in revision ${read.record.revision}.`,
5464
- {
5465
- severity: "warning",
5466
- path: pointer,
5467
- suggestion: "Read a shorter prefix of the pointer to see what is actually there."
5468
- }
6109
+ const diagnostics = [
6110
+ ...read.warnings ?? [],
6111
+ ...missingPaths.map(
6112
+ (pointer) => diagnostic(
6113
+ "W_PATH_NOT_FOUND",
6114
+ `${pointer} does not resolve in revision ${read.record.revision}.`,
6115
+ {
6116
+ severity: "warning",
6117
+ path: pointer,
6118
+ suggestion: "Read a shorter prefix of the pointer to see what is actually there."
6119
+ }
6120
+ )
5469
6121
  )
5470
- );
6122
+ ];
5471
6123
  return success(
5472
6124
  {
5473
6125
  workspace: read.record,
@@ -5536,7 +6188,7 @@ function register8(server, deps) {
5536
6188
  }
5537
6189
  });
5538
6190
  if (!patched.ok) return patched;
5539
- return success({ workspace: patched.record });
6191
+ return success({ workspace: patched.record }, patched.warnings ?? []);
5540
6192
  })
5541
6193
  )
5542
6194
  );
@@ -5582,7 +6234,7 @@ function register8(server, deps) {
5582
6234
  await guarded(async () => {
5583
6235
  const snapshot = await deps.workspaces().snapshot(args.handle);
5584
6236
  if (!snapshot.ok) return snapshot;
5585
- const diagnostics = [];
6237
+ const diagnostics = [...snapshot.warnings ?? []];
5586
6238
  if (!snapshot.record.pinnedRevisions.includes(snapshot.record.revision)) {
5587
6239
  diagnostics.push(
5588
6240
  diagnostic(
@@ -5625,7 +6277,7 @@ function register8(server, deps) {
5625
6277
  "jto_workspace_list",
5626
6278
  {
5627
6279
  title: "List open workspaces",
5628
- 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.",
6280
+ 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.",
5629
6281
  annotations: { readOnlyHint: true, openWorldHint: false },
5630
6282
  inputSchema: S({
5631
6283
  type: "object",
@@ -5640,6 +6292,7 @@ function register8(server, deps) {
5640
6292
  description: "False when this connection has workspaces switched off."
5641
6293
  },
5642
6294
  limits: limitsSchema,
6295
+ persistence: persistenceSchema,
5643
6296
  usage: {
5644
6297
  type: "object",
5645
6298
  properties: {
@@ -5657,12 +6310,24 @@ function register8(server, deps) {
5657
6310
  const store = deps.workspaces();
5658
6311
  const listed = await store.list();
5659
6312
  if (!listed.ok) return listed;
5660
- const budget = isMemoryStore(store) ? { limits: store.limits, usage: store.usage() } : {};
5661
- return success({
5662
- workspaces: listed.records,
5663
- available: store.available,
5664
- ...budget
5665
- });
6313
+ const budget = isMemoryStore(store) ? {
6314
+ limits: store.limits,
6315
+ usage: store.usage(),
6316
+ ...store.persistence && {
6317
+ persistence: {
6318
+ root: store.persistence.root,
6319
+ ...store.persistence.limits
6320
+ }
6321
+ }
6322
+ } : {};
6323
+ return success(
6324
+ {
6325
+ workspaces: listed.records,
6326
+ available: store.available,
6327
+ ...budget
6328
+ },
6329
+ listed.warnings ?? []
6330
+ );
5666
6331
  })
5667
6332
  )
5668
6333
  );
@@ -5670,7 +6335,7 @@ function register8(server, deps) {
5670
6335
  "jto_workspace_close",
5671
6336
  {
5672
6337
  title: "Close a workspace",
5673
- 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.",
6338
+ 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.",
5674
6339
  annotations: {
5675
6340
  readOnlyHint: false,
5676
6341
  destructiveHint: true,
@@ -5717,6 +6382,7 @@ var RESOURCE_URIS = {
5717
6382
  catalog: "jto://catalog",
5718
6383
  renderers: "jto://renderers",
5719
6384
  themes: "jto://themes",
6385
+ themeValues: "jto://themes/values",
5720
6386
  templates: "jto://templates",
5721
6387
  documentSchema: (format) => `jto://schema/${format}/document`,
5722
6388
  themeSchema: (format) => `jto://schema/${format}/theme`
@@ -5768,7 +6434,7 @@ function register9(server, deps) {
5768
6434
  RESOURCE_URIS.themes,
5769
6435
  {
5770
6436
  title: "Built-in themes",
5771
- description: "Theme names shipped with each format, usable as a document\u2019s props.theme or as the tools\u2019 theme option.",
6437
+ 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.",
5772
6438
  mimeType: JSON_MIME
5773
6439
  },
5774
6440
  async (uri) => {
@@ -5781,6 +6447,33 @@ function register9(server, deps) {
5781
6447
  });
5782
6448
  }
5783
6449
  );
6450
+ server.registerResource(
6451
+ "theme-values",
6452
+ RESOURCE_URIS.themeValues,
6453
+ {
6454
+ title: "Built-in theme values",
6455
+ 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.",
6456
+ mimeType: JSON_MIME
6457
+ },
6458
+ async (uri) => {
6459
+ const formats = await Promise.all(
6460
+ FORMAT_NAMES.map(async (format) => {
6461
+ const adapter = deps.getAdapter(format);
6462
+ let themes;
6463
+ try {
6464
+ themes = adapter.getBuiltinThemeValues ? await adapter.getBuiltinThemeValues() : adapter.getBuiltinThemes();
6465
+ } catch {
6466
+ themes = adapter.getBuiltinThemes();
6467
+ }
6468
+ return {
6469
+ format,
6470
+ themes
6471
+ };
6472
+ })
6473
+ );
6474
+ return jsonContents(uri, { formats });
6475
+ }
6476
+ );
5784
6477
  server.registerResource(
5785
6478
  "templates",
5786
6479
  RESOURCE_URIS.templates,
@@ -5829,6 +6522,7 @@ Working rules:
5829
6522
  - 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.
5830
6523
  - 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.
5831
6524
  - 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.
6525
+ - 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.
5832
6526
  - 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.
5833
6527
  - Snapshot before risky changes. jto_workspace_snapshot pins the current revision so a restructuring you cannot cleanly undo is still recoverable.
5834
6528
 
@@ -5858,6 +6552,12 @@ function createServerFactory(deps) {
5858
6552
 
5859
6553
  // src/lib/deps.ts
5860
6554
  function createToolDeps(options = {}) {
6555
+ const workspacePersistence = options.workspacePersistence ?? createWorkspacePersistence({
6556
+ ...options.workspaceDir !== void 0 && {
6557
+ flagDir: options.workspaceDir
6558
+ },
6559
+ ...options.env !== void 0 && { env: options.env }
6560
+ });
5861
6561
  return {
5862
6562
  serverVersion: options.serverVersion ?? SERVER_VERSION,
5863
6563
  outputRoot: options.outputRoot ?? createOutputRoot({
@@ -5866,10 +6566,13 @@ function createToolDeps(options = {}) {
5866
6566
  }),
5867
6567
  getAdapter: options.getAdapter ?? getAdapter,
5868
6568
  workspaces: options.workspaces ?? getWorkspaceStore,
6569
+ ...workspacePersistence !== void 0 && { workspacePersistence },
5869
6570
  maxInlineArtifactBytes: options.maxInlineArtifactBytes ?? MAX_INLINE_ARTIFACT_BYTES
5870
6571
  };
5871
6572
  }
5872
6573
  export {
6574
+ DEFAULT_PERSISTENCE_LIMITS,
6575
+ DEFAULT_WORKSPACE_LIMITS,
5873
6576
  DOCUMENT_SOURCE_RULE,
5874
6577
  ERROR_CODES,
5875
6578
  FORMAT_NAMES,
@@ -5882,15 +6585,20 @@ export {
5882
6585
  SERVER_INSTRUCTIONS,
5883
6586
  SERVER_NAME,
5884
6587
  SERVER_VERSION,
6588
+ WORKSPACE_DIR_ENV,
6589
+ WORKSPACE_ERROR_CODES,
5885
6590
  artifactOutputProperties,
5886
6591
  artifactSchema,
5887
6592
  checkOutputName,
5888
6593
  checkRenderer,
5889
6594
  countDiagnostics,
6595
+ createMemoryWorkspaceStore,
5890
6596
  createOutputRoot,
5891
6597
  createServer,
5892
6598
  createServerFactory,
5893
6599
  createToolDeps,
6600
+ createWorkspacePersistence,
6601
+ createWorkspacePersistenceAt,
5894
6602
  deliverArtifact,
5895
6603
  diagnostic,
5896
6604
  diagnosticSchema,