mellos-mapping 0.22.1 → 0.23.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/web.mjs CHANGED
@@ -2271,7 +2271,7 @@ var require_websocket = __commonJS({
2271
2271
  var http = __require("http");
2272
2272
  var net = __require("net");
2273
2273
  var tls = __require("tls");
2274
- var { randomBytes: randomBytes2, createHash: createHash3 } = __require("crypto");
2274
+ var { randomBytes: randomBytes2, createHash: createHash4 } = __require("crypto");
2275
2275
  var { Duplex, Readable } = __require("stream");
2276
2276
  var { URL: URL2 } = __require("url");
2277
2277
  var PerMessageDeflate2 = require_permessage_deflate();
@@ -2939,7 +2939,7 @@ var require_websocket = __commonJS({
2939
2939
  abortHandshake(websocket, socket, "Invalid Upgrade header");
2940
2940
  return;
2941
2941
  }
2942
- const digest = createHash3("sha1").update(key + GUID).digest("base64");
2942
+ const digest = createHash4("sha1").update(key + GUID).digest("base64");
2943
2943
  if (res.headers["sec-websocket-accept"] !== digest) {
2944
2944
  abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header");
2945
2945
  return;
@@ -3308,7 +3308,7 @@ var require_websocket_server = __commonJS({
3308
3308
  var EventEmitter = __require("events");
3309
3309
  var http = __require("http");
3310
3310
  var { Duplex } = __require("stream");
3311
- var { createHash: createHash3 } = __require("crypto");
3311
+ var { createHash: createHash4 } = __require("crypto");
3312
3312
  var extension2 = require_extension();
3313
3313
  var PerMessageDeflate2 = require_permessage_deflate();
3314
3314
  var subprotocol2 = require_subprotocol();
@@ -3615,7 +3615,7 @@ var require_websocket_server = __commonJS({
3615
3615
  );
3616
3616
  }
3617
3617
  if (this._state > RUNNING) return abortHandshake(socket, 503);
3618
- const digest = createHash3("sha1").update(key + GUID).digest("base64");
3618
+ const digest = createHash4("sha1").update(key + GUID).digest("base64");
3619
3619
  const headers = [
3620
3620
  "HTTP/1.1 101 Switching Protocols",
3621
3621
  "Upgrade: websocket",
@@ -3703,8 +3703,8 @@ var require_websocket_server = __commonJS({
3703
3703
  });
3704
3704
 
3705
3705
  // src/web/cli.ts
3706
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, realpathSync as realpathSync2, rmSync as rmSync3, statSync as statSync2 } from "node:fs";
3707
- import { dirname as dirname7, join as join5, resolve as resolve2 } from "node:path";
3706
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync5, realpathSync as realpathSync2, rmSync as rmSync4, statSync as statSync2 } from "node:fs";
3707
+ import { dirname as dirname8, join as join6, resolve as resolve2 } from "node:path";
3708
3708
  import { fileURLToPath, pathToFileURL } from "node:url";
3709
3709
 
3710
3710
  // src/domain/types.ts
@@ -3924,12 +3924,37 @@ function updateNode(map, input) {
3924
3924
  return ok({ ...map, nodes: map.nodes.map((n) => n.id === input.id ? updated : n) });
3925
3925
  }
3926
3926
 
3927
+ // src/domain/context.ts
3928
+ function sourceError(raw) {
3929
+ if (!Array.isArray(raw) || raw.length > 100) return "sources must be an array of at most 100 file references";
3930
+ for (const item of raw) {
3931
+ if (!item || typeof item !== "object" || Array.isArray(item)) return "source must be an object";
3932
+ const s = item;
3933
+ if (Object.keys(s).some((k) => k !== "path" && k !== "sha256")) return "unknown source field";
3934
+ if (typeof s.path !== "string" || s.path.length > 1024 || !s.path || /[\u0000-\u001f\u007f-\u009f\\:]/.test(s.path) || s.path.startsWith("/") || s.path.split("/").some((p) => !p || p === "." || p === "..")) return "source path must be relative to the project, with forward slashes and no traversal";
3935
+ if (s.sha256 !== void 0 && (typeof s.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(s.sha256))) return "source sha256 must be a lowercase SHA256 hash";
3936
+ }
3937
+ return void 0;
3938
+ }
3939
+ function contextError(raw) {
3940
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "context must be an object";
3941
+ for (const [key, value] of Object.entries(raw)) {
3942
+ if (key !== "summary" && key !== "next") return "unknown context field";
3943
+ if (typeof value !== "string" || value.length > 2e3 || /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/.test(value)) return "context fields must be text of at most 2000 characters";
3944
+ }
3945
+ return void 0;
3946
+ }
3947
+
3927
3948
  // src/domain/text.ts
3928
3949
  var NO_CONTROLS = /^[^\u0000-\u001f\u007f-\u009f]*$/;
3929
3950
  var NO_CONTROLS_TEXT = "one line of text; control characters (ESC, newline, tab) are not allowed";
3930
3951
  var NO_CONTROLS_BUT_BREAKS = /^[^\u0000-\u0008\u000b-\u001f\u007f-\u009f]*$/;
3931
3952
  var NO_CONTROLS_BUT_BREAKS_TEXT = "text with optional newlines (\\n) and tabs; other control characters (ESC, BEL, CR) are not allowed";
3932
3953
  function mapTextError(map) {
3954
+ if (map.context !== void 0) {
3955
+ const error2 = contextError(map.context);
3956
+ if (error2) return error2;
3957
+ }
3933
3958
  const check = (field, value, multiline = false) => value === void 0 || (multiline ? NO_CONTROLS_BUT_BREAKS : NO_CONTROLS).test(value) ? void 0 : `${field}: ${multiline ? NO_CONTROLS_BUT_BREAKS_TEXT : NO_CONTROLS_TEXT}`;
3934
3959
  let error = check("title", map.title);
3935
3960
  if (error) return error;
@@ -3944,6 +3969,10 @@ function mapTextError(map) {
3944
3969
  }
3945
3970
  }
3946
3971
  for (const [i, node] of map.nodes.entries()) {
3972
+ if (node.sources !== void 0) {
3973
+ const error2 = sourceError(node.sources);
3974
+ if (error2) return `nodes[${i}]: ${error2}`;
3975
+ }
3947
3976
  for (const name of ["label", "evidence", "detail"]) {
3948
3977
  error = check(`nodes[${i}].${name}`, node[name], name !== "label");
3949
3978
  if (error) return error;
@@ -4006,8 +4035,8 @@ function optionalString(rec, key, where, path) {
4006
4035
  }
4007
4036
  function parseMap(raw, path) {
4008
4037
  if (!isRecord(raw)) return err({ kind: "bad-shape", path, detail: "root is not an object" });
4009
- if (raw["version"] !== STATE_FILE_VERSION) {
4010
- return err({ kind: "bad-shape", path, detail: `version is ${String(raw["version"])}, expected ${STATE_FILE_VERSION}` });
4038
+ if (raw["version"] !== STATE_FILE_VERSION && raw["version"] !== 2) {
4039
+ return err({ kind: "bad-shape", path, detail: `version is ${String(raw["version"])}, expected ${STATE_FILE_VERSION} or 2` });
4011
4040
  }
4012
4041
  const layers = arrayField(raw, "layers", path, "required");
4013
4042
  if (!layers.ok) return layers;
@@ -4020,6 +4049,11 @@ function parseMap(raw, path) {
4020
4049
  const groups = arrayField(raw, "groups", path, "optional");
4021
4050
  if (!groups.ok) return groups;
4022
4051
  let map = EMPTY_MAP;
4052
+ if (raw["context"] !== void 0) {
4053
+ const error = contextError(raw["context"]);
4054
+ if (error) return err({ kind: "bad-shape", path, detail: error });
4055
+ map = { ...map, context: raw["context"] };
4056
+ }
4023
4057
  const title = optionalString(raw, "title", "map", path);
4024
4058
  if (!title.ok) return title;
4025
4059
  if (title.value !== void 0) map = setTitle(map, title.value);
@@ -4148,6 +4182,11 @@ function parseMap(raw, path) {
4148
4182
  if (!updated.ok) return err({ kind: "invariant-violation", path, violation: updated.error });
4149
4183
  map = updated.value;
4150
4184
  }
4185
+ if (rawNode["sources"] !== void 0) {
4186
+ const error = sourceError(rawNode["sources"]);
4187
+ if (error) return err({ kind: "bad-shape", path, detail: `${where}: ${error}` });
4188
+ map = { ...map, nodes: map.nodes.map((n) => n.id === id.value ? { ...n, sources: rawNode["sources"] } : n) };
4189
+ }
4151
4190
  }
4152
4191
  for (const [i, rawEdge] of edges.value.entries()) {
4153
4192
  const where = `edges[${i}]`;
@@ -4282,15 +4321,84 @@ function loadMapFile(path) {
4282
4321
  return parseMap(raw, path);
4283
4322
  }
4284
4323
 
4324
+ // src/store/transaction.ts
4325
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync3, writeFileSync as writeFileSync2 } from "node:fs";
4326
+ import { dirname as dirname4, join as join3 } from "node:path";
4327
+ import { randomUUID, createHash } from "node:crypto";
4328
+ var LedgerError = class extends Error {
4329
+ constructor(code, message, details = {}) {
4330
+ super(message);
4331
+ this.code = code;
4332
+ this.details = details;
4333
+ }
4334
+ };
4335
+ function storeDirectory(file) {
4336
+ const dir = dirname4(file);
4337
+ return dir.endsWith("/pages") || dir.endsWith("\\pages") ? dirname4(dir) : dir;
4338
+ }
4339
+ function deadOwner(lock) {
4340
+ try {
4341
+ const owner = JSON.parse(readFileSync2(join3(lock, "owner.json"), "utf8"));
4342
+ if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0) return false;
4343
+ try {
4344
+ process.kill(owner.pid, 0);
4345
+ return false;
4346
+ } catch (e) {
4347
+ return e.code === "ESRCH";
4348
+ }
4349
+ } catch {
4350
+ return false;
4351
+ }
4352
+ }
4353
+ function withStoreLock(file, action) {
4354
+ const dir = storeDirectory(file);
4355
+ mkdirSync2(dir, { recursive: true });
4356
+ const lock = join3(dir, ".write-lock");
4357
+ const owner = join3(lock, "owner.json");
4358
+ const token = randomUUID();
4359
+ try {
4360
+ mkdirSync2(lock);
4361
+ } catch (error) {
4362
+ if (error.code !== "EEXIST") throw error;
4363
+ if (!deadOwner(lock)) throw new LedgerError("BUSY", `Another writer owns ${lock}; retry after it completes. An orphan without owner metadata needs manual inspection.`);
4364
+ try {
4365
+ mkdirSync2(join3(lock, ".reap"));
4366
+ } catch {
4367
+ throw new LedgerError("BUSY", "Another process is recovering the writer lock.");
4368
+ }
4369
+ if (!deadOwner(lock)) {
4370
+ rmSync3(join3(lock, ".reap"), { recursive: true, force: true });
4371
+ throw new LedgerError("BUSY", "Writer ownership changed.");
4372
+ }
4373
+ rmSync3(lock, { recursive: true });
4374
+ try {
4375
+ mkdirSync2(lock);
4376
+ } catch {
4377
+ throw new LedgerError("BUSY", "Another writer acquired the recovered lock.");
4378
+ }
4379
+ }
4380
+ let initialized = false;
4381
+ try {
4382
+ writeFileSync2(owner, JSON.stringify({ pid: process.pid, token }), { flag: "wx" });
4383
+ initialized = true;
4384
+ return action();
4385
+ } finally {
4386
+ try {
4387
+ if (!initialized || JSON.parse(readFileSync2(owner, "utf8")).token === token) rmSync3(lock, { recursive: true });
4388
+ } catch {
4389
+ }
4390
+ }
4391
+ }
4392
+
4285
4393
  // src/web/launcher.ts
4286
4394
  import { spawn } from "node:child_process";
4287
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
4288
- import { dirname as dirname5, join as join3 } from "node:path";
4395
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
4396
+ import { dirname as dirname6, join as join4 } from "node:path";
4289
4397
 
4290
4398
  // src/web/source.ts
4291
- import { createHash } from "node:crypto";
4399
+ import { createHash as createHash2 } from "node:crypto";
4292
4400
  import { statSync } from "node:fs";
4293
- import { basename as basename2, dirname as dirname4 } from "node:path";
4401
+ import { basename as basename2, dirname as dirname5 } from "node:path";
4294
4402
  function readWebSnapshot(defaultFile) {
4295
4403
  const pages = listPageFiles(defaultFile).map((file) => {
4296
4404
  const id = pageIdOfFile(defaultFile, file) ?? "";
@@ -4305,15 +4413,15 @@ function readWebSnapshot(defaultFile) {
4305
4413
  }
4306
4414
  });
4307
4415
  if (pages.length === 0) pages.push({ id: "", title: "\u7B49\u5F85\u7B2C\u4E00\u5F20\u5730\u56FE", modified: 0, map: EMPTY_MAP });
4308
- const value = { project: basename2(dirname4(dirname4(defaultFile))), pages };
4309
- return { revision: createHash("sha256").update(JSON.stringify(value)).digest("hex"), value };
4416
+ const value = { project: basename2(dirname5(dirname5(defaultFile))), pages };
4417
+ return { revision: createHash2("sha256").update(JSON.stringify(value)).digest("hex"), value };
4310
4418
  }
4311
4419
 
4312
4420
  // src/web/launcher.ts
4313
- var webRuntimeFile = (defaultFile) => join3(dirname5(defaultFile), "web", "server.json");
4421
+ var webRuntimeFile = (defaultFile) => join4(dirname6(defaultFile), "web", "server.json");
4314
4422
  async function runningWebUrl(defaultFile) {
4315
4423
  try {
4316
- const info = JSON.parse(readFileSync2(webRuntimeFile(defaultFile), "utf8"));
4424
+ const info = JSON.parse(readFileSync3(webRuntimeFile(defaultFile), "utf8"));
4317
4425
  if (!Number.isInteger(info.port) || info.port < 1 || info.port > 65535 || !/^[a-f0-9]{48}$/.test(info.token)) return void 0;
4318
4426
  const url = `http://127.0.0.1:${info.port}/${info.token}/`;
4319
4427
  const response = await fetch(`${url}api/health`, { signal: AbortSignal.timeout(700) });
@@ -4325,14 +4433,14 @@ async function runningWebUrl(defaultFile) {
4325
4433
  async function openWebPreview(defaultFile, entry, page, terminal = false) {
4326
4434
  if (page !== void 0 && (!ID_RULE.test(page) || !readWebSnapshot(defaultFile).value.pages.some((p) => p.id === page))) throw new Error(`No map page named "${page}".`);
4327
4435
  let url = await runningWebUrl(defaultFile);
4328
- if (url && terminal) {
4436
+ if (url) {
4329
4437
  const health = await fetch(`${url}api/health`, { signal: AbortSignal.timeout(2e3) });
4330
4438
  const info = await health.json();
4331
- if (!info.surfaces?.includes("web-terminal")) {
4439
+ if (!info.formats?.includes(2) || terminal && !info.surfaces?.includes("web-terminal")) {
4332
4440
  await fetch(`${url}api/stop`, { method: "POST", signal: AbortSignal.timeout(2e3) });
4333
4441
  const deadline = Date.now() + 3e3;
4334
4442
  while (await runningWebUrl(defaultFile)) {
4335
- if (Date.now() > deadline) throw new Error("Old web viewer is still stopping. Retry opening the terminal.");
4443
+ if (Date.now() > deadline) throw new Error("Old web viewer is still stopping. Retry opening the map.");
4336
4444
  await new Promise((resolve3) => setTimeout(resolve3, 100));
4337
4445
  }
4338
4446
  url = void 0;
@@ -4365,9 +4473,9 @@ import { randomBytes } from "node:crypto";
4365
4473
  import { createServer } from "node:http";
4366
4474
 
4367
4475
  // src/preview/publisher.ts
4368
- import { createHash as createHash2 } from "node:crypto";
4369
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, realpathSync, rmdirSync } from "node:fs";
4370
- import { dirname as dirname6, join as join4, resolve } from "node:path";
4476
+ import { createHash as createHash3 } from "node:crypto";
4477
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync as readdirSync2, realpathSync, rmdirSync } from "node:fs";
4478
+ import { dirname as dirname7, join as join5, resolve } from "node:path";
4371
4479
 
4372
4480
  // src/semantics/vocabulary.ts
4373
4481
  var STATUS_GLYPHS = {
@@ -5084,15 +5192,15 @@ var PREVIEW_DIR_NAME = "previews";
5084
5192
  var ENABLED = ".enabled";
5085
5193
  var PUBLISH_LOCK = ".publish-lock";
5086
5194
  function previewDirectory(defaultFile) {
5087
- return join4(dirname6(defaultFile), PREVIEW_DIR_NAME);
5195
+ return join5(dirname7(defaultFile), PREVIEW_DIR_NAME);
5088
5196
  }
5089
5197
  function previewFile(defaultFile, page) {
5090
5198
  if (page !== void 0 && !ID_RULE.test(page)) throw new Error("Invalid preview page id.");
5091
- return join4(previewDirectory(defaultFile), documentName(page));
5199
+ return join5(previewDirectory(defaultFile), documentName(page));
5092
5200
  }
5093
5201
  function save(path, contents) {
5094
5202
  try {
5095
- if (readFileSync3(path, "utf8") === contents) return;
5203
+ if (readFileSync4(path, "utf8") === contents) return;
5096
5204
  } catch (error) {
5097
5205
  if (error.code !== "ENOENT") throw error;
5098
5206
  }
@@ -5100,19 +5208,19 @@ function save(path, contents) {
5100
5208
  if (!result.ok) throw new Error(describeStoreError(result.error));
5101
5209
  }
5102
5210
  function ownedDirectory(path) {
5103
- mkdirSync2(path, { recursive: true });
5104
- const expected = join4(realpathSync(dirname6(path)), path.slice(dirname6(path).length + 1));
5211
+ mkdirSync3(path, { recursive: true });
5212
+ const expected = join5(realpathSync(dirname7(path)), path.slice(dirname7(path).length + 1));
5105
5213
  const actual = realpathSync(path);
5106
5214
  if (process.platform === "win32" ? actual.toLowerCase() !== expected.toLowerCase() : actual !== expected) {
5107
5215
  throw new Error(`Preview directory redirects outside its parent: ${path}`);
5108
5216
  }
5109
5217
  }
5110
5218
  function acquireLock(directory) {
5111
- const path = join4(directory, PUBLISH_LOCK);
5219
+ const path = join5(directory, PUBLISH_LOCK);
5112
5220
  const deadline = Date.now() + 2e3;
5113
5221
  while (true) {
5114
5222
  try {
5115
- mkdirSync2(path);
5223
+ mkdirSync3(path);
5116
5224
  return () => rmdirSync(path);
5117
5225
  } catch (error) {
5118
5226
  if (error.code !== "EEXIST") throw error;
@@ -5123,7 +5231,7 @@ function acquireLock(directory) {
5123
5231
  }
5124
5232
  function createPreviewPublisher(defaultFile) {
5125
5233
  const directory = previewDirectory(defaultFile);
5126
- const enabledFile = join4(directory, ENABLED);
5234
+ const enabledFile = join5(directory, ENABLED);
5127
5235
  const enabled = () => existsSync3(enabledFile);
5128
5236
  const refresh = (page) => {
5129
5237
  try {
@@ -5141,24 +5249,24 @@ function createPreviewPublisher(defaultFile) {
5141
5249
  }
5142
5250
  if (page !== void 0 && !pages.some((p) => p.page === page)) return err(`No map page named "${page}".`);
5143
5251
  if (page === void 0 && !pages.some((p) => p.page === void 0)) pages.unshift({ page: void 0, map: EMPTY_MAP });
5144
- const images = join4(directory, "images");
5252
+ const images = join5(directory, "images");
5145
5253
  ownedDirectory(images);
5146
5254
  const present = /* @__PURE__ */ new Set();
5147
5255
  for (const item of pages) {
5148
5256
  const svg = renderMapSvg(item.map);
5149
- const digest = createHash2("sha256").update(svg).digest("hex");
5257
+ const digest = createHash3("sha256").update(svg).digest("hex");
5150
5258
  const image = `images/${digest}.svg`;
5151
- save(join4(images, `${digest}.svg`), svg);
5259
+ save(join5(images, `${digest}.svg`), svg);
5152
5260
  const filename = documentName(item.page);
5153
- save(join4(directory, filename), renderMapMarkdown(item.map, image, pages));
5261
+ save(join5(directory, filename), renderMapMarkdown(item.map, image, pages));
5154
5262
  present.add(filename);
5155
5263
  }
5156
5264
  for (const filename of readdirSync2(directory)) {
5157
5265
  if (/^(map|page-[a-z0-9][a-z0-9-]{0,63})\.md$/.test(filename) && !present.has(filename)) {
5158
- save(join4(directory, filename), "# \u5730\u56FE\u5DF2\u5220\u9664\n\n\u6B64\u9875\u9762\u5DF2\u4E0D\u5728\u9879\u76EE\u5730\u56FE\u4E2D\u3002\n\n[\u8FD4\u56DE\u5730\u56FE\u76EE\u5F55](index.md)\n");
5266
+ save(join5(directory, filename), "# \u5730\u56FE\u5DF2\u5220\u9664\n\n\u6B64\u9875\u9762\u5DF2\u4E0D\u5728\u9879\u76EE\u5730\u56FE\u4E2D\u3002\n\n[\u8FD4\u56DE\u5730\u56FE\u76EE\u5F55](index.md)\n");
5159
5267
  }
5160
5268
  }
5161
- const index = join4(directory, "index.md");
5269
+ const index = join5(directory, "index.md");
5162
5270
  save(index, renderPreviewIndex(pages));
5163
5271
  return ok({ path: resolve(path), index: resolve(index), pages: pages.length });
5164
5272
  } finally {
@@ -5381,7 +5489,7 @@ async function startWebService(defaultFile, assets, options = {}) {
5381
5489
  }
5382
5490
  }
5383
5491
  if (req.method === "GET" && route === "api/health") {
5384
- send(res, 200, JSON.stringify({ file: defaultFile, pid: process.pid, surfaces: terminalService ? ["web", "web-terminal"] : ["web"] }));
5492
+ send(res, 200, JSON.stringify({ file: defaultFile, pid: process.pid, formats: [1, 2], surfaces: terminalService ? ["web", "web-terminal"] : ["web"] }));
5385
5493
  return;
5386
5494
  }
5387
5495
  if (req.method === "GET" && route === "api/state") {
@@ -5396,23 +5504,26 @@ async function startWebService(defaultFile, assets, options = {}) {
5396
5504
  return;
5397
5505
  }
5398
5506
  if (req.method === "DELETE" && route.startsWith("api/pages/")) {
5399
- const id = route.slice("api/pages/".length);
5400
- if (id !== "_default" && !ID_RULE.test(id)) {
5401
- send(res, 400, '{"error":"Invalid page"}');
5402
- return;
5403
- }
5404
- if (req.headers["if-match"] !== `"${readWebSnapshot(defaultFile).revision}"`) {
5405
- send(res, 409, '{"error":"\u5730\u56FE\u5DF2\u66F4\u65B0\uFF0C\u8BF7\u91CD\u65B0\u786E\u8BA4\u5220\u9664\u3002"}');
5406
- return;
5407
- }
5408
- const result = deletePageFile(pageFilePath(defaultFile, id === "_default" ? void 0 : id));
5409
- if (!result.ok) {
5410
- send(res, 500, JSON.stringify({ error: describeStoreError(result.error) }));
5507
+ withStoreLock(defaultFile, () => {
5508
+ const id = route.slice("api/pages/".length);
5509
+ if (id !== "_default" && !ID_RULE.test(id)) {
5510
+ send(res, 400, '{"error":"Invalid page"}');
5511
+ return;
5512
+ }
5513
+ if (req.headers["if-match"] !== `"${readWebSnapshot(defaultFile).revision}"`) {
5514
+ send(res, 409, '{"error":"\u5730\u56FE\u5DF2\u66F4\u65B0\uFF0C\u8BF7\u91CD\u65B0\u786E\u8BA4\u5220\u9664\u3002"}');
5515
+ return;
5516
+ }
5517
+ const result = deletePageFile(pageFilePath(defaultFile, id === "_default" ? void 0 : id));
5518
+ if (!result.ok) {
5519
+ send(res, 500, JSON.stringify({ error: describeStoreError(result.error) }));
5520
+ return;
5521
+ }
5522
+ const previews = createPreviewPublisher(defaultFile);
5523
+ const refreshed = previews.enabled() ? previews.refresh() : void 0;
5524
+ send(res, 200, JSON.stringify({ deleted: true, ...refreshed && !refreshed.ok ? { warning: `\u5730\u56FE\u5DF2\u5220\u9664\uFF0CMarkdown \u9884\u89C8\u5F85\u91CD\u65B0\u751F\u6210\uFF1A${refreshed.error}` } : {} }));
5411
5525
  return;
5412
- }
5413
- const previews = createPreviewPublisher(defaultFile);
5414
- const refreshed = previews.enabled() ? previews.refresh() : void 0;
5415
- send(res, 200, JSON.stringify({ deleted: true, ...refreshed && !refreshed.ok ? { warning: `\u5730\u56FE\u5DF2\u5220\u9664\uFF0CMarkdown \u9884\u89C8\u5F85\u91CD\u65B0\u751F\u6210\uFF1A${refreshed.error}` } : {} }));
5526
+ });
5416
5527
  return;
5417
5528
  }
5418
5529
  if (req.method === "POST" && route === "api/stop") {
@@ -5422,7 +5533,7 @@ async function startWebService(defaultFile, assets, options = {}) {
5422
5533
  }
5423
5534
  send(res, 404, '{"error":"Not found"}');
5424
5535
  } catch (error) {
5425
- send(res, 500, JSON.stringify({ error: String(error) }));
5536
+ send(res, error instanceof LedgerError && error.code === "BUSY" ? 409 : 500, JSON.stringify({ error: String(error) }));
5426
5537
  }
5427
5538
  });
5428
5539
  const terminalService = assets.terminal && options.terminalWorker ? attachTerminalService(server, {
@@ -5471,19 +5582,19 @@ async function runWeb(args) {
5471
5582
  if (args[0] === "--serve" && args.length === 2) {
5472
5583
  const file2 = resolve2(args[1]);
5473
5584
  if (await runningWebUrl(file2)) return;
5474
- const directory = dirname7(webRuntimeFile(file2));
5475
- mkdirSync3(directory, { recursive: true });
5476
- if (realpathSync2(directory).toLowerCase() !== join5(realpathSync2(dirname7(directory)), "web").toLowerCase()) throw new Error("Redirected web runtime directory");
5477
- const assets = join5(dirname7(entry), "web");
5585
+ const directory = dirname8(webRuntimeFile(file2));
5586
+ mkdirSync4(directory, { recursive: true });
5587
+ if (realpathSync2(directory).toLowerCase() !== join6(realpathSync2(dirname8(directory)), "web").toLowerCase()) throw new Error("Redirected web runtime directory");
5588
+ const assets = join6(dirname8(entry), "web");
5478
5589
  let service;
5479
5590
  service = await startWebService(file2, {
5480
- html: readFileSync4(join5(assets, "index.html"), "utf8"),
5481
- javascript: readFileSync4(join5(assets, "app.js"), "utf8"),
5482
- css: readFileSync4(join5(assets, "app.css"), "utf8"),
5483
- terminal: { html: readFileSync4(join5(assets, "terminal.html"), "utf8"), javascript: readFileSync4(join5(assets, "terminal.js"), "utf8"), css: readFileSync4(join5(assets, "terminal.css"), "utf8"), xtermCss: readFileSync4(join5(assets, "xterm.css"), "utf8") }
5484
- }, { terminalWorker: join5(dirname7(entry), "terminal-worker.mjs"), onClose: () => {
5591
+ html: readFileSync5(join6(assets, "index.html"), "utf8"),
5592
+ javascript: readFileSync5(join6(assets, "app.js"), "utf8"),
5593
+ css: readFileSync5(join6(assets, "app.css"), "utf8"),
5594
+ terminal: { html: readFileSync5(join6(assets, "terminal.html"), "utf8"), javascript: readFileSync5(join6(assets, "terminal.js"), "utf8"), css: readFileSync5(join6(assets, "terminal.css"), "utf8"), xtermCss: readFileSync5(join6(assets, "xterm.css"), "utf8") }
5595
+ }, { terminalWorker: join6(dirname8(entry), "terminal-worker.mjs"), onClose: () => {
5485
5596
  try {
5486
- if (JSON.parse(readFileSync4(webRuntimeFile(file2), "utf8")).token === service.token) rmSync3(webRuntimeFile(file2));
5597
+ if (JSON.parse(readFileSync5(webRuntimeFile(file2), "utf8")).token === service.token) rmSync4(webRuntimeFile(file2));
5487
5598
  } catch {
5488
5599
  }
5489
5600
  } });
@@ -5515,7 +5626,7 @@ async function runWeb(args) {
5515
5626
  }
5516
5627
  const project = resolve2(args[0]);
5517
5628
  if (!existsSync4(project) || !statSync2(project).isDirectory()) throw new Error(`Project directory does not exist: ${project}`);
5518
- const file = join5(project, STATE_FILE_RELATIVE_PATH);
5629
+ const file = join6(project, STATE_FILE_RELATIVE_PATH);
5519
5630
  if (stop) {
5520
5631
  const url = await runningWebUrl(file);
5521
5632
  if (url) await fetch(`${url}api/stop`, { method: "POST", signal: AbortSignal.timeout(2e3) });
package/docs/codex.md CHANGED
@@ -12,7 +12,7 @@ From the `chatgpt-app` branch run `node install.mjs`; from `main` run
12
12
 
13
13
  The installer checks file hashes and a real MCP handshake, copies the runtime to
14
14
  `~/.mellos/installations/chatgpt-app/`, registers the `mellos-mapping-codex`
15
- marketplace, installs the skill, and registers the six MCP tools at user scope.
15
+ marketplace, installs the skill, and registers the eight MCP tools at user scope.
16
16
  The runtime uses an absolute Node executable and leaves its working directory
17
17
  unset so each conversation writes to its own project. The clone can be deleted
18
18
  or moved after installation. Other plugins, maps and mapping policies are preserved.
@@ -41,6 +41,12 @@ publish the package into OpenAI's public plugin directory.
41
41
 
42
42
  ## Automatic web terminal
43
43
 
44
+ For saved-map discovery, structured CRUD, checkpoints and concurrent updates,
45
+ see [the persistent-map API guide](map-api.md). Start by reading existing pages;
46
+ a new conversation does not require a new map. Restart older MCP/native watchers
47
+ before writing maps with format-2 context or source references. Reopening a Web
48
+ surface replaces services without format-2 support and returns a new URL.
49
+
44
50
  Call `mmap_open {surface: "web-terminal", page: "<slug>"}` and pass the returned
45
51
  `hostOpen` object to `open_in_codex`. It uses `placement: "right"` and a browser
46
52
  target in the current conversation. The local service starts mmap when the
@@ -0,0 +1,148 @@
1
+ # Persistent maps and the MCP API
2
+
3
+ Maps survive process restarts and new conversations. Begin with `mmap_read
4
+ {resource: "pages"}`, match the effort to a saved page, then read only relevant
5
+ records. A conversation is not a page identity. `mmap_view` remains the visual
6
+ representation; `mmap_read` is the editable data contract.
7
+
8
+ ## Read
9
+
10
+ `mmap_read` returns JSON text and the same object in `structuredContent`:
11
+ `resource`, `project`, `page`, `revision`, `total`, `items`, `nextCursor`.
12
+ The default page is represented by a null `page` and the record ID `_default`.
13
+ Named page slugs and resource IDs remain stable; change display titles/labels
14
+ instead of renaming identity. Edge IDs are `from->to`.
15
+
16
+ | resource | Records |
17
+ | --- | --- |
18
+ | pages (default) | Page IDs, titles, kind, counts, context and each page's revision; a broken page is listed with its error |
19
+ | map | One page's metadata/context/counts; it does not embed the entire graph |
20
+ | nodes | Node IDs, labels, status and membership; request detail, evidence and sources through fields |
21
+ | edges | ID, from, to and optional label |
22
+ | layers / groups / lanes | The stored editable records |
23
+ | neighborhood | Related nodes selected by id/ids, direction (dependencies/consumers/both) and depth (0–4) |
24
+ | changes | Source-file verification state for selected nodes and up to 100 affected consumer IDs |
25
+
26
+ Use `id` for exact lookup (missing is `NOT_FOUND`), `ids` for a selection,
27
+ `query` for text matching, and status/layer/group/lane for node filtering.
28
+ `fields` projects records while always retaining identity and edge endpoints.
29
+ The default limit is 30, maximum 100. Repeat the same query with nextCursor;
30
+ if the graph changes, the cursor returns `CONFLICT` instead of skipping records.
31
+ An absent page is `NOT_FOUND`; an empty list is successful. The legacy view's
32
+ empty-map behavior is retained for compatibility.
33
+
34
+ Use a page or record response's revision for that page's next write. The top-level
35
+ revision of a pages listing describes the listing, not any individual page.
36
+ `ifRevision` can avoid resending unchanged graph data. It does not suppress
37
+ source checks: files may change without a graph edit. Pagination checks graph
38
+ revisions, not a filesystem snapshot of source files changing during the query.
39
+
40
+ ```json
41
+ {"resource":"nodes","page":"payments","status":"in-progress","fields":["label","detail","evidence"],"limit":20}
42
+ ```
43
+
44
+ ## Create, update and delete
45
+
46
+ The existing tools and fields remain supported. New writes return a revision
47
+ and structured outcome in addition to the existing summary.
48
+
49
+ - `mmap_declare`: create pages, layers, groups, lanes, nodes and edges. Existing
50
+ IDs are refused. Pass expectedRevision: "absent" to require a new page.
51
+ - `mmap_update`: existing node fields, layer names/ranks, group labels/layers,
52
+ lane labels, complete laneOrder, map title/kind/context and edge patches.
53
+ An edge patch identifies from/to, with optional label, newFrom and newTo.
54
+ A null label clears it. A laneOrder contains every lane exactly once.
55
+ - `mmap_remove`: existing per-resource deletion and cascades. Use deletePage:
56
+ true to delete the targeted page, including the default page. Inbound submap
57
+ references are refused unless references: "keep" is explicit. Unlink nodes
58
+ first when the references should disappear. This form cannot include edits
59
+ or legacy pages batches.
60
+
61
+ Optional fields are unchanged when omitted and removed when set to null where
62
+ the schema permits. context and sources are replaced as whole values. Moving
63
+ a group does not silently move members: include their intended node moves in
64
+ the same update or transaction. Normal single-field changes keep the old graph
65
+ constraints; use a batch for changes that require a coordinated final graph.
66
+
67
+ Every graph writer in the current MCP, HTTP viewer and watcher uses a cooperative
68
+ cross-process project lock. MCP expectedRevision is compared inside that lock
69
+ before loading the proposed changes into the saved map. `CONFLICT` requires a
70
+ fresh read and reconsideration of the edit. `BUSY` means another transaction is
71
+ active; retry after it completes. There is no background lock polling. Locks are
72
+ released on normal completion/error; a confirmed dead PID can be recovered.
73
+ An incomplete owner file after an abrupt crash is refused for manual inspection,
74
+ not guessed stale from its age.
75
+
76
+ Low-level library saveMapFile, hand edits, and older running processes do not
77
+ participate in this contract. Restart MCP processes and native watchers after
78
+ upgrading. Opening a Web surface upgrades services that lack format-2 support;
79
+ existing tabs must reconnect with the newly returned URL. Atomic file
80
+ replacement alone does not make a caller's stale read/modify/write safe.
81
+
82
+ Legacy `mmap_remove {pages:[...]}` remains a separately documented batch of file
83
+ deletions, with explicit partial success and historical reference behavior. It
84
+ does not accept expectedRevision; use per-page deletePage for checked deletion.
85
+ It is not a multi-page transaction.
86
+
87
+ ## One-page mixed transactions
88
+
89
+ `mmap_batch` accepts page, expectedRevision and 1–100 ordered operations. Each
90
+ operation is `{op: "declare" | "update" | "remove", data: {...}}`, with the
91
+ same per-page fields as that tool. Per-operation page, expectedRevision and
92
+ page deletion are excluded. Changes are drafted, the final graph is validated,
93
+ and the page is saved once; a refusal preserves the original file bytes.
94
+
95
+ ```json
96
+ {
97
+ "page":"payments",
98
+ "expectedRevision":"<revision returned by mmap_read>",
99
+ "operations":[
100
+ {"op":"remove","data":{"edges":[{"from":"api","to":"old-store"}]}},
101
+ {"op":"declare","data":{"nodes":[{"id":"new-store","label":"Store","layer":"base"}]}},
102
+ {"op":"declare","data":{"edges":[{"from":"api","to":"new-store"}]}},
103
+ {"op":"update","data":{"context":{"summary":"Payment services","next":"Verify the new store contract"}}}
104
+ ]
105
+ }
106
+ ```
107
+
108
+ Machine-readable error codes include NOT_FOUND, INVALID_STORE, REFUSED,
109
+ CONFLICT, BUSY, INVALID_CURSOR, INVALID_ARGUMENT, REFERENCED and SAVE_FAILED.
110
+ Malformed schema inputs remain MCP invalid-argument errors. Read calls do not
111
+ open panels or create page files.
112
+
113
+ ## Checkpoints and source changes
114
+
115
+ Map context has optional summary and next fields (up to 2,000 characters each).
116
+ Save concise decisions and next actions, rather than copying the conversation.
117
+ Nodes can hold up to 100 sources: `{path: "src/store.ts", sha256: "..."}`.
118
+ Paths are project-relative, forward-slash paths with no traversal. SHA256 is
119
+ optional so sources can be linked before verification.
120
+
121
+ `mmap_read {resource:"changes", page, id}` hashes only the selected node's
122
+ listed files, reports currentSha256 and unchanged/changed/unknown node state,
123
+ and identifies affected consumers. Missing files count as changed. Missing
124
+ baselines, inaccessible files, files over 8 MiB and paths resolving outside
125
+ the project remain unverified. There is no whole-repository source scan and
126
+ no automatic status change. After verification, copy the current hashes into
127
+ sources[].sha256 with a revision-checked update.
128
+
129
+ Classic maps continue to serialize as format 1. Maps containing context or
130
+ source references serialize as format 2; this runtime reads both. Older runtimes
131
+ must refuse format 2 instead of silently dropping its new fields. Removing all
132
+ extension fields allows serialization as format 1 again. Both formats preserve
133
+ the same graph, statuses and existing evidence.
134
+
135
+ ## Project identity
136
+
137
+ Without an explicit MELLOS_MAPPING_CWD or CLAUDE_PROJECT_DIR, the server walks
138
+ up from cwd to the nearest existing map store or Git root. An explicit override
139
+ stays explicit. The mmap command and watcher use the same resolver. Nested
140
+ repositories and worktrees are boundaries; separate branches are not silently
141
+ redirected to a shared writable graph. Carry maps through Git or deliberate
142
+ workspace setup when a new worktree needs them, and recheck source baselines.
143
+
144
+ Codex CLI and App skills both start by reading existing pages. MCP initialization
145
+ also advertises the restore workflow, including for MCP-only installations.
146
+ Viewer selection follows host capabilities; the CLI does not try to use a
147
+ desktop panel tool. After context compaction, repeat the lightweight page/context
148
+ read, then load only the affected resources.
@@ -0,0 +1,11 @@
1
+ /** Optional portable provenance; no host paths or I/O in the map format. */
2
+ export interface SourceRef {
3
+ readonly path: string;
4
+ readonly sha256?: string | undefined;
5
+ }
6
+ export interface MapContext {
7
+ readonly summary?: string | undefined;
8
+ readonly next?: string | undefined;
9
+ }
10
+ export declare function sourceError(raw: unknown): string | undefined;
11
+ export declare function contextError(raw: unknown): string | undefined;