mellos-mapping 0.22.1 → 0.24.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,9 +3703,10 @@ 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 existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync6, realpathSync as realpathSync3, rmSync as rmSync4, statSync as statSync2 } from "node:fs";
3707
+ import { dirname as dirname9, join as join7, resolve as resolve2 } from "node:path";
3708
3708
  import { fileURLToPath, pathToFileURL } from "node:url";
3709
+ import { homedir } from "node:os";
3709
3710
 
3710
3711
  // src/domain/types.ts
3711
3712
  var ok = (value) => ({ ok: true, value });
@@ -3924,12 +3925,37 @@ function updateNode(map, input) {
3924
3925
  return ok({ ...map, nodes: map.nodes.map((n) => n.id === input.id ? updated : n) });
3925
3926
  }
3926
3927
 
3928
+ // src/domain/context.ts
3929
+ function sourceError(raw) {
3930
+ if (!Array.isArray(raw) || raw.length > 100) return "sources must be an array of at most 100 file references";
3931
+ for (const item of raw) {
3932
+ if (!item || typeof item !== "object" || Array.isArray(item)) return "source must be an object";
3933
+ const s = item;
3934
+ if (Object.keys(s).some((k) => k !== "path" && k !== "sha256")) return "unknown source field";
3935
+ 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";
3936
+ 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";
3937
+ }
3938
+ return void 0;
3939
+ }
3940
+ function contextError(raw) {
3941
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "context must be an object";
3942
+ for (const [key, value] of Object.entries(raw)) {
3943
+ if (key !== "summary" && key !== "next") return "unknown context field";
3944
+ 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";
3945
+ }
3946
+ return void 0;
3947
+ }
3948
+
3927
3949
  // src/domain/text.ts
3928
3950
  var NO_CONTROLS = /^[^\u0000-\u001f\u007f-\u009f]*$/;
3929
3951
  var NO_CONTROLS_TEXT = "one line of text; control characters (ESC, newline, tab) are not allowed";
3930
3952
  var NO_CONTROLS_BUT_BREAKS = /^[^\u0000-\u0008\u000b-\u001f\u007f-\u009f]*$/;
3931
3953
  var NO_CONTROLS_BUT_BREAKS_TEXT = "text with optional newlines (\\n) and tabs; other control characters (ESC, BEL, CR) are not allowed";
3932
3954
  function mapTextError(map) {
3955
+ if (map.context !== void 0) {
3956
+ const error2 = contextError(map.context);
3957
+ if (error2) return error2;
3958
+ }
3933
3959
  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
3960
  let error = check("title", map.title);
3935
3961
  if (error) return error;
@@ -3944,6 +3970,10 @@ function mapTextError(map) {
3944
3970
  }
3945
3971
  }
3946
3972
  for (const [i, node] of map.nodes.entries()) {
3973
+ if (node.sources !== void 0) {
3974
+ const error2 = sourceError(node.sources);
3975
+ if (error2) return `nodes[${i}]: ${error2}`;
3976
+ }
3947
3977
  for (const name of ["label", "evidence", "detail"]) {
3948
3978
  error = check(`nodes[${i}].${name}`, node[name], name !== "label");
3949
3979
  if (error) return error;
@@ -4006,8 +4036,8 @@ function optionalString(rec, key, where, path) {
4006
4036
  }
4007
4037
  function parseMap(raw, path) {
4008
4038
  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}` });
4039
+ if (raw["version"] !== STATE_FILE_VERSION && raw["version"] !== 2) {
4040
+ return err({ kind: "bad-shape", path, detail: `version is ${String(raw["version"])}, expected ${STATE_FILE_VERSION} or 2` });
4011
4041
  }
4012
4042
  const layers = arrayField(raw, "layers", path, "required");
4013
4043
  if (!layers.ok) return layers;
@@ -4020,6 +4050,11 @@ function parseMap(raw, path) {
4020
4050
  const groups = arrayField(raw, "groups", path, "optional");
4021
4051
  if (!groups.ok) return groups;
4022
4052
  let map = EMPTY_MAP;
4053
+ if (raw["context"] !== void 0) {
4054
+ const error = contextError(raw["context"]);
4055
+ if (error) return err({ kind: "bad-shape", path, detail: error });
4056
+ map = { ...map, context: raw["context"] };
4057
+ }
4023
4058
  const title = optionalString(raw, "title", "map", path);
4024
4059
  if (!title.ok) return title;
4025
4060
  if (title.value !== void 0) map = setTitle(map, title.value);
@@ -4148,6 +4183,11 @@ function parseMap(raw, path) {
4148
4183
  if (!updated.ok) return err({ kind: "invariant-violation", path, violation: updated.error });
4149
4184
  map = updated.value;
4150
4185
  }
4186
+ if (rawNode["sources"] !== void 0) {
4187
+ const error = sourceError(rawNode["sources"]);
4188
+ if (error) return err({ kind: "bad-shape", path, detail: `${where}: ${error}` });
4189
+ map = { ...map, nodes: map.nodes.map((n) => n.id === id.value ? { ...n, sources: rawNode["sources"] } : n) };
4190
+ }
4151
4191
  }
4152
4192
  for (const [i, rawEdge] of edges.value.entries()) {
4153
4193
  const where = `edges[${i}]`;
@@ -4282,15 +4322,84 @@ function loadMapFile(path) {
4282
4322
  return parseMap(raw, path);
4283
4323
  }
4284
4324
 
4325
+ // src/store/transaction.ts
4326
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync3, writeFileSync as writeFileSync2 } from "node:fs";
4327
+ import { dirname as dirname4, join as join3 } from "node:path";
4328
+ import { randomUUID, createHash } from "node:crypto";
4329
+ var LedgerError = class extends Error {
4330
+ constructor(code, message, details = {}) {
4331
+ super(message);
4332
+ this.code = code;
4333
+ this.details = details;
4334
+ }
4335
+ };
4336
+ function storeDirectory(file) {
4337
+ const dir = dirname4(file);
4338
+ return dir.endsWith("/pages") || dir.endsWith("\\pages") ? dirname4(dir) : dir;
4339
+ }
4340
+ function deadOwner(lock) {
4341
+ try {
4342
+ const owner = JSON.parse(readFileSync2(join3(lock, "owner.json"), "utf8"));
4343
+ if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0) return false;
4344
+ try {
4345
+ process.kill(owner.pid, 0);
4346
+ return false;
4347
+ } catch (e) {
4348
+ return e.code === "ESRCH";
4349
+ }
4350
+ } catch {
4351
+ return false;
4352
+ }
4353
+ }
4354
+ function withStoreLock(file, action) {
4355
+ const dir = storeDirectory(file);
4356
+ mkdirSync2(dir, { recursive: true });
4357
+ const lock = join3(dir, ".write-lock");
4358
+ const owner = join3(lock, "owner.json");
4359
+ const token = randomUUID();
4360
+ try {
4361
+ mkdirSync2(lock);
4362
+ } catch (error) {
4363
+ if (error.code !== "EEXIST") throw error;
4364
+ if (!deadOwner(lock)) throw new LedgerError("BUSY", `Another writer owns ${lock}; retry after it completes. An orphan without owner metadata needs manual inspection.`);
4365
+ try {
4366
+ mkdirSync2(join3(lock, ".reap"));
4367
+ } catch {
4368
+ throw new LedgerError("BUSY", "Another process is recovering the writer lock.");
4369
+ }
4370
+ if (!deadOwner(lock)) {
4371
+ rmSync3(join3(lock, ".reap"), { recursive: true, force: true });
4372
+ throw new LedgerError("BUSY", "Writer ownership changed.");
4373
+ }
4374
+ rmSync3(lock, { recursive: true });
4375
+ try {
4376
+ mkdirSync2(lock);
4377
+ } catch {
4378
+ throw new LedgerError("BUSY", "Another writer acquired the recovered lock.");
4379
+ }
4380
+ }
4381
+ let initialized = false;
4382
+ try {
4383
+ writeFileSync2(owner, JSON.stringify({ pid: process.pid, token }), { flag: "wx" });
4384
+ initialized = true;
4385
+ return action();
4386
+ } finally {
4387
+ try {
4388
+ if (!initialized || JSON.parse(readFileSync2(owner, "utf8")).token === token) rmSync3(lock, { recursive: true });
4389
+ } catch {
4390
+ }
4391
+ }
4392
+ }
4393
+
4285
4394
  // src/web/launcher.ts
4286
4395
  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";
4396
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
4397
+ import { dirname as dirname6, join as join4 } from "node:path";
4289
4398
 
4290
4399
  // src/web/source.ts
4291
- import { createHash } from "node:crypto";
4400
+ import { createHash as createHash2 } from "node:crypto";
4292
4401
  import { statSync } from "node:fs";
4293
- import { basename as basename2, dirname as dirname4 } from "node:path";
4402
+ import { basename as basename2, dirname as dirname5 } from "node:path";
4294
4403
  function readWebSnapshot(defaultFile) {
4295
4404
  const pages = listPageFiles(defaultFile).map((file) => {
4296
4405
  const id = pageIdOfFile(defaultFile, file) ?? "";
@@ -4305,15 +4414,15 @@ function readWebSnapshot(defaultFile) {
4305
4414
  }
4306
4415
  });
4307
4416
  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 };
4417
+ const value = { project: basename2(dirname5(dirname5(defaultFile))), pages };
4418
+ return { revision: createHash2("sha256").update(JSON.stringify(value)).digest("hex"), value };
4310
4419
  }
4311
4420
 
4312
4421
  // src/web/launcher.ts
4313
- var webRuntimeFile = (defaultFile) => join3(dirname5(defaultFile), "web", "server.json");
4422
+ var webRuntimeFile = (defaultFile) => join4(dirname6(defaultFile), "web", "server.json");
4314
4423
  async function runningWebUrl(defaultFile) {
4315
4424
  try {
4316
- const info = JSON.parse(readFileSync2(webRuntimeFile(defaultFile), "utf8"));
4425
+ const info = JSON.parse(readFileSync3(webRuntimeFile(defaultFile), "utf8"));
4317
4426
  if (!Number.isInteger(info.port) || info.port < 1 || info.port > 65535 || !/^[a-f0-9]{48}$/.test(info.token)) return void 0;
4318
4427
  const url = `http://127.0.0.1:${info.port}/${info.token}/`;
4319
4428
  const response = await fetch(`${url}api/health`, { signal: AbortSignal.timeout(700) });
@@ -4325,14 +4434,14 @@ async function runningWebUrl(defaultFile) {
4325
4434
  async function openWebPreview(defaultFile, entry, page, terminal = false) {
4326
4435
  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
4436
  let url = await runningWebUrl(defaultFile);
4328
- if (url && terminal) {
4437
+ if (url) {
4329
4438
  const health = await fetch(`${url}api/health`, { signal: AbortSignal.timeout(2e3) });
4330
4439
  const info = await health.json();
4331
- if (!info.surfaces?.includes("web-terminal")) {
4440
+ if (!info.formats?.includes(2) || terminal && !info.surfaces?.includes("web-terminal")) {
4332
4441
  await fetch(`${url}api/stop`, { method: "POST", signal: AbortSignal.timeout(2e3) });
4333
4442
  const deadline = Date.now() + 3e3;
4334
4443
  while (await runningWebUrl(defaultFile)) {
4335
- if (Date.now() > deadline) throw new Error("Old web viewer is still stopping. Retry opening the terminal.");
4444
+ if (Date.now() > deadline) throw new Error("Old web viewer is still stopping. Retry opening the map.");
4336
4445
  await new Promise((resolve3) => setTimeout(resolve3, 100));
4337
4446
  }
4338
4447
  url = void 0;
@@ -4365,9 +4474,9 @@ import { randomBytes } from "node:crypto";
4365
4474
  import { createServer } from "node:http";
4366
4475
 
4367
4476
  // 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";
4477
+ import { createHash as createHash3 } from "node:crypto";
4478
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync as readdirSync2, realpathSync, rmdirSync } from "node:fs";
4479
+ import { dirname as dirname7, join as join5, resolve } from "node:path";
4371
4480
 
4372
4481
  // src/semantics/vocabulary.ts
4373
4482
  var STATUS_GLYPHS = {
@@ -5084,15 +5193,15 @@ var PREVIEW_DIR_NAME = "previews";
5084
5193
  var ENABLED = ".enabled";
5085
5194
  var PUBLISH_LOCK = ".publish-lock";
5086
5195
  function previewDirectory(defaultFile) {
5087
- return join4(dirname6(defaultFile), PREVIEW_DIR_NAME);
5196
+ return join5(dirname7(defaultFile), PREVIEW_DIR_NAME);
5088
5197
  }
5089
5198
  function previewFile(defaultFile, page) {
5090
5199
  if (page !== void 0 && !ID_RULE.test(page)) throw new Error("Invalid preview page id.");
5091
- return join4(previewDirectory(defaultFile), documentName(page));
5200
+ return join5(previewDirectory(defaultFile), documentName(page));
5092
5201
  }
5093
5202
  function save(path, contents) {
5094
5203
  try {
5095
- if (readFileSync3(path, "utf8") === contents) return;
5204
+ if (readFileSync4(path, "utf8") === contents) return;
5096
5205
  } catch (error) {
5097
5206
  if (error.code !== "ENOENT") throw error;
5098
5207
  }
@@ -5100,19 +5209,19 @@ function save(path, contents) {
5100
5209
  if (!result.ok) throw new Error(describeStoreError(result.error));
5101
5210
  }
5102
5211
  function ownedDirectory(path) {
5103
- mkdirSync2(path, { recursive: true });
5104
- const expected = join4(realpathSync(dirname6(path)), path.slice(dirname6(path).length + 1));
5212
+ mkdirSync3(path, { recursive: true });
5213
+ const expected = join5(realpathSync(dirname7(path)), path.slice(dirname7(path).length + 1));
5105
5214
  const actual = realpathSync(path);
5106
5215
  if (process.platform === "win32" ? actual.toLowerCase() !== expected.toLowerCase() : actual !== expected) {
5107
5216
  throw new Error(`Preview directory redirects outside its parent: ${path}`);
5108
5217
  }
5109
5218
  }
5110
5219
  function acquireLock(directory) {
5111
- const path = join4(directory, PUBLISH_LOCK);
5220
+ const path = join5(directory, PUBLISH_LOCK);
5112
5221
  const deadline = Date.now() + 2e3;
5113
5222
  while (true) {
5114
5223
  try {
5115
- mkdirSync2(path);
5224
+ mkdirSync3(path);
5116
5225
  return () => rmdirSync(path);
5117
5226
  } catch (error) {
5118
5227
  if (error.code !== "EEXIST") throw error;
@@ -5123,7 +5232,7 @@ function acquireLock(directory) {
5123
5232
  }
5124
5233
  function createPreviewPublisher(defaultFile) {
5125
5234
  const directory = previewDirectory(defaultFile);
5126
- const enabledFile = join4(directory, ENABLED);
5235
+ const enabledFile = join5(directory, ENABLED);
5127
5236
  const enabled = () => existsSync3(enabledFile);
5128
5237
  const refresh = (page) => {
5129
5238
  try {
@@ -5141,24 +5250,24 @@ function createPreviewPublisher(defaultFile) {
5141
5250
  }
5142
5251
  if (page !== void 0 && !pages.some((p) => p.page === page)) return err(`No map page named "${page}".`);
5143
5252
  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");
5253
+ const images = join5(directory, "images");
5145
5254
  ownedDirectory(images);
5146
5255
  const present = /* @__PURE__ */ new Set();
5147
5256
  for (const item of pages) {
5148
5257
  const svg = renderMapSvg(item.map);
5149
- const digest = createHash2("sha256").update(svg).digest("hex");
5258
+ const digest = createHash3("sha256").update(svg).digest("hex");
5150
5259
  const image = `images/${digest}.svg`;
5151
- save(join4(images, `${digest}.svg`), svg);
5260
+ save(join5(images, `${digest}.svg`), svg);
5152
5261
  const filename = documentName(item.page);
5153
- save(join4(directory, filename), renderMapMarkdown(item.map, image, pages));
5262
+ save(join5(directory, filename), renderMapMarkdown(item.map, image, pages));
5154
5263
  present.add(filename);
5155
5264
  }
5156
5265
  for (const filename of readdirSync2(directory)) {
5157
5266
  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");
5267
+ 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
5268
  }
5160
5269
  }
5161
- const index = join4(directory, "index.md");
5270
+ const index = join5(directory, "index.md");
5162
5271
  save(index, renderPreviewIndex(pages));
5163
5272
  return ok({ path: resolve(path), index: resolve(index), pages: pages.length });
5164
5273
  } finally {
@@ -5327,12 +5436,117 @@ function attachTerminalService(server, options) {
5327
5436
  };
5328
5437
  }
5329
5438
 
5439
+ // src/support/open-star.ts
5440
+ import { spawn as spawn3 } from "node:child_process";
5441
+ import { win32 } from "node:path";
5442
+
5443
+ // src/support/star-reminder.ts
5444
+ import { existsSync as existsSync4, readFileSync as readFileSync5, realpathSync as realpathSync2 } from "node:fs";
5445
+ import { basename as basename3, dirname as dirname8, join as join6 } from "node:path";
5446
+ var STAR_URL = "https://github.com/GuangminJu/mellos-mapping";
5447
+ var DAY_MS = 864e5;
5448
+ function isNpmInstallation(entry) {
5449
+ try {
5450
+ const root = dirname8(dirname8(realpathSync2(entry)));
5451
+ if (basename3(dirname8(root)).toLowerCase() !== "node_modules" || existsSync4(join6(root, ".git")) || existsSync4(join6(root, ".codex-plugin")) || existsSync4(join6(root, ".claude-plugin"))) return false;
5452
+ const pkg = JSON.parse(readFileSync5(join6(root, "package.json"), "utf8"));
5453
+ return pkg.name === "mellos-mapping" && pkg.bin?.mmap === "dist/mmap.mjs";
5454
+ } catch {
5455
+ return false;
5456
+ }
5457
+ }
5458
+ function starReminderFile(userBase) {
5459
+ return join6(userBase, ".mellos", "support", "star-reminder.json");
5460
+ }
5461
+ function enabledFlag(value) {
5462
+ return !!value && value !== "0" && value.toLowerCase() !== "false";
5463
+ }
5464
+ function validState(value) {
5465
+ if (!value || typeof value !== "object") return false;
5466
+ const s = value;
5467
+ return s.version === 1 && Number.isFinite(s.firstSeen) && s.firstSeen >= 0 && Number.isSafeInteger(s.lastDay) && s.lastDay >= Math.floor(s.firstSeen / DAY_MS) && Number.isSafeInteger(s.days) && s.days >= 1 && s.days <= 3 && typeof s.notified === "boolean";
5468
+ }
5469
+ function createStarReminder(options) {
5470
+ const eligible = isNpmInstallation(options.entry) && !enabledFlag(options.env["CI"]) && !enabledFlag(options.env["MELLOS_MAPPING_NO_STAR"]);
5471
+ const file = starReminderFile(options.userBase);
5472
+ return {
5473
+ visit() {
5474
+ if (!eligible) return false;
5475
+ try {
5476
+ return withStoreLock(file, () => {
5477
+ const now = (options.now ?? Date.now)();
5478
+ if (!Number.isFinite(now) || now < 0) return false;
5479
+ const day = Math.floor(now / DAY_MS);
5480
+ let state;
5481
+ try {
5482
+ const saved2 = JSON.parse(readFileSync5(file, "utf8"));
5483
+ if (!validState(saved2)) return false;
5484
+ state = saved2;
5485
+ } catch (error) {
5486
+ if (error.code !== "ENOENT") return false;
5487
+ state = { version: 1, firstSeen: now, lastDay: day, days: 1, notified: false };
5488
+ }
5489
+ if (state.notified || now < state.firstSeen || day < state.lastDay) return false;
5490
+ if (day > state.lastDay) {
5491
+ state.days = Math.min(3, state.days + 1);
5492
+ state.lastDay = day;
5493
+ }
5494
+ const show = state.days >= 3 && now - state.firstSeen >= 7 * DAY_MS;
5495
+ if (show) state.notified = true;
5496
+ const saved = writeAtomic(file, JSON.stringify(state, null, 2) + "\n", 1);
5497
+ return saved.ok && show;
5498
+ });
5499
+ } catch {
5500
+ return false;
5501
+ }
5502
+ }
5503
+ };
5504
+ }
5505
+
5506
+ // src/support/open-star.ts
5507
+ function starBrowserCommand(platform, windowsRoot = "C:\\Windows") {
5508
+ if (platform === "win32") return {
5509
+ file: win32.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
5510
+ args: [
5511
+ "-NoLogo",
5512
+ "-NoProfile",
5513
+ "-NonInteractive",
5514
+ "-WindowStyle",
5515
+ "Hidden",
5516
+ "-Command",
5517
+ `$ErrorActionPreference = 'Stop'; Start-Process -FilePath '${STAR_URL}'`
5518
+ ]
5519
+ };
5520
+ if (platform === "darwin") return { file: "/usr/bin/open", args: [STAR_URL] };
5521
+ if (platform === "linux") return { file: "xdg-open", args: [STAR_URL] };
5522
+ throw new Error("No supported desktop browser launcher");
5523
+ }
5524
+ function openStarInDefaultBrowser() {
5525
+ const command = starBrowserCommand(process.platform, process.env["SystemRoot"]);
5526
+ return new Promise((resolve3, reject) => {
5527
+ const child = spawn3(command.file, command.args, { windowsHide: true, stdio: "ignore" });
5528
+ let finished = false;
5529
+ const finish = (error) => {
5530
+ if (finished) return;
5531
+ finished = true;
5532
+ clearTimeout(deadline);
5533
+ child.unref();
5534
+ if (error) reject(error);
5535
+ else resolve3();
5536
+ };
5537
+ const deadline = setTimeout(() => finish(), 5e3);
5538
+ child.once("error", (error) => finish(error));
5539
+ child.once("exit", (code) => finish(code === 0 ? void 0 : new Error("Default browser launch failed")));
5540
+ });
5541
+ }
5542
+
5330
5543
  // src/web/service.ts
5331
5544
  async function startWebService(defaultFile, assets, options = {}) {
5332
5545
  const token = options.token ?? randomBytes(24).toString("hex");
5333
5546
  if (!/^[a-f0-9]{48}$/.test(token)) throw new Error("Invalid web token");
5334
5547
  const prefix = `/${token}/`;
5335
5548
  let origin = "", lastRequest = Date.now(), closed = false;
5549
+ let openingStar = false;
5336
5550
  const send = (res, code, body, type = "application/json; charset=utf-8") => {
5337
5551
  res.writeHead(code, {
5338
5552
  "Content-Type": type,
@@ -5381,7 +5595,37 @@ async function startWebService(defaultFile, assets, options = {}) {
5381
5595
  }
5382
5596
  }
5383
5597
  if (req.method === "GET" && route === "api/health") {
5384
- send(res, 200, JSON.stringify({ file: defaultFile, pid: process.pid, surfaces: terminalService ? ["web", "web-terminal"] : ["web"] }));
5598
+ send(res, 200, JSON.stringify({ file: defaultFile, pid: process.pid, formats: [1, 2], surfaces: terminalService ? ["web", "web-terminal"] : ["web"] }));
5599
+ return;
5600
+ }
5601
+ if (req.method === "POST" && route === "api/star-reminder/open") {
5602
+ if (req.headers.origin !== origin) {
5603
+ send(res, 403, '{"error":"Origin refused"}');
5604
+ return;
5605
+ }
5606
+ if (req.url !== `${prefix}api/star-reminder/open` || req.headers["transfer-encoding"] || Number(req.headers["content-length"] ?? 0) !== 0) {
5607
+ send(res, 400, '{"error":"No parameters accepted"}');
5608
+ return;
5609
+ }
5610
+ if (openingStar) {
5611
+ send(res, 409, '{"error":"Browser is already opening"}');
5612
+ return;
5613
+ }
5614
+ openingStar = true;
5615
+ void Promise.resolve().then(options.openStar ?? openStarInDefaultBrowser).then(() => {
5616
+ send(res, 200, '{"opened":true}');
5617
+ }, () => {
5618
+ send(res, 503, '{"error":"\u65E0\u6CD5\u6253\u5F00\u9ED8\u8BA4\u6D4F\u89C8\u5668"}');
5619
+ }).finally(() => {
5620
+ openingStar = false;
5621
+ });
5622
+ return;
5623
+ }
5624
+ if (req.method === "POST" && route === "api/star-reminder/visit") {
5625
+ const requested = new URL(req.url, origin).searchParams.get("page");
5626
+ const id = !requested || requested === "_default" ? "" : requested;
5627
+ const hasMap = options.starReminder && readWebSnapshot(defaultFile).value.pages.some((page) => page.id === id && !page.error && (page.map?.nodes.length ?? 0) > 0);
5628
+ send(res, 200, JSON.stringify({ show: !!hasMap && options.starReminder.visit() }));
5385
5629
  return;
5386
5630
  }
5387
5631
  if (req.method === "GET" && route === "api/state") {
@@ -5396,23 +5640,26 @@ async function startWebService(defaultFile, assets, options = {}) {
5396
5640
  return;
5397
5641
  }
5398
5642
  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) }));
5643
+ withStoreLock(defaultFile, () => {
5644
+ const id = route.slice("api/pages/".length);
5645
+ if (id !== "_default" && !ID_RULE.test(id)) {
5646
+ send(res, 400, '{"error":"Invalid page"}');
5647
+ return;
5648
+ }
5649
+ if (req.headers["if-match"] !== `"${readWebSnapshot(defaultFile).revision}"`) {
5650
+ send(res, 409, '{"error":"\u5730\u56FE\u5DF2\u66F4\u65B0\uFF0C\u8BF7\u91CD\u65B0\u786E\u8BA4\u5220\u9664\u3002"}');
5651
+ return;
5652
+ }
5653
+ const result = deletePageFile(pageFilePath(defaultFile, id === "_default" ? void 0 : id));
5654
+ if (!result.ok) {
5655
+ send(res, 500, JSON.stringify({ error: describeStoreError(result.error) }));
5656
+ return;
5657
+ }
5658
+ const previews = createPreviewPublisher(defaultFile);
5659
+ const refreshed = previews.enabled() ? previews.refresh() : void 0;
5660
+ 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
5661
  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}` } : {} }));
5662
+ });
5416
5663
  return;
5417
5664
  }
5418
5665
  if (req.method === "POST" && route === "api/stop") {
@@ -5422,7 +5669,7 @@ async function startWebService(defaultFile, assets, options = {}) {
5422
5669
  }
5423
5670
  send(res, 404, '{"error":"Not found"}');
5424
5671
  } catch (error) {
5425
- send(res, 500, JSON.stringify({ error: String(error) }));
5672
+ send(res, error instanceof LedgerError && error.code === "BUSY" ? 409 : 500, JSON.stringify({ error: String(error) }));
5426
5673
  }
5427
5674
  });
5428
5675
  const terminalService = assets.terminal && options.terminalWorker ? attachTerminalService(server, {
@@ -5471,19 +5718,19 @@ async function runWeb(args) {
5471
5718
  if (args[0] === "--serve" && args.length === 2) {
5472
5719
  const file2 = resolve2(args[1]);
5473
5720
  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");
5721
+ const directory = dirname9(webRuntimeFile(file2));
5722
+ mkdirSync4(directory, { recursive: true });
5723
+ if (realpathSync3(directory).toLowerCase() !== join7(realpathSync3(dirname9(directory)), "web").toLowerCase()) throw new Error("Redirected web runtime directory");
5724
+ const assets = join7(dirname9(entry), "web");
5478
5725
  let service;
5479
5726
  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: () => {
5727
+ html: readFileSync6(join7(assets, "index.html"), "utf8"),
5728
+ javascript: readFileSync6(join7(assets, "app.js"), "utf8"),
5729
+ css: readFileSync6(join7(assets, "app.css"), "utf8"),
5730
+ terminal: { html: readFileSync6(join7(assets, "terminal.html"), "utf8"), javascript: readFileSync6(join7(assets, "terminal.js"), "utf8"), css: readFileSync6(join7(assets, "terminal.css"), "utf8"), xtermCss: readFileSync6(join7(assets, "xterm.css"), "utf8") }
5731
+ }, { starReminder: createStarReminder({ entry, userBase: homedir(), env: process.env }), terminalWorker: join7(dirname9(entry), "terminal-worker.mjs"), onClose: () => {
5485
5732
  try {
5486
- if (JSON.parse(readFileSync4(webRuntimeFile(file2), "utf8")).token === service.token) rmSync3(webRuntimeFile(file2));
5733
+ if (JSON.parse(readFileSync6(webRuntimeFile(file2), "utf8")).token === service.token) rmSync4(webRuntimeFile(file2));
5487
5734
  } catch {
5488
5735
  }
5489
5736
  } });
@@ -5514,8 +5761,8 @@ async function runWeb(args) {
5514
5761
  else throw new Error(usage);
5515
5762
  }
5516
5763
  const project = resolve2(args[0]);
5517
- if (!existsSync4(project) || !statSync2(project).isDirectory()) throw new Error(`Project directory does not exist: ${project}`);
5518
- const file = join5(project, STATE_FILE_RELATIVE_PATH);
5764
+ if (!existsSync5(project) || !statSync2(project).isDirectory()) throw new Error(`Project directory does not exist: ${project}`);
5765
+ const file = join7(project, STATE_FILE_RELATIVE_PATH);
5519
5766
  if (stop) {
5520
5767
  const url = await runningWebUrl(file);
5521
5768
  if (url) await fetch(`${url}api/stop`, { method: "POST", signal: AbortSignal.timeout(2e3) });
@@ -5524,7 +5771,7 @@ async function runWeb(args) {
5524
5771
  }
5525
5772
  console.log(JSON.stringify({ surface: terminal ? "web-terminal" : "web", url: await openWebPreview(file, entry, page, terminal), visibility: "unconfirmed" }));
5526
5773
  }
5527
- if (process.argv[1] && existsSync4(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync2(process.argv[1])).href) {
5774
+ if (process.argv[1] && existsSync5(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync3(process.argv[1])).href) {
5528
5775
  runWeb(process.argv.slice(2)).catch((error) => {
5529
5776
  console.error(String(error));
5530
5777
  process.exitCode = 1;
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