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
package/dist/export.js
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { extname, resolve } from "node:path";
|
|
3
|
+
import { micromark } from "micromark";
|
|
4
|
+
import { gfmTable, gfmTableHtml } from "micromark-extension-gfm-table";
|
|
5
|
+
import { gfmAutolinkLiteral, gfmAutolinkLiteralHtml } from "micromark-extension-gfm-autolink-literal";
|
|
6
|
+
import { gfmTaskListItem, gfmTaskListItemHtml } from "micromark-extension-gfm-task-list-item";
|
|
7
|
+
import { math } from "micromark-extension-math";
|
|
8
|
+
import { NID_PATTERN, NID_REF_PATTERN } from "./shared/markdown-nids.js";
|
|
9
|
+
import { isInlinePayload, INLINE_PAYLOAD_MIME } from "./shared/payload-inline.js";
|
|
10
|
+
import { readEvents, readEventsBySeqs } from "./db/events.js";
|
|
11
|
+
import { exportFileName } from "./shared/export-selection.js";
|
|
12
|
+
import { getPayloadsDir } from "./db/schema.js";
|
|
13
|
+
import { highlightHtml } from "./highlight.js";
|
|
14
|
+
import { renderDiagram } from "./diagram/index.js";
|
|
15
|
+
import { nidHrefFor } from "./diagram/links.js";
|
|
16
|
+
import { mathHtmlExtension, katexStyleFor } from "./math.js";
|
|
17
|
+
import { MATH_BLOCK_CSS, prepareMathMarkdown } from "./shared/markdown-math.js";
|
|
18
|
+
import { DIAGRAM_EDGE_HIGHLIGHT_CSS } from "./shared/diagram-css.js";
|
|
19
|
+
import { speakerColor, speakerBgColor } from "./shared/speaker-color.js";
|
|
20
|
+
import { formatActivitySpan, formatCost, systemLine, FOLD_COLOR, FOLD_LINK_SCHEME, isSystemEvent, CONVERSATIONAL_EVENT_TYPES_LIST, } from "./shared/narrative-format.js";
|
|
21
|
+
// Which payloads are images — and the type each embeds as — is the same rule
|
|
22
|
+
// the app renders by and the daemon serves by: `src/shared/payload-inline.ts`.
|
|
23
|
+
// Kept as one table there because a filter that disagrees with the type map
|
|
24
|
+
// drops an image from the export without saying anything.
|
|
25
|
+
const isImagePayload = isInlinePayload;
|
|
26
|
+
// Shape of an autolink literal, per GFM. Only needed to *avoid* rewriting one:
|
|
27
|
+
// micromark turns these into links on its own (see renderMarkdown), and a URL
|
|
28
|
+
// whose path happens to contain a narrative id — say
|
|
29
|
+
// https://brane.organelle.dev/claude-4c7289d1 — must survive intact.
|
|
30
|
+
const AUTOLINK_LITERAL = "(?:https?://|www\\.)[^\\s<]+";
|
|
31
|
+
// The two href forms an agent writes a reference as by hand — a bare `nid:seq`
|
|
32
|
+
// and the canonical `brane://nid/<nid>/<seq>`. Both are what expo's Markdown
|
|
33
|
+
// renderer accepts (components/Markdown.tsx), so both turn up in stored content.
|
|
34
|
+
const HAND_WRITTEN_REF = new RegExp(`^(?:brane://nid/(${NID_PATTERN})(?:/(\\d+))?|${NID_REF_PATTERN})$`);
|
|
35
|
+
/** Where a reference points within a colocated directory of `<nid>.html` files.
|
|
36
|
+
* Self references stay in-page (`#seq-N`) so they resolve even when the file
|
|
37
|
+
* stands alone; foreign references point at the sibling file. */
|
|
38
|
+
function refHref(nid, seq, selfNid) {
|
|
39
|
+
if (nid === selfNid)
|
|
40
|
+
return seq ? `#seq-${seq}` : `${nid}.html`;
|
|
41
|
+
return `${nid}.html${seq ? `#seq-${seq}` : ""}`;
|
|
42
|
+
}
|
|
43
|
+
// Turn `nid` / `nid:seq` references in message markdown into relative links so
|
|
44
|
+
// that a directory of exported `<nid>.html` files interlinks. Only references
|
|
45
|
+
// to narratives that actually exist (knownIds) are linked, so arbitrary strings
|
|
46
|
+
// matching the id shape aren't turned into dead links. Skips code fences and
|
|
47
|
+
// bare URLs, matching the live app's linkifier.
|
|
48
|
+
//
|
|
49
|
+
// Markdown links are not skipped outright: a link an agent wrote by hand as
|
|
50
|
+
// `[label](claude-4c7289d1:42)` needs its *destination* rewritten, because
|
|
51
|
+
// micromark sanitizes hrefs against a protocol allowlist and reads `nid:` as an
|
|
52
|
+
// unknown scheme — leaving `<a href="">`. Links to anything else pass through.
|
|
53
|
+
function linkifyNarrativeRefs(md, knownIds, selfNid) {
|
|
54
|
+
if (knownIds.size === 0)
|
|
55
|
+
return md;
|
|
56
|
+
const re = new RegExp(`(` +
|
|
57
|
+
"```[\\s\\S]*?```" +
|
|
58
|
+
"|" + AUTOLINK_LITERAL +
|
|
59
|
+
`)` +
|
|
60
|
+
"|\\[([^\\]]*?)\\]\\(([^)]*?)\\)" +
|
|
61
|
+
"|`" + NID_REF_PATTERN + "`" +
|
|
62
|
+
"|" + NID_REF_PATTERN, "g");
|
|
63
|
+
return md.replace(re, (match, skip, label, dest, btNid, btSeq, bareNid, bareSeq) => {
|
|
64
|
+
if (skip)
|
|
65
|
+
return skip;
|
|
66
|
+
if (dest !== undefined) {
|
|
67
|
+
const ref = dest.match(HAND_WRITTEN_REF);
|
|
68
|
+
if (!ref)
|
|
69
|
+
return match;
|
|
70
|
+
// Groups 1/2 are the `brane://` form, 3/4 the bare one; only one fires.
|
|
71
|
+
const nid = ref[1] ?? ref[3];
|
|
72
|
+
const seq = ref[1] ? ref[2] : ref[4];
|
|
73
|
+
// An unknown nid has nothing to point at, and leaving the link in place
|
|
74
|
+
// would only hand micromark another href to blank. Keep the label as
|
|
75
|
+
// prose instead — same policy the bare-reference branch follows.
|
|
76
|
+
if (!knownIds.has(nid))
|
|
77
|
+
return label;
|
|
78
|
+
return `[${label}](${refHref(nid, seq, selfNid)})`;
|
|
79
|
+
}
|
|
80
|
+
const nid = btNid || bareNid;
|
|
81
|
+
const seq = btSeq || bareSeq;
|
|
82
|
+
if (!nid || !knownIds.has(nid))
|
|
83
|
+
return match;
|
|
84
|
+
return `[${seq ? `${nid}:${seq}` : nid}](${refHref(nid, seq, selfNid)})`;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function escHtml(s) {
|
|
88
|
+
return s
|
|
89
|
+
.replace(/&/g, "&")
|
|
90
|
+
.replace(/</g, "<")
|
|
91
|
+
.replace(/>/g, ">")
|
|
92
|
+
.replace(/"/g, """);
|
|
93
|
+
}
|
|
94
|
+
function formatTimestamp(ms) {
|
|
95
|
+
return new Date(ms).toLocaleString();
|
|
96
|
+
}
|
|
97
|
+
// micromark HTML-escapes code content on output. Reverse that before feeding
|
|
98
|
+
// to Shiki, which wants raw source.
|
|
99
|
+
function unescapeMicromarkHtml(s) {
|
|
100
|
+
return s
|
|
101
|
+
.replace(/</g, "<")
|
|
102
|
+
.replace(/>/g, ">")
|
|
103
|
+
.replace(/"/g, '"')
|
|
104
|
+
.replace(/'/g, "'")
|
|
105
|
+
.replace(/&/g, "&");
|
|
106
|
+
}
|
|
107
|
+
/** `trusted` keeps hrefs micromark would otherwise blank as unknown schemes.
|
|
108
|
+
* It is set only for system rows, whose markdown the daemon wrote itself, so
|
|
109
|
+
* the `brane://fold/…` link `systemLine` builds survives to be styled; the
|
|
110
|
+
* default stays strict for anything a person or agent said. */
|
|
111
|
+
async function renderMarkdown(md, opts = {}) {
|
|
112
|
+
// prepareMathMarkdown runs before the parse, not after: micromark-extension-math
|
|
113
|
+
// reads `$10k–$500k` as math over `10k–` and offers no veto once it has
|
|
114
|
+
// tokenized, so the dollars that aren't delimiters get escaped up front. The
|
|
115
|
+
// app runs the same function on the same string — see shared/markdown-math.ts.
|
|
116
|
+
const html = micromark(prepareMathMarkdown(md), {
|
|
117
|
+
allowDangerousProtocol: opts.trusted === true,
|
|
118
|
+
extensions: [gfmTable(), gfmAutolinkLiteral(), gfmTaskListItem(), math()],
|
|
119
|
+
htmlExtensions: [
|
|
120
|
+
gfmTableHtml(),
|
|
121
|
+
gfmAutolinkLiteralHtml(),
|
|
122
|
+
gfmTaskListItemHtml(),
|
|
123
|
+
mathHtmlExtension(),
|
|
124
|
+
],
|
|
125
|
+
});
|
|
126
|
+
// Collect every fenced block with a known language, highlight in parallel,
|
|
127
|
+
// then substitute back. Works because fenced code blocks can't nest and
|
|
128
|
+
// micromark emits exactly <pre><code class="language-XXX">…</code></pre>.
|
|
129
|
+
const re = /<pre><code class="language-([\w+-]+)">([\s\S]*?)<\/code><\/pre>/g;
|
|
130
|
+
const jobs = [];
|
|
131
|
+
for (const m of html.matchAll(re)) {
|
|
132
|
+
const [match, lang, encoded] = m;
|
|
133
|
+
const raw = unescapeMicromarkHtml(encoded);
|
|
134
|
+
// A mermaid fence draws instead of being highlighted — the *same*
|
|
135
|
+
// `renderDiagram` the app reaches through POST /api/diagram, so an export
|
|
136
|
+
// is a snapshot of the live view rather than a lookalike of it. The SVG is
|
|
137
|
+
// static: no script, which is the export's whole constraint.
|
|
138
|
+
//
|
|
139
|
+
// Nothing else changes, because this loop already substitutes only where
|
|
140
|
+
// the result is non-null. A diagram brane doesn't draw therefore lands as
|
|
141
|
+
// the highlighted code block it would have been anyway.
|
|
142
|
+
if (lang === "mermaid") {
|
|
143
|
+
const drawn = renderDiagram(raw, { hrefFor: opts.diagramHref, edgeHighlight: true });
|
|
144
|
+
jobs.push({
|
|
145
|
+
match,
|
|
146
|
+
promise: Promise.resolve(drawn ? `<div class="diagram">${drawn.svg}</div>` : await highlightHtml(raw, lang)),
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
jobs.push({ match, promise: highlightHtml(raw, lang) });
|
|
151
|
+
}
|
|
152
|
+
if (jobs.length === 0)
|
|
153
|
+
return html;
|
|
154
|
+
const results = await Promise.all(jobs.map((j) => j.promise));
|
|
155
|
+
let out = html;
|
|
156
|
+
jobs.forEach((job, i) => {
|
|
157
|
+
const replacement = results[i];
|
|
158
|
+
if (replacement)
|
|
159
|
+
out = out.replace(job.match, replacement);
|
|
160
|
+
});
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
async function loadImageDataUri(nid, relPath) {
|
|
164
|
+
try {
|
|
165
|
+
// Payload paths are stored as "nid/filename"; getPayloadsDir returns the
|
|
166
|
+
// per-narrative dir, so we only need the basename portion that follows.
|
|
167
|
+
const parts = relPath.split("/");
|
|
168
|
+
const filename = parts[parts.length - 1];
|
|
169
|
+
const dir = getPayloadsDir(nid);
|
|
170
|
+
const target = resolve(dir, filename);
|
|
171
|
+
if (!target.startsWith(dir))
|
|
172
|
+
return null;
|
|
173
|
+
const ext = extname(filename).toLowerCase();
|
|
174
|
+
const mime = INLINE_PAYLOAD_MIME[ext];
|
|
175
|
+
if (!mime)
|
|
176
|
+
return null;
|
|
177
|
+
const buf = await readFile(target);
|
|
178
|
+
return `data:${mime};base64,${buf.toString("base64")}`;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** How many *messages* this narrative holds in a seq range — conversational
|
|
185
|
+
* types only, and only those with something to show, which is exactly what
|
|
186
|
+
* the document's own count means by "messages". Asked of SQLite rather than
|
|
187
|
+
* of a materialized event list: an excerpt must not have to build the
|
|
188
|
+
* conversation in order to say how much of it it left out.
|
|
189
|
+
*
|
|
190
|
+
* `after` / `before` are exclusive, matching `readEvents`. */
|
|
191
|
+
function countMessages(db, nid, range = {}) {
|
|
192
|
+
const types = CONVERSATIONAL_EVENT_TYPES_LIST.map(() => "?").join(",");
|
|
193
|
+
const conditions = [
|
|
194
|
+
"narrative_id = ?",
|
|
195
|
+
`type IN (${types})`,
|
|
196
|
+
// The has-something-to-show rule the render pass applies, in SQL. `payloads`
|
|
197
|
+
// is a JSON array or NULL; `appendEvent` writes NULL rather than "[]", and
|
|
198
|
+
// the literal is matched anyway so a hand-written row can't slip past.
|
|
199
|
+
"((content IS NOT NULL AND content != '') OR (payloads IS NOT NULL AND payloads NOT IN ('', '[]')))",
|
|
200
|
+
];
|
|
201
|
+
const params = [nid, ...CONVERSATIONAL_EVENT_TYPES_LIST];
|
|
202
|
+
if (range.after !== undefined) {
|
|
203
|
+
conditions.push("seq > ?");
|
|
204
|
+
params.push(range.after);
|
|
205
|
+
}
|
|
206
|
+
if (range.before !== undefined) {
|
|
207
|
+
conditions.push("seq < ?");
|
|
208
|
+
params.push(range.before);
|
|
209
|
+
}
|
|
210
|
+
const row = db
|
|
211
|
+
.prepare(`SELECT COUNT(*) AS n FROM events WHERE ${conditions.join(" AND ")}`)
|
|
212
|
+
.get(...params);
|
|
213
|
+
return row.n;
|
|
214
|
+
}
|
|
215
|
+
export async function exportNarrativeHtml(db, nid, opts = {}) {
|
|
216
|
+
// `n.*` plus the newest-event time, exactly as the daemon's list endpoint
|
|
217
|
+
// builds a card's data (server.ts) — the header below is that same card.
|
|
218
|
+
const narrative = db
|
|
219
|
+
.prepare(`SELECT n.*, (
|
|
220
|
+
SELECT time FROM events WHERE narrative_id = n.id ORDER BY seq DESC LIMIT 1
|
|
221
|
+
) AS last_activity FROM narratives n WHERE n.id = ?`)
|
|
222
|
+
.get(nid);
|
|
223
|
+
if (!narrative)
|
|
224
|
+
return null;
|
|
225
|
+
const selectedSeqs = opts.seqs && opts.seqs.length > 0
|
|
226
|
+
? [...new Set(opts.seqs)].sort((a, b) => a - b)
|
|
227
|
+
: null;
|
|
228
|
+
// Which side of the selection this export is. `except` inverts it: the same
|
|
229
|
+
// taps, read as "everything but these".
|
|
230
|
+
const excluding = selectedSeqs !== null && opts.seqsMode === "except";
|
|
231
|
+
// An excerpt reads *only what it exports*. `seq IN (…)` is an index lookup
|
|
232
|
+
// per selected message, so exporting two messages out of ten thousand costs
|
|
233
|
+
// two rows — where reading the conversation and filtering it afterwards cost
|
|
234
|
+
// ten thousand, every one of them with its JSON columns parsed.
|
|
235
|
+
//
|
|
236
|
+
// The complement has no such shortcut and wants none: it exports nearly the
|
|
237
|
+
// whole conversation, so it reads the whole conversation and drops the
|
|
238
|
+
// handful of rows the operator picked. That is a full export's cost, which
|
|
239
|
+
// is what this is.
|
|
240
|
+
const excluded = excluding ? new Set(selectedSeqs) : null;
|
|
241
|
+
const raw = selectedSeqs
|
|
242
|
+
? excluded
|
|
243
|
+
? readEvents(db, nid, { after: 0 }).filter((e) => !excluded.has(e.seq))
|
|
244
|
+
: readEventsBySeqs(db, nid, selectedSeqs)
|
|
245
|
+
: readEvents(db, nid, { after: 0 });
|
|
246
|
+
// How many messages the narrative actually holds, for the footer's "3 of 47".
|
|
247
|
+
// A count, asked of SQLite, not a length of something built. Both scopes are
|
|
248
|
+
// excerpts and both disclose the same way — "44 of 47" is as much an excerpt
|
|
249
|
+
// as "3 of 47", and a reader handed the file can tell neither how it was
|
|
250
|
+
// chosen nor that they should care.
|
|
251
|
+
const totalMessages = selectedSeqs ? countMessages(db, nid) : 0;
|
|
252
|
+
// Every event with something to show is exported, including lifecycle types
|
|
253
|
+
// (`started`, `restarted`, `binding_rejected`) — they carry real context, like
|
|
254
|
+
// the `/twin from <nid>:<seq>` back-pointer that opens a twin's narrative. A
|
|
255
|
+
// selected row that turns out to be empty is dropped by this same rule, so an
|
|
256
|
+
// excerpt is a subset of the full document rather than a different one.
|
|
257
|
+
const events = raw
|
|
258
|
+
.filter((e) => e.content || (e.payloads && e.payloads.length > 0))
|
|
259
|
+
.map((e) => ({
|
|
260
|
+
seq: e.seq,
|
|
261
|
+
time: e.time,
|
|
262
|
+
type: e.type,
|
|
263
|
+
content: e.content ?? null,
|
|
264
|
+
source: e.source ?? null,
|
|
265
|
+
payloads: e.payloads ?? null,
|
|
266
|
+
}));
|
|
267
|
+
// Inline image payloads as data URIs; non-image payloads render as file-name chips.
|
|
268
|
+
const imagePaths = new Set();
|
|
269
|
+
for (const e of events) {
|
|
270
|
+
for (const p of e.payloads ?? []) {
|
|
271
|
+
if (isImagePayload(p))
|
|
272
|
+
imagePaths.add(p);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const imageDataUris = new Map();
|
|
276
|
+
await Promise.all(Array.from(imagePaths).map(async (p) => {
|
|
277
|
+
const uri = await loadImageDataUri(nid, p);
|
|
278
|
+
if (uri)
|
|
279
|
+
imageDataUris.set(p, uri);
|
|
280
|
+
}));
|
|
281
|
+
// Known narrative ids in this daemon — used to gate which references become
|
|
282
|
+
// links, so exports dropped into one directory interlink without producing
|
|
283
|
+
// dead links for strings that merely resemble an id.
|
|
284
|
+
const knownIds = new Set(db.prepare("SELECT id FROM narratives").all().map((r) => r.id));
|
|
285
|
+
// One resolver for the whole document, so its existence lookups are
|
|
286
|
+
// memoised across every diagram in the conversation.
|
|
287
|
+
//
|
|
288
|
+
// It points at the sibling `<nid>.html` files the way prose references
|
|
289
|
+
// already do, *not* at `brane://nid/…`: that scheme is the app's in-band
|
|
290
|
+
// signal and no browser opens it, so a diagram node in a downloaded export
|
|
291
|
+
// would have been a link that does nothing.
|
|
292
|
+
const diagramHref = nidHrefFor(db, (id, seq) => refHref(id, seq, nid));
|
|
293
|
+
const rendered = await Promise.all(events.map(async (e) => ({
|
|
294
|
+
event: e,
|
|
295
|
+
contentHtml: e.content
|
|
296
|
+
? isSystemEvent(e.type)
|
|
297
|
+
? await renderMarkdown(linkifyNarrativeRefs(systemLine({ type: e.type, content: e.content }, { agent: narrative.agent_type, fold: narrative.fold_path }), knownIds, nid), { trusted: true, diagramHref })
|
|
298
|
+
: await renderMarkdown(linkifyNarrativeRefs(e.content, knownIds, nid), { diagramHref })
|
|
299
|
+
: "",
|
|
300
|
+
})));
|
|
301
|
+
const title = `${nid} — brane conversation`;
|
|
302
|
+
// Every "now" in this document is the moment of export, so the activity span
|
|
303
|
+
// and the "Exported …" stamp below it can't disagree.
|
|
304
|
+
const exportedAt = Date.now();
|
|
305
|
+
const exportDate = formatTimestamp(exportedAt);
|
|
306
|
+
// Lifecycle rows are shown but not counted — "12 messages" should mean twelve
|
|
307
|
+
// things said, not twelve rows on the page.
|
|
308
|
+
const exportedMessageSeqs = events.filter((e) => !isSystemEvent(e.type)).map((e) => e.seq);
|
|
309
|
+
const messageCount = exportedMessageSeqs.length;
|
|
310
|
+
// An excerpt says so where the count goes. "3 of 47 messages" is the whole
|
|
311
|
+
// disclosure: it is the one line that tells a reader handed this file that
|
|
312
|
+
// there was more conversation than the document contains.
|
|
313
|
+
const countLine = selectedSeqs
|
|
314
|
+
? `${messageCount} of ${totalMessages} messages`
|
|
315
|
+
: `${messageCount} messages`;
|
|
316
|
+
// The header is the narrative card, rendered statically: identity row (id ·
|
|
317
|
+
// fold) over the meta line (cost · /role · activity span), then the summary.
|
|
318
|
+
// Both lines come from the same `formatCost` / `formatActivitySpan` the app's
|
|
319
|
+
// card uses, so the two can't drift. The card's live status dot and
|
|
320
|
+
// LIVE/IDLE/DEAD badge are deliberately absent — a snapshot has no live
|
|
321
|
+
// status — and 📸 stands in their place.
|
|
322
|
+
const cost = formatCost(narrative.cost_amount, narrative.cost_unit);
|
|
323
|
+
const activitySpan = typeof narrative.created_at === "number"
|
|
324
|
+
? formatActivitySpan(narrative.created_at, narrative.last_activity, exportedAt)
|
|
325
|
+
: null;
|
|
326
|
+
const metaBits = [
|
|
327
|
+
cost ? `<span class="cost">${escHtml(cost)}</span>` : "",
|
|
328
|
+
narrative.role ? `<span class="role">/${escHtml(narrative.role)}</span>` : "",
|
|
329
|
+
activitySpan ? `<span class="label">from ${escHtml(activitySpan)}</span>` : "",
|
|
330
|
+
].filter(Boolean);
|
|
331
|
+
// The span is relative to export time, which a reader opening this months
|
|
332
|
+
// later can't know — so the absolute timestamps ride along as a tooltip.
|
|
333
|
+
const spanTitle = activitySpan && typeof narrative.created_at === "number"
|
|
334
|
+
? ` title="${escHtml(`created ${formatTimestamp(narrative.created_at)}${narrative.last_activity ? ` · last activity ${formatTimestamp(narrative.last_activity)}` : ""}`)}"`
|
|
335
|
+
: "";
|
|
336
|
+
const metaLine = metaBits.length
|
|
337
|
+
? `<div class="meta-line"${spanTitle}><span class="snapshot">📸</span>${metaBits.join(" ")}</div>`
|
|
338
|
+
: "";
|
|
339
|
+
const foldLine = narrative.fold_path
|
|
340
|
+
? ` <span class="fold"><span class="label">in </span>${escHtml(narrative.fold_path)}</span>`
|
|
341
|
+
: "";
|
|
342
|
+
// The agent-written summary describes the whole narrative, so it can
|
|
343
|
+
// paraphrase exactly the messages an `except` export was asked to leave out.
|
|
344
|
+
// A redaction the header then undoes is worse than none; the excerpt keeps it.
|
|
345
|
+
const summaryHtml = narrative.summary && !excluding
|
|
346
|
+
? `<div class="summary">${escHtml(narrative.summary)}</div>`
|
|
347
|
+
: "";
|
|
348
|
+
const palette = opts.palette ?? 0;
|
|
349
|
+
// What an excerpt skipped between two of its messages — one indexed COUNT per
|
|
350
|
+
// gap, whose cost is the gap rather than the conversation. A full export has
|
|
351
|
+
// no gaps by construction and asks nothing.
|
|
352
|
+
const gapBetween = (lo, hi) => hi - lo <= 1 ? 0 : countMessages(db, nid, { after: lo, before: hi });
|
|
353
|
+
const messagesHtml = rendered.map(({ event: e, contentHtml }, i) => {
|
|
354
|
+
const fromOp = e.type === "human_signal";
|
|
355
|
+
const foreign = e.type === "agent_signal";
|
|
356
|
+
// A foreign row wears its speaker's hue — the same `speakerColor` /
|
|
357
|
+
// `speakerBgColor` the app's bubbles use, at the same salt, so the export
|
|
358
|
+
// is a snapshot of the conversation as the operator saw it rather than a
|
|
359
|
+
// lookalike in a single flat green. Inline rather than a class because the
|
|
360
|
+
// hue is per speaker and the document carries no script to compute it.
|
|
361
|
+
const speakerStyle = foreign
|
|
362
|
+
? ` style="background:${speakerBgColor(e.source || "unknown", palette)}"`
|
|
363
|
+
: "";
|
|
364
|
+
const sourceStyle = foreign && e.source
|
|
365
|
+
? ` style="color:${speakerColor(e.source, palette)}"`
|
|
366
|
+
: "";
|
|
367
|
+
const system = isSystemEvent(e.type);
|
|
368
|
+
const time = formatTimestamp(e.time);
|
|
369
|
+
let bubbleClass = "agent";
|
|
370
|
+
if (system)
|
|
371
|
+
bubbleClass = "system";
|
|
372
|
+
else if (fromOp)
|
|
373
|
+
bubbleClass = "operator";
|
|
374
|
+
else if (foreign)
|
|
375
|
+
bubbleClass = "foreign";
|
|
376
|
+
// Attribution header. Every row gets one, in the same slot. Lifecycle events
|
|
377
|
+
// have no `source` — the daemon wrote them — so they name "system"
|
|
378
|
+
// explicitly rather than going anonymous. Operator rows say "operator"
|
|
379
|
+
// rather than the app's "you": an exported file is read by whoever it was
|
|
380
|
+
// handed to, who is generally not the operator. The channel it arrived
|
|
381
|
+
// over (`web`, `cli`, …) rides along as `operator/web`, since a snapshot
|
|
382
|
+
// is the only place that provenance survives.
|
|
383
|
+
//
|
|
384
|
+
// A foreign row's speaker is a second link: the nid points at the sibling
|
|
385
|
+
// `<speaker>.html`, the same way references in message bodies do, so a
|
|
386
|
+
// directory of exports lets the reader follow a foreign narration back to
|
|
387
|
+
// where it was written. Only the nid navigates — the `(nid:seq)` beside it
|
|
388
|
+
// stays the in-page anchor every other row has — matching the app, where
|
|
389
|
+
// the bubble body is for reading and copying, not for leaving the page. A
|
|
390
|
+
// speaker with no sibling file to point at (not a narrative this daemon
|
|
391
|
+
// knows) is written as plain text rather than a dead link.
|
|
392
|
+
const selfAnchor = (text) => `<a href="#seq-${e.seq}">${escHtml(text)}</a>`;
|
|
393
|
+
const sourceHtml = system
|
|
394
|
+
? selfAnchor(`system (${nid}:${e.seq})`)
|
|
395
|
+
: fromOp
|
|
396
|
+
? selfAnchor(`${e.source ? `operator/${e.source}` : "operator"} (${nid}:${e.seq})`)
|
|
397
|
+
: e.source
|
|
398
|
+
? foreign
|
|
399
|
+
? `${knownIds.has(e.source)
|
|
400
|
+
? `<a href="${refHref(e.source, undefined, nid)}" class="speaker">${escHtml(e.source)}</a>`
|
|
401
|
+
: escHtml(e.source)} ${selfAnchor(`(${nid}:${e.seq})`)}`
|
|
402
|
+
: selfAnchor(`${e.source}:${e.seq}`)
|
|
403
|
+
: "";
|
|
404
|
+
const source = sourceHtml
|
|
405
|
+
? `<div class="source${system ? " system-source" : ""}"${sourceStyle}>${sourceHtml}</div>`
|
|
406
|
+
: "";
|
|
407
|
+
const images = (e.payloads ?? [])
|
|
408
|
+
.filter(isImagePayload)
|
|
409
|
+
.map((path) => {
|
|
410
|
+
const dataUri = imageDataUris.get(path);
|
|
411
|
+
if (!dataUri)
|
|
412
|
+
return "";
|
|
413
|
+
return `<img src="${dataUri}" style="max-width:100%;border-radius:8px;margin-top:6px;" />`;
|
|
414
|
+
})
|
|
415
|
+
.join("\n");
|
|
416
|
+
const files = (e.payloads ?? [])
|
|
417
|
+
.filter((p) => !isImagePayload(p))
|
|
418
|
+
.map((path) => {
|
|
419
|
+
const filename = path.split("/").pop() ?? path;
|
|
420
|
+
return `<div class="file-attachment">${escHtml(filename)}</div>`;
|
|
421
|
+
})
|
|
422
|
+
.join("\n");
|
|
423
|
+
const wrapped = contentHtml ? `<div class="content">${contentHtml}</div>` : "";
|
|
424
|
+
// Interior gaps only. What was dropped before the first selected message or
|
|
425
|
+
// after the last is already stated by the header's "3 of 47" — but a gap
|
|
426
|
+
// *between* two shown messages would otherwise read as adjacency, which is
|
|
427
|
+
// the one thing an excerpt must not imply about a conversation.
|
|
428
|
+
const prev = selectedSeqs && i > 0 ? rendered[i - 1].event : null;
|
|
429
|
+
const skipped = prev ? gapBetween(prev.seq, e.seq) : 0;
|
|
430
|
+
const elision = skipped > 0
|
|
431
|
+
? `<div class="row elision-row"><div class="elision">⋯ ${skipped} message${skipped === 1 ? "" : "s"} not shown</div></div>\n`
|
|
432
|
+
: "";
|
|
433
|
+
return `${elision}<div class="row ${bubbleClass}-row" id="seq-${e.seq}">
|
|
434
|
+
<div class="bubble ${bubbleClass}"${speakerStyle}>
|
|
435
|
+
${source}${wrapped}${images}${files}
|
|
436
|
+
<a href="#seq-${e.seq}" class="time">${escHtml(time)}</a>
|
|
437
|
+
</div>
|
|
438
|
+
</div>`;
|
|
439
|
+
}).join("\n");
|
|
440
|
+
// KaTeX's stylesheet and the woff2 faces this document actually uses, as one
|
|
441
|
+
// inline block — so the math renders with no network, no CDN, and no script.
|
|
442
|
+
// Null when nothing on the page is math, which is the common case.
|
|
443
|
+
const katexStyle = katexStyleFor(messagesHtml) ?? "";
|
|
444
|
+
const cleanHtml = `<!DOCTYPE html>
|
|
445
|
+
<html lang="en">
|
|
446
|
+
<head>
|
|
447
|
+
<meta charset="UTF-8">
|
|
448
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
449
|
+
<title>${escHtml(title)}</title>
|
|
450
|
+
<style>
|
|
451
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
452
|
+
body {
|
|
453
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
454
|
+
background: #000; color: #e0e0e0; padding: 16px;
|
|
455
|
+
}
|
|
456
|
+
/* The narrative card, statically. Type scale, colors, and ordering track
|
|
457
|
+
expo/components/NarrativeMeta.tsx — id 15px/600 mono, everything else 13px
|
|
458
|
+
mono, so the whole block shares one baseline. */
|
|
459
|
+
header { padding: 16px 0; border-bottom: 1px solid #2c2c2e; margin-bottom: 16px; }
|
|
460
|
+
header .identity { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0; min-width: 0; }
|
|
461
|
+
header h1 {
|
|
462
|
+
font-size: 15px; color: #d8d8dd; font-weight: 600;
|
|
463
|
+
font-family: Menlo, monospace;
|
|
464
|
+
}
|
|
465
|
+
header .fold { margin-left: 8px; font-size: 13px; font-family: Menlo, monospace; color: #5e8a72; }
|
|
466
|
+
header .meta-line { margin-top: 4px; font-size: 13px; font-family: Menlo, monospace; }
|
|
467
|
+
header .snapshot { margin-right: 8px; }
|
|
468
|
+
header .cost { color: #7bb894; }
|
|
469
|
+
header .role { color: #d8d8dd; }
|
|
470
|
+
header .label { color: #888; }
|
|
471
|
+
header .summary { margin-top: 4px; font-size: 13px; color: #999; }
|
|
472
|
+
header .exported { margin-top: 8px; font-size: 12px; color: #666; }
|
|
473
|
+
.row { display: flex; margin-bottom: 6px; }
|
|
474
|
+
.agent-row { justify-content: flex-start; }
|
|
475
|
+
.operator-row { justify-content: flex-end; }
|
|
476
|
+
.foreign-row { justify-content: center; }
|
|
477
|
+
.system-row { justify-content: center; }
|
|
478
|
+
.bubble {
|
|
479
|
+
max-width: 85%; border-radius: 16px; padding: 10px 14px;
|
|
480
|
+
word-wrap: break-word; overflow-wrap: break-word;
|
|
481
|
+
}
|
|
482
|
+
.agent { background: #1c1c1e; border-bottom-left-radius: 4px; }
|
|
483
|
+
.operator { background: #1a4a7a; border-bottom-right-radius: 4px; }
|
|
484
|
+
.foreign { background: #1a2a1a; border-radius: 12px; }
|
|
485
|
+
/* Lifecycle events (spawn/twin/revive/bind rejection) — present but recessive,
|
|
486
|
+
so they read as stage directions rather than as something someone said. */
|
|
487
|
+
/* Only the fill and the outline differ from a normal bubble — padding, the
|
|
488
|
+
attribution-header / content / timestamp stack, and the type size are all
|
|
489
|
+
deliberately the same, so a system row reads as a message nobody spoke
|
|
490
|
+
rather than as a different kind of object. */
|
|
491
|
+
.system { background: transparent; border: 1px dashed #3a3a3c; border-radius: 12px; }
|
|
492
|
+
.system .content { color: #8a8a8e; }
|
|
493
|
+
/* The gap marker an excerpt puts between two messages that were not adjacent.
|
|
494
|
+
Centred and unbubbled on purpose: it is the document talking about itself,
|
|
495
|
+
not a row anybody wrote, and it must not be mistakable for one. */
|
|
496
|
+
.elision-row { justify-content: center; }
|
|
497
|
+
.elision { font-size: 12px; color: #6a6a6e; font-style: italic; padding: 2px 0; }
|
|
498
|
+
/* The fold a started line ends with (systemLine in shared/narrative-format),
|
|
499
|
+
in the identity row's green. Its href is a brane:// scheme no browser can
|
|
500
|
+
open, so it is inert here — a label, not a link. */
|
|
501
|
+
.system .content a[href^="${FOLD_LINK_SCHEME}"] { color: ${FOLD_COLOR}; text-decoration: none; pointer-events: none; font-family: Menlo, monospace; font-size: 13px; }
|
|
502
|
+
/* The daemon isn't a speaker, so its attribution sits in the dim palette
|
|
503
|
+
rather than the green reserved for agents and the operator. */
|
|
504
|
+
.source.system-source { color: #8a8a8e; }
|
|
505
|
+
.source { color: #4caf50; font-size: 11px; font-family: monospace; margin-bottom: 3px; }
|
|
506
|
+
.source a { color: inherit; text-decoration: none; }
|
|
507
|
+
/* The foreign speaker's nid leaves the page, so it reads as a link. */
|
|
508
|
+
.source a.speaker { text-decoration: underline; }
|
|
509
|
+
.content { font-size: 15px; line-height: 1.4; }
|
|
510
|
+
.content p { margin: 8px 0; }
|
|
511
|
+
.content hr { border: none; height: 1px; background: #444; margin: 12px 0; }
|
|
512
|
+
.content h1 { font-size: 26px; line-height: 1.23; font-weight: 700; margin: 16px 0 8px; color: #f0f0f0; }
|
|
513
|
+
.content h2 { font-size: 22px; line-height: 1.27; font-weight: 700; margin: 14px 0 6px; color: #f0f0f0; }
|
|
514
|
+
.content h3 { font-size: 19px; line-height: 1.32; font-weight: 700; margin: 12px 0 4px; color: #f0f0f0; }
|
|
515
|
+
.content h4 { font-size: 17px; line-height: 1.35; font-weight: 600; margin: 10px 0 4px; color: #e8e8e8; }
|
|
516
|
+
.content h5 { font-size: 16px; line-height: 1.38; font-weight: 600; margin: 8px 0 2px; color: #e8e8e8; }
|
|
517
|
+
.content h6 { font-size: 15px; line-height: 1.4; font-weight: 600; margin: 6px 0 2px; color: #ccc; }
|
|
518
|
+
.content code {
|
|
519
|
+
color: #f8c555; background: #1a1a1a; font-family: Menlo, monospace;
|
|
520
|
+
font-size: 13px; padding: 1px 4px; border-radius: 3px;
|
|
521
|
+
}
|
|
522
|
+
.content pre { background: #1a1a1a; padding: 10px; border-radius: 6px; margin: 4px 0; overflow-x: auto; font-size: 13px; line-height: 1.45; }
|
|
523
|
+
.content pre code { padding: 0; background: none; color: inherit; font-family: Menlo, monospace; }
|
|
524
|
+
.content pre.shiki { padding: 10px; border-radius: 6px; margin: 4px 0; overflow-x: auto; font-size: 13px; line-height: 1.45; font-family: Menlo, monospace; }
|
|
525
|
+
.content pre.shiki code { background: none; padding: 0; color: inherit; font-family: inherit; font-size: inherit; }
|
|
526
|
+
/* Display math is the one block that can be wider than the bubble — a long
|
|
527
|
+
equation scrolls rather than forcing the whole page sideways. KaTeX inherits
|
|
528
|
+
the CSS color property, so it picks up the bubble's text color for free.
|
|
529
|
+
The rules themselves are MATH_BLOCK_CSS, shared with the app so the two
|
|
530
|
+
scroll alike, and so an equation dissolves into the same bubble in both. */
|
|
531
|
+
${MATH_BLOCK_CSS}
|
|
532
|
+
/* A diagram is drawn at its natural size (the SVG carries a fixed width), so a
|
|
533
|
+
wide one scrolls inside the bubble the way a code block does, rather than
|
|
534
|
+
spilling out of it. The app's DiagramView host has the same two rules. */
|
|
535
|
+
.content .diagram { overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; margin: 6px 0; max-width: 100%; }
|
|
536
|
+
.content .diagram > svg { display: block; }
|
|
537
|
+
/* Hover or tap an arrow and it is drawn again on top with its label. The
|
|
538
|
+
rules are DIAGRAM_EDGE_HIGHLIGHT_CSS, shared with the app. */
|
|
539
|
+
${DIAGRAM_EDGE_HIGHLIGHT_CSS}
|
|
540
|
+
.content a { color: #6cb6ff; text-decoration: underline; }
|
|
541
|
+
.content strong { color: #f0f0f0; font-weight: 600; }
|
|
542
|
+
.content em { font-style: italic; }
|
|
543
|
+
.content table { border-collapse: collapse; margin: 6px 0; width: 100%; font-size: 14px; }
|
|
544
|
+
.content th, .content td { border: 1px solid #3a3a3c; padding: 6px 10px; text-align: left; }
|
|
545
|
+
.content th { background: #2c2c2e; color: #f0f0f0; font-weight: 600; }
|
|
546
|
+
.content tr:nth-child(even) { background: rgba(255,255,255,0.03); }
|
|
547
|
+
.content ul, .content ol { margin: 4px 0; padding-left: 24px; }
|
|
548
|
+
.content li { margin: 2px 0; }
|
|
549
|
+
/* GFM task list items. micromark emits a real disabled checkbox input, which is
|
|
550
|
+
the right markup and the wrong look: a disabled control renders in the
|
|
551
|
+
browser's grey whatever accent-color says, on a page that is otherwise black.
|
|
552
|
+
So appearance:none takes the box back and rebuilds it at the same 13px the
|
|
553
|
+
app draws (expo/components/Markdown.tsx, listCheckbox), and a downloaded
|
|
554
|
+
narrative matches the live one.
|
|
555
|
+
NB: this whole stylesheet is a template literal — no backticks below. */
|
|
556
|
+
.content input[type="checkbox"] {
|
|
557
|
+
appearance: none; -webkit-appearance: none;
|
|
558
|
+
width: 13px; height: 13px; margin: 0 6px 0 0; vertical-align: -1px;
|
|
559
|
+
border: 1px solid #8a8a8e; border-radius: 3px; background: transparent;
|
|
560
|
+
}
|
|
561
|
+
.content input[type="checkbox"]:checked { background: #6cb6ff; border-color: #6cb6ff; }
|
|
562
|
+
.content input[type="checkbox"]:checked::after {
|
|
563
|
+
content: "✓"; display: block; text-align: center;
|
|
564
|
+
color: #111; font-size: 10px; line-height: 11px;
|
|
565
|
+
}
|
|
566
|
+
/* The checkbox stands in for the marker, as it does on GitHub. The item holds
|
|
567
|
+
the input directly when the list is tight and inside its paragraph when the
|
|
568
|
+
list is loose, so both shapes are named. */
|
|
569
|
+
.content li:has(> input[type="checkbox"]),
|
|
570
|
+
.content li:has(> p:first-child > input[type="checkbox"]:first-child) { list-style: none; }
|
|
571
|
+
.time { color: #666; font-size: 11px; margin-top: 4px; text-align: right; text-decoration: none; display: block; }
|
|
572
|
+
.file-attachment {
|
|
573
|
+
color: #6cf; font-size: 13px; background: rgba(255,255,255,0.1);
|
|
574
|
+
border-radius: 6px; padding: 6px 10px; margin-top: 6px;
|
|
575
|
+
}
|
|
576
|
+
footer { padding: 16px 0; border-top: 1px solid #2c2c2e; margin-top: 16px; text-align: center; }
|
|
577
|
+
footer .branding { font-size: 12px; color: #555; margin-top: 12px; }
|
|
578
|
+
footer .branding a { color: #7fff9f; text-decoration: underline; text-shadow: 0 0 8px rgba(100, 255, 150, 0.6), 0 0 20px rgba(100, 255, 150, 0.3); }
|
|
579
|
+
#save-btn {
|
|
580
|
+
position: fixed; top: 12px; right: 12px; z-index: 100;
|
|
581
|
+
background: transparent; border: none; font-size: 28px;
|
|
582
|
+
cursor: pointer; padding: 6px;
|
|
583
|
+
filter: drop-shadow(0 0 8px rgba(100, 180, 255, 0.6)) drop-shadow(0 0 20px rgba(100, 180, 255, 0.3));
|
|
584
|
+
transition: filter 0.2s;
|
|
585
|
+
}
|
|
586
|
+
#save-btn:hover, #save-btn:active {
|
|
587
|
+
filter: drop-shadow(0 0 12px rgba(100, 180, 255, 0.9)) drop-shadow(0 0 30px rgba(100, 180, 255, 0.5));
|
|
588
|
+
}
|
|
589
|
+
</style>
|
|
590
|
+
${katexStyle}
|
|
591
|
+
</head>
|
|
592
|
+
<body>
|
|
593
|
+
<header>
|
|
594
|
+
<div class="identity"><h1>${escHtml(nid)}</h1>${foldLine}</div>
|
|
595
|
+
${metaLine}
|
|
596
|
+
${summaryHtml}
|
|
597
|
+
<div class="exported">Exported ${escHtml(exportDate)} · ${escHtml(countLine)}</div>
|
|
598
|
+
</header>
|
|
599
|
+
${messagesHtml}
|
|
600
|
+
<footer>
|
|
601
|
+
<div class="branding">exported from <a href="https://brane.organelle.dev">brane</a></div>
|
|
602
|
+
</footer>
|
|
603
|
+
</body>
|
|
604
|
+
</html>`;
|
|
605
|
+
if (!opts.interactive)
|
|
606
|
+
return cleanHtml;
|
|
607
|
+
// Interactive wrapper: add a 💾 button that downloads the clean HTML.
|
|
608
|
+
// The clean version is embedded as base64 so the button is self-contained.
|
|
609
|
+
const cleanBase64 = Buffer.from(cleanHtml, "utf-8").toString("base64");
|
|
610
|
+
return cleanHtml.replace("</header>", `<button id="save-btn" onclick="saveHtml()">💾</button>
|
|
611
|
+
</header>
|
|
612
|
+
<script>
|
|
613
|
+
function saveHtml() {
|
|
614
|
+
var raw = atob("${cleanBase64}");
|
|
615
|
+
var bytes = new Uint8Array(raw.length);
|
|
616
|
+
for (var i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
|
617
|
+
var blob = new Blob([new TextDecoder().decode(bytes)], { type: 'text/html' });
|
|
618
|
+
var url = URL.createObjectURL(blob);
|
|
619
|
+
var a = document.createElement('a');
|
|
620
|
+
a.href = url;
|
|
621
|
+
a.download = ${JSON.stringify(exportFileName(nid, selectedSeqs ? { seqs: exportedMessageSeqs, total: totalMessages } : null))};
|
|
622
|
+
document.body.appendChild(a);
|
|
623
|
+
a.click();
|
|
624
|
+
document.body.removeChild(a);
|
|
625
|
+
URL.revokeObjectURL(url);
|
|
626
|
+
}
|
|
627
|
+
` + `<` + `/script>`);
|
|
628
|
+
}
|
|
629
|
+
//# sourceMappingURL=export.js.map
|