mellos-mapping 0.20.2 → 0.22.1
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 +125 -88
- package/README.zh-CN.md +122 -84
- package/dist/hook-session-start.mjs +25 -19
- package/dist/mmap.mjs +302 -170
- package/dist/preview.mjs +1418 -0
- package/dist/server.mjs +1198 -400
- package/dist/store-paths.mjs +40 -21
- package/dist/terminal-worker.mjs +3188 -0
- package/dist/watch.mjs +523 -280
- package/dist/web/TERMINAL-LICENSES.txt +70 -0
- package/dist/web/app.css +967 -0
- package/dist/web/app.js +1356 -0
- package/dist/web/index.html +9 -0
- package/dist/web/terminal.css +9 -0
- package/dist/web/terminal.html +7 -0
- package/dist/web/terminal.js +9293 -0
- package/dist/web/xterm.css +285 -0
- package/dist/web.mjs +5535 -0
- package/docs/codex.md +183 -0
- package/lib/domain/text.d.ts +9 -0
- package/lib/domain/text.js +43 -0
- package/lib/domain/types.js +10 -1
- package/lib/preview/index.d.ts +3 -0
- package/lib/preview/index.js +3 -0
- package/lib/preview/markdown.d.ts +8 -0
- package/lib/preview/markdown.js +54 -0
- package/lib/preview/presentation.d.ts +6 -0
- package/lib/preview/presentation.js +14 -0
- package/lib/preview/publisher.d.ts +23 -0
- package/lib/preview/publisher.js +143 -0
- package/lib/preview/svg.d.ts +3 -0
- package/lib/preview/svg.js +74 -0
- package/lib/preview/text.d.ts +4 -0
- package/lib/preview/text.js +13 -0
- package/lib/render/canvas.d.ts +1 -1
- package/lib/render/canvas.js +4 -2
- package/lib/render/draw.js +9 -6
- package/lib/render/render.d.ts +6 -0
- package/lib/render/render.js +50 -18
- package/lib/render/width.js +3 -1
- package/lib/store/atomic.d.ts +18 -0
- package/lib/store/atomic.js +86 -0
- package/lib/store/channels.d.ts +40 -0
- package/lib/store/channels.js +135 -0
- package/lib/store/format.js +3 -1
- package/lib/store/json-text.d.ts +9 -0
- package/lib/store/json-text.js +16 -0
- package/lib/store/maps.d.ts +12 -0
- package/lib/store/maps.js +42 -0
- package/lib/store/migration.d.ts +12 -0
- package/lib/store/migration.js +46 -0
- package/lib/store/pages.d.ts +46 -0
- package/lib/store/pages.js +89 -0
- package/lib/store/policy.d.ts +74 -0
- package/lib/store/policy.js +144 -0
- package/lib/store/store.d.ts +9 -256
- package/lib/store/store.js +10 -694
- package/lib/store/viewers.d.ts +81 -0
- package/lib/store/viewers.js +186 -0
- package/package.json +25 -6
- package/scripts/codex-cli.mjs +42 -0
- package/scripts/codex-register.mjs +27 -94
- package/scripts/mmap.mjs +26 -16
- package/scripts/open-pane.mjs +37 -18
- package/scripts/pane-core.mjs +57 -220
- package/scripts/terminal-session.mjs +137 -0
- package/scripts/tmux-session.mjs +90 -0
- package/scripts/watcher-command.mjs +16 -0
package/docs/codex.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Mellos Mapping · ChatGPT App (Codex mode)
|
|
2
|
+
|
|
3
|
+
This package targets Codex mode in the ChatGPT desktop app, also called Codex App.
|
|
4
|
+
The two editions share the map model, store and renderers. The desktop edition
|
|
5
|
+
has its own skill and includes no Claude SessionStart hook or MCP configuration.
|
|
6
|
+
|
|
7
|
+
## Install and update
|
|
8
|
+
|
|
9
|
+
Requires Node.js 18+ and a Codex CLI with plugin commands (tested with 0.153.4).
|
|
10
|
+
From the `chatgpt-app` branch run `node install.mjs`; from `main` run
|
|
11
|
+
`node install.mjs chatgpt-app`. Committed bundles need no build or npm dependencies.
|
|
12
|
+
|
|
13
|
+
The installer checks file hashes and a real MCP handshake, copies the runtime to
|
|
14
|
+
`~/.mellos/installations/chatgpt-app/`, registers the `mellos-mapping-codex`
|
|
15
|
+
marketplace, installs the skill, and registers the six MCP tools at user scope.
|
|
16
|
+
The runtime uses an absolute Node executable and leaves its working directory
|
|
17
|
+
unset so each conversation writes to its own project. The clone can be deleted
|
|
18
|
+
or moved after installation. Other plugins, maps and mapping policies are preserved.
|
|
19
|
+
|
|
20
|
+
Start a new conversation after installing or updating. Ask:
|
|
21
|
+
“用梅勒斯地图制定计划,并在当前对话右侧终端展示,持续更新验证进度。”
|
|
22
|
+
Run the installer again from a newer release to update. In an edition clone,
|
|
23
|
+
`node install.mjs --check` checks prerequisites, file integrity and MCP without
|
|
24
|
+
changing host configuration. Existing watchers retain old code until restarted.
|
|
25
|
+
|
|
26
|
+
If migrating from the earlier personal-marketplace version, remove its plugin
|
|
27
|
+
with Codex first to avoid two copies of the skill. The registration helper
|
|
28
|
+
`scripts/codex-register.mjs` remains available to maintain those existing installs.
|
|
29
|
+
It replaces only the named MCP entry; custom transport options may need reapplying.
|
|
30
|
+
|
|
31
|
+
To uninstall the release edition:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
codex plugin remove mellos-mapping@mellos-mapping-codex
|
|
35
|
+
codex mcp remove mellos-mapping
|
|
36
|
+
codex plugin marketplace remove mellos-mapping-codex
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Project maps and mapping preferences remain. GitHub distribution does not itself
|
|
40
|
+
publish the package into OpenAI's public plugin directory.
|
|
41
|
+
|
|
42
|
+
## Automatic web terminal
|
|
43
|
+
|
|
44
|
+
Call `mmap_open {surface: "web-terminal", page: "<slug>"}` and pass the returned
|
|
45
|
+
`hostOpen` object to `open_in_codex`. It uses `placement: "right"` and a browser
|
|
46
|
+
target in the current conversation. The local service starts mmap when the
|
|
47
|
+
browser connects; the user does not paste a startup command. No Computer Use
|
|
48
|
+
or desktop keyboard automation is involved. With an older MCP schema:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
node "<plugin root>/dist/web.mjs" "<project>" --terminal --page <slug>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Open the printed JSON `url`. The page provides its own font-size selector,
|
|
55
|
+
help, reconnection and a link to the same map in graphical SVG mode. Font
|
|
56
|
+
changes remeasure terminal cells rather than stretching an image, and do not
|
|
57
|
+
change the host's global terminal font. The terminal uses xterm.js cell rendering;
|
|
58
|
+
the graphical mode remains native SVG and supports SVG export.
|
|
59
|
+
|
|
60
|
+
Both modes use the same project maps. Terminal input/output goes over a local
|
|
61
|
+
WebSocket to a dedicated mmap worker. Each connected browser owns its own
|
|
62
|
+
page/zoom/selection state and renderer; there is no shared shell. Closing the
|
|
63
|
+
tab or stopping the service ends its worker. `q` closes the map until Reconnect.
|
|
64
|
+
Network interruptions get three retry attempts; reopen via MCP if the service
|
|
65
|
+
has exited. A reconnect starts a fresh view on the current page.
|
|
66
|
+
|
|
67
|
+
All assets and the worker ship prebuilt. No node-pty, native compiler, npm install,
|
|
68
|
+
external terminal, remote hosting or account is required. The service accepts
|
|
69
|
+
only bounded map input/resize messages from its own origin and secret URL,
|
|
70
|
+
limits sessions to eight, and keeps one output chunk in flight until the browser
|
|
71
|
+
renders it. Slow clients cannot accumulate unlimited rendered map frames.
|
|
72
|
+
|
|
73
|
+
An already-running service from before this feature is stopped and restarted
|
|
74
|
+
on a web-terminal request. That restart disconnects its existing graphical tabs;
|
|
75
|
+
reopen them with the new URL. For later runtime updates, use `--stop` first.
|
|
76
|
+
|
|
77
|
+
## Optional native desktop terminal
|
|
78
|
+
|
|
79
|
+
Call `mmap_open {surface: "codex-terminal", page: "<slug>"}`. This prepares the
|
|
80
|
+
actual Node executable and absolute watcher/map paths, with PowerShell and POSIX
|
|
81
|
+
commands. It starts no program or window. Use the host's `open_in_codex` tool with
|
|
82
|
+
`placement: "right"` and `target: {type: "terminal"}` in the current conversation.
|
|
83
|
+
|
|
84
|
+
If a supported host tool executes commands in this user terminal, use it.
|
|
85
|
+
If only opening/reading tools are exposed, paste the returned command once.
|
|
86
|
+
That interface cannot provide fully automatic first startup. Agent command PTYs
|
|
87
|
+
are separate; stringifying their numeric session IDs cannot attach them to the
|
|
88
|
+
user terminal. No shell profile modification or external-window workaround is used.
|
|
89
|
+
|
|
90
|
+
Check the map title and controls with `read_thread_terminal` after startup.
|
|
91
|
+
`queued` means a pending panel request; a shell prompt means the map has not
|
|
92
|
+
started. A viewer heartbeat could come from another window. Reuse an existing
|
|
93
|
+
viewer and preserve a manually pinned page. Map writes refresh the watcher.
|
|
94
|
+
|
|
95
|
+
The equivalent manual command, with paths appropriate to the current install:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
node "<plugin root>/dist/watch.mjs" --file "<project>/.mellos/map.json" --page <slug>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Wheel / `+` / `-` changes semantic zoom; drag pans; click pins details;
|
|
102
|
+
double-click enters a submap; `0` resets and `q` exits. `--no-mouse` leaves
|
|
103
|
+
mouse events to the host. Font size follows the app's code/terminal settings.
|
|
104
|
+
After an update, press `q` and rerun the command to load the new watcher.
|
|
105
|
+
|
|
106
|
+
The native watcher uses an alternate screen and reasserts mouse reporting every
|
|
107
|
+
second to recover panel remounts. It outputs changed rows, coalesces mouse-motion
|
|
108
|
+
paints within 16 ms and keeps only the latest pending frame under backpressure.
|
|
109
|
+
A full snapshot every second repairs truncated terminal replay. Viewer-presence
|
|
110
|
+
writes yield on locked files; authoritative map saves retain atomic retries.
|
|
111
|
+
Hover changes border and dependency colors without toggling font weight.
|
|
112
|
+
|
|
113
|
+
Plain `mmap_open {surface: "terminal"}` is the external Windows Terminal launcher
|
|
114
|
+
for Claude Code and Codex CLI. It does not open the desktop integrated terminal.
|
|
115
|
+
|
|
116
|
+
## Desktop right-side map
|
|
117
|
+
|
|
118
|
+
The existing document surface remains available. Call
|
|
119
|
+
`mmap_open {surface: "markdown", page: "<slug>"}`, then open the returned absolute
|
|
120
|
+
Markdown path with the host's file target and `placement: "right"`.
|
|
121
|
+
|
|
122
|
+
The runtime generates `.mellos/previews/page-<slug>.md` (or `map.md` for the
|
|
123
|
+
default page), `index.md`, and content-hashed SVG images. JSON is authoritative.
|
|
124
|
+
The document includes dependencies, status, details, evidence and child-page links.
|
|
125
|
+
Vectors stay sharp when enlarged. Image nodes do not support dragging, hovering,
|
|
126
|
+
animated spinners or double-clicking. Use the document links to visit child maps.
|
|
127
|
+
|
|
128
|
+
Opening this surface enables automatic exports after successful MCP map writes,
|
|
129
|
+
including after server restart. Direct JSON edits or older clients need regeneration:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
node "<plugin root>/dist/preview.mjs" "<project>" --page <slug>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
File generation does not prove visibility or automatic host refresh. Regenerate
|
|
136
|
+
and reopen if stale. `preview: STALE` means the map was saved but exporting failed;
|
|
137
|
+
fix the export without repeating the mutation. Previous SVG images remain for
|
|
138
|
+
already-open documents. `.mellos/previews/` is disposable; removing it disables
|
|
139
|
+
auto-export until the next open. Remove a stale `.publish-lock` only after its
|
|
140
|
+
old exporter has stopped, then regenerate.
|
|
141
|
+
|
|
142
|
+
## Optional interactive web viewer
|
|
143
|
+
|
|
144
|
+
Call `mmap_open {surface: "web", page: "<slug>"}` and open the returned local URL
|
|
145
|
+
with the host's browser target on the right. Or run:
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
node "<plugin root>/dist/web.mjs" "<project>" --page <slug>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The viewer supports vector pan/zoom, hover and pinned details, dependency
|
|
152
|
+
highlighting, search, status filters, page switching, submaps, breadcrumbs,
|
|
153
|
+
semantic group aggregation below 55%, lanes/sequences, SVG export, themes and
|
|
154
|
+
confirmed page deletion. Dragging pans the canvas; nodes retain automatic layout.
|
|
155
|
+
Narrow panels place details beneath a draggable divider. Map edits use MCP tools.
|
|
156
|
+
|
|
157
|
+
Cards, labels and connections are native SVG. Zoom changes the SVG `viewBox`.
|
|
158
|
+
The local service binds to `127.0.0.1`, uses an unpredictable per-launch URL,
|
|
159
|
+
checks Host/Origin and serves bundled assets and project maps only. It polls every
|
|
160
|
+
800 ms. Broken pages show errors without hiding other pages; connection failures
|
|
161
|
+
label the last available data. Manual page choice pins that page. Existing
|
|
162
|
+
Markdown exports and terminal preferences remain available.
|
|
163
|
+
|
|
164
|
+
The detached service survives its MCP parent and stops after five minutes without
|
|
165
|
+
requests. Stop it before reopening after a runtime update:
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
node "<plugin root>/dist/web.mjs" "<project>" --stop
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
A queued browser-open result does not prove that the viewer is visible.
|
|
172
|
+
|
|
173
|
+
## Developer verification
|
|
174
|
+
|
|
175
|
+
Use Node.js 22.12+ for source development. Run `npm ci` then `npm run verify`.
|
|
176
|
+
`npm run package:codex`
|
|
177
|
+
produces the flat plugin; `npm run package:release` produces both installable host
|
|
178
|
+
editions. Runtime tests use actual stdio in separate temporary projects; installation
|
|
179
|
+
checks must use isolated host configuration. No developer path belongs in a release.
|
|
180
|
+
|
|
181
|
+
The [official plugin documentation](https://developers.openai.com/plugins/build/plugins)
|
|
182
|
+
describes local marketplaces and Git distribution. See the
|
|
183
|
+
[plugin usage guide](https://learn.chatgpt.com/docs/plugins) for host installation behavior.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Text remains data at every input and display boundary. No host dependencies. */
|
|
2
|
+
import type { MellosMap } from './types.js';
|
|
3
|
+
export declare const NO_CONTROLS: RegExp;
|
|
4
|
+
export declare const NO_CONTROLS_TEXT = "one line of text; control characters (ESC, newline, tab) are not allowed";
|
|
5
|
+
export declare const NO_CONTROLS_BUT_BREAKS: RegExp;
|
|
6
|
+
export declare const NO_CONTROLS_BUT_BREAKS_TEXT = "text with optional newlines (\\n) and tabs; other control characters (ESC, BEL, CR) are not allowed";
|
|
7
|
+
export declare function mapTextError(map: MellosMap): string | undefined;
|
|
8
|
+
/** Defensive rendering for maps constructed directly by library consumers. */
|
|
9
|
+
export declare function terminalText(text: string, multiline?: boolean): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Explicit ranges also work in the JSON Schema published by the MCP adapter.
|
|
2
|
+
export const NO_CONTROLS = /^[^\u0000-\u001f\u007f-\u009f]*$/;
|
|
3
|
+
export const NO_CONTROLS_TEXT = 'one line of text; control characters (ESC, newline, tab) are not allowed';
|
|
4
|
+
export const NO_CONTROLS_BUT_BREAKS = /^[^\u0000-\u0008\u000b-\u001f\u007f-\u009f]*$/;
|
|
5
|
+
export const NO_CONTROLS_BUT_BREAKS_TEXT = 'text with optional newlines (\\n) and tabs; other control characters (ESC, BEL, CR) are not allowed';
|
|
6
|
+
export function mapTextError(map) {
|
|
7
|
+
const check = (field, value, multiline = false) => value === undefined || (multiline ? NO_CONTROLS_BUT_BREAKS : NO_CONTROLS).test(value)
|
|
8
|
+
? undefined : `${field}: ${multiline ? NO_CONTROLS_BUT_BREAKS_TEXT : NO_CONTROLS_TEXT}`;
|
|
9
|
+
let error = check('title', map.title);
|
|
10
|
+
if (error)
|
|
11
|
+
return error;
|
|
12
|
+
for (const [i, layer] of map.layers.entries()) {
|
|
13
|
+
error = check(`layers[${i}].name`, layer.name);
|
|
14
|
+
if (error)
|
|
15
|
+
return error;
|
|
16
|
+
}
|
|
17
|
+
for (const name of ['lanes', 'groups']) {
|
|
18
|
+
for (const [i, item] of map[name].entries()) {
|
|
19
|
+
error = check(`${name}[${i}].label`, item.label);
|
|
20
|
+
if (error)
|
|
21
|
+
return error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const [i, node] of map.nodes.entries()) {
|
|
25
|
+
for (const name of ['label', 'evidence', 'detail']) {
|
|
26
|
+
// Version-1 files and document exports also support multiline evidence.
|
|
27
|
+
// MCP keeps its narrower one-line evidence input for concise updates.
|
|
28
|
+
error = check(`nodes[${i}].${name}`, node[name], name !== 'label');
|
|
29
|
+
if (error)
|
|
30
|
+
return error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const [i, edge] of map.edges.entries()) {
|
|
34
|
+
error = check(`edges[${i}].label`, edge.label);
|
|
35
|
+
if (error)
|
|
36
|
+
return error;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
/** Defensive rendering for maps constructed directly by library consumers. */
|
|
41
|
+
export function terminalText(text, multiline = false) {
|
|
42
|
+
return text.replace(multiline ? /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g : /[\u0000-\u001f\u007f-\u009f]/g, '?');
|
|
43
|
+
}
|
package/lib/domain/types.js
CHANGED
|
@@ -147,7 +147,16 @@ export function describeMapError(e) {
|
|
|
147
147
|
case 'layer-holds-group':
|
|
148
148
|
return `layer "${e.id}" still holds group "${e.occupant}"; remove its groups (removeGroup) first`;
|
|
149
149
|
case 'edge-not-downward':
|
|
150
|
+
// The fault is one invariant (I4) but the remedy is not: a same-band
|
|
151
|
+
// edge is a modeling error the caller fixes by restructuring, an
|
|
152
|
+
// upward edge is usually a reversed arrow. The refusal is the moment
|
|
153
|
+
// the caller needs the remedy, so it is spelled out here, not only in
|
|
154
|
+
// the skill text they read before the batch was composed.
|
|
150
155
|
return (`edge ${e.from} (rank ${e.fromRank}) -> ${e.to} (rank ${e.toRank}) is not strictly downward; ` +
|
|
151
|
-
|
|
156
|
+
(e.fromRank === e.toRank
|
|
157
|
+
? `same-band siblings may not depend on each other — either "${e.to}" is really a lower concept ` +
|
|
158
|
+
`(declare it on a lower band) or "${e.from}" and "${e.to}" are one node (merge them)`
|
|
159
|
+
: `"${e.from}" would USE "${e.to}" from a lower band — reverse the edge if "${e.to}" is the user, ` +
|
|
160
|
+
`otherwise move or re-rank so "${e.from}" sits above "${e.to}"`));
|
|
152
161
|
}
|
|
153
162
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Pure document projection. JSON remains the sole editable map source. */
|
|
2
|
+
import type { MellosMap } from '../domain/types.js';
|
|
3
|
+
export interface PreviewPage {
|
|
4
|
+
readonly page: string | undefined;
|
|
5
|
+
readonly map: MellosMap;
|
|
6
|
+
}
|
|
7
|
+
export declare function renderMapMarkdown(map: MellosMap, image: string, pages: readonly PreviewPage[]): string;
|
|
8
|
+
export declare function renderPreviewIndex(pages: readonly PreviewPage[]): string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isNeutralKind } from '../semantics/semantics.js';
|
|
2
|
+
import { documentName, isVerified, statusText } from './presentation.js';
|
|
3
|
+
import { cell, markdown as md } from './text.js';
|
|
4
|
+
export function renderMapMarkdown(map, image, pages) {
|
|
5
|
+
const neutral = isNeutralKind(map);
|
|
6
|
+
const rows = [`# ${md(map.title ?? '梅勒斯地图')}`, '', '[所有地图](index.md)', '',
|
|
7
|
+
'> 自动生成的地图预览;修改地图数据后重新生成。', ''];
|
|
8
|
+
if (!neutral) {
|
|
9
|
+
const active = map.nodes.filter(n => n.status === 'in-progress');
|
|
10
|
+
rows.push(`**当前:** ${active.length ? active.map(n => md(n.label)).join('、') : '暂无进行中的模块'}`, '', `已验证 **${map.nodes.filter(isVerified).length} / ${map.nodes.length}** | 回归 **${map.nodes.filter(n => n.status === 'regressed').length}**`, '');
|
|
11
|
+
}
|
|
12
|
+
rows.push('## 分层依赖', '', ``, '');
|
|
13
|
+
if (!neutral)
|
|
14
|
+
rows.push('· 待开发 ⠿ 开发中 ■ 已验证 ✗ 出现回归 □ 完成但缺少证据', '');
|
|
15
|
+
rows.push(map.kind === 'sequence' ? '时间从上向下推进;箭头保留地图中的依赖方向。' : '箭头由使用方指向它依赖的模块;基础层位于下方。', '', '## 模块详情', '');
|
|
16
|
+
const nodes = new Map(map.nodes.map(n => [n.id, n]));
|
|
17
|
+
const layers = [...map.layers].sort((a, b) => map.kind === 'sequence' ? a.rank - b.rank : b.rank - a.rank);
|
|
18
|
+
for (const layer of layers) {
|
|
19
|
+
rows.push(`### ${md(layer.name)}`, '');
|
|
20
|
+
const members = map.nodes.filter(n => n.layer === layer.id);
|
|
21
|
+
if (!members.length)
|
|
22
|
+
rows.push('尚未声明模块。', '');
|
|
23
|
+
for (const node of members) {
|
|
24
|
+
rows.push(`#### ${md(node.label)}${neutral ? '' : ` ${statusText(node)}`}`, '');
|
|
25
|
+
if (node.detail !== undefined)
|
|
26
|
+
rows.push(md(node.detail), '');
|
|
27
|
+
const used = map.edges.filter(e => e.from === node.id);
|
|
28
|
+
rows.push(`**依赖:** ${used.length ? used.map(e => `${md(nodes.get(e.to).label)}${e.label !== undefined ? `(${md(e.label)})` : ''}`).join('、') : '无'}`, '');
|
|
29
|
+
const meta = [node.group === undefined ? undefined : map.groups.find(g => g.id === node.group)?.label,
|
|
30
|
+
node.lane === undefined ? undefined : map.lanes.find(l => l.id === node.lane)?.label,
|
|
31
|
+
node.kind].filter((v) => v !== undefined);
|
|
32
|
+
if (meta.length)
|
|
33
|
+
rows.push(`**归属 / 类型:** ${meta.map(md).join(' · ')}`, '');
|
|
34
|
+
if (node.evidence !== undefined)
|
|
35
|
+
rows.push(`**验证记录:** ${md(node.evidence)}`, '');
|
|
36
|
+
if (node.submap !== undefined)
|
|
37
|
+
rows.push(pages.some(p => p.page === node.submap)
|
|
38
|
+
? `[打开子图:${md(node.submap)}](${documentName(node.submap)})`
|
|
39
|
+
: `子图尚未创建:${md(node.submap)}`, '');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!neutral) {
|
|
43
|
+
rows.push('## 验证记录', '', '| 模块 | 状态 | 最近证据 |', '| --- | --- | --- |');
|
|
44
|
+
for (const node of map.nodes)
|
|
45
|
+
rows.push(`| ${cell(node.label)} | ${statusText(node)} | ${node.evidence === undefined ? '尚未记录' : cell(node.evidence)} |`);
|
|
46
|
+
rows.push('');
|
|
47
|
+
}
|
|
48
|
+
rows.push('---', '', '静态文档:更新时重新生成地图图片与文字。图中节点不支持拖拽、悬停展开或动画。', '');
|
|
49
|
+
return rows.join('\n');
|
|
50
|
+
}
|
|
51
|
+
export function renderPreviewIndex(pages) {
|
|
52
|
+
return ['# 梅勒斯地图 · 页面目录', '', ...pages.map(p => `- [${md(p.map.title ?? p.page ?? '默认地图')}](${documentName(p.page)})`), '',
|
|
53
|
+
'地图预览由项目内的地图数据生成。', ''].join('\n');
|
|
54
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Preview vocabulary; shares status glyphs with every existing map surface. */
|
|
2
|
+
import type { MapNode } from '../domain/types.js';
|
|
3
|
+
export declare function isVerified(node: MapNode): boolean;
|
|
4
|
+
export declare function statusText(node: MapNode): string;
|
|
5
|
+
/** Default and named pages never collide, including a named page called map. */
|
|
6
|
+
export declare function documentName(page: string | undefined): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { statusGlyph, unverifiedDoneGlyph } from '../semantics/semantics.js';
|
|
2
|
+
const LABELS = { planned: '待开发', 'in-progress': '开发中', done: '已验证', regressed: '出现回归' };
|
|
3
|
+
export function isVerified(node) {
|
|
4
|
+
return node.status === 'done' && node.evidence !== undefined;
|
|
5
|
+
}
|
|
6
|
+
export function statusText(node) {
|
|
7
|
+
return node.status === 'done' && !isVerified(node)
|
|
8
|
+
? `${unverifiedDoneGlyph(true)} 完成但缺少证据`
|
|
9
|
+
: `${statusGlyph(node.status, true)} ${LABELS[node.status]}`;
|
|
10
|
+
}
|
|
11
|
+
/** Default and named pages never collide, including a named page called map. */
|
|
12
|
+
export function documentName(page) {
|
|
13
|
+
return page === undefined ? 'map.md' : `page-${page}.md`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Result } from '../domain/types.js';
|
|
2
|
+
import { type PageId } from '../store/store.js';
|
|
3
|
+
export declare const PREVIEW_DIR_NAME = "previews";
|
|
4
|
+
export interface PublishedPreview {
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly index: string;
|
|
7
|
+
readonly pages: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function previewDirectory(defaultFile: string): string;
|
|
10
|
+
export declare function previewFile(defaultFile: string, page?: PageId): string;
|
|
11
|
+
/**
|
|
12
|
+
* Activation is project-local and survives MCP restarts. Each publisher reads
|
|
13
|
+
* it again, so concurrent clients cooperate without a shared server process.
|
|
14
|
+
* SVG names are content-addressed: a document always points at a complete
|
|
15
|
+
* image and file-preview caches cannot reuse the previous state's picture.
|
|
16
|
+
* Old images stay available to open documents; the preview directory is a
|
|
17
|
+
* disposable cache, never source history.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createPreviewPublisher(defaultFile: string): {
|
|
20
|
+
enabled: () => boolean;
|
|
21
|
+
refresh: (page?: PageId) => Result<PublishedPreview, string>;
|
|
22
|
+
activate: (page?: PageId) => Result<PublishedPreview, string>;
|
|
23
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** Filesystem adapter for derived previews. Never writes back into map JSON. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmdirSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { EMPTY_MAP, ID_RULE, err, ok } from '../domain/types.js';
|
|
6
|
+
import { describeStoreError, listPageFiles, loadMapFile, pageIdOfFile, writeFileAtomic } from '../store/store.js';
|
|
7
|
+
import { renderMapMarkdown, renderPreviewIndex } from './markdown.js';
|
|
8
|
+
import { documentName } from './presentation.js';
|
|
9
|
+
import { renderMapSvg } from './svg.js';
|
|
10
|
+
export const PREVIEW_DIR_NAME = 'previews';
|
|
11
|
+
const ENABLED = '.enabled';
|
|
12
|
+
const PUBLISH_LOCK = '.publish-lock';
|
|
13
|
+
export function previewDirectory(defaultFile) {
|
|
14
|
+
return join(dirname(defaultFile), PREVIEW_DIR_NAME);
|
|
15
|
+
}
|
|
16
|
+
export function previewFile(defaultFile, page) {
|
|
17
|
+
if (page !== undefined && !ID_RULE.test(page))
|
|
18
|
+
throw new Error('Invalid preview page id.');
|
|
19
|
+
return join(previewDirectory(defaultFile), documentName(page));
|
|
20
|
+
}
|
|
21
|
+
function save(path, contents) {
|
|
22
|
+
try {
|
|
23
|
+
if (readFileSync(path, 'utf8') === contents)
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error.code !== 'ENOENT')
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
const result = writeFileAtomic(path, contents);
|
|
31
|
+
if (!result.ok)
|
|
32
|
+
throw new Error(describeStoreError(result.error));
|
|
33
|
+
}
|
|
34
|
+
/** Output directories cannot redirect generated writes through a junction. */
|
|
35
|
+
function ownedDirectory(path) {
|
|
36
|
+
mkdirSync(path, { recursive: true });
|
|
37
|
+
const expected = join(realpathSync(dirname(path)), path.slice(dirname(path).length + 1));
|
|
38
|
+
const actual = realpathSync(path);
|
|
39
|
+
if ((process.platform === 'win32' ? actual.toLowerCase() !== expected.toLowerCase() : actual !== expected)) {
|
|
40
|
+
throw new Error(`Preview directory redirects outside its parent: ${path}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Serialize project-wide projections across processes. Source saves stay
|
|
45
|
+
* independent; the next waiting publisher reads them AFTER taking this lock,
|
|
46
|
+
* so an older project snapshot cannot overwrite a later page's preview.
|
|
47
|
+
* A killed exporter leaves a visible failure, never a silently stale success.
|
|
48
|
+
*/
|
|
49
|
+
function acquireLock(directory) {
|
|
50
|
+
const path = join(directory, PUBLISH_LOCK);
|
|
51
|
+
const deadline = Date.now() + 2_000;
|
|
52
|
+
while (true) {
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(path);
|
|
55
|
+
return () => rmdirSync(path);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error.code !== 'EEXIST')
|
|
59
|
+
throw error;
|
|
60
|
+
if (Date.now() >= deadline)
|
|
61
|
+
throw new Error(`Preview export is busy or was interrupted. Retry; if no exporter is running, remove the stale lock directory: ${path}`);
|
|
62
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Activation is project-local and survives MCP restarts. Each publisher reads
|
|
68
|
+
* it again, so concurrent clients cooperate without a shared server process.
|
|
69
|
+
* SVG names are content-addressed: a document always points at a complete
|
|
70
|
+
* image and file-preview caches cannot reuse the previous state's picture.
|
|
71
|
+
* Old images stay available to open documents; the preview directory is a
|
|
72
|
+
* disposable cache, never source history.
|
|
73
|
+
*/
|
|
74
|
+
export function createPreviewPublisher(defaultFile) {
|
|
75
|
+
const directory = previewDirectory(defaultFile);
|
|
76
|
+
const enabledFile = join(directory, ENABLED);
|
|
77
|
+
const enabled = () => existsSync(enabledFile);
|
|
78
|
+
const refresh = (page) => {
|
|
79
|
+
try {
|
|
80
|
+
const path = previewFile(defaultFile, page);
|
|
81
|
+
ownedDirectory(directory);
|
|
82
|
+
const release = acquireLock(directory);
|
|
83
|
+
try {
|
|
84
|
+
const pages = [];
|
|
85
|
+
for (const source of listPageFiles(defaultFile)) {
|
|
86
|
+
const slug = pageIdOfFile(defaultFile, source);
|
|
87
|
+
if (slug !== undefined && !ID_RULE.test(slug))
|
|
88
|
+
throw new Error(`Invalid map page filename: ${source}`);
|
|
89
|
+
const loaded = loadMapFile(source);
|
|
90
|
+
if (!loaded.ok)
|
|
91
|
+
throw new Error(describeStoreError(loaded.error));
|
|
92
|
+
pages.push({ page: slug, map: loaded.value });
|
|
93
|
+
}
|
|
94
|
+
// An unopened project can show a standby default map. A misspelled named
|
|
95
|
+
// page must not manufacture a plausible empty diagram.
|
|
96
|
+
if (page !== undefined && !pages.some(p => p.page === page))
|
|
97
|
+
return err(`No map page named "${page}".`);
|
|
98
|
+
if (page === undefined && !pages.some(p => p.page === undefined))
|
|
99
|
+
pages.unshift({ page: undefined, map: EMPTY_MAP });
|
|
100
|
+
const images = join(directory, 'images');
|
|
101
|
+
ownedDirectory(images);
|
|
102
|
+
const present = new Set();
|
|
103
|
+
for (const item of pages) {
|
|
104
|
+
const svg = renderMapSvg(item.map);
|
|
105
|
+
const digest = createHash('sha256').update(svg).digest('hex');
|
|
106
|
+
const image = `images/${digest}.svg`;
|
|
107
|
+
save(join(images, `${digest}.svg`), svg);
|
|
108
|
+
const filename = documentName(item.page);
|
|
109
|
+
save(join(directory, filename), renderMapMarkdown(item.map, image, pages));
|
|
110
|
+
present.add(filename);
|
|
111
|
+
}
|
|
112
|
+
// An already-open deleted page must stop presenting its old state.
|
|
113
|
+
for (const filename of readdirSync(directory)) {
|
|
114
|
+
if (/^(map|page-[a-z0-9][a-z0-9-]{0,63})\.md$/.test(filename) && !present.has(filename)) {
|
|
115
|
+
save(join(directory, filename), '# 地图已删除\n\n此页面已不在项目地图中。\n\n[返回地图目录](index.md)\n');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const index = join(directory, 'index.md');
|
|
119
|
+
save(index, renderPreviewIndex(pages));
|
|
120
|
+
return ok({ path: resolve(path), index: resolve(index), pages: pages.length });
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
release();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
return err(error instanceof Error ? error.message : String(error));
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const activate = (page) => {
|
|
131
|
+
const published = refresh(page);
|
|
132
|
+
if (!published.ok)
|
|
133
|
+
return published;
|
|
134
|
+
try {
|
|
135
|
+
save(enabledFile, 'Markdown preview updates are enabled for this project.\n');
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
return err(error instanceof Error ? error.message : String(error));
|
|
139
|
+
}
|
|
140
|
+
return published;
|
|
141
|
+
};
|
|
142
|
+
return { enabled, refresh, activate };
|
|
143
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { flipForSequence, isNeutralKind } from '../semantics/semantics.js';
|
|
2
|
+
import { layoutColumns, layoutRows } from '../render/layout.js';
|
|
3
|
+
import { edgePolyline, routeEdges } from '../render/routing.js';
|
|
4
|
+
import { displayWidth, fitWidth } from '../render/width.js';
|
|
5
|
+
import { zoomGeometry } from '../render/zoom-geometry.js';
|
|
6
|
+
import { isVerified, statusText } from './presentation.js';
|
|
7
|
+
import { xml } from './text.js';
|
|
8
|
+
const X = 8;
|
|
9
|
+
const Y = 26;
|
|
10
|
+
const TOP = 20;
|
|
11
|
+
const PALETTES = {
|
|
12
|
+
planned: ['#f5f7fa', '#98a3b2', '#566477'],
|
|
13
|
+
'in-progress': ['#fff4d9', '#c48c24', '#805910'],
|
|
14
|
+
done: ['#e9f5ee', '#67a883', '#286247'],
|
|
15
|
+
regressed: ['#fdecec', '#cc7575', '#923d3d'],
|
|
16
|
+
unverified: ['#fff6e8', '#b49a77', '#785e3e'],
|
|
17
|
+
neutral: ['#f2f5f9', '#a1adbc', '#364558'],
|
|
18
|
+
};
|
|
19
|
+
export function renderMapSvg(map) {
|
|
20
|
+
const neutral = isNeutralKind(map);
|
|
21
|
+
const originals = new Map(map.nodes.map(n => [n.id, n]));
|
|
22
|
+
const oriented = flipForSequence(map);
|
|
23
|
+
// Reserve enough room for the second status line, cap long labels; full text
|
|
24
|
+
// remains in the document and each SVG node's accessible title.
|
|
25
|
+
const shaped = { ...oriented, nodes: oriented.nodes.map(n => {
|
|
26
|
+
const label = fitWidth(n.label, 32);
|
|
27
|
+
return { ...n, label: label + ' '.repeat(Math.max(0, 20 - displayWidth(label))) };
|
|
28
|
+
}) };
|
|
29
|
+
const geo = { ...zoomGeometry(0), boxGap: 6 };
|
|
30
|
+
const columns = layoutColumns(shaped, geo, true, neutral);
|
|
31
|
+
const routing = routeEdges(shaped, columns);
|
|
32
|
+
const rows = layoutRows(columns, geo, routing.gapRowCount, false, map.lanes.length > 0);
|
|
33
|
+
const width = Math.max(440, (columns.contentWidth + 4 + routing.fallbackCount * 2) * X);
|
|
34
|
+
const height = Math.max(100, TOP + rows.legendY * Y);
|
|
35
|
+
const parts = [
|
|
36
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="map-title map-desc">`,
|
|
37
|
+
`<title id="map-title">${xml(map.title ?? '梅勒斯地图')}</title>`,
|
|
38
|
+
`<desc id="map-desc">${map.nodes.length} 个节点,${map.edges.length} 条依赖。完整说明与验证记录在地图文档中。</desc>`,
|
|
39
|
+
'<defs><marker id="arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0 0 L8 4 L0 8 Z" fill="#8995a5"/></marker></defs>',
|
|
40
|
+
`<rect width="${width}" height="${height}" fill="#ffffff"/>`,
|
|
41
|
+
'<g font-family="Segoe UI, Microsoft YaHei, Noto Sans CJK SC, sans-serif">',
|
|
42
|
+
];
|
|
43
|
+
if (map.nodes.length === 0)
|
|
44
|
+
parts.push('<text x="20" y="52" font-size="14" fill="#657286">尚未声明模块,等待地图更新。</text>');
|
|
45
|
+
columns.bands.forEach((band, i) => {
|
|
46
|
+
const top = TOP + rows.barY[i] * Y;
|
|
47
|
+
const boxes = rows.bandBoxes[i];
|
|
48
|
+
const bottom = boxes.reduce((max, b) => Math.max(max, TOP + (b.y + b.h - 1) * Y), top + 60);
|
|
49
|
+
parts.push(`<rect x="8" y="${top - 10}" width="${width - 16}" height="${bottom - top + 26}" rx="5" fill="#fafbfc"/>`);
|
|
50
|
+
parts.push(`<text x="16" y="${top + 5}" font-size="12" fill="#6a7687">${xml(fitWidth(band.name, Math.floor((width - 40) / X)))}</text>`);
|
|
51
|
+
});
|
|
52
|
+
if (rows.laneHeaderY !== undefined)
|
|
53
|
+
map.lanes.forEach((lane, i) => {
|
|
54
|
+
const region = columns.lanes[i];
|
|
55
|
+
parts.push(`<text x="${(region.x + region.w / 2) * X}" y="${TOP + rows.laneHeaderY * Y + 5}" text-anchor="middle" font-size="12" fill="#566477">${xml(fitWidth(lane.label, region.w))}</text>`);
|
|
56
|
+
});
|
|
57
|
+
for (const edge of routing.edges) {
|
|
58
|
+
const points = edgePolyline(edge, rows).map(([x, y]) => `${x * X},${TOP + y * Y}`).join(' ');
|
|
59
|
+
parts.push(`<polyline points="${points}" fill="none" stroke="#8995a5" stroke-width="1.4" marker-end="url(#arrow)"/>`);
|
|
60
|
+
}
|
|
61
|
+
for (const box of rows.boxOf.values()) {
|
|
62
|
+
const node = originals.get(box.node.id);
|
|
63
|
+
const palette = neutral ? PALETTES.neutral : node.status === 'done' && !isVerified(node) ? PALETTES.unverified : PALETTES[node.status];
|
|
64
|
+
const x = box.x * X, y = TOP + box.y * Y, w = (box.w - 1) * X, h = (box.h - 1) * Y;
|
|
65
|
+
const dash = !neutral && node.status === 'planned' ? ' stroke-dasharray="5 4"' : '';
|
|
66
|
+
parts.push(`<g data-node="${xml(node.id)}"><title>${xml(node.label)}${neutral ? '' : ` · ${xml(statusText(node))}`}</title>`);
|
|
67
|
+
parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="5" fill="${palette[0]}" stroke="${palette[1]}" stroke-width="1.5"${dash}/>`);
|
|
68
|
+
parts.push(`<text x="${x + w / 2}" y="${y + 21}" text-anchor="middle" font-size="14" fill="${palette[2]}">${xml(box.label.trim())}</text>`);
|
|
69
|
+
const secondary = neutral ? node.kind ?? node.id : statusText(node);
|
|
70
|
+
parts.push(`<text x="${x + w / 2}" y="${y + 40}" text-anchor="middle" font-size="11" fill="${palette[2]}">${xml(fitWidth(secondary, box.w - 4))}</text></g>`);
|
|
71
|
+
}
|
|
72
|
+
parts.push('</g></svg>');
|
|
73
|
+
return parts.join('\n');
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Literal user content stays data in both XML and Markdown output. */
|
|
2
|
+
export function xml(text) {
|
|
3
|
+
return text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g, '')
|
|
4
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
5
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
6
|
+
}
|
|
7
|
+
export function markdown(text) {
|
|
8
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
9
|
+
.replace(/([\\`*_[\]{}()#+.!|~-])/g, '\\$1').replace(/\r?\n/g, ' \n');
|
|
10
|
+
}
|
|
11
|
+
export function cell(text) {
|
|
12
|
+
return markdown(text).replace(/ \n/g, '<br>');
|
|
13
|
+
}
|
package/lib/render/canvas.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* produced.
|
|
14
14
|
*/
|
|
15
15
|
import type { RenderOptions, Viewport } from './options.js';
|
|
16
|
-
export type Style = 'none' | 'dim' | 'amber' | 'green' | 'greenDim' | 'red' | 'faint';
|
|
16
|
+
export type Style = 'none' | 'dim' | 'amber' | 'green' | 'greenDim' | 'red' | 'faint' | 'focus';
|
|
17
17
|
/** SGR parameter per style; combined with bold ("1") at emit time. */
|
|
18
18
|
export declare const SGR: Readonly<Record<Style, string>>;
|
|
19
19
|
export declare const ANSI_RESET = "\u001B[0m";
|