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/README.md +11 -4
- package/README.zh-CN.md +10 -4
- package/dist/hook-session-start.mjs +3 -1
- package/dist/mmap.mjs +1 -1
- package/dist/preview.mjs +41 -2
- package/dist/server.mjs +857 -412
- package/dist/store-paths.mjs +29 -1
- package/dist/terminal-worker.mjs +443 -300
- package/dist/watch.mjs +443 -300
- package/dist/web.mjs +176 -65
- package/docs/codex.md +7 -1
- package/docs/map-api.md +148 -0
- package/lib/domain/context.d.ts +11 -0
- package/lib/domain/context.js +27 -0
- package/lib/domain/text.js +11 -0
- package/lib/domain/types.d.ts +3 -0
- package/lib/store/format.js +18 -3
- package/lib/store/project.d.ts +2 -0
- package/lib/store/project.js +29 -0
- package/lib/store/store.d.ts +6 -1
- package/lib/store/store.js +6 -1
- package/lib/store/transaction.d.ts +12 -0
- package/lib/store/transaction.js +91 -0
- package/package.json +4 -2
- package/scripts/codex-register.mjs +1 -1
- package/scripts/mmap.mjs +1 -1
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:
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
|
3707
|
-
import { dirname as
|
|
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
|
|
4288
|
-
import { dirname as
|
|
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
|
|
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(
|
|
4309
|
-
return { revision:
|
|
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) =>
|
|
4421
|
+
var webRuntimeFile = (defaultFile) => join4(dirname6(defaultFile), "web", "server.json");
|
|
4314
4422
|
async function runningWebUrl(defaultFile) {
|
|
4315
4423
|
try {
|
|
4316
|
-
const info = JSON.parse(
|
|
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
|
|
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
|
|
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
|
|
4369
|
-
import { existsSync as existsSync3, mkdirSync as
|
|
4370
|
-
import { dirname as
|
|
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
|
|
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
|
|
5199
|
+
return join5(previewDirectory(defaultFile), documentName(page));
|
|
5092
5200
|
}
|
|
5093
5201
|
function save(path, contents) {
|
|
5094
5202
|
try {
|
|
5095
|
-
if (
|
|
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
|
-
|
|
5104
|
-
const expected =
|
|
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 =
|
|
5219
|
+
const path = join5(directory, PUBLISH_LOCK);
|
|
5112
5220
|
const deadline = Date.now() + 2e3;
|
|
5113
5221
|
while (true) {
|
|
5114
5222
|
try {
|
|
5115
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
5257
|
+
const digest = createHash3("sha256").update(svg).digest("hex");
|
|
5150
5258
|
const image = `images/${digest}.svg`;
|
|
5151
|
-
save(
|
|
5259
|
+
save(join5(images, `${digest}.svg`), svg);
|
|
5152
5260
|
const filename = documentName(item.page);
|
|
5153
|
-
save(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
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 =
|
|
5475
|
-
|
|
5476
|
-
if (realpathSync2(directory).toLowerCase() !==
|
|
5477
|
-
const assets =
|
|
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:
|
|
5481
|
-
javascript:
|
|
5482
|
-
css:
|
|
5483
|
-
terminal: { html:
|
|
5484
|
-
}, { terminalWorker:
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
package/docs/map-api.md
ADDED
|
@@ -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;
|