brane 0.1.1 → 0.3.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/AGENTS.md +362 -0
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +91 -0
- package/DESIGN.md +402 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +205 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +116 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +332 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1094 -0
- package/dist/commands/token.js +153 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6349 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +633 -0
- package/dist/db/tokens.js +583 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +82 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +197 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +49 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +262 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +85 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +137 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +115 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +587 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +581 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +202 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +141 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +53 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +211 -0
- package/docs/token-rotation.md +413 -0
- package/docs/view-urls.md +114 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +79 -27
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +48 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.travis.yml +0 -7
- package/lib/brane.cjs.js +0 -12
- package/lib/brane.cjs.js.map +0 -1
- package/lib/brane.d.ts +0 -11
- package/lib/brane.js +0 -6
- package/lib/brane.js.map +0 -1
- package/lib/tests.d.ts +0 -1
- package/lib/tests.js +0 -8
- package/lib/tests.js.map +0 -1
- package/rollup.config.js +0 -21
- package/scripts/test.sh +0 -8
- package/src/brane.ts +0 -21
- package/src/tests.ts +0 -8
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { micromark } from "micromark";
|
|
4
|
+
import { math } from "micromark-extension-math";
|
|
5
|
+
import { MATH_BLOCK_CSS, prepareMathMarkdown, isMathContent, renderMathHtml } from "../shared/markdown-math.js";
|
|
6
|
+
import { mathHtmlExtension, katexStyleFor } from "../math.js";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
// Rendering LaTeX is the easy half. The hard half is deciding which dollars are
|
|
11
|
+
// delimiters — narratives are full of prices, shell variables and awk fields —
|
|
12
|
+
// and making the app and the HTML export agree about it, since they render the
|
|
13
|
+
// same markdown through the same functions and any disagreement shows up as an
|
|
14
|
+
// export that no longer matches what the operator saw.
|
|
15
|
+
function render(md) {
|
|
16
|
+
return micromark(prepareMathMarkdown(md), {
|
|
17
|
+
extensions: [math()],
|
|
18
|
+
htmlExtensions: [mathHtmlExtension()],
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/** "display" | "inline" | "none" — how a source's math came out. */
|
|
22
|
+
function mode(md) {
|
|
23
|
+
const html = render(md);
|
|
24
|
+
if (!html.includes("katex"))
|
|
25
|
+
return "none";
|
|
26
|
+
return html.includes("math-display") ? "display" : "inline";
|
|
27
|
+
}
|
|
28
|
+
describe("dollar-sign guard", () => {
|
|
29
|
+
it("renders real inline math", () => {
|
|
30
|
+
for (const md of ["$B = Tc^2$", "$\\Lambda$", "$g_{\\mu\\nu}$", "$R$", "$\\sqrt{-g}$"]) {
|
|
31
|
+
assert.equal(mode(md), "inline", md);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
it("leaves currency alone — including the range that Pandoc's digit rule catches", () => {
|
|
35
|
+
// `$10k–$500k` is the motivating case: a naive parser reads "10k–" as math.
|
|
36
|
+
for (const md of ["Between $10k and $500k.", "It costs $5 and $10 more.", "$3/M in, $15/M out"]) {
|
|
37
|
+
assert.equal(mode(md), "none", md);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
it("leaves shell and awk alone even outside a code span", () => {
|
|
41
|
+
for (const md of ["${curcontext%:*:*}:organelle-command-$state", "awk '{print $1, $9}' and $2"]) {
|
|
42
|
+
assert.equal(mode(md), "none", md);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
it("ends a code span at its paragraph, so a stray backtick run guards nothing past it", () => {
|
|
46
|
+
const html = render("mentions ```lua in prose.\n\nDinner was $5 and $10.\n");
|
|
47
|
+
assert.doesNotMatch(html, /class="math/);
|
|
48
|
+
assert.match(html, /\$5 and \$10/);
|
|
49
|
+
});
|
|
50
|
+
it("keeps the real math in a line that also has prices", () => {
|
|
51
|
+
// Escaping only the *opening* delimiter is what makes this work: the closer
|
|
52
|
+
// of a rejected run gets its own hearing as the next candidate's opener.
|
|
53
|
+
const html = render("$5 and $10 and $x$ here");
|
|
54
|
+
assert.equal((html.match(/class="katex"/g) ?? []).length, 1);
|
|
55
|
+
assert.ok(html.includes("$5 and $10 and"), "the prices survive as prose");
|
|
56
|
+
});
|
|
57
|
+
it("never escapes inside code, where a backslash would be literal", () => {
|
|
58
|
+
for (const md of ["Use `$HOME` and `$PATH` here.", "```sh\necho $HOME $PATH\n```", " echo $HOME"]) {
|
|
59
|
+
assert.ok(!prepareMathMarkdown(md).includes("\\$"), md);
|
|
60
|
+
assert.equal(mode(md), "none", md);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
it("respects a dollar the author already escaped", () => {
|
|
64
|
+
assert.equal(prepareMathMarkdown("Costs \\$5 and \\$10."), "Costs \\$5 and \\$10.");
|
|
65
|
+
});
|
|
66
|
+
it("scores the individual rules", () => {
|
|
67
|
+
assert.equal(isMathContent("x + y", ""), true);
|
|
68
|
+
assert.equal(isMathContent("", ""), false, "empty");
|
|
69
|
+
assert.equal(isMathContent(" x", ""), false, "leading space");
|
|
70
|
+
assert.equal(isMathContent("x ", ""), false, "trailing space");
|
|
71
|
+
assert.equal(isMathContent("10k–", "5"), false, "digit after the closer");
|
|
72
|
+
assert.equal(isMathContent("{nid}", ""), false, "shell expansion");
|
|
73
|
+
assert.equal(isMathContent('line[1] \\"', ""), false, "raw double quote");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe("display math", () => {
|
|
77
|
+
it("promotes a one-line $$…$$ paragraph to a display block", () => {
|
|
78
|
+
// The form agents actually write. micromark-extension-math only knows `$$`
|
|
79
|
+
// as a fence, so without the promotion this renders inline.
|
|
80
|
+
assert.equal(mode("$$B = m\\ddot U$$"), "display");
|
|
81
|
+
assert.equal(mode("Swap it:\n\n$$B = m\\ddot U = m = T.$$\n\nDone."), "display");
|
|
82
|
+
});
|
|
83
|
+
it("still accepts the fenced form", () => {
|
|
84
|
+
assert.equal(mode("$$\nB = Tc^2\n$$"), "display");
|
|
85
|
+
});
|
|
86
|
+
it("leaves $$…$$ inline when it shares its line with prose", () => {
|
|
87
|
+
assert.equal(mode("the value $$x$$ inline"), "inline");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe("TeX delimiters", () => {
|
|
91
|
+
// `\(…\)` and `\[…\]` are the OpenAI house style, and they dominate any
|
|
92
|
+
// absorbed archive — 1576 and 351 occurrences against 578 single-dollar runs
|
|
93
|
+
// in the corpus this was built against. Markdown would eat both halves as
|
|
94
|
+
// escaped brackets and leave the LaTeX sitting in the prose.
|
|
95
|
+
it("renders \\(…\\) as inline math", () => {
|
|
96
|
+
assert.equal(mode("The claim \\(B = Tc^2\\) holds."), "inline");
|
|
97
|
+
});
|
|
98
|
+
it("renders \\[…\\] alone on a line as display math", () => {
|
|
99
|
+
assert.equal(mode("Swap it:\n\n\\[B = m\\ddot U\\]\n\nDone."), "display");
|
|
100
|
+
assert.equal(mode("\\[\n\\dot\\sigma = -A(t)\\sigma\n\\]"), "display");
|
|
101
|
+
});
|
|
102
|
+
it("keeps \\[…\\] inline when it shares its line with prose", () => {
|
|
103
|
+
assert.equal(mode("the value \\[x^2\\] inline"), "inline");
|
|
104
|
+
});
|
|
105
|
+
it("leaves TeX delimiters inside code alone", () => {
|
|
106
|
+
assert.equal(mode("Write `\\(x\\)` for inline."), "none");
|
|
107
|
+
assert.equal(mode("```\n\\(x\\)\n```"), "none");
|
|
108
|
+
});
|
|
109
|
+
it("ignores an unmatched opener", () => {
|
|
110
|
+
assert.equal(mode("An open \\( with no close."), "none");
|
|
111
|
+
});
|
|
112
|
+
it("renders a ```math fence as display math, but leaves ```latex as source", () => {
|
|
113
|
+
// GitHub's convention: ```math is an equation, ```latex is a listing.
|
|
114
|
+
assert.equal(mode("```math\nB = m\\ddot U\n```"), "display");
|
|
115
|
+
assert.equal(mode("```latex\n\\frac{a}{b}\n```"), "none");
|
|
116
|
+
});
|
|
117
|
+
it("normalizes to the dollar forms, so one syntax reaches both renderers", () => {
|
|
118
|
+
assert.equal(prepareMathMarkdown("The claim \\(B = Tc^2\\) holds."), "The claim $B = Tc^2$ holds.");
|
|
119
|
+
assert.equal(prepareMathMarkdown("\\[B = m\\ddot U\\]"), "$$\nB = m\\ddot U\n$$");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe("math nested inside other blocks", () => {
|
|
123
|
+
// Every one of these came from a real absorbed conversation, and every one of
|
|
124
|
+
// them lost equations before it was fixed. The failure mode is always the
|
|
125
|
+
// same: a rewrite that is correct in isolation and blind to the block it sits
|
|
126
|
+
// in. They are worth pinning because none of them errors — the equation just
|
|
127
|
+
// stops being there.
|
|
128
|
+
it("keeps a blockquoted display equation", () => {
|
|
129
|
+
// `$$` swallows the rest of its line as an info string, so emitting the
|
|
130
|
+
// fence without the `> ` fed the equation into it and micromark dropped it.
|
|
131
|
+
const md = "> \\[\n> \\mathbb{E}\\,T = O(n)\n> \\]";
|
|
132
|
+
assert.equal(mode(md), "display");
|
|
133
|
+
assert.ok(render(md).includes("O(n)"), "the equation survives");
|
|
134
|
+
});
|
|
135
|
+
it("keeps a display equation indented inside a list item", () => {
|
|
136
|
+
// Opening the fence at the item's indent and closing it at column 0 ends
|
|
137
|
+
// the list and leaves the fence hanging.
|
|
138
|
+
const md = "3. **Form**: written as:\n\n \\[ i\\hbar \\partial_t \\Psi = \\hat{H}\\Psi \\]\n\n Here.";
|
|
139
|
+
const html = render(md);
|
|
140
|
+
assert.ok(html.includes("math-display"), "renders as display");
|
|
141
|
+
assert.ok(html.includes("hbar"), "the equation survives");
|
|
142
|
+
});
|
|
143
|
+
it("renders math in a deeply indented list item", () => {
|
|
144
|
+
// Four spaces of list indent are indent, not an indented code block.
|
|
145
|
+
assert.equal(mode(" - outer\n - inner \\( A \\to A\\alpha \\) here"), "inline");
|
|
146
|
+
});
|
|
147
|
+
it("tolerates the padding spaces TeX delimiters usually carry", () => {
|
|
148
|
+
// `\( i \)` is idiomatic; `$ i $` is what the dollar guard throws out.
|
|
149
|
+
assert.equal(prepareMathMarkdown("Here, \\( i \\) is the unit."), "Here, $i$ is the unit.");
|
|
150
|
+
});
|
|
151
|
+
it("leaves a whole indented code block alone, not just its first line", () => {
|
|
152
|
+
// Only the first line of an indented block follows a blank one, so a
|
|
153
|
+
// per-line test lets every later line back in — which is how ASCII art
|
|
154
|
+
// drawn in dollar signs got backslashes inserted through it.
|
|
155
|
+
const md = "Art:\n\n ooo$$$$ooo\n oo$$$$$$oo\n o$$ $$ $$o\n";
|
|
156
|
+
assert.equal(prepareMathMarkdown(md), md);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe("renderMathHtml", () => {
|
|
160
|
+
it("emits MathML alongside the visible layout, for screen readers", () => {
|
|
161
|
+
const html = renderMathHtml("x^2", false);
|
|
162
|
+
assert.ok(html.includes("katex-mathml"));
|
|
163
|
+
assert.ok(html.includes("<math"));
|
|
164
|
+
});
|
|
165
|
+
it("falls back to the literal source rather than KaTeX's error markup", () => {
|
|
166
|
+
const html = renderMathHtml("\\bogusmacro{", false);
|
|
167
|
+
assert.ok(!html.includes("katex"), "no half-rendered error node");
|
|
168
|
+
assert.equal(html, "$\\bogusmacro{$");
|
|
169
|
+
});
|
|
170
|
+
it("escapes the fallback, since it lands in an HTML document", () => {
|
|
171
|
+
// `<script>` on its own is perfectly good LaTeX — two relations around a
|
|
172
|
+
// word — so reaching the fallback needs input KaTeX also can't parse.
|
|
173
|
+
assert.equal(renderMathHtml("<script>\\nosuchmacro", false), "$<script>\\nosuchmacro$");
|
|
174
|
+
});
|
|
175
|
+
it("reads a markdown-escaped asterisk as an asterisk", () => {
|
|
176
|
+
// `\*` isn't TeX; it's a model escaping `*` so markdown won't read emphasis,
|
|
177
|
+
// then leaving the escape inside the math. Half the unparseable equations in
|
|
178
|
+
// the absorbed archive are this.
|
|
179
|
+
const html = renderMathHtml("t^\\*", false);
|
|
180
|
+
assert.ok(html.includes("katex"), "renders rather than falling back");
|
|
181
|
+
});
|
|
182
|
+
it("escapes markup on the success path too, which is KaTeX's job", () => {
|
|
183
|
+
const html = renderMathHtml("<script>", false);
|
|
184
|
+
assert.ok(html.includes("katex"), "this one really does render");
|
|
185
|
+
assert.ok(!html.includes("<script>"), "no raw tag reaches the document");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
describe("katexStyleFor", () => {
|
|
189
|
+
it("returns null for a document with no math, so it costs nothing", () => {
|
|
190
|
+
assert.equal(katexStyleFor("<p>no math here</p>"), null);
|
|
191
|
+
});
|
|
192
|
+
it("inlines every font it keeps, and reaches for no network", () => {
|
|
193
|
+
const style = katexStyleFor(render("$$\\frac{d^2U}{dt^2} = \\sqrt{-g}$$"));
|
|
194
|
+
assert.ok(style, "math document gets a stylesheet");
|
|
195
|
+
assert.equal((style.match(/url\((?!data:)/g) ?? []).length, 0, "no url() survives that isn't a data: URI");
|
|
196
|
+
assert.ok(style.includes("data:font/woff2;base64,"));
|
|
197
|
+
});
|
|
198
|
+
it("carries only the families the document reaches for", () => {
|
|
199
|
+
const style = katexStyleFor(render("$x$"));
|
|
200
|
+
const faces = (style.match(/@font-face/g) ?? []).length;
|
|
201
|
+
assert.ok(faces > 0, "at least the base family");
|
|
202
|
+
assert.ok(faces < 20, `subset, not all twenty faces (got ${faces})`);
|
|
203
|
+
// Fraktur is only reachable via \mathfrak, which this document never uses.
|
|
204
|
+
assert.ok(!style.includes("KaTeX_Fraktur-Regular.woff2"));
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
/** What a display block does when the equation is wider than the bubble.
|
|
208
|
+
*
|
|
209
|
+
* KaTeX won't wrap display math and TeX has no line-breaking for it, so the
|
|
210
|
+
* only honest answer is a scroller. Getting one is less obvious than setting
|
|
211
|
+
* `overflow-x`, and the failure mode when it's wrong is the dangerous kind:
|
|
212
|
+
* the equation is trimmed at both ends and still looks whole.
|
|
213
|
+
*/
|
|
214
|
+
describe("MATH_BLOCK_CSS — wide equations scroll rather than truncate", () => {
|
|
215
|
+
const katexCss = readFileSync(join(dirname(createRequire(import.meta.url).resolve("katex/package.json")), "dist", "katex.min.css"), "utf8");
|
|
216
|
+
it("sizes the inner block to its content, which is what gives it somewhere to scroll", () => {
|
|
217
|
+
// Without this the block is exactly the container's width and its content
|
|
218
|
+
// overflows *centered* — spilling both ways, with the left spill outside
|
|
219
|
+
// scrollWidth and therefore unreachable.
|
|
220
|
+
assert.match(MATH_BLOCK_CSS, /\.math-display\s*>\s*\.katex-display\s*>\s*\.katex\s*\{[^}]*min-width:\s*max-content/);
|
|
221
|
+
});
|
|
222
|
+
it("outranks the KaTeX rule it is correcting", () => {
|
|
223
|
+
// Three classes to KaTeX's two, so it wins on specificity no matter which
|
|
224
|
+
// stylesheet the document loads last — the app appends ours, the export
|
|
225
|
+
// emits KaTeX's after it.
|
|
226
|
+
assert.match(katexCss, /\.katex-display>\.katex\{[^}]*white-space:nowrap/, "the rule being corrected still exists");
|
|
227
|
+
const ours = (MATH_BLOCK_CSS.match(/\.math-display\s*>\s*\.katex-display\s*>\s*\.katex/) ?? [])[0];
|
|
228
|
+
assert.ok(ours, "our override is present");
|
|
229
|
+
assert.equal((ours.match(/\./g) ?? []).length, 3);
|
|
230
|
+
});
|
|
231
|
+
it("fades the equation itself, rather than washing anything over it", () => {
|
|
232
|
+
// A mask makes the block's own paint transparent, so a faded edge shows
|
|
233
|
+
// the bubble it sits on — which is why nothing here needs to be told what
|
|
234
|
+
// color that bubble is.
|
|
235
|
+
const block = MATH_BLOCK_CSS.match(/\.math-display\s*\{[^}]*\}/)[0];
|
|
236
|
+
assert.match(block, /mask-image:\s*linear-gradient/);
|
|
237
|
+
assert.ok(!/--math-bg/.test(MATH_BLOCK_CSS), "no background color to be told about");
|
|
238
|
+
});
|
|
239
|
+
it("fades an edge only while there is something past it", () => {
|
|
240
|
+
// The fade widths ride a scroll timeline over the block itself, which is
|
|
241
|
+
// how a stylesheet gets to know something it would otherwise have to
|
|
242
|
+
// measure: no scrollable overflow means an inactive timeline, so the
|
|
243
|
+
// registered initial widths stand and neither edge fades. The ranges put
|
|
244
|
+
// one animation at each end of the travel, so the far edge is crisp once
|
|
245
|
+
// you have actually reached it.
|
|
246
|
+
const block = MATH_BLOCK_CSS.match(/\.math-display\s*\{[^}]*\}/)[0];
|
|
247
|
+
assert.match(block, /animation-timeline:\s*scroll\(self x\), scroll\(self x\)/);
|
|
248
|
+
assert.match(block, /animation-range:\s*0 32px, calc\(100% - 32px\) 100%/);
|
|
249
|
+
for (const prop of ["--math-fade-start", "--math-fade-end"]) {
|
|
250
|
+
assert.match(MATH_BLOCK_CSS, new RegExp(`@property ${prop} \\{[^}]*initial-value: 0px`), `${prop} rests at no fade`);
|
|
251
|
+
}
|
|
252
|
+
// The end-side animation runs backwards — full fade until you arrive.
|
|
253
|
+
assert.match(MATH_BLOCK_CSS, /@keyframes brane-math-fade-end \{ from \{ --math-fade-end: 28px; \} to \{ --math-fade-end: 0px; \} \}/);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
//# sourceMappingURL=markdown-math.test.js.map
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
4
|
+
import { gfmAutolinkLiteral } from "micromark-extension-gfm-autolink-literal";
|
|
5
|
+
import { gfmAutolinkLiteralFromMarkdown } from "mdast-util-gfm-autolink-literal";
|
|
6
|
+
import { narrativeIdLinks } from "../shared/markdown-nids.js";
|
|
7
|
+
// These exercise the live app's markdown pipeline (expo/components/Markdown.tsx)
|
|
8
|
+
// through the same two extensions it installs: GFM autolink literals, then the
|
|
9
|
+
// narrative-id tree transform. The transform is the part worth pinning — it
|
|
10
|
+
// replaced a regex over raw markdown source, and the whole reason for the swap
|
|
11
|
+
// is that the parser, not a pattern, decides what counts as prose.
|
|
12
|
+
const KNOWN = new Set(["claude-4c7289d1", "goose-aa11bb22"]);
|
|
13
|
+
/** Parses the way the app does and returns the resulting mdast tree. */
|
|
14
|
+
function render(md, knownIds = KNOWN) {
|
|
15
|
+
return fromMarkdown(md, {
|
|
16
|
+
extensions: [gfmAutolinkLiteral()],
|
|
17
|
+
mdastExtensions: [gfmAutolinkLiteralFromMarkdown(), narrativeIdLinks(knownIds)],
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/** Flattens a tree to `[type, url|value]` pairs for the leaves that carry text,
|
|
21
|
+
* so assertions read as "what the reader ends up seeing". */
|
|
22
|
+
function leaves(node, out = []) {
|
|
23
|
+
if (node.type === "link") {
|
|
24
|
+
out.push(["link", node.url]);
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
if (node.type === "inlineCode" || node.type === "code" || node.type === "text") {
|
|
28
|
+
out.push([node.type, node.value]);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
for (const child of node.children ?? [])
|
|
32
|
+
leaves(child, out);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function urls(md, knownIds) {
|
|
36
|
+
return leaves(render(md, knownIds)).filter(([t]) => t === "link").map(([, u]) => u);
|
|
37
|
+
}
|
|
38
|
+
describe("autolink literals", () => {
|
|
39
|
+
it("links bare http(s) URLs", () => {
|
|
40
|
+
assert.deepEqual(urls("see https://example.com/a?b=1 now"), ["https://example.com/a?b=1"]);
|
|
41
|
+
});
|
|
42
|
+
it("links www. literals with an implied scheme", () => {
|
|
43
|
+
assert.deepEqual(urls("see www.example.com now"), ["http://www.example.com"]);
|
|
44
|
+
});
|
|
45
|
+
it("links bare email addresses via mailto:", () => {
|
|
46
|
+
assert.deepEqual(urls("mail ben@example.com now"), ["mailto:ben@example.com"]);
|
|
47
|
+
});
|
|
48
|
+
it("leaves trailing sentence punctuation outside the link", () => {
|
|
49
|
+
const l = leaves(render("read https://example.com/docs."));
|
|
50
|
+
assert.deepEqual(l, [
|
|
51
|
+
["text", "read "],
|
|
52
|
+
["link", "https://example.com/docs"],
|
|
53
|
+
["text", "."],
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
it("balances trailing parens", () => {
|
|
57
|
+
// GFM keeps a closing paren only when it balances one inside the URL.
|
|
58
|
+
assert.deepEqual(urls("(see https://en.wikipedia.org/wiki/Foo_(bar))"), [
|
|
59
|
+
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
it("does not touch URLs inside code spans or fenced blocks", () => {
|
|
63
|
+
assert.deepEqual(urls("`https://example.com`"), []);
|
|
64
|
+
assert.deepEqual(urls("```\nhttps://example.com\n```"), []);
|
|
65
|
+
});
|
|
66
|
+
it("leaves an explicit markdown link's href alone", () => {
|
|
67
|
+
assert.deepEqual(urls("[docs](https://example.com/x)"), ["https://example.com/x"]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe("narrative id linkification", () => {
|
|
71
|
+
it("links a bare id and an id:seq reference", () => {
|
|
72
|
+
assert.deepEqual(urls("see claude-4c7289d1 and claude-4c7289d1:42"), [
|
|
73
|
+
"brane://nid/claude-4c7289d1",
|
|
74
|
+
"brane://nid/claude-4c7289d1/42",
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
it("keeps the surrounding prose intact around a reference", () => {
|
|
78
|
+
assert.deepEqual(leaves(render("before claude-4c7289d1:7 after")), [
|
|
79
|
+
["text", "before "],
|
|
80
|
+
["link", "brane://nid/claude-4c7289d1/7"],
|
|
81
|
+
["text", " after"],
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
it("links only ids that actually exist", () => {
|
|
85
|
+
assert.deepEqual(urls("claude-deadbeef is not a narrative"), []);
|
|
86
|
+
assert.deepEqual(urls("claude-4c7289d1", new Set()), []);
|
|
87
|
+
});
|
|
88
|
+
it("promotes a code span that is exactly one reference", () => {
|
|
89
|
+
assert.deepEqual(urls("`claude-4c7289d1:42`"), ["brane://nid/claude-4c7289d1/42"]);
|
|
90
|
+
});
|
|
91
|
+
it("leaves a code span that merely contains a reference as code", () => {
|
|
92
|
+
assert.deepEqual(leaves(render("`brane list claude-4c7289d1`")), [
|
|
93
|
+
["inlineCode", "brane list claude-4c7289d1"],
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
it("does not touch references inside fenced code", () => {
|
|
97
|
+
assert.deepEqual(urls("```\nclaude-4c7289d1\n```"), []);
|
|
98
|
+
});
|
|
99
|
+
it("does not rewrite the label or href of an existing link", () => {
|
|
100
|
+
const l = leaves(render("[claude-4c7289d1](https://example.com/x)"));
|
|
101
|
+
assert.deepEqual(l, [["link", "https://example.com/x"]]);
|
|
102
|
+
});
|
|
103
|
+
it("links references inside emphasis, headings and list items", () => {
|
|
104
|
+
assert.deepEqual(urls("*claude-4c7289d1*"), ["brane://nid/claude-4c7289d1"]);
|
|
105
|
+
assert.deepEqual(urls("# claude-4c7289d1"), ["brane://nid/claude-4c7289d1"]);
|
|
106
|
+
assert.deepEqual(urls("- claude-4c7289d1"), ["brane://nid/claude-4c7289d1"]);
|
|
107
|
+
});
|
|
108
|
+
it("links several references in one text run", () => {
|
|
109
|
+
assert.deepEqual(urls("claude-4c7289d1 then goose-aa11bb22:3"), [
|
|
110
|
+
"brane://nid/claude-4c7289d1",
|
|
111
|
+
"brane://nid/goose-aa11bb22/3",
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
it("skips unknown ids without disturbing known ones beside them", () => {
|
|
115
|
+
assert.deepEqual(leaves(render("claude-deadbeef vs claude-4c7289d1 ok")), [
|
|
116
|
+
["text", "claude-deadbeef vs "],
|
|
117
|
+
["link", "brane://nid/claude-4c7289d1"],
|
|
118
|
+
["text", " ok"],
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
it("leaves a URL whose path contains a narrative id intact", () => {
|
|
122
|
+
// The regression that motivated moving off a source-level regex: the old
|
|
123
|
+
// linkifier rewrote the id *inside* the URL, producing nested link syntax.
|
|
124
|
+
assert.deepEqual(urls("https://brane.organelle.dev/claude-4c7289d1"), [
|
|
125
|
+
"https://brane.organelle.dev/claude-4c7289d1",
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
it("gives every synthesized node a key, so React can list them", () => {
|
|
129
|
+
// Nodes the parser produced are keyed by the renderer's own extension,
|
|
130
|
+
// which runs before this one; nodes this transform *creates* have to bring
|
|
131
|
+
// their own. So the paragraph's rewritten children are what's under test.
|
|
132
|
+
const paragraph = render("before claude-4c7289d1:7 after").children[0];
|
|
133
|
+
const keys = [];
|
|
134
|
+
const collect = (n) => {
|
|
135
|
+
keys.push(n.key);
|
|
136
|
+
for (const c of n.children ?? [])
|
|
137
|
+
collect(c);
|
|
138
|
+
};
|
|
139
|
+
for (const child of paragraph.children)
|
|
140
|
+
collect(child);
|
|
141
|
+
assert.equal(keys.length, 4); // text, link, link's label, text
|
|
142
|
+
assert.ok(keys.every((k) => typeof k === "string" && k.length > 0), `missing keys: ${keys}`);
|
|
143
|
+
assert.equal(new Set(keys).size, keys.length, `duplicate keys: ${keys}`);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
//# sourceMappingURL=markdown-nids.test.js.map
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
4
|
+
import { gfmAutolinkLiteral } from "micromark-extension-gfm-autolink-literal";
|
|
5
|
+
import { gfmAutolinkLiteralFromMarkdown } from "mdast-util-gfm-autolink-literal";
|
|
6
|
+
import { narrativeIdLinks } from "../shared/markdown-nids.js";
|
|
7
|
+
import { protocolLinks } from "../shared/markdown-protocols.js";
|
|
8
|
+
// `/ramify` in a message should open the protocol. The hard part isn't linking
|
|
9
|
+
// it — it's *not* linking the slashes that fill ordinary agent chatter: paths,
|
|
10
|
+
// dates, `and/or`. These pin both halves.
|
|
11
|
+
const KNOWN = new Set(["ramify", "memory", "LIVE_loop", "reviewer.draft"]);
|
|
12
|
+
function render(md, known = KNOWN) {
|
|
13
|
+
return fromMarkdown(md, {
|
|
14
|
+
extensions: [gfmAutolinkLiteral()],
|
|
15
|
+
mdastExtensions: [gfmAutolinkLiteralFromMarkdown(), protocolLinks(known)],
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function leaves(node, out = []) {
|
|
19
|
+
if (node.type === "link") {
|
|
20
|
+
out.push(["link", node.url]);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
if (node.type === "inlineCode" || node.type === "code" || node.type === "text") {
|
|
24
|
+
out.push([node.type, node.value]);
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
for (const child of node.children ?? [])
|
|
28
|
+
leaves(child, out);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
function urls(md, known) {
|
|
32
|
+
return leaves(render(md, known)).filter(([t]) => t === "link").map(([, u]) => u);
|
|
33
|
+
}
|
|
34
|
+
describe("protocol command linkification", () => {
|
|
35
|
+
it("links a bare command", () => {
|
|
36
|
+
assert.deepEqual(urls("run /ramify now"), ["brane://protocol/ramify"]);
|
|
37
|
+
});
|
|
38
|
+
it("links names with underscores and dots", () => {
|
|
39
|
+
assert.deepEqual(urls("/LIVE_loop and /reviewer.draft"), [
|
|
40
|
+
"brane://protocol/LIVE_loop",
|
|
41
|
+
"brane://protocol/reviewer.draft",
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
it("links only protocols that actually exist", () => {
|
|
45
|
+
assert.deepEqual(urls("/nonesuch is not a protocol"), []);
|
|
46
|
+
assert.deepEqual(urls("/ramify", new Set()), []);
|
|
47
|
+
});
|
|
48
|
+
it("keeps the surrounding prose intact", () => {
|
|
49
|
+
assert.deepEqual(leaves(render("try /memory please")), [
|
|
50
|
+
["text", "try "],
|
|
51
|
+
["link", "brane://protocol/memory"],
|
|
52
|
+
["text", " please"],
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
it("leaves the command's arguments as prose", () => {
|
|
56
|
+
assert.deepEqual(leaves(render("/ramify fix the auth module")), [
|
|
57
|
+
["link", "brane://protocol/ramify"],
|
|
58
|
+
["text", " fix the auth module"],
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
it("drops trailing sentence punctuation from the link", () => {
|
|
62
|
+
assert.deepEqual(leaves(render("as described in /memory.")), [
|
|
63
|
+
["text", "as described in "],
|
|
64
|
+
["link", "brane://protocol/memory"],
|
|
65
|
+
["text", "."],
|
|
66
|
+
]);
|
|
67
|
+
assert.deepEqual(leaves(render("either /ramify, or not")), [
|
|
68
|
+
["text", "either "],
|
|
69
|
+
["link", "brane://protocol/ramify"],
|
|
70
|
+
["text", ", or not"],
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
it("ignores a path segment that happens to name a protocol", () => {
|
|
74
|
+
assert.deepEqual(urls("see src/memory/index.ts"), []);
|
|
75
|
+
assert.deepEqual(urls("~/memory holds the db"), []);
|
|
76
|
+
assert.deepEqual(urls("./memory"), []);
|
|
77
|
+
assert.deepEqual(urls("/memory/index.ts"), []);
|
|
78
|
+
});
|
|
79
|
+
it("ignores a slash used as a conjunction", () => {
|
|
80
|
+
assert.deepEqual(urls("read and/or write"), []);
|
|
81
|
+
});
|
|
82
|
+
it("promotes a code span that is exactly one command", () => {
|
|
83
|
+
assert.deepEqual(urls("`/ramify`"), ["brane://protocol/ramify"]);
|
|
84
|
+
});
|
|
85
|
+
it("leaves a code span that merely contains a command as code", () => {
|
|
86
|
+
assert.deepEqual(leaves(render("`/ramify fix auth`")), [
|
|
87
|
+
["inlineCode", "/ramify fix auth"],
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
it("does not touch commands inside fenced code or existing links", () => {
|
|
91
|
+
assert.deepEqual(urls("```\n/ramify\n```"), []);
|
|
92
|
+
assert.deepEqual(urls("[/ramify](https://example.com/x)"), ["https://example.com/x"]);
|
|
93
|
+
});
|
|
94
|
+
it("leaves a URL whose path names a protocol intact", () => {
|
|
95
|
+
assert.deepEqual(urls("https://brane.organelle.dev/memory"), [
|
|
96
|
+
"https://brane.organelle.dev/memory",
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
it("links several commands in one run", () => {
|
|
100
|
+
assert.deepEqual(urls("/ramify then /memory"), [
|
|
101
|
+
"brane://protocol/ramify",
|
|
102
|
+
"brane://protocol/memory",
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
it("links commands inside emphasis, headings and list items", () => {
|
|
106
|
+
assert.deepEqual(urls("*/ramify*"), ["brane://protocol/ramify"]);
|
|
107
|
+
assert.deepEqual(urls("# /ramify"), ["brane://protocol/ramify"]);
|
|
108
|
+
assert.deepEqual(urls("- /ramify"), ["brane://protocol/ramify"]);
|
|
109
|
+
});
|
|
110
|
+
it("coexists with narrative-id linkification", () => {
|
|
111
|
+
const tree = fromMarkdown("claude-4c7289d1 ran /ramify", {
|
|
112
|
+
extensions: [gfmAutolinkLiteral()],
|
|
113
|
+
mdastExtensions: [
|
|
114
|
+
gfmAutolinkLiteralFromMarkdown(),
|
|
115
|
+
narrativeIdLinks(new Set(["claude-4c7289d1"])),
|
|
116
|
+
protocolLinks(KNOWN),
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
assert.deepEqual(leaves(tree).filter(([t]) => t === "link").map(([, u]) => u), [
|
|
120
|
+
"brane://nid/claude-4c7289d1",
|
|
121
|
+
"brane://protocol/ramify",
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
it("gives every synthesized node a key, so React can list them", () => {
|
|
125
|
+
const paragraph = render("run /ramify now").children[0];
|
|
126
|
+
const keys = [];
|
|
127
|
+
const collect = (n) => {
|
|
128
|
+
keys.push(n.key);
|
|
129
|
+
for (const c of n.children ?? [])
|
|
130
|
+
collect(c);
|
|
131
|
+
};
|
|
132
|
+
for (const child of paragraph.children)
|
|
133
|
+
collect(child);
|
|
134
|
+
assert.equal(keys.length, 4); // text, link, link's label, text
|
|
135
|
+
assert.ok(keys.every((k) => typeof k === "string" && k.length > 0), `missing keys: ${keys}`);
|
|
136
|
+
assert.equal(new Set(keys).size, keys.length, `duplicate keys: ${keys}`);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
//# sourceMappingURL=markdown-protocols.test.js.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { mkdtempSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { WebSocketServer } from "ws";
|
|
10
|
+
// The bridge when the agent's harness gives up on a call: Esc, or a killed
|
|
11
|
+
// background task, arrives as MCP `notifications/cancelled`. The bridge used
|
|
12
|
+
// to answer it "Method not found" — an error with no id, which Claude Code
|
|
13
|
+
// logs as a connection error — and never told the daemon, so a cancelled
|
|
14
|
+
// listen stayed parked and its narrative kept glowing as awaiting direction.
|
|
15
|
+
// Driven for real, as in mcp-bridge-reconnect.test.ts: a child runs the
|
|
16
|
+
// bridge, this test plays daemon on a WebSocket and agent on stdio.
|
|
17
|
+
const BRIDGE = pathToFileURL(join(dirname(fileURLToPath(import.meta.url)), "..", "mcp", "server.js")).href;
|
|
18
|
+
function childEnv(port) {
|
|
19
|
+
const env = { ...process.env, PORT: String(port), HOME: mkdtempSync(join(tmpdir(), "brane-bridge-")) };
|
|
20
|
+
for (const k of Object.keys(env)) {
|
|
21
|
+
if (k.startsWith("BRANE_") || k === "CLAUDE_CODE_SESSION_ID" || k === "NODE_TEST_CONTEXT")
|
|
22
|
+
delete env[k];
|
|
23
|
+
}
|
|
24
|
+
return env;
|
|
25
|
+
}
|
|
26
|
+
describe("the MCP bridge when a call is cancelled", () => {
|
|
27
|
+
it("tells the daemon, answers nothing, and drops a late result", async () => {
|
|
28
|
+
const received = [];
|
|
29
|
+
const sockets = [];
|
|
30
|
+
const server = createServer();
|
|
31
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
32
|
+
server.on("upgrade", (req, sock, head) => {
|
|
33
|
+
wss.handleUpgrade(req, sock, head, (ws) => {
|
|
34
|
+
sockets.push(ws);
|
|
35
|
+
ws.send(JSON.stringify({ type: "welcome", brane_id: "b", narrative_id: "external-test0002", bind_secret: "s" }));
|
|
36
|
+
ws.on("message", (d) => {
|
|
37
|
+
const msg = JSON.parse(d.toString());
|
|
38
|
+
received.push(msg);
|
|
39
|
+
// listen parks: no answer until the test sends one by hand.
|
|
40
|
+
if (msg.type === "tool_call" && msg.tool !== "listen") {
|
|
41
|
+
ws.send(JSON.stringify({ type: "tool_result", id: msg.id, result: { ok: msg.tool } }));
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", () => r()));
|
|
47
|
+
const port = server.address().port;
|
|
48
|
+
const child = spawn(process.execPath, ["--input-type=module", "-e",
|
|
49
|
+
`const { startMcpBridge } = await import(${JSON.stringify(BRIDGE)});
|
|
50
|
+
startMcpBridge(async () => "ws://127.0.0.1:" + process.env.PORT + "/ws/mcp?token=t");`], { env: childEnv(port), stdio: ["pipe", "pipe", "pipe"] });
|
|
51
|
+
const lines = [];
|
|
52
|
+
let buf = "";
|
|
53
|
+
child.stdout.on("data", (d) => {
|
|
54
|
+
buf += d.toString();
|
|
55
|
+
let i;
|
|
56
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
57
|
+
const line = buf.slice(0, i);
|
|
58
|
+
buf = buf.slice(i + 1);
|
|
59
|
+
if (line.trim())
|
|
60
|
+
lines.push(JSON.parse(line));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
const send = (m) => child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...m }) + "\n");
|
|
64
|
+
const until = async (pred, ms) => {
|
|
65
|
+
const end = Date.now() + ms;
|
|
66
|
+
while (!pred()) {
|
|
67
|
+
if (Date.now() > end)
|
|
68
|
+
throw new Error("timed out waiting");
|
|
69
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
await until(() => sockets.length === 1, 3000);
|
|
74
|
+
send({ id: 7, method: "tools/call", params: { name: "listen", arguments: { after: 3 } } });
|
|
75
|
+
await until(() => received.some((m) => m.type === "tool_call"), 3000);
|
|
76
|
+
const listenCall = received.find((m) => m.type === "tool_call");
|
|
77
|
+
send({ method: "notifications/cancelled", params: { requestId: 7, reason: "user-cancel" } });
|
|
78
|
+
await until(() => received.some((m) => m.type === "tool_cancel"), 3000);
|
|
79
|
+
assert.deepEqual(received.find((m) => m.type === "tool_cancel"), { type: "tool_cancel", id: listenCall.id });
|
|
80
|
+
// The daemon answers anyway (it raced the cancel), and a notification
|
|
81
|
+
// the bridge has never heard of arrives: neither may produce output.
|
|
82
|
+
sockets[0].send(JSON.stringify({ type: "tool_result", id: listenCall.id, result: { late: true } }));
|
|
83
|
+
send({ method: "notifications/somethingNew" });
|
|
84
|
+
// A later call's answer is the fence: everything before it has landed.
|
|
85
|
+
send({ id: 8, method: "tools/call", params: { name: "whoami", arguments: {} } });
|
|
86
|
+
await until(() => lines.some((l) => l.id === 8), 3000);
|
|
87
|
+
assert.deepEqual(lines.map((l) => l.id), [8], "only the live call is ever answered");
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
child.kill();
|
|
91
|
+
for (const s of sockets)
|
|
92
|
+
s.terminate();
|
|
93
|
+
wss.close();
|
|
94
|
+
server.close();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
//# sourceMappingURL=mcp-bridge-cancel.test.js.map
|