Skip to content

Transcripts, Context Discovery & Context CLI

dazeb edited this page Sep 17, 2026 · 2 revisions

Transcripts, Context Discovery & Context CLI

This layer gives agent CLIs a way to read each other's conversation history without going through the running Electron app. It has three cooperating concerns:

  1. Transcript reading — parse the JSONL session files that Claude Code writes.
  2. Local discovery state — small files in the project folder that record each node's transcript path, the explicit links between nodes, and the prompt markers that tell an agent the CLI exists.
  3. A standalone CLI — termsprawl-context, which resolves the links, loads the peer transcripts, and prints a bounded dump to stdout.

All of it is Electron-free, fail-open, and safe to run from a one-shot node process inside the project directory.

Module map

File Responsibility
src/core/transcript.ts Reads session_name and printable user/assistant turns out of a Claude Code JSONL transcript; formats the linked-context dump.
src/core/transcript-index.ts Persists the nodeId → transcript path mapping under <cwd>/.termsprawl/transcripts/.
src/core/context-discovery.ts Plants and refreshes the project-local discovery markers (managed block in .termsprawl/AGENTS.md, skill file under .claude/skills/termsprawl-context/).
src/core/context-links.ts Stores node-to-node context links as one JSON file per canonical pair; list/add/remove/peers.
src/core/context-cli.ts The single CLI implementation: argv parsing, ContextCliIO abstraction, the real fs-backed IO factory, run loop.
src/core/context-cli-entry.ts Thin binding of process.argv → core logic → exit code. Never imported by the app.
scripts/build-context-cli.mjs Bundles the core into the committed, dependency-free scripts/termsprawl-context.mjs.
scripts/termsprawl-context.mjs The shipped artifact; a self-contained plain-node ES module with all helpers inlined.

On-disk state

Everything lives under the project cwd; no module in this layer writes to the user's home directory or a root AGENTS.md.

<cwd>/.termsprawl/
  AGENTS.md                    # managed block between <!-- termsprawl:context-links --> markers
  project.json                 # read-only here: nodes[].data.title supplies display names
  transcripts/<nodeId>.json    # { "version": 1, "path": "/abs/path/to/<session>.jsonl" }
  links/<lo>--<hi>.json        # { "version": 1, "a": "<lo>", "b": "<hi>" }
  bin/termsprawl-context       # the command the discovery markers point at
<cwd>/.claude/skills/termsprawl-context/SKILL.md

Link file names are canonical: a < b lexicographically, so linkFilePath(cwd, a, b) and linkFilePath(cwd, b, a) resolve to the same path and addLink is idempotent. The transcript index is keyed by node id, and recordTranscriptPath is a documented no-op for unsafe ids, an empty path, or any I/O failure.

The transcript-index header comment states that the app learns each node's transcript path from hook events, while the standalone CLI has no event history — the persisted index is the bridge between the two. That write side is not in this layer's files, but it is the contract this module assumes.

Call chain

flowchart TD
  subgraph Agent["Agent process (cwd = project root)"]
    A["Claude session"] -->|"reads at session start"| AG[".termsprawl/AGENTS.md managed block"]
    A -->|"skill discovery"| SK[".claude/skills/termsprawl-context/SKILL.md"]
    A -->|"TERMSPRAWL_NODE_ID injected at spawn"| CLI["termsprawl-context --cwd . --self $TERMSPRAWL_NODE_ID"]
  end

  CLI --> ENTRY["context-cli-entry.ts"]
  ENTRY --> PARSE["parseContextArgs"]
  PARSE -->|"error"| EXIT2["stderr + exit 2"]
  PARSE -->|"cwd, self"| RUN["runContextCli"]

  RUN --> PEERS["io.peers(self) → peersOf → listLinks(.termsprawl/links)"]
  RUN --> PATH["io.transcriptPath(peer) → readTranscriptPath(.termsprawl/transcripts)"]
  RUN --> TURNS["io.turns(path) → readTranscriptTurns(session JSONL)"]
  RUN --> TITLE["io.nodeTitle(peer) → .termsprawl/project.json"]
  RUN --> FMT["formatLinkedContext"]
  FMT --> OUT["stdout blocks joined by '\\n---\\n', exit 0"]
Loading

Key nodes. parseContextArgs accepts only --cwd <dir> and --self <nodeId>; anything else is an error answered with process.exit(2) and a termsprawl-context: prefixed message on stderr. runContextCli never fails: it returns 0 even when there are no peers, no transcript paths, or no printable turns, printing nothing in those cases. createRealContextIO(cwd) binds the pure run loop to the filesystem, reading nodeTitle out of .termsprawl/project.json and falling back to the raw node id when the title is absent or unparseable.

Transcript parsing rules

readTranscriptTurns(path) streams the file as text, splits on \n, and parses each non-blank line independently. Malformed lines are skipped (fail-open), and only entries whose type is exactly user or assistant are considered.

For each such entry, contentToText accepts message.content as either a plain string or an array of typed blocks; only blocks with type === 'text' survive, joined by newlines. tool_use, tool_result, and thinking blocks are dropped entirely, and a message with no printable text is skipped. Each surviving turn's text is truncated to LINKED_TURN_MAX_CHARS (2000), and the result keeps only the last MAX_LINKED_TURNS (40) turns. The caps are exported so callers and tests share the same constants.

readSessionNameFromTranscript(path) implements the same reader shape but only tracks the latest non-empty string session_name. It is the input to node-title mirroring — the comment is explicit that node titles come from the transcript rather than OSC title escapes. A missing file, unreadable file, junk lines, or absent names all yield null, so the caller keeps the existing title.

formatLinkedContext({ peerId, peerTitle, turns }) produces exactly the stdout shape the CLI emits:

# linked context from <peerTitle> (<peerId>)
## user
<text>
## assistant
<text>

Context links

context-links.ts is a pure fs helper layer with three explicit error codes: SELF, BAD_ID, and IO. addLink rejects unsafe or identical ids before touching the filesystem and returns { ok: true } only after the write succeeds; removeLink is a force: true delete that tolerates a missing file; listLinks skips directory entries that are not .json files and silently drops files that fail parseLinkFile. A parsed link must have version === 1, two distinct ids that both pass isSafeProjectId, or it is discarded.

peersOf(cwd, id) filters every valid link down to the opposite endpoint, in no particular order. The CLI therefore resolves peers in one pass and tolerates stale or corrupt link files by ignoring them rather than aborting.

Note the shared safety predicate: isSafeProjectId matches /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ and is imported from workspace-files.ts, so the same id discipline that guards the workspace store also guards transcript-index filenames, link filenames, and the CLI arguments that reach them.

Discovery markers

ensureContextDiscovery(cwd) writes two markers so a Claude session whose cwd is the project can find the CLI:

  • .termsprawl/AGENTS.md — a managed block delimited by <!-- termsprawl:context-links --> and <!-- /termsprawl:context-links -->, instructing the agent to run .termsprawl/bin/termsprawl-context --cwd . --self "$TERMSPRAWL_NODE_ID" and to read only what that command prints.
  • .claude/skills/termsprawl-context/SKILL.md — a skill header plus the same instruction, rewritten only when the body differs.

upsertManagedAgentsBlock is idempotent: it finds the markers, keeps user prose above and below them, and re-emits before\n\n<block>\n\n<after> (trimmed, terminated by a newline). Calling it twice with no surrounding change returns the same string, so ensureContextDiscovery can skip the write. Both writes are wrapped in try/catch and are best-effort only.

The TERMSPRAWL_NODE_ID environment variable referenced by the markers is injected at spawn (documented as main/withAgentNodeIdEnv); this layer only consumes it as a shell expansion inside the marker text.

Build and packaging

scripts/build-context-cli.mjs runs a Vite programmatic SSR library build with entry src/core/context-cli-entry.ts, format es, target node22, rollupOptions.external: [] so everything except node: builtins is inlined, then renames the emitted context-cli-entry.js to scripts/termsprawl-context.mjs. The script is invoked as pnpm run build:cli, and its output is committed so tests and packaged copies have a stable artifact. The same script additionally builds src/core/agent-tool-entry.ts into out/tools/agent-tool-entry.mjs — a separate concern from this page.

Because the shipped artifact and the TS source are the same implementation, the source of truth for CLI behavior is src/core/context-cli.ts plus the three core helpers, and scripts/termsprawl-context.mjs is a generated, readable mirror.

Boundary conditions and failure modes

Condition Behavior
Unknown argv, missing --cwd/--self value, or missing either flag { error } from parseContextArgs; entry prints to stderr and exits 2.
No peers for self runContextCli returns 0 and prints nothing.
Peer has no indexed transcript path, or the path is unreadable Peer is skipped; remaining peers still print.
Peer transcript has no printable turns Peer is skipped.
project.json missing or title not a string nodeTitle returns null; the CLI falls back to the node id.
Link file wrong version, junk JSON, same-id pair, unsafe id parseLinkFile returns null; listLinks drops it.
addLink with identical ids { error: { code: 'SELF' } }.
addLink write failure { error: { code: 'IO', message } }; no throw.
Transcript-index file wrong version or bad path readTranscriptPath returns null (treated as absent).
Discovery marker write fails Swallowed; discovery is best-effort and never runs outside cwd.
Any transcript read error readSessionNameFromTranscript → null; readTranscriptTurns → [].

Extension points

  • New transcript format. Extend contentToText / readTranscriptTurns; the caps and the TranscriptTurn shape are the stable contract the CLI and formatter depend on.
  • New discovery surface. Add another marker in ensureContextDiscovery, reusing the managed-block pattern for idempotency.
  • New CLI IO behavior. Implement ContextCliIO and inject it into runContextCli; createRealContextIO is just the disk-backed default, which is why tests can run the CLI fully in-process.
  • Layout changes. INDEX_VERSION and LINK_FILE_VERSION are the migration seams: readers reject mismatched versions and degrade to "no data" instead of throwing, so a bump is safe for old files.
  • Different peer semantics. peersOf is the single resolution point; anything richer (directed links, link metadata) would change LinkPair and parseLinkFile first.

Sources:

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally