You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/03 03:20:45 UTC
(skywalking-ai-sessionizer) branch main created (now a29e3ff)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a change to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-ai-sessionizer.git
at a29e3ff Initial import of SkyWalking AI Sessionizer
This branch includes the following new commits:
new a29e3ff Initial import of SkyWalking AI Sessionizer
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(skywalking-ai-sessionizer) 01/01: Initial import of SkyWalking AI Sessionizer
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-ai-sessionizer.git
commit a29e3ffbf899ab0e11525963d010c3c0a56411dc
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Thu Sep 3 11:20:35 2026 +0800
Initial import of SkyWalking AI Sessionizer
Conversation-level observability for long-lived AI agents: it assembles
fragmented agent telemetry - transcripts, subagent streams, workflow
journals - into one durable conversation structure, and serves it as a
page. Collection from local Claude Code files, assembly into an
append-only chain of immutable rounds, and the reading view are in;
static export and OTLP push are not yet.
This is the current tree of the personal project it grew from, imported
without its history. The module path is the ASF repository, every file
carries the ASF header, NOTICE is in the ASF form, and .asf.yaml sets
squash-only merges and a protected main behind the CI's single required
check. CI uses only apache/*, actions/* and github/* actions, and its
Docker job builds the image on pull requests and publishes it to GHCR
for linux/amd64 and linux/arm64 on pushes to main and on version tags,
with plain shell for login and buildx so no third-party action is needed.
Co-Authored-By: Claude Fable 5.1 <no...@anthropic.com>
---
.asf.yaml | 44 +
.claude/skills/collect/SKILL.md | 123 ++
.dockerignore | 12 +
.github/workflows/ci.yaml | 142 ++
.gitignore | 23 +
.golangci.yml | 36 +
.licenserc.yaml | 64 +
CLAUDE.md | 128 ++
Dockerfile | 41 +
LICENSE | 202 ++
Makefile | 109 ++
NOTICE | 5 +
README.md | 101 +
cmd/asz/main.go | 646 +++++++
cmd/asz/parse.go | 334 ++++
cmd/asz/view.go | 63 +
design-notes/01-collection-and-raw-storage.md | 1322 +++++++++++++
design-notes/02-parse-and-assembly.md | 623 ++++++
design-notes/03-read-and-export.md | 626 ++++++
design-notes/README.md | 10 +
docs/README.md | 15 +
docs/en/adapters/claude-code-glossary.md | 127 ++
docs/en/adapters/claude-code.md | 338 ++++
.../concepts-and-designs/conversation-assembly.md | 141 ++
docs/en/concepts-and-designs/glossary.md | 181 ++
docs/en/concepts-and-designs/overview.md | 53 +
.../unified-conversation-model.md | 248 +++
docs/menu.yml | 36 +
go.mod | 5 +
go.sum | 4 +
internal/adapters/claudecode/claudecode.go | 119 ++
internal/adapters/claudecode/claudecode_test.go | 289 +++
internal/adapters/claudecode/collect.go | 463 +++++
internal/adapters/claudecode/collect_test.go | 187 ++
internal/adapters/claudecode/convert.go | 417 ++++
internal/adapters/claudecode/discover.go | 370 ++++
internal/adapters/claudecode/glossary.go | 133 ++
internal/adapters/claudecode/glossary_test.go | 97 +
internal/adapters/claudecode/helpers_test.go | 27 +
internal/adapters/claudecode/index.go | 372 ++++
internal/adapters/claudecode/matcher.go | 144 ++
internal/adapters/claudecode/matcher_test.go | 116 ++
internal/adapters/claudecode/regress_test.go | 466 +++++
internal/adapters/claudecode/reindex.go | 98 +
internal/adapters/claudecode/tail.go | 261 +++
internal/assemble/assemble.go | 356 ++++
internal/assemble/calls.go | 199 ++
internal/assemble/emit.go | 125 ++
internal/assemble/epoch.go | 181 ++
internal/assemble/segment.go | 189 ++
internal/assemble/spawn.go | 368 ++++
internal/assemble/streams.go | 201 ++
internal/assemble/talk.go | 308 +++
internal/assemble/tools.go | 188 ++
internal/config/config.go | 170 ++
internal/index/codec.go | 255 +++
internal/index/fromrecord.go | 182 ++
internal/index/index.go | 227 +++
internal/index/index_test.go | 196 ++
internal/index/intern.go | 110 ++
internal/index/lookup.go | 328 ++++
internal/parse/parse.go | 461 +++++
internal/parse/view.go | 43 +
internal/storage/cursor.go | 189 ++
internal/storage/indexstate.go | 121 ++
internal/storage/landed.go | 138 ++
internal/storage/lock.go | 92 +
internal/storage/state.go | 170 ++
internal/storage/storage_test.go | 202 ++
internal/storage/zone.go | 210 ++
internal/verify/session.go | 105 +
internal/verify/verify.go | 206 ++
internal/verify/verify_test.go | 205 ++
internal/view/api.go | 720 +++++++
internal/view/index.html | 206 ++
internal/view/page.go | 49 +
internal/view/page.html | 2023 ++++++++++++++++++++
internal/view/step.go | 505 +++++
internal/view/view.go | 233 +++
pkg/model/model.go | 205 ++
pkg/model/term.go | 154 ++
pkg/sessiondata/fields.go | 74 +
pkg/sessiondata/reader.go | 152 ++
pkg/sessiondata/sessiondata.go | 278 +++
pkg/sessiondata/writer.go | 112 ++
pkg/sessionflow/asb_test.go | 519 +++++
pkg/sessionflow/chain.go | 317 +++
pkg/sessionflow/fold.go | 313 +++
pkg/sessionflow/id.go | 111 ++
pkg/sessionflow/round.go | 327 ++++
pkg/sessionflow/sessionflow.go | 223 +++
tests/adapter/claudecode/local/builder_test.go | 571 ++++++
tests/adapter/claudecode/local/chain_test.go | 503 +++++
.../00000001-0000-4000-8000-000000000001.jsonl | 18 +
.../subagents/agent-a0000001aaaa00001.jsonl | 3 +
.../subagents/agent-a0000001aaaa00001.meta.json | 1 +
.../wf_buildcheck/agent-a0000001bbbb00002.jsonl | 1 +
.../workflows/wf_buildcheck/journal.jsonl | 2 +
.../workflows/wf_buildcheck.json | 1 +
.../01-full-conversation/memory/notes.md | 1 +
.../01-full-conversation/not-a-uuid.jsonl | 1 +
.../workflows/scripts/build-check-wf_buildcheck.js | 3 +
.../00000002-0000-4000-8000-00000000000A.jsonl | 1 +
.../claudecode/local/claudecode-files/README.md | 29 +
tests/adapter/claudecode/local/coverage_test.go | 207 ++
tests/adapter/claudecode/local/growth_test.go | 189 ++
tests/adapter/claudecode/local/local_test.go | 331 ++++
tests/adapter/claudecode/local/multiround_test.go | 304 +++
tests/adapter/claudecode/local/parse_test.go | 462 +++++
tests/adapter/claudecode/local/shared_test.go | 76 +
tests/adapter/claudecode/local/values_test.go | 213 +++
111 files changed, 24524 insertions(+)
diff --git a/.asf.yaml b/.asf.yaml
new file mode 100644
index 0000000..6c14047
--- /dev/null
+++ b/.asf.yaml
@@ -0,0 +1,44 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+github:
+ description: Apache SkyWalking AI Sessionizer - conversation-level observability for long-lived AI agents
+ homepage: https://skywalking.apache.org/
+ labels:
+ - skywalking
+ - observability
+ - apm
+ - ai
+ - llm
+ - agent
+ - claude-code
+ enabled_merge_buttons:
+ squash: true
+ merge: false
+ rebase: false
+ dependabot_updates: true
+ protected_branches:
+ main:
+ required_status_checks:
+ strict: true
+ contexts:
+ - Required
+ required_pull_request_reviews:
+ dismiss_stale_reviews: true
+ required_approving_review_count: 1
diff --git a/.claude/skills/collect/SKILL.md b/.claude/skills/collect/SKILL.md
new file mode 100644
index 0000000..ffd4716
--- /dev/null
+++ b/.claude/skills/collect/SKILL.md
@@ -0,0 +1,123 @@
+---
+name: collect
+description: Collect this machine's local Claude Code conversation data with the asz collector. Resolves the local Claude data directory (honouring CLAUDE_CONFIG_DIR and XDG_CONFIG_HOME), builds the binary, lists what is discoverable, and lands everything into the storage root. Use when asked to collect, backfill, re-collect, or inspect local Claude Code sessions, or to check what the collector can see.
+user-invocable: true
+---
+
+# Collect local Claude Code data
+
+Runs the `claude-code-local` adapter over this machine's own Claude Code files.
+It reads only what is already on disk — nothing needs to be enabled in Claude
+Code, and it works on history that predates the collector.
+
+## 1. Resolve the source directory
+
+The location is **not** fixed. Resolve in this order and report which one won:
+
+```sh
+if [ -n "$CLAUDE_CONFIG_DIR" ]; then SRC="$CLAUDE_CONFIG_DIR/projects"
+elif [ -n "$XDG_CONFIG_HOME" ]; then SRC="$XDG_CONFIG_HOME/claude/projects"
+else SRC="$HOME/.claude/projects"
+fi
+ls -d "$SRC" && du -sh "$SRC"
+```
+
+`asz` performs the same resolution internally, so normally you do not pass
+`source_root` at all. Set it only when collecting from a copy or a mounted corpus.
+
+## 2. Build
+
+```sh
+make build # -> ./bin/asz
+```
+
+## 3. See what is discoverable before collecting
+
+```sh
+./bin/asz sources
+```
+
+Reports one row per session with its stream, meta, journal and manifest counts,
+then a footer naming any session whose files span more than one source
+directory. Read it for two things:
+
+- **`filtered: N session(s) excluded by config`** — the default config excludes
+ `/private/tmp/**` (Claude Code's own agent scratchpads). If a session you
+ expect is missing, this is why.
+- **sessions spanning several directories** — normal. A child agent that ran in
+ a different working directory is filed under that directory instead.
+
+## 4. Collect
+
+Default storage root is `./data`, relative to the working directory.
+
+```sh
+./bin/asz collect -once # single pass; the backfill path
+./bin/asz collect # watch mode, 5s interval
+```
+
+To land somewhere else, write a config and pass it:
+
+```yaml
+# collect.yaml
+storage:
+ root: /tmp/asz-data
+adapters:
+ - name: claude-code-local
+ enabled: true
+ collector:
+ mode: once
+```
+
+```sh
+./bin/asz collect -config collect.yaml
+```
+
+## 5. Read the result line
+
+```
+sessions=61 sources=5831 landed=5831 records=364051 bytes=1.0GB
+gone=0 conflicts=0 busy=0 pending=0 errors=0 (2m16s)
+```
+
+| Field | Meaning |
+| --- | --- |
+| `landed` | sources that produced new data this pass |
+| `gone` | source deleted since last pass — **normal**, Claude Code prunes transcripts, and our landed copy outlives them |
+| `conflicts` | a source was rotated, truncated or rewritten behind the cursor; collection stopped for it |
+| `busy` | session skipped because another collector holds its lock |
+| `pending` | a source still had data when the per-pass drain limit was reached |
+
+**`pending` or `errors` above zero means the pass did not collect everything**,
+and `asz collect` exits non-zero. A clean pass is `pending=0 errors=0`.
+
+Re-running is cheap and safe: cursors make a steady-state pass ~1s over several
+thousand sources, and landing is idempotent by design.
+
+## What lands where
+
+```
+<root>/<session-id>/
+ session.state next_seq, liveness
+ streams/main/ cursor · transcript-<ts>-<seq>.jsonl
+ streams/<agent-id>/ cursor · transcript-… · meta-cursor · meta-…
+ journal/<wf-id>/ cursor · journal-<ts>-<seq>.jsonl
+ manifest/<run-id>/ cursor · manifest-<ts>-<seq>.jsonl
+```
+
+Landed files are **write-once and read-only**. Each is a header line plus one
+line per source record, with the source bytes preserved verbatim in `payload`.
+
+Purging a session is `rm -rf <root>/<session-id>/`.
+
+## If something looks wrong
+
+- **A session is missing** — check the `filtered` line first, then `asz sources`.
+- **`conflicts` is non-zero** — the cursor for that source is now sticky. Inspect
+ `state` in its `cursor` file; the source was rotated, truncated, or rewritten
+ behind the position we had already consumed.
+- **Nothing is discovered** — confirm step 1 resolved to a directory that exists
+ and contains per-project subdirectories.
+
+Do not hand-edit anything under the storage root. Cursors and landed files are
+consistent only as written.
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..64455f1
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,12 @@
+.git
+.github
+bin
+dist
+data
+.sf
+.local
+*.wal
+docs
+design-notes
+tests
+*.md
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..bf6a036
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,142 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: CI
+
+on:
+ push:
+ branches: [main]
+ tags: ['v*']
+ pull_request:
+ branches: [main]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+# Every action used here is in the apache/*, actions/* or github/* namespace.
+# The ASF GitHub Actions policy allows only those without a reviewed SHA pin,
+# so docker login, buildx and the push are plain shell steps rather than
+# third-party actions.
+jobs:
+ license:
+ name: License
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Check license headers
+ uses: apache/skywalking-eyes/header@v0.9.0
+ - name: Check dependency licenses
+ uses: apache/skywalking-eyes/dependency@v0.9.0
+
+ build:
+ name: Build and Test
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.25'
+ cache: true
+ - name: Verify go.mod is tidy
+ run: |
+ go mod tidy
+ git diff --exit-code go.mod go.sum
+ - name: Vet
+ run: make vet
+ - name: Build
+ run: make build
+ # `make test` covers ./... including the end-to-end adapter suite under
+ # tests/. It is split out here so a failure names which layer broke.
+ - name: Unit tests
+ run: go test -race -count=1 ./pkg/... ./internal/...
+ - name: End-to-end adapter tests
+ run: go test -race -count=1 ./tests/...
+ - name: Full suite
+ run: make test
+
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.25'
+ cache: true
+ - name: Lint
+ run: make lint
+
+ # The container image. A pull request builds it; a push to main or a
+ # version tag builds it for both platforms and publishes it to GHCR under
+ # this repository's name.
+ docker:
+ name: Docker
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ env:
+ IMAGE: ghcr.io/${{ github.repository }}
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up buildx
+ run: docker buildx create --use --name asz
+ - name: Image tags
+ id: meta
+ run: |
+ image=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
+ tags="$image:sha-${GITHUB_SHA::8}"
+ if [ "$GITHUB_REF" = "refs/heads/main" ]; then tags="$tags,$image:latest"; fi
+ case "$GITHUB_REF" in refs/tags/v*) tags="$tags,$image:${GITHUB_REF#refs/tags/}" ;; esac
+ echo "tags=$tags" >> "$GITHUB_OUTPUT"
+ - name: Build
+ if: github.event_name == 'pull_request'
+ run: docker buildx build --platform linux/amd64 --load -t asz:pr .
+ - name: Smoke test
+ if: github.event_name == 'pull_request'
+ run: docker run --rm asz:pr glossary | head -c 400
+ - name: Log in to GHCR
+ if: github.event_name == 'push'
+ run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
+ - name: Build and push
+ if: github.event_name == 'push'
+ run: |
+ set -eu
+ args=""
+ for t in $(echo "${{ steps.meta.outputs.tags }}" | tr ',' ' '); do args="$args -t $t"; done
+ docker buildx build --platform linux/amd64,linux/arm64 $args --push .
+
+ # Single fan-in job so branch protection needs only one required check.
+ result:
+ name: Required
+ if: always()
+ needs: [license, build, lint, docker]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Fail if any job failed
+ if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
+ run: exit 1
+ - name: Success
+ run: echo "all required checks passed"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..98982dc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,23 @@
+# Build output
+/bin/
+/dist/
+*.exe
+*.test
+*.out
+coverage.txt
+
+# Go workspace
+go.work
+go.work.sum
+
+# Editors / OS
+.idea/
+.vscode/
+.DS_Store
+
+# Collected conversation data. /data is the DEFAULT storage root, so running
+# `asz collect` in a checkout would otherwise stage private transcripts.
+/data/
+/.sf/
+/.local/
+*.wal
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..f1383fb
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,36 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+run:
+ timeout: 5m
+linters:
+ enable:
+ - errcheck
+ - govet
+ - ineffassign
+ - staticcheck
+ - unused
+ - gofmt
+ - goimports
+ - misspell
+ - revive
+ - bodyclose
+ - errorlint
+issues:
+ exclude-rules:
+ - path: _test\.go
+ linters: [errcheck]
diff --git a/.licenserc.yaml b/.licenserc.yaml
new file mode 100644
index 0000000..6fec7da
--- /dev/null
+++ b/.licenserc.yaml
@@ -0,0 +1,64 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+header:
+ license:
+ spdx-id: Apache-2.0
+ copyright-owner: Apache Software Foundation
+ content: |
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ pattern: |
+ Licensed to the Apache Software Foundation \(ASF\) under one
+ or more contributor license agreements\. See the NOTICE file
+
+ paths-ignore:
+ - '.git/**'
+ - '.gitignore'
+ - '.dockerignore'
+ - 'LICENSE'
+ - 'NOTICE'
+ - 'go.mod'
+ - 'go.sum'
+ - '**/*.md'
+ - '**/testdata/**'
+ # Fixture corpora are captured data, not source. They reproduce another
+ # program's file format byte for byte, so a header would corrupt them.
+ - '**/claudecode-files/**'
+ - 'dist/**'
+ - 'bin/**'
+ comment: on-failure
+
+dependency:
+ files:
+ - go.mod
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..b15ee39
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,128 @@
+# SkyWalking AI Sessionizer
+
+Conversation-level observability for long-lived AI agents. It assembles fragmented agent
+telemetry — transcripts, subagent streams, workflow journals — into one durable conversation
+structure.
+
+Two boundaries carry the design: **Conversation** is the durable ownership boundary and its
+identity is supplied, never inferred; **ExecutionStream** is the ordered continuity boundary,
+within which model-message continuity is evaluated.
+
+Apache-2.0. A subproject of Apache SkyWalking: `github.com/apache/skywalking-ai-sessionizer`.
+
+## Documentation
+
+| Where | What |
+| --- | --- |
+| `docs/` | **Official docs.** Indexed by `docs/menu.yml`; SkyWalking layout (`docs/en/...`). |
+| `docs/en/concepts-and-designs/unified-conversation-model.md` | The model. Runtime-agnostic — no Claude Code vocabulary belongs here. |
+| `docs/en/concepts-and-designs/conversation-assembly.md` | The eight-stage pipeline and the round chain. |
+| `docs/en/concepts-and-designs/glossary.md` | Every word with a fixed meaning, and what each is **not**. |
+| `docs/en/adapters/claude-code-glossary.md` | What Claude Code calls each of them. Generated by `asz glossary`. |
+| `docs/en/adapters/claude-code.md` | How Claude Code maps onto the model, and what it cannot supply. |
+| `design-notes/` | **Working notes, not documentation.** Measurements, corrections, open questions. Unpolished by intent. |
+
+Every claim in the docs is backed by a measurement against a real corpus. When changing them,
+keep that standard: state what was measured, on what sample, and say `unavailable` rather than
+approximating.
+
+## Writing
+
+Comments, docs and commit messages use **plain, widely understood English**. Someone reading this
+project is often not a native English speaker, and the code is the hard part — the prose should not
+be.
+
+- **No abbreviations or slang.** Write "remove duplicate records", not "dedup". Write "configuration",
+ not "config", outside identifiers.
+- **No unusual or figurative words** where a common one works: use "essential" not "load-bearing",
+ "hide" not "launder", "insert" not "splice", "block" not "veto", "cut out" not "amputate".
+- **Short sentences.** One idea each. Prefer a period to a dash or a semicolon.
+- **Say why, not what.** The code already says what it does. A comment earns its place by recording
+ the reason, the measurement, or the failure that forced the choice.
+
+## Structure
+
+```
+cmd/asz/ CLI: sources · collect · index · show · parse · conversation · view · glossary · verify
+pkg/sessiondata/ Session Data: the landed record, `.sd` — public, adapters produce these
+pkg/model/ the conversation vocabulary: node kinds, relations, qualification
+pkg/sessionflow/ Session Flow: the round chain of conversation structure, `.sf`
+internal/index/ derived lookup structure the assembler resolves against
+internal/assemble/ the eight-stage pipeline, index in and structure out
+internal/parse/ one round: assemble, compare against the chain, publish the delta
+internal/storage/ landing zone, atomic writes, cursors, session/index state, locks
+internal/verify/ contiguity and digest checks over landed data
+internal/view/ reads a conversation and serves it as a page
+internal/adapters/claudecode/ the claude-code-local adapter
+internal/config/ YAML configuration
+tests/adapter/claudecode/local/ end-to-end suite over a synthetic fixture corpus
+```
+
+`pkg/` is public because third-party adapters and consumers need it. Built-in adapters stay in
+`internal/`.
+
+**Phase 1 (implemented):** pull collection from local Claude Code files, plus a derived index built
+while landing. No configuration of Claude Code required, and it works on history that already exists.
+**Phase 2 (implemented):** assembly into a conversation, published as an append-only chain of
+immutable rounds — see `design-notes/02` and the assembly doc.
+**Phase 3 (in progress):** read and export. `asz view` serves the conversations as a page: a list at
+`/`, and one conversation at `/c/{id}` with its transcript, its flow timeline and the evidence behind
+every step. It reads the folded chain on demand and writes nothing. A static export and OTLP push
+belong here too; both add measurement only, never structure.
+
+## Build
+
+```sh
+make build # -> ./bin/asz
+make test # whole suite, -race
+make test-e2e # only the end-to-end adapter tests, verbose
+make check # vet, lint, license headers, dependency licenses, tests — what CI runs
+make docker # the container image, as CI builds and publishes it
+make help # all targets
+```
+
+Go 1.25+. One dependency: `gopkg.in/yaml.v3`.
+
+Every source file carries the Apache-2.0 header; `make license-fix` inserts missing ones.
+`license-eye` is pinned to v0.9.0 — v0.7.0 does not build under Go 1.26.
+
+## Invariants worth knowing before changing assembly code
+
+- **The conversation is the fold of every round.** There is no other state. A round is immutable and
+ is never rewritten; later evidence produces a new revision in a later round.
+- **Entity ids come from stable evidence, never from position.** An id that shifts when an earlier
+ source is backfilled cannot supersede its own earlier revision.
+- **A round carries no wall-clock time**, so the same evidence reproduces the same bytes and digest.
+ Anything mutable or temporal lives in `conversation.state`, outside every digest.
+- **Absence in a round means unchanged, never deleted.** Removal is an explicit tombstone, and a
+ resolved reference is superseded with that state rather than dropped.
+- **Landed files plus the round chain are the whole conversation.** Nothing else has to travel with
+ them: the index rebuilds from the landed files, and the chain head is recovered by listing the
+ rounds directory. An archive or a bundle can be read, verified and re-parsed with no source files
+ and no collector.
+- **Assembly reads the index, never the payloads.** Structure needs identifiers; text is read only
+ when a conversation is rendered.
+- **A prompt cycle id is not unique across streams.** Anything keyed on it must be scoped by stream.
+- **Never use a stop reason to find a call's last fragment.** A main transcript stamps it on every
+ fragment. Line order is the only rule that works on both stream kinds.
+- **Cross-stream flow is a relation, never containment.** A child's work stays under the child.
+
+## Invariants worth knowing before changing collector code
+
+- **Landed files are write-once.** temp → fsync → rename → `chmod 0444`. Never appended.
+- **Payload bytes are the source bytes**, byte-for-byte. `json.RawMessage` both directions.
+- **Land before committing the cursor.** At-least-once is intentional; the reverse loses data.
+- **`next_seq` is monotonic across all streams in a session**, so a session is processed
+ single-threaded and under a lock. The filesystem is the authority on the counter.
+- **Never use `bufio.Scanner` on source records.** Real lines reach ~1 MB, past its 64 KB
+ default, where it fails silently.
+- **The index is derived and disposable.** Delete it and it rebuilds from landed files; a schema
+ bump discards rather than migrates. Nothing downstream may treat it as authoritative.
+- **The index stores roles, not runtime field names.** A runtime's vocabulary stops at its adapter,
+ so a rename there cannot reach the model. `internal/index` and the model docs must stay free of
+ `promptId`, `parentUuid`, `agentId` and their kin.
+- **Both vocabularies survive.** The model's words are the project's, but a reader can ask for the
+ runtime's with `-terms native`. The adapter declares a glossary and a test fails if a name the
+ model can emit has no entry - including entries that say the runtime has no word for it.
+- **A record type is not what a record is.** A `user` record is usually a tool result, not a human —
+ only ~7% are typed by a person. Check `origin` and the content blocks, never `type` alone.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1a606ff
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,41 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# The binary is cross-compiled on the build host: Go builds every target
+# platform from one machine, so a multi-platform image needs no emulation
+# and no third-party build action.
+FROM --platform=$BUILDPLATFORM golang:1.25 AS build
+ARG TARGETOS
+ARG TARGETARCH
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
+ go build -trimpath -ldflags="-s -w" -o /out/asz ./cmd/asz
+
+# Static binary, no shell, non-root. The image reads and writes only its
+# storage root, mounted at /asz/data, which is also the default the binary
+# resolves when run from /asz.
+FROM gcr.io/distroless/static-debian12:nonroot
+COPY --from=build /out/asz /usr/local/bin/asz
+WORKDIR /asz
+VOLUME ["/asz/data"]
+EXPOSE 8787
+ENTRYPOINT ["/usr/local/bin/asz"]
+# Inside a container the page must listen on every interface, not loopback.
+CMD ["view", "0.0.0.0:8787"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..a321961
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,109 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+BINARY := asz
+BIN_DIR := bin
+GO := go
+
+GOLANGCI_LINT_VERSION := v1.64.8
+LICENSE_EYE_VERSION := v0.9.0
+
+.DEFAULT_GOAL := check
+
+$(BIN_DIR):
+ @mkdir -p $(BIN_DIR)
+
+## build: compile the binary into ./bin
+.PHONY: build
+build: $(BIN_DIR)
+ $(GO) build -o $(BIN_DIR)/$(BINARY) ./cmd/$(BINARY)
+
+## test: run the whole suite, unit and end-to-end
+.PHONY: test
+test:
+ $(GO) test -race -count=1 ./...
+
+## test-e2e: run only the end-to-end adapter tests against the fixture corpus
+.PHONY: test-e2e
+test-e2e:
+ $(GO) test -race -count=1 -v ./tests/...
+
+## coverage: run tests with a coverage profile
+.PHONY: coverage
+coverage:
+ $(GO) test -race -count=1 -coverprofile=coverage.txt -covermode=atomic ./...
+
+## fmt: format the tree
+.PHONY: fmt
+fmt:
+ $(GO) fmt ./...
+
+## vet: run go vet
+.PHONY: vet
+vet:
+ $(GO) vet ./...
+
+$(BIN_DIR)/golangci-lint: $(BIN_DIR)
+ @echo "installing golangci-lint $(GOLANGCI_LINT_VERSION)"
+ @GOBIN=$(CURDIR)/$(BIN_DIR) $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
+
+## lint: run golangci-lint
+.PHONY: lint
+lint: $(BIN_DIR)/golangci-lint
+ $(BIN_DIR)/golangci-lint run ./...
+
+$(BIN_DIR)/license-eye: $(BIN_DIR)
+ @echo "installing license-eye $(LICENSE_EYE_VERSION)"
+ @GOBIN=$(CURDIR)/$(BIN_DIR) $(GO) install github.com/apache/skywalking-eyes/cmd/license-eye@$(LICENSE_EYE_VERSION)
+
+## license-check: verify every source file carries the Apache-2.0 header
+.PHONY: license-check
+license-check: $(BIN_DIR)/license-eye
+ $(BIN_DIR)/license-eye header check
+
+## license-fix: insert missing license headers
+.PHONY: license-fix
+license-fix: $(BIN_DIR)/license-eye
+ $(BIN_DIR)/license-eye header fix
+
+## dep-check: validate the licenses of every dependency
+.PHONY: dep-check
+dep-check: $(BIN_DIR)/license-eye
+ $(BIN_DIR)/license-eye dependency check
+
+## tidy: verify go.mod and go.sum are current
+.PHONY: tidy
+tidy:
+ $(GO) mod tidy
+ @git diff --exit-code go.mod go.sum || (echo "go.mod/go.sum are not tidy; run 'make tidy' and commit"; exit 1)
+
+## check: everything CI runs
+.PHONY: check
+docker: ## Build the container image, as CI builds and publishes it
+ docker build -t skywalking-ai-sessionizer:dev .
+
+check: vet lint license-check dep-check test
+
+## clean: remove build output
+.PHONY: clean
+clean:
+ @rm -rf $(BIN_DIR) coverage.txt
+
+## help: list targets
+.PHONY: help
+help:
+ @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## //' | awk -F': ' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..3da6f81
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,5 @@
+Apache SkyWalking AI Sessionizer
+Copyright 2026 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ad40306
--- /dev/null
+++ b/README.md
@@ -0,0 +1,101 @@
+# SkyWalking AI Sessionizer
+
+**Conversation-level observability, measurement, and export for long-lived AI agents.**
+
+> **Status:** pre-alpha. The conversation model and the Claude Code data mapping are defined and
+> evidence-backed, and Phase 1 collection is implemented. The assembler, export and preview are not.
+
+SkyWalking AI Sessionizer assembles fragmented agent telemetry into one durable conversation structure. It
+preserves sessions as source provenance, keeps parent and child-agent execution lineages separate,
+measures model-message continuity, and projects the same committed snapshot into storage, export and
+a local preview.
+
+## Why
+
+Agent runtimes record a single user-visible conversation as many unrelated artifacts — transcripts,
+traces, logs, provider request bodies, tool events, subagent metadata. That conversation can outlive
+one process, reactivate after a long idle period, and contain several concurrent agent lineages.
+
+Trace-level inspection alone cannot answer: what did the whole conversation do; which input, model
+response, tool result or child-agent result led to the next model call; did that call preserve the
+previous message history or start a new context; which agents contributed; and what should be
+measured, stored and exported as one unit.
+
+## Model
+
+```text
+Conversation durable identity · ownership boundary
+ └ Segment activity window · the COMMIT unit
+ └ Session observed source provenance
+ ├ ExecutionStream main ordered parent-agent lineage
+ │ └ Context epoch × N model-context lifetime
+ │ └ Talk × N one readable input → run → output
+ │ └ Run → Step × N
+ └ ExecutionStream child × N independent context per child agent
+```
+
+Two boundaries carry the design. **Conversation** is the durable aggregation and ownership boundary,
+and its identity is supplied — never inferred from a person, an account, or timestamp proximity.
+**ExecutionStream** is the ordered continuity boundary; model-message continuity is evaluated within
+one stream and one context epoch, never across them.
+
+See the [Unified Conversation Model](docs/en/concepts-and-designs/unified-conversation-model.md).
+
+## Evidence discipline
+
+Nothing is presented as observed unless it was observed.
+
+Every claim carries a qualification (`observed_replayable`, `observed_report_only`, `proposed`,
+`unavailable`) and every correlation carries a resolution state (`exact_unique`, `exact_ambiguous`,
+`strong_inference`, `unresolved`, `conflict`). An exact identifier with several candidates stays
+ambiguous — the assembler never silently chooses one. Where a runtime cannot supply something, the
+adapter reports it as `unavailable` rather than approximating it.
+
+## Adapters
+
+| Runtime | Status | Collection |
+| --- | --- | --- |
+| [Claude Code](docs/en/adapters/claude-code.md) | collection implemented | local files — no configuration required, and it works on history that already exists |
+| Codex | planned | — |
+| LangChain / LangGraph | planned | — |
+
+## Quick start
+
+```sh
+make build # builds ./bin/asz
+./bin/asz sources # list discovered sessions and their sources
+./bin/asz collect -once # land everything currently on disk
+```
+
+## Documentation
+
+Official documentation lives in [`docs/`](docs/) and is indexed by
+[`docs/menu.yml`](docs/menu.yml).
+
+[`design-notes/`](design-notes/) holds working engineering notes — measurements, corrections and open
+questions produced while designing against real runtime data. They are deliberately unpolished and
+are **not** part of the published documentation.
+
+## Contributing
+
+Early contributions should focus on schemas, privacy-safe fixtures, deterministic assembly,
+qualification rules and golden tests. Please avoid adding inferred identities or causal edges that
+cannot retain their source evidence and resolution state.
+
+## License
+
+[Apache License 2.0](LICENSE).
+
+## Docker
+
+CI publishes a multi-platform image to GHCR on every push to `main` and on
+every `v*` tag. It carries the `asz` binary only. Mount a storage root at
+`/asz/data`; by default the container serves the page on port 8787.
+
+```sh
+docker run --rm -p 8787:8787 -v "$PWD/data:/asz/data" \
+ ghcr.io/apache/skywalking-ai-sessionizer:latest
+```
+
+Any `asz` command runs the same way: put it after the image name.
+`make docker` builds the image locally.
diff --git a/cmd/asz/main.go b/cmd/asz/main.go
new file mode 100644
index 0000000..ae9820b
--- /dev/null
+++ b/cmd/asz/main.go
@@ -0,0 +1,646 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Command asz is the SkyWalking AI Sessionizer command-line interface.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+ "github.com/apache/skywalking-ai-sessionizer/internal/config"
+ "github.com/apache/skywalking-ai-sessionizer/internal/index"
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+ "github.com/apache/skywalking-ai-sessionizer/internal/verify"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessionflow"
+)
+
+const usage = `asz - SkyWalking AI Sessionizer: conversation-level observability for long-lived AI agents
+
+Usage:
+ asz sources [-config FILE] list discovered sessions and their sources
+ asz collect [-config FILE] [-once] land new source data into the storage root
+ asz index [-config FILE] [SESSION] report what the derived index holds
+ asz show [-config FILE] SESSION ID resolve a record id or tool-use id to its payload
+ asz parse [-config FILE] [SESSION] assemble conversation structure into a round chain
+ asz conversation [-config FILE] ID fold a conversation's rounds and show the structure
+ asz view [-config FILE] [ADDR] serve the conversations as a page (default 127.0.0.1:8787)
+ asz glossary what the runtime calls the things the model names
+ asz verify [-config FILE] [SESSION] check landed data and round chains are intact
+
+Flags:
+ -config FILE configuration file (default: built-in defaults)
+ -once single pass, then exit (overrides collector.mode)
+ -terms MODE name things as "unified" (default), "native", or "both"
+`
+
+// positional holds the command's non-flag arguments, filled once flags parse.
+var positional []string
+
+// termMode selects whose words to print: the model's, the runtime's, or both.
+var termMode string
+
+// arg returns the nth positional argument, or "" when there is none.
+func arg(n int) string {
+ if n < len(positional) {
+ return positional[n]
+ }
+ return ""
+}
+
+func main() {
+ if len(os.Args) < 2 {
+ fmt.Fprint(os.Stderr, usage)
+ os.Exit(2)
+ }
+ cmd := os.Args[1]
+ fs := flag.NewFlagSet(cmd, flag.ExitOnError)
+ cfgPath := fs.String("config", "", "configuration file")
+ once := fs.Bool("once", false, "single pass, then exit")
+ terms := fs.String("terms", "unified", "name things in the `mode`: unified, native, or both")
+ _ = fs.Parse(os.Args[2:])
+ // Take the positional arguments from the flag set, not by scanning argv for
+ // anything without a leading dash. A flag's VALUE has no leading dash either,
+ // so scanning reads "-config asz.yaml" as the positional "asz.yaml".
+ positional = fs.Args()
+ termMode = *terms
+
+ cfg, err := config.Load(*cfgPath)
+ if err != nil {
+ fatal(err)
+ }
+
+ var run func(*config.Config, config.Adapter, bool) error
+ switch cmd {
+ case "sources":
+ run = cmdSources
+ case "collect":
+ run = cmdCollect
+ case "index":
+ run = cmdIndex
+ case "show":
+ run = cmdShow
+ case "parse":
+ run = cmdParse
+ case "conversation":
+ run = cmdConversation
+ case "glossary":
+ run = cmdGlossary
+ case "view":
+ run = cmdView
+ case "verify":
+ run = cmdVerify
+ default:
+ fmt.Fprint(os.Stderr, usage)
+ os.Exit(2)
+ }
+
+ for _, ad := range cfg.Adapters {
+ if !ad.Enabled {
+ continue
+ }
+ if ad.Name != config.AdapterClaudeCodeLocal {
+ fmt.Fprintf(os.Stderr, "skipping unknown adapter %q\n", ad.Name)
+ continue
+ }
+ if err := run(cfg, ad, *once); err != nil {
+ fatal(err)
+ }
+ }
+}
+
+func cmdSources(_ *config.Config, ad config.Adapter, _ bool) error {
+ root, err := claudecode.ResolveSourceRoot(ad.SourceRoot)
+ if err != nil {
+ return err
+ }
+ all, err := claudecode.Discover(root)
+ if err != nil {
+ return err
+ }
+ m := claudecode.NewMatcher(ad.Include, ad.Exclude)
+ var sessions []claudecode.Session
+ for _, s := range all {
+ if m.Match(s) {
+ sessions = append(sessions, s)
+ }
+ }
+ fmt.Printf("source root: %s\n", root)
+ if n := len(all) - len(sessions); n > 0 {
+ fmt.Printf("filtered : %d session(s) excluded by config\n", n)
+ }
+ fmt.Println()
+
+ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(tw, "SESSION\tDIRS\tSTREAMS\tMETA\tJOURNAL\tMANIFEST")
+ var totalStreams int
+ for _, s := range sessions {
+ var streams, metas, journals, manifests int
+ for _, src := range s.Sources {
+ switch src.Kind {
+ case claudecode.SrcMainTranscript, claudecode.SrcAgentTranscript:
+ streams++
+ case claudecode.SrcAgentMeta:
+ metas++
+ case claudecode.SrcJournal:
+ journals++
+ case claudecode.SrcWorkflowManifest:
+ manifests++
+ }
+ }
+ totalStreams += streams
+ fmt.Fprintf(tw, "%s\t%d\t%d\t%d\t%d\t%d\n", s.ID, len(s.Dirs), streams, metas, journals, manifests)
+ }
+ if err := tw.Flush(); err != nil {
+ return err
+ }
+ fmt.Printf("\n%d sessions, %d streams\n", len(sessions), totalStreams)
+
+ // A session spread over several source directories is normal - a child
+ // agent running in a different working directory is filed under that
+ // directory instead. Surfacing it makes the session-first grouping visible.
+ var multi []string
+ for _, s := range sessions {
+ if len(s.Dirs) > 1 {
+ multi = append(multi, fmt.Sprintf(" %s (%d dirs)", s.ID, len(s.Dirs)))
+ }
+ }
+ if len(multi) > 0 {
+ sort.Strings(multi)
+ fmt.Printf("\n%d session(s) span more than one source directory:\n", len(multi))
+ for _, m := range multi {
+ fmt.Println(m)
+ }
+ }
+ return nil
+}
+
+// cmdIndex reports what the derived index holds, which is the surface the
+// assembler resolves structure against.
+func cmdIndex(cfg *config.Config, _ config.Adapter, _ bool) error {
+ zoneRoot, err := cfg.ResolvedRoot()
+ if err != nil {
+ return err
+ }
+ z := storage.NewZone(zoneRoot)
+
+ want := arg(0)
+
+ sessions, err := os.ReadDir(zoneRoot)
+ if err != nil {
+ return err
+ }
+ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(tw, "SESSION\tENTRIES\tBLOCKS\tSTREAMS\tSTRINGS\tINDEXED_SEQ")
+ var totE, totB int
+ var detail *index.Index
+ var detailID string
+ for _, d := range sessions {
+ if !d.IsDir() {
+ continue
+ }
+ id := d.Name()
+ if want != "" && id != want {
+ continue
+ }
+ ix, ok, err := index.Load(z.IndexDir(id), id)
+ if err != nil || !ok {
+ continue
+ }
+ stt, _ := storage.LoadIndexState(z.IndexStatePath(id), id)
+ fmt.Fprintf(tw, "%s\t%d\t%d\t%d\t%d\t%d\n", id, len(ix.Entries), len(ix.Blocks),
+ len(ix.Streams()), ix.Strings.Len(), stt.IndexedSeq)
+ totE += len(ix.Entries)
+ totB += len(ix.Blocks)
+ if want != "" {
+ detail, detailID = ix, id
+ }
+ }
+ if err := tw.Flush(); err != nil {
+ return err
+ }
+ fmt.Printf("\ntotal: %d entries, %d joinable blocks\n", totE, totB)
+
+ if detail != nil {
+ printIndexDetail(detail, detailID)
+ }
+ return nil
+}
+
+func printIndexDetail(ix *index.Index, id string) {
+ ix.Build()
+ names := map[index.Kind]string{
+ index.KindUser: "user", index.KindAssistant: "assistant",
+ index.KindAttachment: "attachment", index.KindSystem: "system",
+ index.KindMeta: "agent_meta", index.KindJournal: "journal",
+ index.KindManifest: "manifest", index.KindScript: "script",
+ index.KindOther: "other", index.KindUnknown: "unknown",
+ }
+ byKind := map[index.Kind]int{}
+ msgs := map[uint32]int{}
+ var withCall, withRecord, withRun int
+ for i := range ix.Entries {
+ e := &ix.Entries[i]
+ byKind[e.Kind]++
+ if e.Record != 0 {
+ withRecord++
+ }
+ if e.Call != 0 {
+ withCall++
+ msgs[e.Call]++
+ }
+ if e.Run != 0 {
+ withRun++
+ }
+ }
+ fmt.Printf("\n%s\n\nrecord kinds:\n", id)
+ type kv struct {
+ k index.Kind
+ n int
+ }
+ var ks []kv
+ for k, n := range byKind {
+ ks = append(ks, kv{k, n})
+ }
+ sort.Slice(ks, func(a, b int) bool { return ks[a].n > ks[b].n })
+ for _, x := range ks {
+ fmt.Printf(" %-12s %8d\n", names[x.k], x.n)
+ }
+
+ var tu, tr int
+ toolNames := map[uint32]int{}
+ for i := range ix.Blocks {
+ if b := &ix.Blocks[i]; b.Kind == index.BlockToolUse {
+ tu++
+ toolNames[b.Name]++
+ } else if b.Kind == index.BlockToolResult {
+ tr++
+ }
+ }
+ fmt.Printf("\njoinable blocks:\n tool_use %8d\n tool_result %8d\n", tu, tr)
+ if len(msgs) > 0 {
+ fmt.Printf("\nprovider calls: %d distinct message ids over %d fragment records (mean %.2f)\n",
+ len(msgs), withCall, float64(withCall)/float64(len(msgs)))
+ }
+ fmt.Printf("\nidentity coverage: uuid=%d run=%d of %d entries\n",
+ withRecord, withRun, len(ix.Entries))
+
+ type tn struct {
+ n int
+ name string
+ }
+ var tns []tn
+ for tid, n := range toolNames {
+ tns = append(tns, tn{n, ix.Strings.String(tid)})
+ }
+ sort.Slice(tns, func(a, b int) bool { return tns[a].n > tns[b].n })
+ fmt.Println("\ntop tools:")
+ for i, t := range tns {
+ if i >= 8 {
+ break
+ }
+ fmt.Printf(" %-14s %7d\n", t.name, t.n)
+ }
+
+ // A sample of entries, showing that one index spans every stream and every
+ // landed sequence in the session.
+ fmt.Println("\nsample entries (one index, all streams, all rounds):")
+ et := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(et, " SEQ\tROW\tKIND\tSTREAM\tRECORD\tCALL")
+ shown, perStream := 0, map[uint32]int{}
+ for i := range ix.Entries {
+ e := &ix.Entries[i]
+ if perStream[e.Stream] >= 2 || shown >= 12 {
+ continue
+ }
+ perStream[e.Stream]++
+ shown++
+ fmt.Fprintf(et, " %d\t%d\t%s\t%s\t%s\t%s\n",
+ e.Seq, e.Row, names[e.Kind],
+ trunc(ix.Strings.String(e.Stream), 18),
+ trunc(ix.Strings.String(e.Record), 13),
+ trunc(ix.Strings.String(e.Call), 16))
+ }
+ _ = et.Flush()
+}
+
+func trunc(s string, n int) string {
+ if s == "" {
+ return "-"
+ }
+ if len(s) <= n {
+ return s
+ }
+ return s[:n-1] + "…"
+}
+
+// cmdShow resolves an identifier through the index to the landed record it
+// names, and prints the payload.
+//
+// This is the same path materialisation takes, on one record: the index answers WHERE a
+// record is - which landed file, which line - and only then are the bytes read.
+// cmdVerify checks that landed data is contiguous and intact, using only the
+// landed files - the source is usually gone by the time anyone asks.
+func cmdVerify(cfg *config.Config, _ config.Adapter, _ bool) error {
+ zoneRoot, err := cfg.ResolvedRoot()
+ if err != nil {
+ return err
+ }
+ z := storage.NewZone(zoneRoot)
+
+ want := arg(0)
+ entries, err := os.ReadDir(zoneRoot)
+ if err != nil {
+ return err
+ }
+
+ var sessions, streams, records, problems, relanded int
+ for _, d := range entries {
+ if !d.IsDir() || (want != "" && d.Name() != want) {
+ continue
+ }
+ rep, err := verify.Session(z, d.Name())
+ if err != nil {
+ return err
+ }
+ if len(rep.Streams) == 0 {
+ continue
+ }
+ sessions++
+ streams += len(rep.Streams)
+ records += rep.Records
+ problems += rep.Problems
+ relanded += rep.Relanded
+ if !rep.OK() {
+ fmt.Printf("%s: %d problem(s)\n", rep.Session, rep.Problems)
+ for _, line := range rep.Details() {
+ fmt.Printf(" %s\n", line)
+ }
+ }
+ }
+
+ fmt.Printf("checked %d session(s), %d stream(s), %d records\n", sessions, streams, records)
+ if relanded > 0 {
+ // Expected after an interrupted pass, not a fault: data is landed before
+ // the cursor is committed, so a crash repeats rather than loses.
+ fmt.Printf("%d record(s) landed more than once by an interrupted pass; assembly removes them\n", relanded)
+ }
+
+ // A conversation's rounds are checked too. They are a digest chain, so a
+ // missing or edited round is detectable in a way landed files are not, and
+ // the check costs one pass over a directory that is far smaller than the data.
+ chains, rounds, err := verifyChains(zoneRoot, want)
+ if err != nil {
+ return err
+ }
+ if chains > 0 {
+ fmt.Printf("checked %d conversation chain(s), %d round(s)\n", chains, rounds)
+ }
+
+ if problems > 0 {
+ return fmt.Errorf("%d contiguity or integrity problem(s)", problems)
+ }
+ fmt.Println("all landed data is contiguous and matches its digests")
+ return nil
+}
+
+// verifyChains walks every conversation chain in the zone.
+func verifyChains(root, want string) (chains, rounds int, err error) {
+ base := filepath.Join(root, "_conversations")
+ items, rerr := os.ReadDir(base)
+ if rerr != nil {
+ if os.IsNotExist(rerr) {
+ return 0, 0, nil
+ }
+ return 0, 0, rerr
+ }
+ for _, d := range items {
+ if !d.IsDir() || (want != "" && d.Name() != want) {
+ continue
+ }
+ files, verr := sessionflow.OpenChain(root, d.Name()).Verify()
+ if verr != nil {
+ return chains, rounds, verr
+ }
+ chains++
+ rounds += len(files)
+ }
+ return chains, rounds, nil
+}
+
+func cmdShow(cfg *config.Config, _ config.Adapter, _ bool) error {
+ zoneRoot, err := cfg.ResolvedRoot()
+ if err != nil {
+ return err
+ }
+ z := storage.NewZone(zoneRoot)
+
+ session, want := arg(0), arg(1)
+ if session == "" || want == "" {
+ return fmt.Errorf("usage: asz show SESSION ID (a record uuid or a tool_use id)")
+ }
+
+ ix, ok, err := index.Load(z.IndexDir(session), session)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return fmt.Errorf("no index for session %s", session)
+ }
+
+ // A tool-use id resolves to every block carrying it - normally the call and
+ // its result, which is the join in its most direct form.
+ if blocks := ix.ToolBlocks(want); len(blocks) > 0 {
+ fmt.Printf("tool-use id %s resolves to %d block(s)\n\n", want, len(blocks))
+ for _, b := range blocks {
+ e := &ix.Entries[b.Entry]
+ kind := "tool_use"
+ if b.Kind == index.BlockToolResult {
+ kind = "tool_result"
+ }
+ fmt.Printf("── %s block %d of record stream=%s\n", kind, b.Ord, ix.Strings.String(e.Stream))
+ if err := printEntry(z, ix, session, e); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+
+ e, found := ix.EntryByRecord(want)
+ if !found {
+ return fmt.Errorf("id %q not found in the index for %s", want, session)
+ }
+ return printEntry(z, ix, session, e)
+}
+
+// printEntry locates one indexed record on disk and prints it.
+func printEntry(z *storage.Zone, ix *index.Index, session string, e *index.Entry) error {
+ stream := ix.Strings.String(e.Stream)
+ run := ix.Strings.String(e.Run)
+ dir := z.StreamDir(session, stream)
+ if run != "" {
+ dir = z.RunDir(session, run)
+ }
+
+ // seq selects the landed file; row selects the line within it.
+ path, err := landedPathForSeq(dir, e.Seq)
+ if err != nil {
+ return err
+ }
+ fmt.Printf(" index : seq=%d row=%d\n", e.Seq, e.Row)
+ fmt.Printf(" file : %s\n", strings.TrimPrefix(path, z.Root()+"/"))
+
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ r, err := sessiondata.NewReader(f)
+ if err != nil {
+ return err
+ }
+ for row := uint32(1); ; row++ {
+ rec, rerr := r.Next()
+ if rerr != nil {
+ return fmt.Errorf("row %d not found in %s", e.Row, path)
+ }
+ if row != e.Row {
+ continue
+ }
+ fmt.Printf(" source: ord=%d off=%d sha=%s bytes=%d (where it came from)\n",
+ rec.Ord, rec.Off, rec.Sha, rec.Bytes)
+ fmt.Printf(" from : %s", rec.From)
+ if rec.Trigger != "" {
+ fmt.Printf(" trigger: %s", rec.Trigger)
+ }
+ if len(rec.Flags) > 0 {
+ fmt.Printf(" flags: %s", strings.Join(rec.Flags, ","))
+ }
+ fmt.Println()
+ for i, p := range rec.Parts {
+ fmt.Printf(" part %d %-9s", i, p.Kind)
+ switch {
+ case p.Name != "":
+ fmt.Printf("%s", p.Name)
+ case p.Of != "":
+ fmt.Printf("of %s", trunc(p.Of, 24))
+ }
+ fmt.Printf(" %s, %d bytes\n", p.State, p.Bytes)
+ if p.Text != "" {
+ fmt.Printf(" %s\n", trunc(strings.ReplaceAll(p.Text, "\n", " "), 300))
+ }
+ if len(p.Data) > 0 {
+ fmt.Printf(" %s\n", trunc(string(p.Data), 300))
+ }
+ }
+ for _, d := range rec.Dropped {
+ fmt.Printf(" dropped %s, %d bytes (%s)\n", d.What, d.Bytes, d.Why)
+ }
+ fmt.Println()
+ return nil
+ }
+}
+
+// landedPathForSeq finds the landed file carrying a sequence.
+func landedPathForSeq(dir string, seq uint32) (string, error) {
+ items, err := os.ReadDir(dir)
+ if err != nil {
+ return "", err
+ }
+ suffix := fmt.Sprintf("-%06d.jsonl", seq)
+ for _, it := range items {
+ if strings.HasSuffix(it.Name(), suffix) {
+ return filepath.Join(dir, it.Name()), nil
+ }
+ }
+ return "", fmt.Errorf("no landed file with seq %d in %s", seq, dir)
+}
+
+func cmdCollect(cfg *config.Config, ad config.Adapter, once bool) error {
+ root, err := claudecode.ResolveSourceRoot(ad.SourceRoot)
+ if err != nil {
+ return err
+ }
+ zoneRoot, err := cfg.ResolvedRoot()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(zoneRoot, 0o755); err != nil {
+ return err
+ }
+ col := claudecode.New(root, storage.NewZone(zoneRoot), ad.Collector.MaxDeltaBytes)
+ match := claudecode.NewMatcher(ad.Include, ad.Exclude).Match
+
+ fmt.Printf("source root : %s\nstorage root: %s\n", root, zoneRoot)
+
+ pass := func() error {
+ start := time.Now()
+ st, err := col.CollectAll(match)
+ if err != nil {
+ return err
+ }
+ fmt.Printf("[%s] sessions=%d sources=%d landed=%d records=%d bytes=%s indexed=%d gone=%d conflicts=%d busy=%d pending=%d errors=%d (%s)\n",
+ time.Now().Format("15:04:05"), st.Sessions, st.SourcesSeen, st.SourcesLanded,
+ st.Records, humanBytes(st.Bytes), st.Indexed, st.SourcesGone, st.Conflicts,
+ st.Busy, st.Pending, len(st.Errors), time.Since(start).Round(time.Millisecond))
+ for _, e := range st.Errors {
+ fmt.Fprintf(os.Stderr, " error: %v\n", e)
+ }
+ if !st.Complete() {
+ // A pass that under-collected must not look like a clean one.
+ return fmt.Errorf("pass incomplete: %d source(s) still pending, %d error(s)",
+ st.Pending, len(st.Errors))
+ }
+ return nil
+ }
+
+ if once || ad.Collector.Mode == config.ModeOnce {
+ return pass()
+ }
+ for {
+ if err := pass(); err != nil {
+ return err
+ }
+ time.Sleep(ad.Collector.Interval)
+ }
+}
+
+func humanBytes(n int64) string {
+ const unit = 1024
+ if n < unit {
+ return fmt.Sprintf("%dB", n)
+ }
+ div, exp := int64(unit), 0
+ for m := n / unit; m >= unit; m /= unit {
+ div *= unit
+ exp++
+ }
+ return fmt.Sprintf("%.1f%cB", float64(n)/float64(div), "KMGTPE"[exp])
+}
+
+func fatal(err error) {
+ fmt.Fprintf(os.Stderr, "%s: %v\n", filepath.Base(os.Args[0]), err)
+ os.Exit(1)
+}
diff --git a/cmd/asz/parse.go b/cmd/asz/parse.go
new file mode 100644
index 0000000..1a57db3
--- /dev/null
+++ b/cmd/asz/parse.go
@@ -0,0 +1,334 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+ "text/tabwriter"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+ "github.com/apache/skywalking-ai-sessionizer/internal/config"
+ "github.com/apache/skywalking-ai-sessionizer/internal/parse"
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/model"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessionflow"
+)
+
+// cmdParse turns landed records into conversation structure and appends one
+// round to each conversation's chain.
+//
+// The conversation id defaults to the session id, which is the adapter's
+// documented mapping: nothing on disk reliably links two Claude Code sessions.
+// An application that considers several sessions one piece of work supplies its
+// own id instead.
+func cmdParse(cfg *config.Config, _ config.Adapter, _ bool) error {
+ zoneRoot, err := cfg.ResolvedRoot()
+ if err != nil {
+ return err
+ }
+ z := storage.NewZone(zoneRoot)
+ want := arg(0)
+
+ sessions, err := sessionDirs(zoneRoot)
+ if err != nil {
+ return err
+ }
+
+ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(tw, "SESSION\tROUND\tSEQ\tNODES\tRELS\tUNRES\tTALKS\tRUNS\tSTEPS\tTOOLS\tCHILDREN")
+ var rounds, failed int
+ for _, id := range sessions {
+ if want != "" && id != want {
+ continue
+ }
+ r, err := parse.Session(z, parse.Options{
+ Conversation: id, Session: id,
+ // Landed files name the adapter that wrote them, so an index can be
+ // rebuilt from them alone - no source files, no collector.
+ Reindex: claudecode.RebuildIndex,
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, " %s: %v\n", id, err)
+ failed++
+ continue
+ }
+ st := r.Stats
+ round := "-"
+ if r.Changed() {
+ round = fmt.Sprintf("%d", r.Number)
+ rounds++
+ }
+ fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d/%d\t%d/%d\n",
+ id, round, r.ThroughSeq, r.Nodes, r.Relations, r.Unresolved,
+ st.Talks, st.Runs, st.Steps,
+ st.ToolsResolved, st.ToolUses, st.SpawnsResolved, st.Spawns)
+ }
+ if err := tw.Flush(); err != nil {
+ return err
+ }
+ fmt.Printf("\n%d round(s) written\n", rounds)
+ if failed > 0 {
+ // A pass that could not parse some sessions must not look like a clean
+ // one. A watch loop or a CI step reads the exit status, not the log.
+ return fmt.Errorf("%d session(s) failed to parse", failed)
+ }
+ return nil
+}
+
+// termsFor returns how to render a name, from the -terms flag.
+//
+// A reader who knows their own agent product recognises its words; a reader
+// comparing two products needs one vocabulary. Neither is the right default for
+// both, so the choice is theirs and nothing is lost either way.
+func termsFor(mode string) func(string) string {
+ g := claudecode.Glossary()
+ switch mode {
+ case "native":
+ return g.Native
+ case "both":
+ return func(u string) string {
+ if n := g.Native(u); n != u {
+ return u + " (" + n + ")"
+ }
+ return u
+ }
+ }
+ return func(u string) string { return u }
+}
+
+// cmdGlossary prints what a runtime calls the things the model names.
+func cmdGlossary(_ *config.Config, _ config.Adapter, _ bool) error {
+ g := claudecode.Glossary()
+ fmt.Printf("dialect %s\n\n", g.Dialect)
+ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(tw, "MODEL\tRUNTIME\tWHERE\tNOTE")
+ var derived int
+ for _, t := range g.Terms() {
+ native, where := t.Native, t.Where
+ if t.Derived() {
+ derived++
+ native, where = "—", "derived here"
+ }
+ fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", t.Unified, native, where, t.Note)
+ }
+ if err := tw.Flush(); err != nil {
+ return err
+ }
+ terms := g.Terms()
+ fmt.Printf("\n%d terms: %d named by the runtime, %d derived by this project\n",
+ len(terms), len(terms)-derived, derived)
+ return nil
+}
+
+// cmdConversation folds a conversation's round chain and prints the structure.
+//
+// The fold is the whole definition of the conversation as of the latest round.
+// There is no other state, which is why this reads the chain rather than any
+// cached copy of it.
+func cmdConversation(cfg *config.Config, _ config.Adapter, _ bool) error {
+ zoneRoot, err := cfg.ResolvedRoot()
+ if err != nil {
+ return err
+ }
+ id := arg(0)
+ if id == "" {
+ return fmt.Errorf("usage: asz conversation CONVERSATION")
+ }
+
+ chain := sessionflow.OpenChain(zoneRoot, id)
+ files, err := chain.Verify()
+ if err != nil {
+ return err
+ }
+ if len(files) == 0 {
+ return fmt.Errorf("no rounds for conversation %s; run asz parse first", id)
+ }
+ v, err := chain.Fold()
+ if err != nil {
+ return err
+ }
+
+ fmt.Printf("conversation %s\n", id)
+ fmt.Printf(" rounds %d, head %s\n", v.Round, trunc(v.Digest, 13))
+ fmt.Printf(" landed through seq %d\n", v.ThroughSeq)
+ fmt.Printf(" entities %d nodes, %d relations, %d unresolved (%d still open)\n\n",
+ len(v.Nodes), len(v.Relations), len(v.Unresolved), len(v.OpenUnresolved()))
+
+ term := termsFor(termMode)
+ byKind := map[string]int{}
+ for _, n := range v.Nodes {
+ byKind[n.Kind]++
+ }
+ type kv struct {
+ k string
+ n int
+ }
+ var ks []kv
+ for k, n := range byKind {
+ ks = append(ks, kv{k, n})
+ }
+ sort.Slice(ks, func(a, b int) bool {
+ if ks[a].n != ks[b].n {
+ return ks[a].n > ks[b].n
+ }
+ return ks[a].k < ks[b].k
+ })
+ fmt.Println("node kinds:")
+ for _, x := range ks {
+ fmt.Printf(" %-40s %6d\n", term(x.k), x.n)
+ }
+
+ byRel := map[string]int{}
+ quality := map[string]int{}
+ for _, r := range v.Relations {
+ byRel[r.Type]++
+ quality[r.Quality]++
+ }
+ if len(byRel) > 0 {
+ fmt.Println("\nrelations:")
+ for _, t := range sortedKeys(byRel) {
+ fmt.Printf(" %-40s %6d\n", term(t), byRel[t])
+ }
+ fmt.Println("\ncorrelation quality:")
+ for _, q := range sortedKeys(quality) {
+ fmt.Printf(" %-40s %6d\n", term(q), quality[q])
+ }
+ }
+
+ if open := v.OpenUnresolved(); len(open) > 0 {
+ fmt.Printf("\nstill unresolved (%d):\n", len(open))
+ for i, u := range open {
+ if i >= 12 {
+ fmt.Printf(" … and %d more\n", len(open)-i)
+ break
+ }
+ fmt.Printf(" %-26s %s\n", u.Kind, u.Reason)
+ }
+ }
+
+ printTree(v, term)
+ return nil
+}
+
+// printTree renders the containment tree, which carries direct ownership only.
+//
+// Cross-stream flow does not appear here. A child agent's work stays under the
+// child's own stream and the parent holds a reference to it, so nothing is
+// shown twice.
+func printTree(v *sessionflow.View, term func(string) string) {
+ fmt.Println("\nstructure (containment only; cross-stream flow is a relation):")
+
+ // Breadth is capped per level rather than by a running total, so a session
+ // with hundreds of children still shows what is under the first of them.
+ const breadth = 6
+ var walk func(id string, depth int)
+ walk = func(id string, depth int) {
+ if depth > 6 {
+ return
+ }
+ children := v.Children(id)
+ sort.SliceStable(children, func(i, j int) bool {
+ return treeRank(children[i].Kind) < treeRank(children[j].Kind)
+ })
+ shown := 0
+ for _, c := range children {
+ if shown == breadth {
+ fmt.Printf(" %s… %d more\n", strings.Repeat(" ", depth), len(children)-shown)
+ break
+ }
+ shown++
+ label := term(c.Kind)
+ if name := shortID(c.ID); name != "" {
+ label += " " + name
+ }
+ fmt.Printf(" %s%s\n", strings.Repeat(" ", depth), label)
+ walk(c.ID, depth+1)
+ }
+ }
+
+ var roots []string
+ for id, n := range v.Nodes {
+ if n.Parent == "" || v.Nodes[n.Parent] == nil {
+ roots = append(roots, id)
+ }
+ }
+ sort.Strings(roots)
+ for _, id := range roots {
+ fmt.Printf(" %s %s\n", term(v.Nodes[id].Kind), shortID(id))
+ walk(id, 1)
+ }
+}
+
+// treeRank orders a node's children so the spine is shown before the leaves.
+func treeRank(kind string) int {
+ switch kind {
+ case model.KindStream:
+ return 0
+ case model.KindEpoch:
+ return 1
+ case model.KindTalk:
+ return 2
+ case model.KindRun:
+ return 3
+ case model.KindLLMCall:
+ return 4
+ case model.KindSegment:
+ return 9
+ }
+ return 5
+}
+
+func shortID(id string) string {
+ if i := strings.LastIndex(id, "/"); i >= 0 {
+ id = id[i+1:]
+ }
+ if len(id) > 22 {
+ return id[:21] + "…"
+ }
+ return id
+}
+
+func sortedKeys(m map[string]int) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// sessionDirs lists the sessions present in the storage root.
+func sessionDirs(root string) ([]string, error) {
+ items, err := os.ReadDir(root)
+ if err != nil {
+ return nil, err
+ }
+ var out []string
+ for _, d := range items {
+ // A conversation's rounds live beside the sessions under a reserved name,
+ // so it must not be mistaken for one.
+ if d.IsDir() && !strings.HasPrefix(d.Name(), "_") {
+ out = append(out, d.Name())
+ }
+ }
+ sort.Strings(out)
+ return out, nil
+}
diff --git a/cmd/asz/view.go b/cmd/asz/view.go
new file mode 100644
index 0000000..0a3ed5e
--- /dev/null
+++ b/cmd/asz/view.go
@@ -0,0 +1,63 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package main
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/config"
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+ "github.com/apache/skywalking-ai-sessionizer/internal/view"
+)
+
+// cmdView serves the conversations in the storage root.
+//
+// It reads and never writes: the chain, the landed records and the index are
+// all opened read-only, so a viewer can run beside a collector without taking
+// the lock that would block it.
+func cmdView(cfg *config.Config, _ config.Adapter, _ bool) error {
+ zoneRoot, err := cfg.ResolvedRoot()
+ if err != nil {
+ return err
+ }
+ addr := arg(0)
+ if addr == "" {
+ addr = "127.0.0.1:8787"
+ }
+ srv := view.New(storage.NewZone(zoneRoot))
+ ids, err := srv.List()
+ if err != nil {
+ return err
+ }
+ if len(ids) == 0 {
+ return fmt.Errorf("no conversations in %s; run asz parse first", zoneRoot)
+ }
+
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ return err
+ }
+ fmt.Printf("reading : %s\n", zoneRoot)
+ fmt.Printf("serving : %d conversation(s)\n", len(ids))
+ fmt.Printf("\n http://%s\n\n", ln.Addr())
+ fmt.Fprintln(os.Stderr, "ctrl-c to stop")
+ return http.Serve(ln, srv.Handler())
+}
diff --git a/design-notes/01-collection-and-raw-storage.md b/design-notes/01-collection-and-raw-storage.md
new file mode 100644
index 0000000..2036154
--- /dev/null
+++ b/design-notes/01-collection-and-raw-storage.md
@@ -0,0 +1,1322 @@
+# Design Plan 01 — Claude Code Collection and Raw Storage
+
+**Status:** draft for discussion. Not implemented.
+**Scope:** how raw source data is acquired and stored, per session. The per-session processing
+pipeline is Plan 02; conversation output (bundle/export/preview) is Plan 03. Neither is settled
+here, and nothing in this document should be read as fixing them.
+
+**Collection phasing (decided):**
+
+| | Channel | Posture | Delivers |
+| --- | --- | --- | --- |
+| **Phase 1** | local logs — `~/.claude/projects/**` | pull (file tailing) | the whole conversation: structure, content, lineage, epochs. No configuration, and it works on history that already exists. |
+| **Phase 2** | OTLP | push (receiver) | measurement only: per-call `duration_ms` and `cost_usd`, tool durations, permission decisions. Requires user configuration and captures only from the moment it is enabled. |
+
+Phase 1 is self-sufficient: it is the product. Phase 2 is additive and changes nothing in the Phase 1
+layout — `log-` and `trace-` are already reserved prefixes (§2.2). Raw provider bodies
+(`OTEL_LOG_RAW_API_BODIES`) are a separate opt-in, orthogonal to both phases, and are the only route
+to a verified `input_manifest` (§1.8, §1.13).
+
+**Relationship to `AgentSessionizer_Design_v0.8`:** v0.8 is the direction, not a specification.
+Where this plan contradicts it, the observed evidence wins and the contradiction is deliberate.
+
+**Evidence basis:** a live corpus of ~2,920 JSONL files (~1.2 GB) across 38–43 project directories —
+52–61 main transcripts, ~107 direct subagent transcripts, ~2,580 workflow-agent transcripts, 176
+workflow journals — plus the 2.1.245 binary, plus live runs. Every measured claim was produced by
+one agent and then re-measured by an independent adversarial verifier on a larger sample; where they
+disagreed, the verifier's number is used and the disagreement is noted.
+
+**Version coverage caveat (applies to everything below):** the corpus spans **only Claude Code
+2.1.220 – 2.1.251** (14 versions). No 2.0.x transcript exists on this machine. Every claim is
+therefore *untested* below 2.1.220 rather than verified stable.
+
+**Corpus liveness caveat:** the corpus grew during measurement (main transcripts went from 60 to 61
+inside one 20-minute run). Absolute counts are snapshots. A collector must tolerate files appearing
+and growing mid-scan.
+
+---
+
+## 1. Evidence base — what Claude Code actually exposes
+
+### 1.1 File layout
+
+```
+~/.claude/projects/<slugified-cwd>/<session-id>.jsonl main stream
+~/.claude/projects/<slugified-cwd>/<session-id>/subagents/agent-<agent-id>.jsonl
+~/.claude/projects/<slugified-cwd>/<session-id>/subagents/agent-<agent-id>.meta.json
+~/.claude/projects/<slugified-cwd>/<session-id>/subagents/workflows/<wf-id>/agent-<agent-id>.jsonl
+~/.claude/projects/<slugified-cwd>/<session-id>/subagents/workflows/<wf-id>/journal.jsonl
+~/.claude/projects/<slugified-cwd>/<session-id>/workflows/wf_<run-id>.json run manifests
+~/.claude/projects/<slugified-cwd>/<session-id>/workflows/scripts/
+```
+
+Note workflow agents live under `subagents/workflows/`, **not** under `<session-id>/workflows/` —
+that sibling directory holds run manifests and scripts only.
+
+`agent-*.jsonl` ↔ `agent-*.meta.json` is a perfect 1:1 across all ~2,686 pairs, zero orphans in
+either direction.
+
+### 1.2 Record model — four corrections to the obvious reading
+
+**A line is not always one content block.** 156 records corpus-wide carry a multi-element
+`message.content` array on one line — 11 assistant and **145 user**, including 37 of shape
+`[image, text]` where `content[0]` is a base64 PNG. Reading `.message.content[0]` renders a base64
+image blob as the user's prompt, and silently drops `tool_use` blocks on the assistant side —
+breaking the tool join. **Always iterate the full array.** Not version-specific; observed in
+2.1.220, .221, .223, .227, .237.
+
+**Fragments of one response are not always consecutive.** A `user`/`tool_result` for an earlier
+parallel `tool_use` is written in between, and the `parentUuid` chain runs *through* it. Assembling
+by walking consecutive lines, or assuming `parentUuid` adjacency implies same-message, mis-assembles
+roughly 1,000 manifests.
+
+**`uuid` is not unique within a file.** 1,530 duplicated `(file, uuid)` pairs, confined to 10 main
+transcripts, **multiplicity exactly 2 — never 3**. Copies differ in `slug`, `promptId`, and
+sometimes `cwd`/`gitBranch`/`parentUuid`. The ceiling of 2 is the useful engineering fact: drop duplicates by
+`(file, uuid)` terminates.
+
+> These blocks were initially read as resume replays. They are not — see **§1.12b**, where they turn
+> out to be the corpus's strongest chain-external evidence. Duplicate removal still applies; the *interpretation*
+> changes.
+
+> **Duplicate rule:** keep the **first** occurrence for position, merging in fields the later copy adds
+> (e.g. `slug`). Keeping the last relocates up to 415 records from their true chronological position
+> to the resume point and scrambles line-order reconstruction. Timestamps are identical between
+> copies, so they cannot arbitrate.
+>
+> *Contested:* one pass measured "later copy never richer", the verifier measured "later copy larger
+> in a third to half of pairs." Unresolved — see §1.9. The keep-first-and-merge rule is safe under
+> either reading.
+
+**`cwd` and `gitBranch` are per-record write-time stamps, not session constants.** One session
+showed 52 distinct `cwd` and 14 distinct `gitBranch` values. Only `sessionId`, `entrypoint` and
+`userType` are reliable per-file constants (`slug` varies across replay copies).
+
+### 1.3 Provider calls
+
+**Group by `(file, message.id)` — not by `requestId`.** `message.id` is present on 115,162/115,162
+assistant records; `requestId` is absent on 92 including genuine provider calls, and 2 `requestId`s
+map to two `message.id`s because a `<synthetic>` refusal companion reuses the real call's
+`requestId`. Grouping by `requestId` puts a locally fabricated block inside a real provider call.
+Neither key ever appears in more than one file.
+
+**Synthetic filter:** `.message.model == "<synthetic>"`, equivalently `.message.id` not matching
+`^msg_`. Both agree 97/97. Do **not** use `requestId == null` — 24 of 97 synthetics carry a non-null
+`requestId`, and 2 genuine calls have no `requestId` key at all.
+
+**Ordering is by line, never by timestamp.** Timestamps are monotone within a group, but 372 groups
+have tied timestamps, and at file level 2.76% of timestamped records are out of order even after
+duplicate records.
+
+**Usage: take the last fragment. Never sum, and do not assume fragments agree.**
+
+| | behaviour |
+| --- | --- |
+| main transcripts | identical final usage stamped on every fragment (14,167/14,171 groups) |
+| subagent transcripts | **streaming partials** — `output_tokens: 1, 2, 3 …` on all but the last |
+
+`output_tokens` is monotone non-decreasing in line order with last == max in **56,524/56,524**
+groups, zero counterexamples. Summing inflates by a mean of 2.14× (max 10×). This is the single most
+likely source of silently wrong numbers.
+
+**Terminal-fragment test:** the terminal fragment is identified by the presence of
+`{iterations, server_tool_use, speed}`. Prefer `speed` — `iterations` is JSON `null` on the 97
+synthetic records, so a `has("iterations")` test returns true for them.
+
+**7.14% of calls have no terminal record at all** (main 0.02%, subagent 13.70%) — usage and
+`stop_reason` are permanently unavailable for those. The rate tracks subagent workload composition,
+not version.
+
+**Fragment count distribution:** 1:18,318 · 2:19,918 · 3:16,666 · 4:1,386 · 5–12:236. Mean 2.04,
+max 12.
+
+**Useful streaming invariant:** after duplicate uuids are removed, no two provider calls' line ranges overlap
+(0 of 56,524). A group's fragments occupy a contiguous line span interrupted only by its own
+`tool_result` records. A streaming collector can close a ProviderCall the moment a line with a
+different `message.id` arrives.
+
+**Retry attempts are partially derivable.** `type:"system", subtype:"api_error"` records carry
+`retryAttempt`/`retryInMs`/`maxRetries`. 48 of 556 also carry `.error.requestId` — a real `req_…`
+id, present exactly on failures that got an HTTP response (e.g. 529), absent on transport failures.
+Those 48 are disjoint from all successful requestIds, so each failed attempt has its own id.
+`.error.status` is present on 50 of 556.
+
+**Content is complete in transcripts — with one exception.** User prompt text, assistant prose, tool
+inputs and tool results are all stored verbatim and untruncated. This is the reason transcripts, not
+OTLP events, are the v1 channel: events redact `user_prompt` / `assistant_response` by default,
+truncate content at ~60 KB, and omit tool inputs unless `OTEL_LOG_TOOL_DETAILS` is set.
+
+**The exception: thinking text is stripped.** The signature is retained, the reasoning is not:
+
+```json
+{"type":"thinking","thinking":"","signature":"CAISkwMKpgEIERgCKkBzgtqcryyQ3cbpBYwiBCwKlBijnqr7oxML…"}
+```
+
+Measured corpus-wide: **2,603 of 2,604 files containing thinking blocks have empty thinking text**
+(54 main + 2,549 subagent), across every version 2.1.220–2.1.251. Exactly one subagent file retains
+real text (31 blocks, 61–1,742 chars) — unexplained, and worth chasing.
+
+**Cause identified, and it is not a persistence choice.** A controlled comparison inside one session
+(`221ac729…`, all agents at 2.1.227, same agent type) isolates the model as the only variable:
+`claude-haiku-4-5-20251001` retained 31 thinking blocks with real text (61–1,742 chars); eleven
+sibling `claude-opus-5` agents retained none. A live capture then showed the mechanism — the request
+body carries
+
+```json
+"thinking": {"type":"adaptive","display":"omitted"}
+```
+
+Claude Code *asks the API not to return thinking text*. The signature comes back, the reasoning does
+not.
+
+**Verdict: thinking text is unrecoverable by any local mechanism.** Two independent redactions stack:
+the request suppresses it at the API, and the raw-body export redacts it again — a live capture on
+2.1.245 shows the response's thinking block as literally `"<REDACTED>"` (10 chars) beside a 596-char
+signature. OTLP events redact it unconditionally too. Treat reasoning content as `unavailable` in
+the coverage model and do not design around recovering it.
+
+Consequence: a Talk tree can show *that* the model reasoned, with duration and token cost, but not
+*what* it reasoned.
+
+**Injected context is captured.** The `attachment` record type — the third-largest in the corpus at
+~21,000 records, 32 distinct `attachment.type` values — carries what the harness pushed into the
+conversation alongside the human turns:
+
+```
+skill_listing deferred_tools_delta agent_listing_delta nested_memory
+mcp_instructions_delta command_permissions directory date_change
+task_reminder read_truncation_notice selected_lines_in_ide plan_mode_exit
+```
+
+So a reconstruction is not limited to human/assistant turns: skill catalogues, tool-availability
+deltas, memory injections, MCP instructions, IDE state and permission changes are all on disk in
+chain order.
+
+**But the transcript is provably not a superset of the model's context.** Three things are absent
+from every transcript — the **system prompt**, **tool schemas** (only names are recorded; 0 files
+contain `"tools":[`), and **thinking text** (§1.3, which matters extra because thinking blocks are
+re-sent in later requests and were therefore genuinely in context). And there is direct proof rather
+than inference: **`compactMetadata.preservedMessages.allUuids` contains 5 uuids that exist nowhere
+on disk** (§1.6) — records the runtime held in context and never wrote.
+
+**Consequence for `input_manifest.state`: `truncated`, never `full`, and never `unavailable`.**
+`message_refs[]` can be built by walking the chain, so a manifest exists; `system_block_refs[]` and
+`tool_definition_refs[]` cannot. Critically, the message membership so derived is **inferred, not
+observed** — nothing in the transcript proves those messages were the ones serialized into the
+request. Verifying the §2.2 continuity invariant requires raw bodies (§1.8); no amount of file
+reading substitutes.
+
+### 1.4 Tool joins — effectively perfect, once duplicates are removed
+
+Across 2,924 files / 356,583 records / 207,984 tool blocks:
+
+- Every `(file, tool_use_id)` maps to exactly **one** `tool_result` record once duplicates are removed and exactly
+ **one** assistant `tool_use` record. 103,503 keys, all cardinality 1. Zero fan-out, zero orphans,
+ zero cross-file leakage.
+- `sourceToolAssistantUUID` is present on **103,503/103,503** tool_result records, always equals
+ `parentUuid`, and always resolves to the assistant fragment carrying the matching `tool_use`.
+ Zero mismatches. It is fully redundant with `parentUuid`, so it adds no join power — but it is a
+ free consistency check.
+- **Before duplicates are removed** the join is *exact_ambiguous* for 441 ids (882 of 64,722 tool_results, 1.4%),
+ purely from replay copies. Removing duplicates is a precondition, not an optimisation.
+- `tool_use_id` is not only `toolu_` — 35 values are `srvtoolu_` (server-side tools such as web
+ search). A `^toolu_` regex silently drops them. 0 cross-file collisions on 103,940 distinct ids.
+
+**Parallel tool batches have two on-disk layouts.** Layout A (80.5%) is strictly linear and
+interleaved — `tool_use, tool_result, tool_use, tool_result` — producing no fork. Layout B forks the
+`parentUuid` chain. Only the per-pair invariant generalises; do not assume a fork to group a batch.
+
+**`toolUseResult` is a main-transcript enrichment.** Present on 100.0% of unique main-transcript
+tool_result records; on workflow subagents it is **exclusively** the error-string form (zero
+object-typed). Structured tool telemetry (`structuredPatch`, `stdout`/`stderr` split, agent usage)
+exists only for the parent stream. The key is *absent*, not null, on child records — a
+`toolUseResult === null` test never fires.
+
+**`tool_result.content` is not always a string.** Over 20,000 records: string 19,707, list-of-image
+124, list-of-text 104, list-of-**`tool_reference`** 92 — an undocumented block type of shape
+`{"type":"tool_reference","tool_name":"Monitor"}`, 194 corpus-wide.
+
+**Long output spills to a file, but the field is unreliable.** Only 45 of 516 spill records carry
+`.toolUseResult.persistedOutputPath`; the path is recoverable in 506 of 516 by parsing
+`Full output saved to: <path>` out of the preview text. Also, the content block is truncated
+relative to `toolUseResult.stdout` by ~28 KB in the truncation cases — `toolUseResult.stdout` is the
+only authoritative copy of long output.
+
+**Interrupts do not break the tool join** — the `[Request interrupted by user]` record is written
+*after* the tool_result. But the next user turn can re-parent to an earlier uuid, which is a real
+`ExecutionStream` discontinuity.
+
+Zero historically unresolved tool_uses exist in this corpus — but that is a property of *this*
+corpus (no session was killed mid-call), not an invariant. An `unresolved` state is still required.
+
+### 1.5 Subagents — three spawn mechanisms, not one
+
+This is where my earlier write-up was most wrong. There is no single field spanning all three.
+
+| Spawn kind | Count | Parent→child join |
+| --- | --- | --- |
+| **Agent tool** | 89 | `meta.json.toolUseId` → `tool_use` name `Agent` (89/89), *and* parent's `toolUseResult.agentId` |
+| **Skill fork** | 16 (4 sessions) | `toolUseResult.agentId` with `status:"forked"` only — meta.json has **no** `toolUseId` |
+| **Workflow** | ~2,579 | `Workflow` tool_result's `toolUseResult.transcriptDir` (absolute path of the run dir) + `toolUseResult.runId`; and `journal.jsonl` `{type:"started", agentId}` → 2,583/2,583 bijection |
+
+An earlier pass claimed workflow children have no parent `tool_use` and that `journal.jsonl` is
+their sole causal record. **Both refuted** — `toolUseResult.transcriptDir` is an exact_unique join
+from a parent tool_use straight to the directory holding every workflow child.
+
+Confirmed by full re-scan of all 2,686 agent files (218,393 records):
+
+- filename `agentId` == in-record `agentId`, 2,686/2,686; zero collisions; zero files with >1 `agentId`
+- zero files with >1 `sessionId`; **zero files whose `sessionId` differs from the parent directory** —
+ the child always shares the parent's session, with no deviation
+- `isSidechain`/`agentId` partition the two file classes perfectly: 0 sidechain records in mains,
+ 0 top-level `agentId` in mains
+- `agentId` conforms to `^a[0-9a-f]{16}$` in 224,130/224,130 occurrences
+- `spawnDepth`: 2,654×1, 31×2, 1 null. **No depth ≥ 3 exists.** All 31 depth-2 agents carry
+ `parentAgentId`, resolving to Skill-forked parents.
+- the child's first user record is a bare string **byte-identical** to the parent `tool_use`
+ `input.prompt`, 89/89
+- `"Task"` does not exist as a tool name anywhere — the tool is `Agent`
+
+**Async return.** `toolUseResult.status:"async_launched"` is the launch acknowledgement, distinct
+from the result — the field distinguishes them explicitly. Completion arrives as a
+`<task-notification>` XML blob whose `<task-id>` **is** the agentId.
+
+> **Caveat that breaks my earlier "exact end-to-end" claim:** the notification's `<tool-use-id>` is
+> *not* reliably resolvable in the transcript it lands in — **28 of 54 fail**, because nested
+> children notify the MAIN session while their spawning `tool_use` lives in an agent file. For
+> depth-2 the split is clean and exact: all 31 toolUseIds resolve in the parent *agent* file, all 31
+> agentIds appear as `<task-id>` in the *main* transcript. So the edge is exact but **cross-file** —
+> resolution must search the session's whole file set, not one file.
+
+`<status>` vocabulary is `completed | failed | killed | stopped`, and **1,183 notifications carry no
+`<status>` element at all** — parsing it unconditionally will null-deref.
+
+`agentType` does **not** identify a workflow spawn (a workflow step can request a named agent type).
+The reliable discriminator is on-disk path / journal membership.
+
+**The journal leads the filesystem.** For in-flight runs, journalled `started` agentIds had no
+`agent-*.jsonl` yet — one even had a `result` journalled while its transcript was still missing.
+A collector meeting a live session must treat this as normal, not as corruption.
+
+### 1.6 Context epochs and session identity
+
+**Compaction is explicit and exactly linked.** `{"type":"system","subtype":"compact_boundary"}` with
+`compactMetadata`. 26 boundaries corpus-wide, zero in any subagent file.
+
+The critical field is **`logicalParentUuid`** — an explicit pointer to the last pre-compaction
+message, present on 26/26 boundaries and on **no other record type anywhere** (0 occurrences among
+133,347 other main records and 221k+ subagent records). Exact rule, 26/26:
+`logicalParentUuid == compactMetadata.preservedMessages.allUuids | last`.
+
+So epochs *are* explicitly chained; `parentUuid: null` on the boundary is a DAG-root display
+convention, not an absence of linkage. This is precisely the explicit epoch boundary v0.8 §4.2
+demands, and it means compaction never has to be inferred.
+
+Also established:
+
+- `boundary.uuid == summary.parentUuid`, `isCompactSummary == true`, summary is the **very next
+ line** — 26/26, zero exceptions.
+- The summary's timestamp is **always earlier** than its own boundary's, by 1 ms to 1,097 ms, 26/26.
+ **Never order an epoch boundary by timestamp.**
+- `preservedMessages.allUuids` is a strict superset of `uuids`. `uuids` resolves 26/26; **`allUuids`
+ contains 5 uuids that exist nowhere on disk** — records held in context but never written. Since
+ `logicalParentUuid == allUuids | last`, one boundary's `logicalParentUuid` is itself unresolvable.
+ Both must be typed unresolved-capable.
+- The preserved segment does **not** abut the boundary: the last preserved uuid is immediately
+ before it in only 16/26; gaps reach 705 records. Resolve by uuid only, never by file proximity.
+- `trigger` is `auto` 24 / `manual` 2. `preCompactDiscoveredTools` is absent on 2/26 and `isMeta`
+ absent on 5/26 — both must be optional in a Go struct.
+
+**Resume appends to the same file with the same sessionId.** Proven by a 659-hour gap where
+`version` jumps 2.1.220 → 2.1.251 and the chain still links. Across all 60 main + 2,851 subagent
+files: **zero dangling parentUuids, zero pointing cross-file.** Root count per file =
+`1 + N_compactions + N_replay_restarts`.
+
+**`/clear` starts a new sessionId and a new file**, and is detectable at the **head of the successor
+file** (line 3–4) as a fixed 3-record chain: a root `user` record with `isMeta:true` and
+`<local-command-caveat>`, then a `user` record whose content is the string
+`<command-name>/clear</command-name>`, then `system/local_command` with empty stdout. So "this
+session was born by clearing a predecessor" is detectable — though the record carries no pointer to
+the predecessor's sessionId.
+
+**There is essentially no durable on-disk conversation identity spanning sessionIds.** Candidates,
+all weak:
+
+| Candidate | Verdict |
+| --- | --- |
+| `bridgeSessionId` | spans 2 sessionIds in 1 of 20; can be the empty string; one session has two values. Not 1:1. |
+| `.session_id` (snake_case, ≠ `.sessionId`) | **contested** — see §1.9 |
+| `slug`, `ai-title`, `cwd`, `gitBranch` | per-record or per-session, mutate mid-session |
+
+This confirms v0.8's core premise: **the conversation ID must be supplied by the application.**
+No local heuristic is sound.
+
+### 1.7 OTLP — traces exist but are largely dead code
+
+Claude Code emits all three signals, on very different terms. Three instrumentation scopes:
+`com.anthropic.claude_code` (metrics), `com.anthropic.claude_code.events` (logs),
+`com.anthropic.claude_code.tracing` v1.0.0 (spans).
+
+Metrics (8 counters) and logs (~25 events) need only `CLAUDE_CODE_ENABLE_TELEMETRY=1` plus the
+relevant exporter var.
+
+**Traces are double-gated:** `CLAUDE_CODE_ENABLE_TELEMETRY=1` **and**
+`CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1`, plus `OTEL_TRACES_EXPORTER`. Without the beta flag every
+`startSpan` returns a no-op dummy span. A first-party managed-config toggle can set the beta flag
+for spawned sessions, so it is reachable from configuration, not only from a raw env var.
+
+> **Correction to my earlier claim.** I wrote that `claude_code.subagent.spawn` and
+> `claude_code.compaction` matter to the design. **They are dead code for external users
+> in 2.1.245** — gated behind a hardcoded-false function and an Anthropic-internal endpoint path,
+> confirmed in 2.1.224 too. Only **five** span names are reachable: `claude_code.interaction`,
+> `.llm_request`, `.tool`, `.tool.blocked_on_user`, `.tool.execution`.
+> `.subagent.spawn`, `.compaction`, `.hook`, `.bash.subprocess`, `.mcp.rpc` are unreachable.
+
+The decisive consequence for lineage: **events carry no `agent_id`.** Agent lineage
+(`agent_id`, `parent_agent_id`) exists **only on spans** — i.e. only under the enhanced-telemetry
+beta. On the metrics/logs path there is no subagent identity, no Conversation ID, and no Segment
+concept at all.
+
+What events *do* carry joins cleanly: `session.id`, `prompt.id`, `message.uuid`, `request_id`,
+`tool_use_id` — all with exact transcript counterparts. Two caveats: the slash-command
+`user_prompt` emit site emits **no** `message.uuid` (so that join is unresolved for every
+`/command`), and `tool_decision.source` is a composite string (`hook:…`, `rule:…`, `mode:…`) that
+must be split on `:`, not treated as an enum.
+
+Also noted: an always-on 300 s metric reader exists **outside** the telemetry guard for
+enterprise/team accounts.
+
+### 1.8 Raw provider bodies — no proxy needed
+
+**`OTEL_LOG_RAW_API_BODIES=file:<dir>` is a first-class, undocumented raw-body dump.** Verified live
+on 2.1.245 with subscription OAuth: 12 provider calls captured across 4 sessions including a
+subagent spawn. It writes the fully-serialized Messages request params to
+`<dir>/<randomUUID>.request.json` and the assembled response to `<dir>/<req_…>.response.json`.
+
+The joins are exact:
+
+- **The response file's basename *is* the Anthropic request-id, and equals the transcript's
+ `.requestId`.** Independently confirmed by MITM: the proxy saw response header
+ `request-id: req_011CeZxce27ffvNEPbBWiUUx` and the transcript wrote the same value.
+- `requestId → sessionId` is 1:1 with zero collisions across 85,652 distinct ids.
+- The request body carries `.metadata.user_id` holding the session_id.
+- **`.diagnostics.previous_message_id` chains request N to the response of request N−1 by `msg_`
+ id** — the ordered-model-input chain the design needs, materialized in the wire body itself.
+
+**Independently reproduced on 2.1.245 with subscription OAuth.** A live capture in this project
+confirms the mechanism and shows the request body is far richer than the design assumed. Top-level
+keys: `betas, context_management, diagnostics, max_tokens, messages, metadata, model, output_config,
+stream, system, thinking, tools`.
+
+| Field | Observed |
+| --- | --- |
+| `system` | array of 4 blocks, 11,765 chars total — the complete system prompt |
+| `tools` | 13 tools **with full `input_schema`** |
+| `messages` | the exact ordered array as serialized |
+| `cache_control` | present, with ttl and scope, e.g. `{"type":"ephemeral","ttl":"1h","scope":"global"}` |
+| `metadata.user_id` | a JSON string holding `device_id`, `account_uuid`, **`session_id`** |
+| `diagnostics.previous_message_id` | the chain to the prior response |
+
+**Three independent exact joins back to the transcript**, all verified on the captured pair:
+
+1. the response filename is the request-id — found in the transcript (2 fragment lines)
+2. `metadata.user_id.session_id` — resolves to a real transcript file
+3. `system[0]` is an `x-anthropic-billing-header` block carrying `cc_prompt_id=…`, which equals the
+ transcript's `promptId`, plus `cc_version` and `cc_entrypoint`
+
+This removes the largest and most privacy-fraught piece of the design, and makes
+`input_manifest.state = full` genuinely reachable — system blocks, tool definitions, ordered message
+refs and cache breakpoints all present as *observed* rather than inferred, which transcripts alone
+can never do (§1.3).
+
+**One asymmetry to plan around:** the response file is named `<request-id>.response.json`, but the
+request file is named by a client-side random UUID (`<uuid>.request.json`) — the request-id does not
+exist until the response arrives. Pairing a request to its response therefore needs either the
+`api_request` event (which carries both `client_request_id` and `request_id`) or the body's own
+`diagnostics.previous_message_id` chain. `clientRequestId` is absent from transcripts entirely.
+
+**Thinking is redacted here too** — the captured response's thinking block is `"<REDACTED>"`
+(§1.3). Raw bodies do not recover reasoning.
+
+**The proxy route is a measurement-perturbing observer and should not be the default.** With
+`ANTHROPIC_BASE_URL` pointed at a recorder, Claude Code drops the `cache-diagnosis-2026-04-07` and
+`advanced-tool-use-2025-11-20` betas, loses `.diagnostics` entirely, and collapses two
+globally-scoped system `cache_control` breakpoints into one. It also hard-fails with 403 under OAuth
+unless the forwarder speaks HTTP/2. It remains useful as a cross-check, not as the collection path.
+
+### 1.9 Contested and unresolved
+
+Recorded rather than silently resolved:
+
+1. **`.session_id` (snake_case) as a cross-session identity.** One pass calls it a
+ conversation-lineage root surviving `/clear` and spanning 4 sessionIds over 13 days, gated by
+ entrypoint (present on 11,721 of 12,959 `cli` records, 0 on `claude-vscode`/`sdk-cli`). Another
+ measures 15 distinct `(sessionId, session_id)` pairs of which **11 agree**, with all 8,037
+ disagreements traced to **one stale sticky value** inherited by 3 sessions — and shows zero uuid
+ overlap with the claimed ancestor. Needs a decisive test before any use.
+2. **Whether the later copy of a duplicated uuid is ever richer.** Passes disagree. The
+ keep-first-and-merge rule is safe either way, but the disagreement should be settled.
+3. ~~Does the raw-body dump retain thinking text?~~ **SETTLED — no.** Live capture on 2.1.245: the
+ response's thinking block is `"<REDACTED>"`. Combined with the request's
+ `thinking.display="omitted"`, reasoning text is unrecoverable by any local mechanism (§1.3).
+ *Remaining sub-question:* raw-body behaviour under concurrency and inside subagents is still
+ unmeasured.
+4. ~~Why one subagent file retains thinking text?~~ **SETTLED — the model.** Controlled comparison
+ within one session at one version isolates it: haiku-4-5 retains text, opus-5 does not (§1.3).
+5. **Whether transcripts are ever truncated or rotated.** All evidence says append-only, which the
+ storage design depends on (§2.6). Not proven.
+6. **Hook `agent_id` coverage** — reported present on every hook fired inside a subagent and absent
+ on the main thread, from 5 live headless sessions. Needs wider confirmation.
+
+### 1.10 Hooks — the best lineage surface, with a sharp edge
+
+31 hook events, extracted from the binary's registry and verified by 5 live headless sessions with
+all events wired. The common envelope carries `session_id`, `transcript_path`, `cwd`, `prompt_id`,
+`permission_mode`, **`agent_id`**, `agent_type`, `effort`.
+
+`agent_id` is present on every hook fired **inside** a subagent and absent on the main thread —
+which is exactly the E0/E1 stream discrimination transcripts blur. The parent→child edge is
+delivered twice: `SubagentStop.agent_transcript_path` names the child file directly, and
+`PostToolUse(tool_name="Agent").tool_response.agentId` pairs the parent's `tool_use_id` to the
+child's `agent_id` (verified unambiguous with 3 concurrent subagents).
+
+Compaction is fully instrumented: `PreCompact{trigger, custom_instructions}` → `SubagentStop`
+(a phantom internal agent with `agent_type:""`) → `SessionStart{source:"compact"}` →
+`PostCompact{trigger, compact_summary}`.
+
+Three hard limits:
+
+- Hooks are **synchronous and on the critical path** (~5.6 ms/invocation measured, 60 s default
+ timeout). A collector hook must never do work inline.
+- The naive `cat >> spool.jsonl` pattern **demonstrably corrupts lines above ~64 KB** under
+ concurrent subagents. Appends must be single-`write(2)`-sized or go through a serializing writer.
+- Hooks never expose `message.id` or `requestId`, so they cannot produce ProviderCall manifests.
+
+Note the published docs are ahead of and inconsistent with the shipped binary — code to observed
+payloads. Also: the current `settings.json` on this machine has **no hooks block**, so no
+hook-based collection point exists yet.
+
+### 1.11 Sidecar surfaces worth collecting
+
+| Surface | Verdict |
+| --- | --- |
+| `file-history/<sessionId>/<sha256(abs_path)[:16]>@vN` | **Collect.** Directory name *is* the sessionId (26/26); blob name verified by recomputation. `file-history-delta.messageId` is the exact uuid of the assistant `tool_use` line that performed the edit (59/59). |
+| `history.jsonl` | **Collect, with care.** Neither complete nor a superset: 37 of 52 transcripts have zero history lines, but 330 of 345 sessionIds exist *only* here (transcripts pruned). |
+| `~/.claude.json` `.projects[<cwd>]` | **Collect.** Carries `lastSessionFirstPrompt` (verbatim first human prompt) and `lastSessionId`. Entry shapes vary 9–35 keys. |
+| `telemetry/1p_failed_events.*.json` | **Optional.** Not OTLP — a proprietary `ClaudeCodeInternalEvent` NDJSON spool of *undelivered* events, 41 `tengu_*` names, numeric payload base64-encoded in `additional_metadata`. Useful for cache-breakpoint and context-size signals absent elsewhere. |
+| `sessions/<pid>.json` | **Optional.** Live process registry, deleted on exit. `bridgeSessionId` joins the transcript's `bridge-session` record only after stripping a `session_` vs `cse_` prefix difference. |
+| `shell-snapshots/`, `session-env/`, `backups/`, `paste-cache/` | **Skip.** `session-env` is empty; `backups` rotates in minutes; 91% of paste-cache hashes have no surviving blob. |
+
+Two traps worth naming:
+
+- **`file-history` deltas and snapshots must be unioned.** Deltas only ever name `@v1` blobs (337);
+ every blob at `@v2…@v49` (1,667 of 2,004) is reachable **only** through
+ `file-history-snapshot.trackedFileBackups`. Neither surface alone is complete. The version counter
+ is per-`(session, file)`, not per-file, so the exact key is `(sessionId, backupFileName)`.
+- **`history.jsonl` `.pastedContents` has two shapes.** 148 of 305 carry a full inline `content`
+ string (≤1,006 chars); the rest carry only `contentHash`. Reading only `contentHash` drops half
+ the payloads.
+
+### 1.12a Reconstruction: the `parentUuid` graph is a DAG, not a chain
+
+**This is the finding with the largest implementation consequence.** Parallel tool calls fork the
+graph: `tool_use#1 → { tool_use#2 (same message.id), tool_result#1 }`. An ancestor walk from the next
+provider call traverses one branch and silently drops the other `tool_use` and its `tool_result`.
+
+> **33,798 of 86,687 provider calls (39%) reconstruct under ancestor-walk to a message list carrying
+> a `tool_use` with no matching `tool_result` — a body the Messages API would reject.**
+> Line-order reconstruction is balanced in 2,622 of 2,623 tool-bearing files (the one exception being
+> a live in-flight file at snapshot time).
+
+**Rule: assemble by `(file, message.id)` grouping plus line order within an epoch. Never by ancestor
+walk.** Treat a fork as a topological hint, not a branch to choose between.
+
+Supporting measurements:
+
+- 4,073 fork nodes corpus-wide. **Every fork has exactly 2 children, never more.** Shapes:
+ `assistant:tool_use + user:tool_result` = 3,905; `assistant:* + system:api_error` = 160; 3 others.
+- The *sequential* multi-tool case is a plain chain (`tool_use1 → tool_result1 → tool_use2 → …`,
+ all one `message.id`). Only parallel dispatch forks.
+- Fork rate is 0.49% in main transcripts but **1.69% in subagents**, which hold ~69% of all provider
+ calls — so main-transcript-only measurement understates exposure. 31.8% of subagent calls carry
+ ≥2 `tool_use` blocks versus ~3% in main.
+- Fragments of one `message.id` are **never** interleaved with fragments of a different one
+ (0 of 86,716 calls), which is what makes `message.id` grouping a genuinely independent second
+ derivation from the chain.
+- Order inversion: for prompt-bearing turns the request puts reminders **first** and the user's text
+ **last**, while the transcript writes the user record first with attachments as descendants. For
+ tool_result turns the two orders agree. No chain walk discovers this.
+
+### 1.12b The duplicate-uuid blocks are evidence, not noise
+
+§1.2 records these as resume replays. **That framing is wrong and understates them badly.**
+
+Measured across all 10 affected files: the re-emitted block is **exactly** the set of user/assistant
+records lying before it that are *off* the following compaction boundary's ancestry.
+`dup_not_off = 0` in 10/10 files; `off_not_dup = 0` in 8/10.
+
+Claude Code is dumping precisely the context members its own `parentUuid` chain fails to reach. That
+makes these blocks an independent, chain-external recording of the real message set at 10 sites, and
+it establishes the reconstruction rule directly:
+
+> **context = chain ancestry ∪ off-chain siblings**
+
+The 2 exceptions are equally informative: 1f04957c has 330 and e84d2dab 37 off-chain records that
+were *not* re-emitted, clustering in whole abandoned runs. So off-chain records partition cleanly
+into **(a) parallel-tool siblings — re-emitted, genuinely in context** and **(b) abandoned or errored
+branches — not re-emitted, genuinely dropped.** That is ground truth for a decision the assembler
+would otherwise have to guess at.
+
+Also corrected: all 10 duplicate blocks terminate on the line immediately before a `compact_boundary`;
+only 2 of 10 are dense contiguous prefix replays; 0 of 1,530 lie in the ancestry of the following
+boundary's `logicalParentUuid`.
+
+### 1.12c What the continuity invariant can and cannot see
+
+Established by mutation testing — corrupting the reconstruction and measuring whether the check
+notices.
+
+**Blind to message content, completely.** Ten destructive mutations moved the failure count by
+**exactly 0** on both populations: `content[0]`-only, dropping thinking blocks, grouping by
+`requestId`, dropping the `isCompactSummary` record, dropping `isMeta` records, blanking every
+tool_result payload, blanking every text block, replacing blocks by their type string, replacing
+blocks by one indistinguishable token, and collapsing every record to a single indistinguishable
+block.
+
+Evaluated against transcripts, the check is therefore a function of exactly one thing: **the
+role-segmented record-count sequence along the chain.** It cannot observe a single byte of any
+message body.
+
+**Loud about intra-message order.** Ordering a message's fragments by uuid instead of line number
+takes main failures 178 → 9,714 and subagent 518 → 30,437.
+
+> v0.8 must describe this as a **structural cross-check on
+> `(message.id grouping × parentUuid tree × fragment order)`** — never as checking the request body.
+
+**Do not use it as a ranking oracle.** It scores three known-wrong reconstructions *better* than the
+correct one: keep-LAST duplicate handling (−19), dropping `<synthetic>` assistants (−13), and line-order
+reconstruction that readmits abandoned fork branches (−20 main, −518 subagent). Its safe use is
+strictly as a falsifier.
+
+**It never crosses a compaction boundary**, so it can never observe the one place where
+`next = prev + assistant + new` is definitely false.
+
+### 1.12d Semantic equality must ignore `cache_control`
+
+Measured on real adjacent capture pairs: **byte equality of the shared prefix fails 4/4; equality
+after stripping `cache_control` holds 6/6.** In one pair `messages[0]` is byte-identical at 22,477
+bytes while `messages[1]` and `[2]` differ by exactly 48 bytes each — entirely the removal of
+`{"ttl":"1h","type":"ephemeral"}`. Breakpoints migrate forward on every call.
+
+v0.8's "recursive semantic equality, not raw byte equality" is therefore **required, not
+stylistic**, and the design must state explicitly that the comparison ignores `cache_control` — a
+reconstructor cannot place these anyway, since transcripts contain none.
+
+On real wire bodies the invariant **holds**: across a 3-call chain,
+`next.messages[0..k] == prev.messages[0..k]` modulo `cache_control`, 100%, with the appended tail
+exactly `assistant(prev_response) + new user input`.
+
+### 1.12e What the log does not contain
+
+Not message content — that is complete (§1.3). What is missing is the **injected preamble prepended
+to `messages[0]` on the wire**:
+
+- an 8,338-char `<system-reminder>` block wrapping CLAUDE.md, `userEmail` and `currentDate`.
+ `grep -c claudeMd` over transcripts returns 0. Subagents receive it too.
+- the wrapper text and ordering of agent/skill/deferred-tool listings — the underlying data survives
+ as `attachment` records (88.8–98.7% recoverable) but not the rendered form
+- `tools[]` mutates between adjacent calls (96 → 116 one turn apart) via deferred loading, recorded
+ only as a `deferred_tools_delta` attachment
+
+**Scale matters here and is easy to misread.** The preamble is a fixed ~8 KB (no project CLAUDE.md)
+to ~40 KB (a 31 KB project CLAUDE.md). Against a real session carrying 12,027,973 chars of
+conversation content that is ~0.3%. A character-weighted "99% not recoverable" figure measured on a
+26 KB probe session is not representative.
+
+**The attachment→request mapping is beta-gated and unstable**, which alone defeats transcript-only
+list reconstruction. Same machine, same day, two schemes: without the
+`mid-conversation-system-2026-04-07` beta the attachments become separate `<system-reminder>`-wrapped
+user text blocks *before* the prompt; with it they merge into a single 13,953-char **`role:"system"`
+message** placed *after* the user message. The `betas` list is not in the transcript, so a
+reconstructor cannot tell which applied. `role:"system"` inside `messages[]` is a fifth message class
+the model must accommodate.
+
+### 1.12f Human turns that live only in attachments
+
+**614 genuinely human-typed turns exist only as `queued_command` attachment records** with no
+`.message` twin. A reader that walks `.message` alone loses them.
+
+They must be filtered: `queued_command` carries `commandMode` (`prompt` | `task-notification`) and
+`origin.kind` (`human` | absent). Of 1,558 unique records only 960 are human input; including the
+other 598 in would insert machine-generated `<task-notification>` blobs as if the user had typed them.
+
+### 1.12g Corpus hazards for any absence test
+
+- **28.6% of main-transcript lines carry no `uuid` at all** — 38,340 of 133,887, across 14
+ metadata-only record types. None is context-bearing, but a parser assuming every line has a uuid
+ will crash or mis-position.
+- **177 of 2,935 files contain zero user/assistant/system records** — metadata only. A reconstructor
+ treating every `.jsonl` as a session emits 177 empty conversations.
+- **The corpus is self-contaminated by this investigation.** Sessions analysing the data wrote the
+ strings under test into their own transcripts. A bare `grep` for `cache_control` returns 18 files,
+ all of them research prose. Any "no transcript contains X" claim must grep the *field*
+ (`"uuid":"…"`) or apply a pre-analysis mtime cutoff.
+- **The corpus is live.** Subagent pair counts drifted 56,960 → 57,022 within 20 minutes. Quote
+ counts with a timestamp; never treat them as constants.
+
+### 1.13 Two hazards that will bite an implementation
+
+**`sessionId` is not reliably lowercase.** 2 of 47 transcripts use an uppercase UUID
+(`41090DAB-113C-…`). Every join must be byte-exact and never case-folded.
+
+**Lines exceed `bufio.Scanner`'s default limit by 15×.** The longest single JSONL line measured is
+**979,632 bytes**; the largest file is 59 MB. `bufio.Scanner` silently fails at its 64 KB default
+token size. Use `bufio.Reader` with an explicit large buffer, or a streaming JSON decoder.
+
+---
+
+## 2. Raw storage model
+
+### 2.0 Collection posture (decided)
+
+**Claude Code speaks exactly one protocol outward: OTLP.** Exporter selection in the binary accepts
+`console|otlp|prometheus` for metrics and `console|otlp` for logs and traces; any other value
+throws. There is no webhook, no custom sink, no alternative wire format. Pushing to us means running
+an OTLP receiver, or nothing.
+
+Three possible postures:
+
+| Posture | Mechanism | Cost to the user | Yield |
+| --- | --- | --- | --- |
+| **Pull** | tail `~/.claude/projects/**` | none — Claude Code does not know we exist | all structure and content (§1.2–1.6) |
+| **Push** | OTLP receiver (gRPC 4317 / HTTP 4318) | `CLAUDE_CODE_ENABLE_TELEMETRY=1` + `OTEL_EXPORTER_OTLP_*` | measurement only; no agent identity (§1.7) |
+| **Exec** | hooks — Claude Code runs our binary, JSON on stdin | edits `settings.json`; on the critical path | `agent_id`, liveness, compaction lifecycle (§1.10) |
+
+**v1 is pull only.** SkyWalking AI Sessionizer faces the filesystem, not the agent. Beyond the channel
+reasoning below, one practical argument dominates: **pull works on history that already exists** —
+2,900 files and 1.2 GB on the development machine today. Push and exec capture only from the moment
+they are configured and can never see yesterday. For a tool whose purpose is reconstructing
+conversations, that is a categorical difference.
+
+This also fixes the meaning of `src` in the envelope header: for a pull adapter it is the local
+source path, which is not redundant with the layout because the layout deliberately drops the
+project slug (we key by session id) and the workflow directory (we flatten children by agent id).
+`src` is the only place both survive, and it is what makes `off` verifiable. For a future push
+adapter `src` becomes the wire-side origin and `off` drops out.
+
+### 2.0.1 Root location and channel selection (decided)
+
+**`<root>` is assigned by SkyWalking AI Sessionizer configuration.** Default is a directory under the system
+temp dir. In tests, the root is placed side by side with the fixtures in the same folder, so a test
+case is self-contained and needs no absolute paths.
+
+**v1 collects transcripts only.** Rationale, from §1.7 and the event inventory:
+
+- Every structural edge — stream lineage, tool joins, the spawn chain, epoch boundaries — lives in
+ transcripts at higher fidelity than any other channel.
+- OTLP **events carry no usable agent identity**: `query_source` is normalized to coarse
+ `main | subagent | auxiliary` (the raw `agent:builtin:<name>` string is never exported), and
+ `subagent_completed` is emitted with no `agent_id` at all.
+- OTLP **spans** would carry `agent_id`/`parent_agent_id`, but require
+ `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA`, and the two spans that would matter most
+ (`subagent.spawn`, `compaction`) are dead code.
+
+What events *do* bring, and why they are a Plan 02+ addition rather than a v1 requirement:
+per-call `duration_ms` and `cost_usd` (absent from transcripts entirely), tool `duration_ms` and
+`error_type`, and `tool_decision` with a composite `source` (`hook:` / `rule:` / `mode:`).
+`assistant_response.message.uuid` is also useful — it is the uuid of the **last** transcript line of
+a split message, i.e. the terminal fragment that §1.3 shows is the only authoritative one.
+
+One asymmetry to remember if raw bodies are enabled later: the response file is named
+`<request-id>.response.json` and joins to the transcript directly, but the **request** file cannot
+be — the request-id does not exist until the response arrives, so it is named by a client-side id,
+and `clientRequestId` is absent from transcripts. Joining a request body therefore needs either the
+`api_request` event (which carries both `client_request_id` and `request_id`) or the body's own
+`.diagnostics.previous_message_id` chain, which works only first-party.
+
+The layout below reserves `log-*` and `trace-*` so adding either channel later requires no rework.
+
+### 2.1 Principles
+
+1. **Session is the primary partition.** Retention, purge, and the pipeline are all session-scoped.
+ Purge must be a directory delete.
+2. **Files are write-once.** temp name → fsync → rename → `chmod 0444`. Never appended, never rewritten.
+3. **The landed file is a buffer, not a wire capture.** OTLP arrives cross-session and must be
+ demultiplexed, which means re-serialization. We do not claim byte-exactness with what the client
+ sent, and must not imply it.
+4. **Loss is detectable.** A monotonic per-session sequence makes a gap visible.
+5. **Collection never blocks the agent.**
+
+### 2.2 Layout
+
+```
+<root>/<session-id>/
+ session.state next_seq · liveness · last_scan
+ rebuild_progress assembler watermark
+ streams/
+ main/ transcript.cursor · transcript-<ts>-<seq>.jsonl ExecutionStream E0
+ <agent-id>/ transcript.cursor · transcript-<ts>-<seq>.jsonl E1..En
+ meta.cursor · meta-<ts>-<seq>.jsonl agent-*.meta.json
+ runs/<run-id>/ journal.cursor · journal-<ts>-<seq>.jsonl workflow run journal
+ manifest.cursor · manifest-<ts>-<seq>.jsonl workflows/wf_<run-id>.json
+ script.cursor · script-<ts>-<seq>.jsonl workflows/scripts/*.js
+```
+
+**One directory per workflow run**, not three parallel ones keyed by the same id -
+mirroring "everything about one stream in one folder".
+
+**Cursors are named `<kind>.cursor`, not `<kind>-cursor`.** Landed files are
+`<kind>-<ts>-<seq>.jsonl`, so a hyphen would make a cursor share a prefix with the data it
+tracks and turn any prefix scan into a bug.
+
+**Cursors live beside their stream, not in one session-level file.** A session can hold ~2,700
+subagent streams, so a single cursor file would mean rewriting a ~200 KB file for a one-line change
+on every pass. Per-stream cursors mean only what changed is written.
+
+`session.state` stays session-scoped because `next_seq` must be monotonic **across all streams** —
+that is what collapses `rebuild_progress` to a single watermark. Consequence: **a session is the unit
+of work and is processed single-threaded**; parallelism is across sessions, never within one.
+
+**No `origin` file.** The path states the session and stream, the filename
+prefix states the kind, and the envelope header (§2.3a) already carries `src`, `session`, `stream`
+and `adapter` on every file. A separate metadata file would only restate them. Metadata gets its own
+file when content is large enough that finding or indexing it is the problem — which is not the case
+for a source path and two ids.
+
+For the same reason `meta.json` is not copied as a raw `.json` blob: it is source content, so it
+lands as an ordinary record stream (`kind: agent_meta`, header + one record) and needs no special
+read path.
+
+That is the complete v1 tree. Three prefixes are **reserved but not written in v1**, listed here so
+adding a channel later needs no restructuring:
+
+| Reserved | Would hold | Blocked on |
+| --- | --- | --- |
+| `log-<ts>-<seq>.jsonl` | the 25 `claude_code.*` log events, demultiplexed per session, one record per line — the source of per-call `duration_ms` / `cost_usd`, tool durations, and `tool_decision` | §2.0: measurement, not structure |
+| `trace-<ts>-<seq>.jsonl` | the 5 reachable spans (`interaction`, `llm_request`, `tool`, `tool.blocked_on_user`, `tool.execution`) | requires `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA` |
+| `rawbody/<request-id>.{request,response}.json` | `OTEL_LOG_RAW_API_BODIES` output — the only path to a `full` input manifest | privacy; must be opt-in with redaction |
+
+`<ts>` is receipt time, compact RFC 3339 with nanoseconds (`20260831T091204.481523000Z`) —
+sortable, no `:`. `<seq>` is **one monotonic counter per session across all signals**, resumed on
+restart from the highest sequence on disk.
+
+**Every stream is a folder, including main.** The main transcript is ExecutionStream E0; giving it
+the same shape as the children means the pipeline reads one directory pattern rather than two code
+paths, and everything about one stream lives together.
+
+**Child folders are flat by `agent-id`, deliberately not mirroring the source tree.** Two reasons:
+
+1. `agentId` is globally unique (`^a[0-9a-f]{16}$`, 2,686 distinct, zero collisions, never in two
+ files), so nesting is not needed for uniqueness.
+2. **The storage path must not encode a relationship the pipeline is supposed to derive.** Nesting
+ by `wf-id` or by `parentAgentId` would make the tree assert a join; when such a join turns out to
+ be wrong — as several did during this investigation — correcting it would mean *moving files*,
+ breaking the write-once guarantee. This matters most for the 31 depth-2 agents, whose parent is
+ another agent rather than the main stream.
+
+The grouping is preserved as *data*, not as path structure, via three independent routes: the
+envelope header's `src` (which retains the full source path including the workflow directory), the
+landed `meta` stream (`agentType`, `spawnDepth`, `toolUseId`, `parentAgentId`), and the journal's
+`started` records.
+
+**A child's owning session is known two independent ways** — from the source path
+(`projects/<slug>/<session-id>/subagents/…`) and from the records themselves, which carry the
+parent's `sessionId` (verified 2,686/2,686, zero deviation). The collector must cross-check them at
+land time and raise a conflict on mismatch rather than silently preferring one.
+
+Raw bodies are keyed by request-id because the source filename already *is* the request-id, and that
+value joins directly to the transcript's `requestId` (§1.8).
+
+### 2.3 Why `.jsonl` and not `.json`
+
+For write-once files the appendability argument is weak, so the real reasons are:
+
+- **Line number is a stable record ordinal.** A source reference becomes *(file, line)* — an
+ integer — instead of a JSON pointer into nested structure.
+- **Byte offsets are valid resume points.** Our tailer cursor is a byte offset; in a line-delimited
+ file an offset always lands on a record boundary.
+- **O(1) memory streaming.** Files reach 59 MB; whole-file parse is not viable.
+- **One toolchain.** Claude Code's own transcripts are `.jsonl`.
+- **A torn write costs one line, not the file.**
+
+For OTLP we therefore flatten each export to **one `LogRecord` or `Span` per line**, with resource
+and scope attributes attached, rather than storing the nested `ExportLogsServiceRequest`. The
+nesting buys nothing after demultiplexing and costs the ordinal.
+
+`.meta.json` and `rawbody/*.json` stay `.json` — they are single documents at source, not streams.
+
+### 2.3a Uniform record envelope
+
+Every landed **record stream** — regardless of source — uses one shape: a header line carrying
+everything invariant for the file, then one line per record carrying only what varies.
+
+```jsonl
+{"h":1,"seq":175,"at":"2026-08-31T09:12:06.114000000Z","kind":"transcript",
+ "adapter":"claude-code/0.1.0",
+ "src":"-Users-…-python/92a5d58e-…/subagents/agent-afdc6064f2f8dcb06.jsonl",
+ "session":"92a5d58e-36cf-4838-a58c-66058206b903","stream":"afdc6064f2f8dcb06",
+ "state":"available","derivation":null}
+{"ord":1042,"off":532231,"sha":"9f3a1c2b…","payload":{…}}
+{"ord":1043,"off":533104,"sha":"1b2c4d5e…","payload":{…}}
+```
+
+**Source kinds.** Note that `log` and `event` are not two sources: Claude Code's events *are* OTLP
+log records, with body = the string `claude_code.<event_name>`.
+
+```
+transcript | otlp_span | otlp_log | provider_body | agent_meta | journal | derived
+```
+
+`derived` is SkyWalking AI Sessionizer's own output; the slot is reserved now so Plan 02 needs no format
+change.
+
+**Why header + records.** `kind`, `src`, `session`, `stream` are constant for a whole delta file.
+Repeating them per record costs ~200 bytes × ~350k records ≈ 70 MB of pure redundancy. Hoisting them
+also makes each file self-describing — it stays interpretable if moved out of its directory, which a
+bare stream of source lines is not. For OTLP the header pays off twice: resource and scope
+attributes are per-export constants and belong there rather than on every record.
+
+**Per-record fields.**
+
+| Field | Purpose |
+| --- | --- |
+| `ord` | ordinal **in the source**, not in this file — deltas start mid-file, so these differ, and only `ord` is stable across passes |
+| `off` | byte offset in source; lets any record be re-verified against the original while it exists |
+| `sha` | digest of the payload bytes |
+| `payload` | the source record, **byte-for-byte** |
+
+**Byte-fidelity is preserved.** The payload is copied through as raw bytes rather than re-serialized
+(`json.RawMessage` in Go), so key order, whitespace and number precision all survive. The envelope
+does not cost us the exactness of the wrapped record.
+
+**Per-source mapping.**
+
+| Source | `kind` | `ord` | `off` | Notes |
+| --- | --- | --- | --- | --- |
+| main / subagent transcript | `transcript` | source line no. | byte offset | payload verbatim |
+| workflow journal | `journal` | source line no. | byte offset | payload verbatim |
+| `agent-*.meta.json` | `agent_meta` | 0 | — | single document: header + 1 record |
+| OTLP logs *(reserved)* | `otlp_log` | index in export | — | `derivation:"otlp_flatten"`; resource+scope in header |
+| OTLP spans *(reserved)* | `otlp_span` | index in export | — | same |
+| raw bodies *(reserved)* | `provider_body` | 0 | — | header + 1 record |
+| sessionizer output | `derived` | — | — | Plan 02+ |
+
+`state` is the record-level content state (`available | redacted | truncated | omitted |
+unavailable`). In v1 nothing is redacted at collection time, so it is `available` in the header and
+never overridden; the slot exists for privacy modes later. Field-level redaction — such as the
+stripped thinking text in §1.3 — is a coverage concern for Plan 02, not a collection-time one.
+
+**State files are not record streams.** `cursor`, `session.state` and `rebuild_progress` stay
+plain line-oriented key-value text (§2.5). The rule is: streams get envelopes, state files stay
+simple.
+
+### 2.4 Sequence, duplicates, and what digests can honestly prove
+
+Protobuf serialization is **not canonical** — the spec does not guarantee byte-identical output for
+the same logical message. "Same content ⇒ same bytes" is false in general.
+
+What *is* true is narrower: an OTLP exporter retrying a failed export re-sends the same serialized
+buffer. That is a property of exporter behaviour, not of protobuf.
+
+Therefore:
+
+- **Drop duplicates at the receiver's edge**, in memory, on the digest of received wire bytes *before*
+ demultiplexing. A retry hits the cache and is dropped.
+- **No digests in filenames for duplicate checks.** After demultiplexing the bytes are ours; a digest attests
+ to our buffer's integrity, not to what the client sent.
+- **The assembler must tolerate duplicate records regardless** — an intermediate Collector can
+ re-batch, delivering the same record inside two payloads no digest will match. It must also drop duplicates
+ transcript records by `(file, uuid)` for the independent reason in §1.2.
+
+Sequence gaps detect *our* losses. They cannot detect client-side drops, because OTLP carries no
+client sequence number. That should be reported as unknown, not papered over.
+
+### 2.5 State files
+
+All are plain line-oriented text, rewritten atomically via temp-and-rename, holding current state
+rather than history. If an audit trail is wanted later, that is a separate append-only log.
+
+#### Two cursor kinds
+
+The six source kinds (§2.8) divide into growing files and rewritten files, and they need different
+cursors.
+
+**Append cursor** — for `.jsonl` sources that only grow:
+
+```
+# streams/afdc6064f2f8dcb06/cursor
+schema 1
+kind append
+source subagents/agent-afdc6064f2f8dcb06.jsonl # relative to source_root
+dev 16777232
+ino 8461299
+offset 532231 # bytes consumed → next read starts here
+ord 1042 # source line consumed through → next batch starts at ord 1043
+last_uuid 5acb6271-ea30-4e55-a27f-ff8a41f579c0
+tail_sha256 1a2b3c… # sha256 of [offset-1MiB, offset)
+last_seq 175
+size_at_scan 532231
+updated_at 2026-08-31T12:00:00.000000000Z
+state active # active | source_gone | conflict
+```
+
+Each field earns its place against a specific failure:
+
+| Field | Catches |
+| --- | --- |
+| `dev` + `ino` | the file was replaced or rotated under the same path |
+| `offset` vs `size` | truncation, and "is there anything new" without trusting mtime |
+| `tail_sha256` | rewrite of recently-consumed bytes |
+| `ord` | avoids re-counting lines from byte 0 to continue numbering |
+| `last_uuid` | semantic drift — **diagnostic only** (28.6% of lines carry no uuid, §1.12g) |
+| `state` | distinguishes a pruned source (normal) from a conflict (stop) |
+
+`offset` is both "position consumed" and "next round's starting point" — one value, no second field.
+They diverge only for the in-flight tail: if the file is 90,000 bytes but the last complete newline
+is at 85,820, we record 85,820. Recording what was *scanned* rather than *consumed* would be wrong.
+
+**Why a tail window rather than a full prefix digest.** Re-hashing `[0, offset)` every pass means
+re-reading up to 59 MB per cycle. The window is sized at 1 MiB, comfortably above the largest
+observed line (979,632 bytes), so it always spans at least one complete record boundary. Combined
+with `ino` (rotation) and `size < offset` (truncation), the only uncovered case is an in-place edit
+far behind the cursor — not a Claude Code behaviour. Full-prefix hashing becomes an opt-in strict
+mode.
+
+**Snapshot cursor** — for whole-file JSON that is *rewritten*, not appended:
+
+```
+# manifest/wf_0477d6c4-5e3/cursor
+schema 1
+kind snapshot
+source workflows/wf_0477d6c4-5e3.json
+content_sha256 9f3a1c2b…
+size 18422
+mtime 1788046628
+last_seq 176
+updated_at 2026-08-31T12:00:00.000000000Z
+state active
+```
+
+A snapshot cursor lands a **new immutable version whenever the digest changes**, turning a mutable
+source into an immutable version history. `wf_<run-id>.json` genuinely needs this — it carries
+terminal state (`completed`, `killed`), `durationMs`, `totalTokens` and `result`, so it is rewritten
+as a run progresses. `agent-<id>.meta.json` uses the same cursor but in practice lands exactly once
+(§2.8).
+
+#### `rebuild_progress`
+
+Assembler state. Because `<seq>` is monotonic **across all streams**, it collapses to one watermark
+regardless of how many child streams exist:
+
+```
+updated_at 2026-08-31T09:14:02Z
+pass 41
+landed_seq 174
+```
+
+That is the point of the shared counter — without it this file would need thousands of per-stream
+lines. It is safe because the collector assigns the sequence at write time, so a file never appears
+later with a lower sequence and the watermark cannot skip a hole.
+
+### 2.6 The mutable-source problem
+
+Transcripts are appended to **live**, so they cannot be referenced in place — they violate the
+write-once model everything else depends on.
+
+Proposal: **tail them into immutable deltas.** Each `transcript-<ts>-<seq>.jsonl` contains only the
+bytes appended since the last cursor. Mutability is then confined to the tailer.
+
+Evidence supports this strongly: resume appends to the same file, and across all 60 main + 2,851
+subagent files there are **zero dangling parentUuids and zero cross-file references** (§1.6). The
+replay phenomenon (§1.2) is also an *append* — a contiguous block written at the resume point — so
+the file stays append-only and the prefix digest holds. Whether truncation ever occurs is unproven
+(§1.9), which is exactly what the prefix-digest check exists to catch.
+
+Open question: continuous tailing (a daemon) versus on-demand scanning at rebuild time. On-demand is
+simpler and loses nothing **provided** transcripts are never truncated. The §1.9 item decides this.
+
+---
+
+### 2.7 Pull-mode positioning and completeness (Phase 1)
+
+Positioning is a **pull-only** concern. Phase 2 (push) has no cursor — it has an at-least-once
+delivery problem instead. Different failure modes, which is the main reason the two postures should
+not share an abstraction beyond "produces landed records".
+
+**A session is never "complete".** Resume appends to the *same* file with the *same* sessionId —
+proven by a 659-hour gap in one transcript where `version` jumped 2.1.220 → 2.1.251 and the chain
+still linked (§1.6). A file quiet for a month can grow again. The collector can therefore never mark
+a session done and stop watching; it can only report **quiet since T**. This propagates: retention
+cannot trigger on "completed", and segment closing needs the `close_candidate` → gates → `committed`
+staging of v0.8 §3.3 rather than a bare idle timeout.
+
+Five levels of completeness, each with its own rule:
+
+| Level | Rule |
+| --- | --- |
+| **Byte** | consume only up to the last `\n`; a trailing partial line is an in-flight write — never parse it, never advance past it |
+| **Record** | free once byte-level holds (this is why `.jsonl`, §2.3) |
+| **Provider call** | close call C when a line with a different `message.id` arrives. **Do not wait for the terminal fragment** — 7.14% of calls never get one (§1.3), so waiting would hang on one call in seven. Mark `terminal_missing` instead. |
+| **Stream** | a subagent is finished per the journal `result`/`failed`, the `<task-notification>` status, or the parent's tool_result — but **the journal leads the filesystem** (§1.5), so "told it finished, haven't seen its data yet" is a normal state |
+| **Session** | no session-end record exists. The only liveness signal is `~/.claude/sessions/<pid>.json`, present while the process runs and deleted on exit. State model is `live \| quiet \| unknown` — never `complete`. |
+
+**Cursor shape** — four fields, each catching a different failure:
+
+```
+src <path> dev=16777232 ino=8461299 offset=8823104
+ uuid=ccb3597f-… sha256=1a2b3c…
+```
+
+- `offset` — resume point, always on a line boundary
+- `dev`+`ino` — catches **rotation**: same path, different inode means replaced, not appended
+- `uuid` — cheap semantic anchor; the last record consumed should still be there
+- `sha256` — digest of bytes `[0, offset)`, the authoritative rewrite check
+
+Re-hashing 59 MB every pass is wasteful. Check the cheap invariants first — `size >= offset`, inode
+unchanged — and do a full re-verify only when one trips. Go's `sha256` implements
+`encoding.BinaryMarshaler`, so the rolling state can be persisted and extended rather than
+recomputed in the forward direction.
+
+### 2.8 Pull-mode collection process (Phase 1)
+
+#### The seven source kinds
+
+| # | Source | Nature | Cursor |
+| --- | --- | --- | --- |
+| 1 | `<session-id>.jsonl` | append-only | append |
+| 2 | `subagents/agent-<id>.jsonl` | append-only | append |
+| 3 | `subagents/agent-<id>.meta.json` | write-once | snapshot |
+| 4 | `subagents/workflows/<wf>/agent-<id>.jsonl` | append-only | append |
+| 5 | `subagents/workflows/<wf>/journal.jsonl` | append-only | append |
+| 6 | `workflows/wf_<run-id>.json` | **rewritten** | snapshot |
+| 7 | `workflows/scripts/<label>-wf_<run-id>.js` | rewritten | snapshot |
+
+**Workflow scripts carry their run id in the filename**, which is what makes them
+collectable despite living outside their session's directory:
+
+```
+<label>-wf_<run-id>.js session from the path, run id from the name
+```
+
+Split on the **LAST** `-wf_`: a run id contains hyphens (`wf_afa31e47-f6c`), so a greedy
+match starts at the first occurrence and swallows any later one. Verified on a real corpus:
+172/172 filenames parse, and 172/172 run ids resolve to a manifest in their own session.
+
+A script is JavaScript, not JSON, so it lands with `state: "raw"` - wrapped as a JSON string
+so the landed file stays parseable, with the bytes recoverable exactly.
+
+**`meta.json` is write-once.** 200 of 200 sampled meta files are *older* than their own
+`agent-<id>.jsonl` — zero equal, zero newer. Its content is spawn-time only (`agentType`,
+`description`, `spawnDepth`, `toolUseId`, `parentAgentId`, `model`); no status, result or duration.
+There is **no registry file** — each agent owns its own sidecar, so nothing is ever removed from a
+list. Pairing is exact: 2,697 meta ↔ 2,697 jsonl, zero orphans either direction.
+
+**`journal.jsonl`** records `{"type":"started","key":"v2:<sha>","agentId":…}` and `{"type":"result",…}`
+— 389 started / 347 result in the sample, the shortfall being in-flight and killed agents. It is
+**workflow-only**; Agent-tool spawns and Skill-forks have no journal. And it **leads the filesystem**:
+a `started` — occasionally even a `result` — appears before the child transcript exists. Normal, not
+a gap.
+
+**No single kind carries spawn lineage.** Of 300 sampled meta files, 210 hold only
+`{agentType, spawnDepth}` — **70% of children carry no parent pointer in their own sidecar**, because
+they are workflow subagents. The link is distributed:
+
+| Mechanism | Parent side | Child side |
+| --- | --- | --- |
+| Agent tool | main: `tool_use{name:"Agent"}` + `toolUseResult.agentId` | `meta.json.toolUseId` — redundant, so cross-checkable |
+| Skill fork | main: `toolUseResult.agentId`, `status:"forked"` | **nothing** |
+| Workflow | main: `Workflow` result → `toolUseResult.transcriptDir` + `runId` | `journal.jsonl` `started` records |
+| Depth 2 | — | `meta.json.parentAgentId` |
+
+So all six kinds are collected. The main transcript alone cannot enumerate workflow children; the
+child files alone cannot name the parent for 70% of children.
+
+#### Discovery is session-first, not project-first
+
+**A session's files scatter across several project directories.** Measured: **7 of 61 sessions span
+more than one slug**, one of them across six.
+
+The cause is **workflow scripts, not execution streams.** Claude Code files a run's script under
+whatever working directory the agent had at the time; the conversation data - main transcript, child
+streams, journals, manifests - stays in one directory. Before scripts were collected, those extra
+directories yielded nothing and were correctly not recorded. Now that scripts are collected the
+directories are real again, which is why the exclusion matcher keys on the session's PRIMARY
+directory: a script filed elsewhere must not disqualify the conversation it belongs to.
+
+```
+f9d8df3b-c47b-4f56-829c-37f4d81486cf
+ transcript -Users-wusheng-github-skywalking-horizon-ui
+ dir -Users-wusheng-github-skywalking-horizon-ui
+ dir -Users-wusheng-github-skywalking-horizon-ui-test-e2e
+ dir -Users-wusheng-github-skywalking-horizon-ui-apps-bff-src
+ dir -Users-wusheng-github-skywalking-horizon-ui-apps-bff-src-store-audit
+ dir -Users-wusheng-github-skywalking-horizon-ui-apps-bff-src-client-banyandb
+```
+
+Cause: a subagent running in a different working directory is filed under *that* cwd's slug, not the
+session's launch slug — the `cwd`-variance of §1.2 made visible in the filesystem.
+
+**Rule:** scan every project directory for UUID-shaped entries — both `.jsonl` files and directories —
+then **group by session id across slugs and union them**. A project-first walk misses most subagents
+for 11% of sessions.
+
+This also makes filters session-scoped: excluding a slug must not cut streams out of a session
+being collected, so `include`/`exclude` are evaluated as "does any part of this session match", never
+per-directory.
+
+Two further discovery rules from the same scan:
+
+- **Filter directory names by UUID shape.** `memory/` is a sibling directory inside project
+ directories and is **not** a session. It is excluded.
+- **Pruning is real and not atomic.** 12 of 41 session directories have no main transcript — the
+ `<session>.jsonl` was deleted while the whole `subagents/` tree survived. Expect child streams with
+ no parent. Separately, 32 of 61 main transcripts have no subagent directory at all, which is simply
+ a session that spawned no agents.
+
+#### Directory names are slugified cwd, and irreversible
+
+`/Users/wusheng/github/skywalking-horizon-ui` → `-Users-wusheng-github-skywalking-horizon-ui`.
+Verified across all 25 directories carrying a transcript; two mismatches revealed the full rule —
+`/`, `.` **and space** all collapse to `-`:
+
+```
+/Users/…/Application Support/JetBrains/GoLand2026.1/projects/…
+ → -Users-…-Application-Support-JetBrains-GoLand2026-1-projects-…
+/private/tmp/agentsessionizer-cc-v04.psixvf
+ → -private-tmp-agentsessionizer-cc-v04-psixvf
+```
+
+So a slug cannot be reversed to a path. **Forward slugification is deterministic; reverse is not** —
+which is why config accepts real paths and slugifies them to match, and never attempts the inverse.
+The real `cwd` is recoverable from record content, but it varies within a session (one session showed
+10,379 records at the project root plus Downloads, a scratchpad and several subdirectories), so it is
+not a session-level key.
+
+#### Configuration
+
+```yaml
+storage:
+ root: ./data # beside the binary; tests point it beside their fixtures
+
+adapters:
+ - name: claude-code-local
+ enabled: true
+ # source_root resolution: this value → $CLAUDE_CONFIG_DIR/projects
+ # → $XDG_CONFIG_HOME/claude/projects → ~/.claude/projects
+ source_root: ""
+ # Entries starting with "/" are real paths, slugified forward for matching.
+ # Anything else is a glob against the directory name. Evaluated per SESSION.
+ include: [] # empty = all
+ exclude:
+ - "/private/tmp/**" # Claude Code's own agent scratchpads
+ collector:
+ mode: watch # watch | once
+ interval: 5s
+ max_delta_bytes: 8MiB
+```
+
+`claude-code-local` names both the runtime and the collection posture, leaving room for a future
+`claude-code-otlp` reading the same runtime by push. `collector` is an adapter attribute, not a
+global section, because cadence is a property of how a given adapter reaches its source.
+
+`mode: once` matters as much as `watch`: the first run over existing history is a backfill of ~1.2 GB
+across ~2,900 files, not a tail. Same code path, different exit condition.
+
+A `sources list` command is needed so nobody has to guess at slugs — printing slug, resolved `cwd`
+(from record content) and session count.
+
+#### The consuming workflow
+
+```
+┌─ DISCOVER (session-first) ────────────────────────────────────────┐
+│ walk all project dirs → UUID-shaped .jsonl files AND directories │
+│ group by session id ACROSS slugs → union │
+│ skip non-UUID dirs (memory/) │
+│ liveness: does ~/.claude/sessions/*.json name this session? │
+│ CHANGE TEST: append → stat().size vs cursor.offset │
+│ snapshot → content digest vs cursor.content_sha256 │
+└───────────────────────────┬───────────────────────────────────────┘
+ │ sessions with ≥1 changed source
+┌─ PER SESSION (single-threaded) ───────────────────────────────────┐
+│ │
+│ VALIDATE ino ≠ cursor.ino → rotation → conflict, stop │
+│ size < cursor.offset → truncation → conflict, stop │
+│ file gone → source_gone (NOT an error) │
+│ else verify tail_sha256 │
+│ ▼ │
+│ TAIL seek(offset); read to the LAST COMPLETE '\n' │
+│ bytes past it = in-flight write → leave them │
+│ unparseable line mid-stream → conflict │
+│ ▼ │
+│ LAND seq = session.state.next_seq++ │
+│ write .tmp: header + records (payload verbatim) │
+│ fsync → rename → chmod 0444 │
+│ ▼ │
+│ COMMIT cursor → tmp → rename │
+└───────────────────────────────────────────────────────────────────┘
+```
+
+**Land before commit — deliberately at-least-once.** A crash between the two re-lands the same
+records next pass. That is safe *for free*, because the assembler must already drop duplicates by
+`(file, uuid)` for an unrelated reason (§1.2 — Claude Code itself writes duplicates). The reverse
+order would lose data. No write-ahead log is needed.
+
+**Change detection is `size` vs `offset`, never mtime.** One `stat()` per file; ~2,900 stats is
+milliseconds, and it needs no assumption about clock skew or timestamp granularity.
+
+**Source deletion is normal.** 330 of 345 session ids in `history.jsonl` have no surviving
+transcript. The cursor moves to `state=source_gone` and the landed deltas remain — so the landing
+zone outlives the source, and retains conversations Claude Code has already discarded.
+
+**Files appearing mid-pass is normal**, because the workflow journal leads the filesystem. Each pass
+re-discovers rather than caching a file list.
+
+#### Open: conflict recovery
+
+Rotation or a tail-digest mismatch means the source changed underneath us. Stopping and flagging is
+right; what follows is not settled. Proposed default: **re-land from offset 0 into a new stream
+generation** (`streams/<id>@2/`), so nothing is lost and nothing is silently merged. Evidence says
+this should never fire — transcripts look strictly append-only — but §1.9 lists that as unproven.
+
+## 3. Deferred
+
+**Phase 1 (local logs) — remaining plans:**
+
+- **Plan 02 — per-session pipeline:** duplicate uuids removed first, then fragment reassembly by
+ `(file, message.id)` **in line order, never by ancestor walk** (§1.12a), stream partitioning, the
+ three spawn joins, epoch boundaries via `logicalParentUuid`, segment gates, continuity audit
+ scoped as a structural cross-check (§1.12c).
+- **Plan 03 — output:** conversation bundle, export projection, preview.
+
+**Phase 2 (OTLP) — additive, no Phase 1 rework:**
+
+- OTLP receiver, per-record demultiplex by `session.id`, landing into the reserved `log-` / `trace-`
+ prefixes (§2.2).
+- Measurement only: per-call `duration_ms` and `cost_usd`, tool durations, `tool_decision`.
+ No structural contribution (§1.7).
+- Metrics are a third, separate pipeline with its own retention. Nothing in metrics contributes to
+ rebuilding a conversation.
+
+**Orthogonal opt-in, either phase:**
+
+- Raw provider bodies. The only route to a verified `input_manifest` and the only way to check the
+ continuity invariant against what was actually sent (§1.8, §1.12d). Highest privacy risk in the
+ system; must be opt-in with redaction.
+
+---
+
+## 4. Open questions for discussion
+
+1. **Does the collector own an OTLP endpoint, or read files only?** Given §1.7 — no `agent_id` on
+ events, and lineage spans unreachable without a beta flag — the OTLP channel now looks
+ substantially *weaker* than transcripts for structure. It contributes timing and cross-validation.
+ This weakens the case for a receiver in v1.
+2. **Should hooks be the second channel instead of OTLP?** §1.10 says hooks carry `agent_id` on
+ every subagent hook, name the child transcript directly, and expose the full compaction lifecycle
+ — everything the unreachable spans would have given us, without a beta flag. Cost: they are on
+ the critical path and require editing the user's `settings.json`.
+3. **Is `OTEL_LOG_RAW_API_BODIES` in scope for v1?** It makes exact model-input membership reachable
+ and needs no proxy. It is also the highest-privacy-risk surface in the system and must be opt-in
+ with redaction.
+4. **Where does `<root>` live** — under `~/.claude/`, alongside it, or a configured path?
+5. **Retention:** per-session TTL, or unbounded until explicitly purged?
diff --git a/design-notes/02-parse-and-assembly.md b/design-notes/02-parse-and-assembly.md
new file mode 100644
index 0000000..2717c7a
--- /dev/null
+++ b/design-notes/02-parse-and-assembly.md
@@ -0,0 +1,623 @@
+# Design Plan 02 — Parse and Assembly
+
+**Status:** implemented. The pipeline is `internal/assemble`, the round chain is `pkg/sessionflow`, the
+round driver is `internal/parse`, and `asz parse` / `asz conversation` drive them.
+**Scope:** turning landed records into a conversation structure. Collection is Plan 01 and is
+implemented; export and preview are Plan 03 and are not designed.
+
+Every rule below is forced by a measurement. Where a rule looks arbitrary, the evidence that forced
+it is cited, because several of them are the opposite of the obvious choice.
+
+Numbers marked **(corpus)** were re-measured over the whole corpus — 2,970 files, 1.09 GB, 365,825
+records, 25 project directories — and each was then re-derived independently by a second pass that
+was told to refute it. Where the two disagreed, the number below is the one that survived. Several
+claims in the first draft did not.
+
+---
+
+## 0. What parse consumes
+
+Parse reads the **index**, not the payloads.
+
+```
+365,554 index entries describing 1.0 GB of landed payload
+47 MB total index ~12x smaller
+16 MB / 4.5 ms largest single session, load + rebuild every lookup map
+```
+
+Structure resolution needs identifiers, not content: removing duplicate records needs record ids, provider-call
+grouping needs message ids, tool joins need tool-use ids, spawn joins need agent and run ids. None
+of it reads message text. Payloads are read only in the final materialisation step, and only for
+records that appear in the output.
+
+The index is derived and disposable; deleting it rebuilds from landed files.
+
+---
+
+## 1. The pipeline
+
+Eight stages. The order is forced: each depends on the one before, and two of them are wrong in the
+obvious formulation.
+
+### 1.1 Remove duplicate records, keeping the first
+
+**Nothing downstream is correct before this.** Record ids are not unique within a source file.
+
+**(corpus)** 1,533 repeated ids among 318,199 distinct ids across 2,970 files. **Multiplicity is
+exactly 2, never 3**, so a single pass is provably enough — no iteration, no fixed point. They occur
+in 11 of 62 main transcripts and in **0 of 2,908 subagent transcripts**.
+
+**They are not a resume.** The first draft said they were. In 11 of 11 files the repeated run ends on
+the record immediately before a `system`/`compact_boundary` — every time, with no exception. This is
+the runtime re-writing a block of history just before it resets model context. It matters
+operationally: duplicate removal and epoch cutting touch the same lines, so **removal must run
+first**, and the epoch cut must not read the re-written block as new content.
+
+**First occurrence wins, and the reason is data, not order.** The later copy is the worse one:
+
+- `promptId` is rewritten on 538 of 1,533 pairs, collapsing onto the cycle that was active at reset
+ time. Keeping the last would attribute those records to the wrong turn.
+- `toolUseResult` is present on one side of 536 pairs, and the first copy is larger in 430 of them
+ and smaller in **zero**. 43.4% of captured tool output would be discarded, and in 314 pairs the
+ later copy has `stdout` blanked to `""`.
+- `parentUuid` differs on 40 pairs, with both parents present in the file, so keeping the last
+ changes the graph that stages 1.3 and 1.7 walk.
+
+The `message` object itself is byte-identical in 1,533 of 1,533 pairs. Only the envelope differs —
+which is exactly why this looks harmless and is not.
+
+**Nothing distinguishes an original from a copy except line order.** Timestamps are identical
+1,533/1,533, message ids identical where present, block ids identical. So "first" has to mean first
+in file order; there is no content test to fall back on.
+
+**44,585 of 365,825 lines carry no record id at all** — sidecar types keyed only by session. A pass
+that indexes by id must not treat a missing id as one colliding key. `internal/index` skips them:
+an entry with no identity is always its own record.
+
+> The index applies all of this. `Canonical()` is the deduplicated view, and a repeated copy is
+> excluded from **every** derived lookup, not only from id resolution.
+
+### 1.2 Partition streams
+
+`main` and one stream per agent id, already separated by the landing layout. A child stream **always
+shares the parent's session** — **(corpus)** 2,908 subagent files, zero exceptions, and no file
+carries more than one session id.
+
+**Stream membership comes from the file, never from a field.** `isSidechain` looks like the
+discriminator and is not: it is absent on 28.7% of main records, so a truthiness test misclassifies
+nearly a third of the parent lineage. Inside a subagent file it is a constant, so it carries no
+per-record information there either. It is not indexed.
+
+**(corpus)** Nesting stops at depth 2: 2,683 sidecars at depth 1, 31 at depth 2, one with the field
+absent — which must default to 1 rather than fail. The layout carries **no** depth information; all
+2,715 agent transcripts sit flat in two directories. Depth is only in the sidecar. All 31 depth-2
+agents come from one session, so any "sample N sessions" method has a good chance of seeing none and
+concluding wrongly that the maximum is 1.
+
+**A recursive walk is required.** A non-recursive listing of `subagents/` finds 107 files where a
+recursive walk finds 2,910 — a 96.3% miss, because most children live under
+`subagents/workflows/<run-id>/`.
+
+### 1.3 Assemble provider calls by `(file, message.id)` in **line order**
+
+**Never by walking `parentUuid` forward.** The graph is a DAG, not a chain: parallel tool dispatch
+forks it, so a forward walk traverses one branch and silently drops the other `tool_use` and its
+result.
+
+The ban is on the **direction**, not on the edge. Walking *backward* - from a record to its nearest
+ancestor carrying a given field - is single-valued and correct, because a record has exactly one
+containment parent. Stage 1.7 depends on that. Only forward traversal is ambiguous.
+
+**(corpus)** The forward walk drops a `tool_use` on 16,063 of 85,057 tool-bearing calls — 18.9%
+pooled, 2.1% on the parent lineage, 25.9% on child streams. The first draft explained this as
+parallel dispatch forking the graph. That is wrong: no fragment ever has two children sharing its
+message id, so there is no branch to choose. The real reason is that a call's fragments are
+**interleaved with their own tool results**, in the parent chain and in line order alike — 24.0% of
+multi-fragment calls have foreign records inside their line span. So grouping must be a full scan
+keyed by message id, and must not assume a call occupies a contiguous run of anything.
+
+Group by `message.id`, **not** `requestId`: a `<synthetic>` companion record can reuse a real call's
+`requestId`, which would put fabricated content inside a real call.
+
+Synthetic filter: `message.model == "<synthetic>"`. Not `requestId == null` — 24 of 97 synthetics
+carry a non-null `requestId`, and 2 genuine calls have none.
+
+**Usage: take the last fragment in LINE ORDER. Never sum, and never use the stop reason to find it.**
+
+The stop reason is not a terminal marker. **(corpus)** a main transcript stamps the same stop reason
+on every fragment of a call (23,324 of 23,329 groups); a child stream stamps it only on the last. A
+stop-reason test therefore picks the first fragment on main and the last on a child. Line order is
+the only rule that works on both.
+
+Summing is wrong for a different reason on each side. Main repeats the call's final usage on every
+fragment, so adding them multiplies the real number by the fragment count — **(corpus)** 2.18x
+pooled, 9.0x worst case. Child streams write streaming partials that climb 1, 2, 3, where the last
+value is the whole count and summing adds only 2%.
+
+**(corpus)** ~10% of calls never report a stop reason (0.02% main, 14.5% subagent). Two traps here:
+
+- Their usage block is **present and wrong** — output counts of 1 to 5, a streaming stub. 5,739 of
+ 7,410 such calls have a last value of 5 or less, against 101 of 66,751 finished ones. So usage
+ availability is gated on the stop reason, never on the field being present.
+- They are **not in flight**. Only 72 of 7,410 sit at the end of a file; the rest are mid-file with
+ the conversation continuing past them, and 7,311 had their tools actually run. Nothing waits.
+
+Fragments per call: **(corpus)** 2.09 pooled, ranging 1.59 to 2.51 per session. Treating records as
+calls inflates every count by that factor.
+
+Synthetic records have a further signature: **(corpus)** all 122 have a UUID-shaped message id while
+all 184,330 real ones are prefixed `msg_`, with no overlap. So a fabricated record can never collide
+with a real call's id.
+
+### 1.4 Join tools
+
+`tool_use_id` → the index's tool lookup. **(corpus)** every `(file, tool_use_id)` maps to exactly one
+result once duplicates are removed. `tool_use` → `tool_result` is strictly one to one; a provider
+call to its tools is one to many, up to 16.
+
+**There is a second, fully redundant join the first draft missed.** Every tool result carries a
+top-level `sourceToolAssistantUUID`, and it matched the record holding the corresponding `tool_use`
+in **105,409 of 105,409 cases** — a direct record-to-record edge, and a free integrity check on the
+id join.
+
+**`srvtoolu_` is not a tool-use id.** The first draft said it was a second prefix to accept. It is an
+id *inside a WebSearch result payload*, one level below the tool call, and the assembler never joins
+on it. Nothing may match on a prefix either way.
+
+**Absence of `is_error` does not mean success** — it is present on 85.5% of result blocks.
+
+**`toolUseResult` is a parent-lineage field.** Present on 100% of main-transcript tool results and
+2.1% of child-stream ones, so everything read from it — captured output, and the spawn joins in §1.5
+— is implicitly a parent-lineage mechanism.
+
+**It is not always an object.** On roughly 7% of results it is a bare string. Decoding it into a
+struct fails, and because the failure covers the whole record, every identifier on that record is
+lost with it. It must be read as raw JSON and shape-checked.
+
+Execution timing is reported `unavailable`. "No local record" is slightly too strong: three tools do
+give a real duration (`WebFetch.durationMs`, `WebSearch.durationSeconds`, `Agent.totalDurationMs`)
+and could populate it for exactly those. A configured budget such as a timeout is not a measurement,
+and a timestamp delta includes model and harness latency, so neither is used.
+
+### 1.5 Join child agents to the calls that started them
+
+No single field spans them. The first draft ordered these by how obvious they look; the corpus
+orders them by how much work they do, and the two orders are almost reversed.
+
+| Mechanism | Join | Share **(corpus)** |
+| --- | --- | --- |
+| **Workflow** | the launch result's `runId`, plus `journal.jsonl` `started` records naming each child | ~96% |
+| **Agent tool** | the parent's `toolUseResult.agentId` | |
+| **Skill fork** | the same field with `status:"forked"`; there is no separate pointer to the call, so the tool id comes from the result's own content block | |
+| **Nesting** | the child sidecar's `parentAgentId` | 31 children |
+| | *together* | ~4% |
+
+**The sidecar is not a general parent pointer.** The first draft said 70% of children lack one; the
+real figure is worse and the conclusion different. A sidecar normally holds only `{agentType,
+spawnDepth}` — nothing about who started it. It names a parent for a *nested* child and nothing else,
+so it is a 3.3% edge case, not a primary join. Without `parentAgentId` a child of a child is
+attributed to the main stream, flattening all 31 depth-2 spawns.
+
+**The journal answers one question only:** which children belong to a run. It carries no timestamp,
+no name, no parent pointer and no tool id — just an agent id and a content hash. So the launch
+itself still has to come from the parent's own result, which names the run. That is how a workflow
+spawn becomes an edge between the call and the child, with no invented node standing for the run.
+
+**`status` has three values, not two.** `async_launched` is a launch acknowledgement and not a
+result. `forked` is a skill fork. `completed` is a **synchronous return, and it IS the result** — the
+first draft did not mention it, and treating it as an acknowledgement loses a real output.
+
+Completion arrives as a notification, and its two ids are not equally good. **`<task-id>` is a
+generic handle** whose namespace depends on what was launched — an agent id only ~8% of the time,
+otherwise a background-command id, a workflow task id, or a monitor id. Joining on it
+unconditionally matches the wrong thing most of the time. `<tool-use-id>` is exact and is the primary
+key; the call it names already has a spawn edge, so resolving through the call works wherever the
+task id does not. A single notification can also carry **several** `<task-id>` elements.
+
+Two further cautions hold as written. The `<tool-use-id>` is **not always resolvable in the file it
+lands in** — a nested child notifies the main lineage while the call that started it lives in an
+agent file — so resolution searches the whole session. And `<status>` is **absent entirely** on a
+large class of notifications, so nothing may require it.
+
+**A child with no parent at all exists.** 99.93% resolve; two in the corpus do not. That is a state
+to record, not an assumption to assert — an orphan stream stays attached to the session and to no
+Talk, because guessing a parent is worse than saying none was found.
+
+**`agent.output` ownership is a policy, not an observation.** The first draft read as if the parent
+simply did not receive the child's output. It does: a `status:"completed"` result and a notification
+both carry a copy. The rule is that we *deliberately drop* the parent's copy and keep a reference,
+because otherwise a rendered conversation repeats every subagent's work inside its parent. The index
+marks those records so a renderer knows there is something to drop.
+
+`agent.output` belongs to the **child** stream. The parent gets a compact boundary that references
+it, never absorbs it — this is what stops a rendered conversation duplicating every subagent's work.
+
+### 1.6 Cut context epochs
+
+`system/compact_boundary` carries **`logicalParentUuid`**, an explicit pointer to the last message
+before the reset. **(corpus)** present on 27 of 27 boundaries and on no other record type anywhere.
+Epochs are never inferred.
+
+- **the boundary re-roots the chain.** Its own `parentUuid` is null on 27 of 27, so
+ `logicalParentUuid` is the *only* link back. Anything walking parents across a reset stops dead
+ without it.
+- boundary `uuid` == the `parentUuid` of the following `isCompactSummary:true` record, so the pair is
+ joined by containment
+- the summary's timestamp is **always earlier** than its boundary's, by up to ~1.4 s — so an epoch is
+ ordered by landed position and never by time
+- `logicalParentUuid` is **never** the immediately preceding line, so proximity cannot substitute
+- `preservedMessages.allUuids` may name records that exist nowhere on disk, so both it and
+ `logicalParentUuid` have to be unresolved-capable
+- the summary carries a `promptId` but **no `origin`** — which is exactly the case §1.7 has to handle
+ without an explicit trigger
+- child streams never reset — **(corpus)** 0 in 221,592 child records, so every child stream is
+ exactly one epoch
+- searching for the literal text `compact_boundary` is unsafe: it appears far more often in message
+ content than in real boundary records
+
+### 1.7 Build Talks and Runs
+
+This is the stage that produces the conversation a person reads, and the one with the least
+runtime support: neither Talk nor Run is a Claude Code concept. Both are derived, so the boundary
+rule has to be stated exactly.
+
+**Two runtime signals map onto one model concept. Conflating them is the trap.**
+
+`promptId` and `origin` are Claude Code's own undocumented fields. Neither is a model concept and
+neither may appear outside this adapter; the model has Talk, and nothing below it.
+
+| Side | | Meaning | Sampled session |
+| --- | --- | --- | --- |
+| runtime | `promptId` | one *prompt cycle* | 65 |
+| runtime | `origin.kind` | what triggered that cycle: `human` or `task-notification` | 36 / 29 |
+| **model** | **Talk** | a human trigger plus the notification cycles it caused | **36** |
+
+The identity is close but not exact:
+
+```text
+distinct promptId == count(origin.kind=="human") + count(origin.kind=="task-notification") + cycles with no origin at all
+```
+
+The third term is the correction. **A local slash command is a person acting and the runtime records
+no origin for it**, so a rule that required an explicit human marker would leave those cycles
+attached to whatever came before. The rule is therefore:
+
+> **A Talk starts on an external trigger, OR on a cycle where no record states a trigger at all.**
+> Everything else continues the interaction in progress.
+
+**(corpus)** the identity also only holds *after* duplicate removal: re-emitted records rewrite
+`origin`, so a splitter running before removal over-counts human triggers by up to 15%.
+
+**`origin` is not in one place.** It sits on the record and also on the attachment inside it —
+missing the second location loses 27.6% of all trigger markers. And the value is an open set: a third
+kind exists beyond the two obvious ones, so anything switching on exactly two values will meet
+something it does not expect.
+
+A background agent finishing and the parent resuming is mechanically a new prompt cycle, but nobody
+said anything — it is the same interaction continuing. This is exactly the
+`agent.call -> launch_ack -> child stream -> agent.output -> runtime.notification -> parent resumes`
+chain of §1.5, and it lives INSIDE one Talk.
+
+**Prompt cycle ids are not unique across streams.** A child stream is written under a cycle id that
+also appears in the parent, and one id was observed in three different child streams. Keying a Talk
+on the cycle alone therefore puts a child's records inside the parent's turn and leaves the child
+with no run of its own. The key is (stream, cycle).
+
+*Verification on a real 59,495-record session:* the parent lineage assembles to **307 Talks**, which
+is exactly the number of `origin.kind=="human"` records measured independently on the raw file, from
+336 distinct prompt cycles.
+
+**Membership is resolved by walking backward, never by line proximity.**
+
+`promptId` appears on `user` records only - assistant records carry none, verified across 115,184.
+But every assistant record reaches one through `parentUuid`:
+
+```text
+assistant records tested 400
+reached a promptId via parentUuid 400 (100%)
+hops needed min 1, median 2, max 7
+```
+
+**(corpus)** the walk must not be capped at a small constant: 140 of 44,973 records need more than 7
+hops. Attachments are ordinary hops in the chain — they carry a parent and are named as a parent by
+later records — so the walk must pass through them rather than skip anything that is not a message.
+
+So a record's Talk is found by walking back to the nearest ancestor carrying a cycle id, then reading
+that record's trigger. (The index exposes these as `Cycle` and `Trigger`; the runtime field names
+stop at the adapter.) Line-order proximity would agree in the simple case and diverge exactly where
+it matters — a forked chain, a re-emitted block, an interrupt that reattaches to an earlier record —
+putting records in the wrong turn.
+
+**A child stream is exactly one Talk**: the delegated prompt in, the final output out, nobody outside
+the agent involved. Child streams never reset context, so each is also exactly one epoch.
+
+This one is asserted from the stream's nature, not measured from the data. **(corpus)** child
+records carry `promptId` on 82,378 of 221,592 records but `origin` on exactly **one** record in the
+whole child corpus — so a child stream has many cycles and essentially no triggers, and the rule
+cannot be derived the way it is on the parent lineage. The implementation creates the single Talk up
+front and files every cycle in it as a Run.
+
+**Human turns that exist only as attachments must be recovered.** These are `queued_command` records
+with no message twin, and a reader following messages alone loses real human input — **(corpus)** a
+third of all human utterances live only here. They are filtered on `commandMode == "prompt"`; without
+the mode, completion blobs queued the same way are read as things the user typed.
+
+**They are not Talk starts.** **(corpus)** 886 of 918 are mid-turn steering absorbed into a cycle
+already running. They belong *inside* the enclosing Talk as human content, placed by ancestry — and
+no cycle id may be invented for them.
+
+**Ownership rules** from the model: `context.injection` is first-class, `message.synthetic` is never
+counted as agent output, `agent.launch_ack` is not a result, and `agent.output` is owned by the
+child stream and only referenced by the parent.
+
+### 1.8 Close segments — candidate, then gates
+
+A quiet period creates a **close candidate**, not a commit, because a long gap often falls inside a
+turn rather than between two.
+
+**(corpus)** the "~32%" of the first draft was one number answering two different questions. Both
+matter, and the gate depends on the second:
+
+| Gap threshold | Candidates | Mis-splits a prompt cycle | Mis-splits a **Talk** |
+| --- | --- | --- | --- |
+| 10 min | 697 | 23.67% | **38.74%** |
+| 15 min | 444 | — | 30.41% |
+| 20 min | 302 | 9.60% | 21.52% |
+
+The Talk figure is the one to gate against: splitting at a notification cycle still severs a Talk.
+There is nothing special about ten minutes — it is the worst point on the curve below fifteen. The
+threshold is a setting, recorded in the round header's policy version, and 20 minutes buys a large
+reduction in mis-splits for 57% fewer candidates. Whether provider-error records sit on the
+segmentation timeline moves the first column by five points, so that choice is stated too.
+
+**Time runs backwards sometimes.** **(corpus)** 0.315% of consecutive records step back, by up to
+4.06 hours, and provider-error records are the main cause: their timestamp is when the failed
+request began, not when the record was written. Anything ordering by time — the gap computation
+included — must clamp a negative delta rather than read it as an enormous gap.
+
+Gates before committing:
+
+| Gate | Why |
+| --- | --- |
+| `activity_boundary` | the idle gap is real |
+| `no_crossing_open_operation` | **no tool request whose result lands on the far side of the gap, and no child still running.** Without this a request could be frozen into a committed segment while its result arrives in the next one, and a committed segment is never rewritten |
+| `lateness_watermark` | no late evidence still expected |
+| `conversation_identity` | the conversation id is resolved |
+
+The second gate does real work rather than being a safety net: it is what makes "committed segments
+are immutable" safe to promise.
+
+**(corpus)** it is not about a request left dangling at the end of a file — that is 0 of 25,892 on
+the parent lineage, because the runtime always writes a result, including a failure. It is about a
+result arriving after the quiet period, which happens on 85 of 697 long gaps (12.2%). The two are
+different things and the first draft conflated them.
+
+---
+
+## 2. Output structure — the round chain
+
+Implemented in [`pkg/sessionflow`](../pkg/sessionflow/). Its tests are the specification;
+what follows is why it has this shape.
+
+**A round is an append-only immutable delta, and the conversation is the fold of every round.**
+
+```text
+state₀ = empty
+stateₙ = apply(stateₙ₋₁, roundₙ)
+```
+
+The obvious shape — rewrite the whole structure every round — works against everything beneath
+it. Landed files append, cursors advance, the index appends. Rewriting a 15 MB structure every five
+seconds to add a handful of nodes is the one place the design would stop being incremental.
+
+Pure append does not work either, because **assembly revises what it already emitted**. A `tool`
+node lands with `result: unavailable` and gains its result later. A provider call gains fragments.
+An `agent.call` gains its child stream once the child transcript appears — and the workflow journal
+names children before their transcripts exist, so this is routine, not an edge.
+
+The resolution is that a revision is a *new line in a later round*, never an edit to an earlier one.
+That is what lets a round be digested, archived or shipped the instant it is written.
+
+### 2.1 Layout
+
+```text
+data/_conversations/<conversation-id>/
+ conversation.state mutable head pointer
+ rounds/r000001-<digest12>.sf immutable, 0444
+ rounds/r000002-<digest12>.sf
+```
+
+The split is deliberate. **Rounds carry nothing mutable and nothing temporal**; `conversation.state`
+carries the head round, its digest, how far landed evidence has been consumed, and when. Rounds are
+linked by digest, not by filename: round N names the digest of round N−1, so a holder of round 3
+that has not seen round 2 can store it but cannot apply it.
+
+### 2.2 Frames
+
+```jsonl
+{"t":"header","schema":"sf/1","conversation":"C1","session":"S1","round":2,"previous":"<r1 digest>",
+ "from_seq":46,"through_seq":57,"input_digest":"<chained landed digest>","parser":"v1","policy":"v1"}
+{"t":"node","id":"main/t8","revision":2,"kind":"talk","stream":"main"}
+{"t":"node","id":"main/t8/r1/c3","revision":2,"kind":"llm.call","parent":"main/t8/r1",
+ "ref":{"seq":12,"row":340},"refs":[{"seq":12,"row":340},{"seq":12,"row":341}]}
+{"t":"relation","id":"rel/starts/main-t8-r1-c3/stream-a4646a5c9","revision":2,"type":"starts",
+ "from":"main/t8/r1/c3","to":"stream/a4646a5c9","quality":"exact_unique","via":"toolUseResult.agentId",
+ "evidence":[{"seq":12,"row":345}]}
+{"t":"unresolved","id":"unres/tool_result/toolu_01Y","revision":2,"kind":"tool_result",
+ "ref":"tool/toolu_01Y","state":"open"}
+{"t":"commit","digest":"<sha256 of lines 1..n-1>","counts":{"nodes":1284,"relations":96,"unresolved":3}}
+```
+
+The commit digest covers every line before it, so a round verifies itself: recompute over lines
+1..n−1 and compare. It is also the value the next round names as its `previous`.
+
+### 2.3 The four rules
+
+**1. Entity ids come from stable evidence, never from position.** A node keyed by "the 14th record in
+the stream" gets a different key the moment an earlier source is backfilled — and a changed key
+cannot supersede its own earlier revision, so the fold would hold both, one stale and one current,
+with no way to tell them apart. Ids are built from the runtime's own identity for a thing, scoped by
+session and stream; where the runtime supplies no identity, from the landed location, which is
+itself immutable once written.
+
+**2. Last writer wins, per id, whole-entity.** A later revision replaces the earlier one entirely.
+Not field-by-field: a partial merge cannot express "this attribute is now absent", so a correction
+that removes something could never be recorded.
+
+**3. Absence means unchanged.** An entity missing from round 7 is exactly the entity round 4
+published. Removal is therefore explicit — a revision with `tombstone` set — and never inferred from
+silence. Likewise an unresolved entry that gets resolved is *superseded* with `state:"resolved"`, not
+deleted: absence would be indistinguishable from "never noticed".
+
+**4. Order is chain order, not timestamp order.** Rounds fold in round order, entities within a round
+in file order. Timestamps come from the observed runtime and can go backwards across sources; the
+chain cannot.
+
+### 2.4 Determinism, and what it forced
+
+The property worth protecting is: *same previous digest + same landed range + same parser and policy
+versions ⇒ same round bytes and same digest*. Without it a chain can only be trusted, never
+re-derived. Three things in the earlier draft broke it, and each had to change:
+
+- **`revision` was a per-entity counter.** Deriving it required replaying the chain, and two
+ producers reaching the same state by different paths would disagree. It is now **the round number
+ that produced the entity** — readable straight off the header, and identical for any producer of
+ that round.
+- **The header carried a timestamp.** Wall-clock time cannot be reproduced, so it would put every
+ re-derivation in disagreement with the original. Time moved to `conversation.state`, outside every
+ digest.
+- **`input_digest` was described as a digest over all evidence read so far.** Recomputing it would
+ make each round cost proportional to the whole conversation — a long-running one would slow down
+ without bound, which is the exact property an incremental design exists to avoid. It is now
+ chained: `H(previousₙ₋₁ ‖ sorted digests of what round n consumed)`, proportional to what is new
+ and still binding every round transitively to all evidence before it. Inputs are sorted because
+ directory iteration order is not stable.
+
+### 2.5 What verification checks
+
+`Chain.Verify` walks the chain and checks four things, each catching a failure the others miss:
+
+| Check | Catches |
+| --- | --- |
+| rounds are 1..N with no gap | a missing round — its successors are unverifiable without it |
+| each round names its predecessor's digest | a fork, or an edited round |
+| each round's digest matches its bytes | tampering or truncation (`Read` does this) |
+| `from_seqₙ == through_seqₙ₋₁ + 1` | **skipped landed evidence — no digest reveals this** |
+
+The last is the one worth stating separately. Digests prove the rounds are the rounds; only the
+sequence check proves they read everything.
+
+### 2.6 What shipping does not solve
+
+A round references content by `{"seq":…,"row":…}` into landed files. A receiver holding only rounds
+can render the structure but not the text. Either the landed files travel too, or the export inlines
+content for the nodes it describes — an export contract decision, deferred to Plan 03.
+
+**Content is referenced, never inlined**, for the same reason: storing each call's input would
+multiply storage by conversation depth — roughly 220 MB for a session whose actual content is 10 MB.
+
+**Input manifests are deltas.** `base` + `appended`, which is exactly the continuity invariant, so
+the representation *is* the claim. A full manifest appears only where the chain genuinely breaks:
+compaction, fork, stream start. Those bases coincide with epoch boundaries.
+
+**Containment is single-parent; causality is a sparse typed relation** carrying `via` and `quality`,
+so an exact id match is distinguishable from an inference.
+
+---
+
+## 3. Continuous build
+
+Rounds and segments are independent axes: the round is the **append** unit, the segment is the
+**commit** unit. Many rounds accumulate while work continues; a segment closes only on an idle gap
+plus gates. A segment close is recorded as an ordinary round — one that publishes the segment
+boundary — not as a separate mechanism or a rewrite.
+
+Watermark chain, each stage trailing the last:
+
+```text
+assembled_seq <= indexed_seq <= next_seq - 1
+```
+
+where `assembled_seq == conversation.state.through_seq`. A crash between publishing a round and
+saving state leaves a round on disk that state does not mention, so **the filesystem is the
+authority**: recovery lists the rounds directory and takes the highest, rather than trusting the
+pointer.
+
+**A per-stream record-id index must persist across commits.** A resume replays records whose first
+occurrence may sit in an already-committed segment; without it, a replayed block lands as a
+duplicate node. (This is the same rule the derived index enforces in `internal/index`: first
+occurrence wins, and the duplicate is excluded from *every* lookup, not only from id resolution.)
+
+**The invariant that keeps this honest:**
+
+```text
+fold(rounds 1..N) == full parse of landed evidence 1..through_seqₙ
+```
+
+A full rebuild from the index is the reference implementation — simple and obviously correct — and
+the fold is checked against it. Where they disagree the fold is wrong, and the disagreement is a
+test failure.
+
+The earlier draft asserted something weaker and wrong: that revision N+1 is a *prefix extension* of
+revision N. It is not, and cannot be. Late evidence supersedes entities emitted rounds ago, which is
+the whole point; a replay reordering after duplicates are removed or a compaction re-rooting an epoch changes what an
+earlier round said. Prefix extension holds for the round *chain* — no round is ever rewritten — but
+never for the folded result.
+
+**Parser and policy versions are chain generations.** A change to either that alters meaning does not
+retroactively fix earlier rounds and must not silently mix with them: it starts a new chain. Both
+versions are in every header so a reader can detect the mix rather than fold through it.
+
+---
+
+## 4. The unresolved catalog
+
+Recorded as data, with two disciplines that make it useful rather than noisy.
+
+**Transient vs permanent**, because both genuinely occur:
+
+| Reference | Kind |
+| --- | --- |
+| journal `started` names an agent whose transcript does not exist yet | transient **while the run is live**; once the run is over it is a pruned or lost file, so it is terminal |
+| `<task-notification>` `<tool-use-id>` absent from the file it landed in | transient — resolves elsewhere in the session |
+| `tool_use` with no `tool_result` | transient unless the stream ended. **(corpus)** 0 of 25,892 on the parent lineage and 7 of 79,418 on child streams, always the final line of a truncated file |
+| workflow child `started` with no `result` | **not an error** — **(corpus)** 10.24% of workflow children never get one |
+| `allUuids` entry existing nowhere on disk | terminal — held in context, never written |
+| `logicalParentUuid` unresolvable | terminal |
+| child stream nothing names as spawned | terminal — **(corpus)** 2 exist |
+| source in `source_gone` | terminal — pruned before collection |
+
+**A denominator.** "3 unresolved" means nothing; "3 of 8,045 tool_use ids" means something.
+
+A transient reference is promoted only on **terminal evidence** — a pruned source, a finished run, a
+runtime that wrote no such record. Never on elapsed rounds: "still open after N rounds" is a
+statement about how often we looked, not about the data. That is why `state` has three values
+(`open`, `resolved`, `terminal`) rather than a counter.
+
+---
+
+## 5. Open questions
+
+1. **Does a context reset force a segment close?** I lean no — an epoch is a model-context lifetime,
+ a segment is a commit window; treating them as one makes segments depend on the runtime's reset
+ timing. There is a reason to revisit it: a reset is preceded by a block of re-emitted records, so
+ the two events are already entangled at the record level.
+2. **Where on the threshold curve to sit.** The table in §1.8 is measured; the choice is not made.
+ 10 minutes mis-splits 38.74% of Talks and 20 minutes mis-splits 21.52% at 57% fewer candidates.
+ The default is 10 minutes because the gates catch what the threshold gets wrong, but that reasoning
+ deserves a measurement of its own — how often the gates actually save a bad candidate.
+3. **Where a session's conversation id comes from** when an application supplies one, and how it is
+ recorded so cold reactivation preserves it. Today `asz parse` uses the session id, which is the
+ adapter's documented default and the only sound one.
+4. **How a viewer folds the chain.** The fold this document describes is the assembler's: apply every
+ round in order and keep the result. A viewer needs the same answer but not necessarily the same
+ path — it may want the state as of an earlier round, or the history of one entity across rounds,
+ or to stream rounds as they arrive rather than read them all first. That is Plan 03 work and is
+ deliberately not designed here; what Plan 02 guarantees is only that the rounds contain enough to
+ support any of those.
+
+5. **Retention now spans two directories.** Landed files under `data/<session>/` and rounds under
+ `data/_conversations/<id>/` age independently, and a round references landed records by
+ `(seq, row)`. Pruning landed data while keeping rounds leaves a structure whose content cannot be
+ read; pruning rounds while keeping landed data is safe but discards the parse. There is no policy
+ for this yet, and the coupling should be made explicit before either directory gets a TTL.
diff --git a/design-notes/03-read-and-export.md b/design-notes/03-read-and-export.md
new file mode 100644
index 0000000..14418b5
--- /dev/null
+++ b/design-notes/03-read-and-export.md
@@ -0,0 +1,626 @@
+# Design Plan 03 — Read and Export
+
+**Status:** sections 0-4 are implemented. Section 5, the viewer, is designed and
+not built.
+**Scope:** getting a conversation out of the round chain and in front of a reader. Collection is
+Plan 01 and assembly is Plan 02; both are implemented and neither changes.
+
+The plan changed shape once already. It began as "export and preview" and the first real question -
+what does a viewer read? - answered itself immediately: it reads the round chain, because a second format would
+only drift from the first. The interesting question turned out to be the one behind it, in
+[section 3](#3-the-contract-is-the-structure-file-and-that-exposes-a-hole): the model is
+runtime-agnostic about structure and not about content, and a viewer is the first thing that would
+notice. [Section 4](#4-two-formats-sf-and-sd) proposes the two formats that fix it.
+
+Every number below was measured on the same corpus Plans 01 and 02 were measured on: 62 sessions,
+1,100.1 MB of landed records, 319,264 assembled nodes. Where a number is missing it says
+`unmeasured` rather than an estimate.
+
+---
+
+## 0. Why this plan exists
+
+**Nothing has read the output yet.** Plan 02 produced 319,264 nodes across 62 sessions and no reader
+has looked at one. That matters more than it sounds: most of the model's decisions were justified by
+a claim about reading — a tool use is one step because *a reader thinks of it as one event*, a child
+agent's output belongs to the child because otherwise *a rendered conversation shows the same work
+twice*, a delegation stays inside one Talk because *a reader sees one interaction*. Those are
+falsifiable claims about reading, and only a reader can falsify them.
+
+So this plan's real output is not a viewer. It is the set of decisions a viewer forces.
+
+### Three things a reader needs that the chain does not carry
+
+**1. There is no time anywhere.** Measured: 0 of 319,264 nodes carry any temporal field.
+
+```text
+100% id · kind · revision
+ 99% parent · ref · stream
+ 64% attrs
+ 0% anything temporal
+```
+
+This is not an oversight. A round is deliberately time-free so that the same evidence reproduces
+the same bytes and the same digest. But a node's *observed* time — when the runtime says the thing
+happened — comes from a landed record that is immutable, so it is just as reproducible as the
+node's parent or kind. It could be carried; it is not. A viewer cannot draw a timeline, group by
+day, or say "twenty minutes later" without opening every referenced record.
+
+**2. A Talk has no name.** A reader opening a session sees a list of ids. The largest session has
+922 of them. Nothing in the chain distinguishes one from another.
+
+**3. Nothing resolves a reference to bytes.** A node says `{"seq":1,"row":1004}`. The only thing
+that turns that into text is `asz show`, which prints one record. There is no path from a folded
+conversation to what anybody said.
+
+### And one the maintainer raised
+
+**A viewer's fold is not the assembler's fold.** The assembler applies every round in order and
+keeps the result. A viewer may want the state as of an *earlier* round, or the *history* of one
+entity across rounds, or to render rounds as they *arrive* rather than reading them all first. The
+rounds hold enough to answer any of these. Which of them to serve, and how, is undecided.
+
+---
+
+## 1. Where the bytes are
+
+The whole corpus, by what the bytes actually contain:
+
+| MB | share | what |
+| ---: | ---: | --- |
+| 370.0 | 33.6% | scaffolding — record ids, usage blocks, envelope |
+| 334.8 | 30.4% | tool response, content block |
+| 146.4 | 13.3% | **thinking signatures** |
+| 83.8 | 7.6% | tool response, **a structured duplicate of the same output** |
+| 75.0 | 6.8% | tool command |
+| 58.7 | 5.3% | injected context |
+| 18.4 | 1.7% | message text |
+| 14.3 | 1.3% | assistant text |
+| 0.1 | 0.0% | **thinking text** |
+
+Four readings of that table, each of which constrains the contract:
+
+**Tool responses are 38.1% and are 6× the size of the commands.** Asking what a tool *did* is cheap;
+carrying what it *returned* is the whole problem.
+
+**What a person came to read is 3.0%.** Message text plus assistant text is 32.7 MB of 1,100 MB. The
+conversation as read is a rounding error on the conversation as stored.
+
+**13.3% is cryptographic signature on thinking blocks whose text is 0.1 MB.** A signature exists so
+a provider can verify a block handed back to it. It means nothing to a reader, and it is the second
+largest single category in the corpus.
+
+**7.6% is the same tool output stored a second time.** A main-stream tool result carries both the
+content block and a structured form with `stdout`/`stderr` split out. Both are real — the structured
+one has detail the block lacks — but a reader wants one of them.
+
+### The share is not stable, which rules out a fixed policy
+
+Per session, over the 28 sessions larger than 1 MB:
+
+```text
+tool-response share min 17.3% median 35.1% max 83.5%
+```
+
+A rule tuned to the median is wrong by a factor of two at both ends. Whatever the contract does
+about tool output, it cannot be a constant.
+
+### Compression is not the answer either
+
+| | size | ratio |
+| --- | ---: | ---: |
+| largest session, landed | 200 MB | — |
+| zip / gzip | 68 MB | 2.8× |
+| zstd -19 | 45 MB | 4.3× |
+| **its round chain** | **21 MB → 1.4 MB** | **15×** |
+
+Rounds compress fifteen times because they are tens of thousands of near-identical structured lines.
+Landed content does not, because a third of it is high-entropy signature and captured output.
+
+Removing the two known-wasteful categories *before* compressing beats compressing harder: 45.2 MB →
+26.9 MB, a 40% cut that no compression level recovers on its own.
+
+---
+
+## 2. What a reader asks for
+
+A Talk is the natural unit — it is the thing a reader opens. Measured across all 4,941 of them,
+structure only, no content:
+
+```text
+one talk's subtree p50 12.7 KB 42 nodes
+ p90 43.4 KB 140 nodes
+ p99 111.1 KB 348 nodes
+ max 413.8 KB 1,475 nodes
+```
+
+The median talk's structure fits in a network round trip. The whole largest session is 16 MB of
+structure — too much for a first screen, fine for a background load.
+
+That asymmetry is the shape of the answer: **structure is small enough to be eager, content is not.**
+
+### 2.1 Nothing needs to be cached
+
+A design review set the threshold: if rendering a p99 talk takes longer than 200 ms, build an index
+for it; otherwise never. Measured on the largest real session, 53,106 nodes and 922 talks:
+
+| | |
+| --- | ---: |
+| fold the whole chain | 302 ms |
+| build and order the talk list | 1 ms |
+| **one talk's subtree, p99** | **13 µs** |
+| walk every subtree in the session | 11 ms |
+
+So: fold on demand, cache nothing. Four independent designs each proposed a derived read artifact -
+a bundle, a view directory, four projections, a locator index - and all four were specified before
+anyone had rendered a single talk.
+
+The first measurement said p99 was 104 ms, which looked like it might justify one. It was
+`View.Children` scanning all 53,106 nodes on every call, so a 42-node subtree cost 2.2 million
+comparisons. A parent-to-children map built once took the same walk from 22 seconds to 11
+milliseconds. The lesson is worth keeping: **measure the naive path before designing around it**,
+because the first number may be measuring the implementation rather than the problem.
+
+---
+
+## 3. The contract is the structure file, and that exposes a hole
+
+**A viewer reads the round chain. It does not read landed files, and it does not read a format
+invented for viewers.** The chain already is the read contract: it carries the structure, it references the
+content, and it is verifiable on its own. Inventing a second shape for viewers would mean two things
+to keep in step, and the second would be the one that drifts.
+
+But the chain references content; it does not describe it. And that is where the layering breaks.
+[Section 4](#4-two-formats-sf-and-sd) is the answer that came out of it.
+
+### 3.1 What is runtime-agnostic today, and what is not
+
+The project's rule is that a runtime's vocabulary stops at its adapter. Measured against the code,
+that is half true:
+
+| | who interprets it | runtime-agnostic? |
+| --- | --- | --- |
+| **structure** | the adapter maps its identifiers onto index roles | **yes** |
+| **content** | nobody — the payload is the source bytes, untouched | **no** |
+
+Landing preserves payloads byte-for-byte, on purpose: it is what makes a landed record replayable
+and its digest meaningful. The consequence is that the only description of what is *inside* a
+payload is the runtime's own schema.
+
+So a viewer rendering one `message.assistant` node has to do this:
+
+```text
+node.ref {seq:1, row:1004}
+ -> landed record
+ -> payload.message.content[0].text <- Claude Code's shape, not the model's
+```
+
+Nothing stops it, and nothing tells it that walking `.message.content[]` is an adapter's business.
+The first viewer that ships would hard-code one agent product's JSON into the reader, and the
+model's whole claim to be runtime-agnostic would be true only of the parts nobody looks at.
+
+**There is also no adapter interface at all.** `internal/adapters/claudecode` is a package of free
+functions that `cmd/asz` calls by name. Nothing states what an adapter must provide, so a second one
+has nothing to implement against.
+
+### 3.2 Two axes, currently collapsed into one
+
+A landed header carries a single `adapter` string, `claude-code-local/0.1.0`. It is doing two jobs:
+
+- **transport** — how records arrive: reading local files, or receiving a push
+- **dialect** — how records are interpreted: Claude Code's schema, Codex's, another agent's
+
+Those vary independently. A push receiver for Claude Code shares every interpretation rule with the
+local reader and no acquisition code. A local reader for Codex shares the acquisition shape and no
+interpretation rules.
+
+```text
+ dialect: claude-code dialect: codex dialect: …
+transport: local implemented — —
+transport: push — — —
+```
+
+Keeping the *transport* in the adapter name is right and already documented: a push adapter is a
+separate entry rather than a mode flag, because the collection posture changes what can be promised
+about completeness. Keeping the *dialect* there too is what makes the table above impossible to
+fill in without duplication.
+
+### 3.3 The surface an adapter has to provide
+
+Three responsibilities, of which only the first two exist and only one is named:
+
+| | today | needed |
+| --- | --- | --- |
+| **acquire** — get records into the zone | `claudecode.Collector`, called directly | a transport interface; pull and push are two implementations |
+| **interpret structure** — identifiers onto roles | `claudecode.IndexEntry`, a free function | a dialect interface, dispatched by the landed header |
+| **interpret content** — a payload into something readable | *missing* | a dialect method returning a runtime-agnostic content unit |
+
+The third is the one a viewer is blocked on. Its shape is constrained by things already measured:
+
+- It must be able to return **part** of a payload. A single tool result runs to 1.1 MB, and the
+ tool-response share of a session varies from 17.3% to 83.5%, so truncation cannot be a caller's
+ afterthought.
+- It must report **content state**, not just bytes. `available`, `truncated`, `redacted`,
+ `unavailable` are already in the model; a reader has to be told which one it got, or the model's
+ honesty rule stops at the last hop.
+- It must be addressable by exactly what a node carries: a landed record and an optional block
+ ordinal. Nothing else is in scope, because nothing else is in a round's reference.
+
+An adapter that cannot interpret a payload it produced is still useful — the structure is already
+assembled — so this is `unavailable` rather than an error, and a reader is told the content cannot
+be described rather than shown a guess.
+
+### 3.4 What this changes about the plan
+
+The viewer is no longer the first thing to build, and it was never the point. **The adapter contract
+is,** because a viewer written before it exists will encode Claude Code into the reader, and the
+second agent product will find that out the expensive way.
+
+It also reframes what Plan 03 is for. It is not "export and preview". It is the point at which the
+model has to be honest about content the way it is already honest about structure.
+
+---
+
+## 4. Two formats: `.sf` and `.sd`
+
+The names say what each is for. **Session Flow** is the structure — how the conversation went.
+**Session Data** is the detail — what was actually in it.
+
+```text
+data/<session>/streams/main/
+ transcript-<ts>-000001.jsonl landed the source bytes, byte-identical, 0444
+ transcript-<ts>-000001.sd derived the same records, normalised
+
+data/_conversations/<id>/
+ rounds/r000001-<digest>.sf published the structure, immutable, digest-chained
+ conversation.state cache the head pointer
+```
+
+`.sf` is what was called `asb`. Nothing about it changed but the name and the schema string.
+
+### 4.1 Why a second file rather than a read-time conversion
+
+The alternative is to leave landed payloads as they are and have the dialect interpret them each
+time something reads one. That was the shape of §3.3, and it is worse in three ways.
+
+**The conversion is already happening.** Collection parses every payload to build the index. Doing
+it again per read is the same work, an unbounded number of times.
+
+**The reader cannot be trusted to stay clean.** A dialect method that returns content on demand is a
+rule that has to be *obeyed*. A normalised file on disk is a rule that is *enforced* — there is no
+runtime shape to reach for, because the reader never receives one.
+
+**The dialect surface is bigger than it looks.** Measured: `toolUseResult` takes **49 distinct key
+sets** across the corpus and is a bare string 7.2% of the time. Bash returns
+`stdout`/`stderr`/`interrupted`, Edit returns `structuredPatch`, Workflow returns
+`runId`/`taskId`/`transcriptDir`. That is a per-tool schema, and it is exactly what must not reach a
+reader.
+
+The cost of a second file is that it stores content twice. That is acceptable only because **`.sd`
+is derived and disposable**, in the same sense the index is: raw payloads stay, so a wrong
+normalisation is a rebuild, not data loss. Nothing may treat `.sd` as authoritative.
+
+### 4.2 The vocabulary is small because the industry settled it
+
+Every content block in 3,032 files, 1.1 GB:
+
+| count | Claude Code block | keys |
+| ---: | --- | --- |
+| 108,243 | `tool_result` | `content` `is_error` `tool_use_id` |
+| 108,149 | `tool_use` | `id` `name` `input` `caller` |
+| 60,382 | `thinking` | `thinking` `signature` |
+| 22,458 | `text` | `text` |
+| 41 | `image` | `source{data, media_type}` |
+| 1 | `fallback` | `from{model}` `to{model}` |
+
+**Six types, four of which cover 99.99%.** This is the strongest evidence that a unified format is
+worth defining: the thing being unified is not a moving target. A message, a thought, a call, its
+result, and an attachment is the whole of what an agent does.
+
+### 4.3 What a `.sd` record carries
+
+One `.sd` per landed file, at the **same sequence number**. That is deliberate: an `.sf` node
+already addresses content as `{seq, row, block}`, and keeping the sequence and the row makes the
+existing coordinate address the normalised form unchanged. No second addressing scheme.
+
+```jsonl
+{"h":1,"schema":"sd/1","dialect":"claude-code/1","seq":1,"session":"…","stream":"main",
+ "of":"transcript-20260901T003722Z-000001.jsonl","of_sha":"<digest of the landed file>"}
+{"row":1004,"id":"<record id>","parent":"<its containment parent>","call":"<provider call>",
+ "run":"<the agent loop>","ts":"2026-08-31T16:14:42.170Z","trigger":"external",
+ "parts":[{"k":"text","text":"Two answers, and the second is the one that needs specifying…"}]}
+{"row":1005,"id":"…","parts":[{"k":"call","id":"toolu_01…","name":"Bash",
+ "input":{"command":"make check"}}]}
+{"row":1006,"id":"…","parts":[{"k":"result","of":"toolu_01…","failed":false,
+ "text":"ok github.com/…","state":"truncated","bytes":1118873}]}
+```
+
+Two halves, and the split is the point:
+
+- **record level** — the identifiers the structure is built from, in ROLE names. `id`, `parent`,
+ `call`, `run`, `trigger`, `ts`. These are what `internal/index` already stores; `.sd` is where
+ the adapter writes them down instead of the index deriving them from a runtime's fields.
+- **part level** — what the content IS.
+
+**`.sd` says what the bytes are. `.sf` says what role they play.** A `part` of kind `text` might be a
+person's turn, an agent's answer, or an injected reminder — which of those it is comes from the
+structure, and `.sd` does not guess.
+
+### 4.4 Part kinds
+
+| kind | is | carries |
+| --- | --- | --- |
+| `text` | readable text | `text` |
+| `reasoning` | the model's own reasoning | `text` |
+| `call` | a request to run something | `id` `name` `input` |
+| `result` | what it returned | `of` `text` `data` `failed` |
+| `media` | an image or document | `media_type` `data` or a reference |
+| `unknown` | the dialect could not describe it | the raw location, `state:"unavailable"` |
+
+Every part also carries `state` (`available` · `truncated` · `redacted` · `unavailable`) and
+`bytes`, the size of the original. Those two are not decoration: one tool result reaches 1.1 MB and
+the tool-response share of a session runs from 17.3% to 83.5%, so a reader is routinely shown part
+of something and has to be told so.
+
+`unknown` is what keeps the format honest. A dialect meeting a shape it does not recognise records
+where the bytes are and says it could not describe them — the same rule the rest of the model
+follows, rather than a guess or a silent drop. The attachment list this project just removed failed
+exactly here: it dropped 635 records it did not recognise.
+
+### 4.5 The mapping, Claude Code to `.sd`
+
+Record level:
+
+| Claude Code | `.sd` | note |
+| --- | --- | --- |
+| `uuid` | `id` | |
+| `parentUuid` | `parent` | |
+| `message.id` | `call` | never `requestId` — a fabricated record can reuse a real one |
+| `promptId` | `run` | one loop the agent performed for one trigger |
+| `timestamp` | `ts` | |
+| `origin.kind`, else `attachment.origin.kind` | `trigger` | reading only the first loses 27.6% |
+| `logicalParentUuid` | `continues` | the record a new context resumes from |
+| `toolUseResult.toolUseId`, or a notification's tool id | `tool` | the tool use this record is about |
+| `toolUseResult.agentId`, a journal `agentId`, a notification's task id | `child` | the child stream this record names |
+| the `wf_…` directory, or `toolUseResult.runId` | `batch` | children started together; a join key, with nothing in the model for it |
+| `isCompactSummary`, `subtype`, `isMeta`, … | flags | as the index already maps them |
+| `requestId`, `agentId`, `sessionId`, `cwd`, `gitBranch`, `slug`, `version`, `userType` | — | not carried; identity is the file's, or unused |
+
+Part level:
+
+| Claude Code | `.sd` part | note |
+| --- | --- | --- |
+| `content[i].type == "text"` | `text` | |
+| `"thinking"` → `.thinking` | `reasoning` | **`.signature` is dropped** — 146.4 MB, 13.3% of the corpus, and the reasoning text it accompanies is 0.1 MB. It is material a provider verifies, not something a person reads. |
+| `"redacted_thinking"` | `reasoning`, `state:"redacted"` | |
+| `"tool_use"` / `"server_tool_use"` | `call` | `caller` is `{"type":"direct"}` on all 108,149; carried as `unknown` if it ever is not |
+| `"tool_result"` → `content`, `is_error` | `result` | absence of `is_error` does not mean success |
+| `"image"` → `source{data, media_type}` | `media` | |
+| `"fallback"` → `from`/`to` model | `unknown` | one occurrence; a control event, not content |
+| `message.content` as a bare string | one `text` part | 4,429 records |
+| `toolUseResult` object | merged into the `result` part | **49 key sets**; the readable output is `stdout`/`stderr`/`content`, the rest is per-tool structure that does not survive normalisation and is left in the landed record |
+| `toolUseResult` string | `result.text` | 7.2%; would break a struct decode |
+| `attachment.text` / `.content` | `text`, or `unknown` | 28 attachment types, heterogeneous |
+
+**An MCP call is a `call`.** Measured: 39 of 26,940 tool uses, structurally identical to any other —
+same block type, same keys, same `toolu_` id scheme, same result shape. The server is a prefix on
+the name, `mcp__<server>__<tool>`, which is an attribute and not a kind. The sample is thin, 0.1%,
+so this is stated as observed rather than as a rule.
+
+### 4.6 What else changes
+
+| | today | after |
+| --- | --- | --- |
+| `pkg/asb` | the round chain | **done** - `pkg/sessionflow`, schema `sf/1` |
+| `*.asb.jsonl` | round file | **done** - `*.sf` |
+| — | | `*.sd`, and a package that reads and writes it |
+| `internal/index` | built from raw payloads by the adapter | built from `.sd`, dialect-free |
+| the adapter | collect · index · (interpret content, missing) | **collect · convert to `.sd`.** Nothing else. |
+
+That last row is the whole point. Once `.sd` exists, the adapter's only remaining job is turning its
+runtime's bytes into the unified form. Everything above it — index, assembly, structure, rendering —
+reads `.sd` and has no dialect in it at all. **A second adapter is one converter.**
+
+Two things deliberately do NOT change:
+
+- **Landed files stay, byte-identical.** They are what `.sd` is derived from, what `asz verify`
+ checks, and what makes a wrong normalisation recoverable.
+- **`.sf` stays immutable, time-free and digest-chained.** `.sd` is per landed file and therefore
+ immutable by construction, so it needs no chain of its own.
+
+### 4.7 Both vocabularies, side by side
+
+The model strips runtime vocabulary, and that is right for storing a
+conversation and wrong for showing one. A person debugging their own session
+recognises `promptId`; telling them a "run" makes them translate in their head.
+A person comparing two agent products needs the opposite.
+
+So the adapter also declares a **glossary**: what its runtime calls each thing
+the model names. A reader chooses which words to see, and neither is presented
+as the other.
+
+```text
+asz conversation ID talk · run · llm.call · tool
+asz conversation -terms native ID talk · promptId · message.id · tool_use + tool_result
+asz conversation -terms both ID run (promptId)
+asz glossary the whole table
+```
+
+Measured on Claude Code: **59 terms, 36 named by the runtime and 23 derived
+here.** That ratio is the useful part. If everything had a native name the model
+would be a rename of one product rather than a model of any; if nothing did, it
+would have no evidence under it.
+
+Three rules keep the table honest, each enforced by a test:
+
+- **Every name the model can emit has an entry**, even if only to say the
+ runtime has no word for it. The failure otherwise is silent: a reader asks for
+ native terms, meets a name with no entry, and cannot tell "no native name" from
+ "nobody wrote one down".
+- **An empty native name is an answer, not a gap.** A Talk, a Segment, a
+ correlation quality - no agent product records them, and saying so beats a
+ plausible word that sends a reader looking for a field that does not exist.
+- **A native name must say where to find it.** Being told `logicalParentUuid`
+ without being told it sits on a `compact_boundary` record is half an answer.
+
+The glossary lives beside the extraction code, so a rename in the runtime
+changes both together. It is keyed by dialect (`claude-code/1`), not by adapter,
+because a push receiver for the same runtime would share every word of it.
+
+### 4.8 Open
+
+1. **Where the dialect is recorded.** `.sd` carries it in its own header, which settles it for
+ reading. The landed envelope still conflates transport and dialect in one `adapter` string.
+2. **Whether the index survives.** If `.sd` carries the record-level identifiers, the index becomes
+ a lookup structure over `.sd` rather than a separate extraction. It may still earn its place as a
+ fixed-width binary form; that is a performance question, and `unmeasured`.
+3. **What `.sd` costs.** Dropping signatures and scaffolding should make it much smaller than the
+ landed data, but the number is `unmeasured`. It decides whether `.sd` can simply replace landed
+ files in an export.
+4. **Media.** 41 images, base64 inline. Inline in `.sd` is fine at that volume and would not be at
+ another; no store is proposed yet.
+
+---
+
+## 5. The viewer
+
+The reference is the v0.8 preview page, and the direction it sets is right: an
+**observability console**, not a chat log. A timeline across the top, a
+transcript beside it, and an inspector that answers *where did this come from*.
+That shape is what this model is for - a chat log would render the messages and
+throw away every relation, quality and unresolved reference the last two phases
+exist to produce.
+
+### 5.1 What the preview establishes
+
+| | |
+| --- | --- |
+| **Ground** | deep navy `#07111f`, surface `#0d1929`, lines `#263b55` |
+| **Text** | `#e9f0f8`, muted `#9cb0c7`, faint `#6f859f` |
+| **Accents** | blue `#58a8ff` · green `#35c980` · orange `#f4a641` · purple `#ac8cff` · teal `#31c7bd` · pink `#ef6eae` · red `#ff6f78` |
+| **Type** | Inter, with a system mono for identifiers |
+| **Layout** | toolbar · timeline canvas with a minimap and gap labels · transcript list · inspector |
+| **Inspector** | four tabs: Details · Relations · Evidence · Raw |
+
+Two details in it matter more than the styling.
+
+**It already shows qualification.** `unavailable` appears six times and
+`strong_inference` four. The preview was drawn before any of this was measured,
+and it was drawn honestly - a viewer that hides how a join was made would undo
+the phase that produced it.
+
+**It has a gap label on the timeline.** Which is the segment question rendered:
+a quiet period is drawn as a gap rather than silently closing one thing and
+opening another.
+
+### 5.2 What has changed under it
+
+The preview names things the model has since renamed, and asks for two it cannot
+have.
+
+| preview | now | note |
+| --- | --- | --- |
+| `agent_response` | `message.assistant` | |
+| `agent_instruction` | `message.external` | |
+| `tool_activity` | `tool` | |
+| `agent_call` · `agent.prompt` | `agent.call` | |
+| `agent.run` · `agent_run` | `run` | |
+| `control_trigger` | `trigger` on a Run | external or notification |
+| **`tool_execution`** | — | **does not exist.** The preview draws a tool as call → execution → result. Only two of those are recorded. Three tools report a real duration; for the rest it is `unavailable`, and a timestamp gap is not a substitute. |
+
+And three things it wanted are now available that were not when it was drawn:
+
+- **a title**, in 41 of 42 conversations
+- **a label on a child stream** - what that agent was asked to do
+- **usage per provider call**, and **`turn.duration`** measured by the runtime on
+ 465 turns
+
+### 5.3 How it reads
+
+**Fold on demand. Cache nothing.** Measured on the largest real conversation:
+
+```text
+fold the whole chain 302 ms
+build and order the talk list 1 ms
+one talk's subtree, p99 13 µs
+```
+
+A design review set the threshold at 200 ms for building an index; the answer is
+thirteen microseconds. Every derived read artefact that four independent designs
+proposed - a bundle, a view directory, four projections, a locator - solves a
+problem that does not exist at this size.
+
+**Time comes from the index, not from the round.** A round is deliberately
+time-free so its bytes stay reproducible, and a timeline needs a time per node.
+Both hold: a node carries `{seq, row}`, the index carries the observed time for
+that position, and the viewer builds the lookup once when it loads a
+conversation. Nothing is added to a published round.
+
+### 5.4 The shape
+
+`asz view` - a local HTTP server on the standard library, no build step, no
+dependency, no database. It serves one page and a small JSON surface over the
+chain it already has.
+
+```text
+GET /conversations id, title, talks, span, size
+GET /c/{id} the folded view: talks, streams, segments
+GET /c/{id}/talk/{n} one talk's subtree, with times
+GET /c/{id}/record/{seq}/{row} one landed record, its parts
+```
+
+Four endpoints because the preview has four questions: which conversation, what
+happened in it, what happened in this turn, and what exactly did this step say.
+
+The inspector's four tabs come free from what is already stored:
+
+| tab | source |
+| --- | --- |
+| Details | the node's `kind`, `attrs`, and its resolved time |
+| Relations | every edge touching it, with `quality` and `via` |
+| Evidence | its `ref` and `refs`, and what they resolve to |
+| Raw | the `.sd` record: parts, flags, usage, and what was dropped |
+
+### 5.5 Order of work
+
+1. **The server and the transcript.** One conversation, its talks, one talk
+ rendered with every step, quality badge and unresolved row visible. This is
+ the slice that falsifies the most: whether a Talk is the unit a reader opens,
+ whether quoting names one, and whether a wall of badges is readable.
+2. **The timeline.** Needs the time lookup and the gap labels, and answers
+ whether segments as measured look right to a person.
+3. **The inspector.** Relations and Evidence are the phases 1 and 2 work made
+ visible; Raw is the honesty check - a reader can always see what we stored.
+4. **Export.** Only after a viewer exists, because until then nobody knows what
+ an export has to contain.
+
+### 5.6 What it must not do
+
+- **No chat-log rendering.** Dropping relations and quality would waste the model.
+- **No inferred timing.** A tool with no reported duration shows none.
+- **No hidden unresolved references.** They are the product of the honesty rule
+ and belong on screen, not in a log.
+- **No cache before it is measured to be needed.** The numbers above say it is not.
+
+## 6. Open questions
+
+1. **Retention across two directories.** Carried over from Plan 02 and now sharper: a round
+ references landed records, so pruning landed data while keeping rounds leaves a structure whose
+ content cannot be read. A reader meeting that must be told the content is gone, not shown an
+ empty one — which means content state has to reach the screen.
+2. **Reading a conversation that is still being written.** `unmeasured`. Collection and parse both
+ run while someone could be reading.
+3. **Search.** Nothing indexes text today; the derived index holds identifiers only, deliberately.
+4. **Where the dialect is recorded.** A landed header carries one `adapter` string that names both
+ transport and dialect. Interpretation needs the dialect alone. Deriving it from the name by
+ convention works for the one adapter that exists and will not survive the second. Adding a field
+ changes an envelope that is already on disk in immutable files, so the migration has to be
+ thought through rather than assumed: the envelope is versioned (`h:1`), and landed files are
+ never rewritten, so a reader would have to handle both. `unmeasured`: whether any existing
+ landed data would need a compatibility path, or whether re-collection is acceptable.
+5. **Whether a runtime's own schema changes under us.** Claude Code's record shape is not a
+ published contract and has already shifted during this project. Structure survives that - the
+ adapter absorbs a rename - but content interpretation is a second surface with the same
+ exposure, and nothing detects a shape change today. A dialect that meets a payload it does not
+ recognise must report `unavailable` rather than guess, which is the same rule the rest of the
+ model follows.
diff --git a/design-notes/README.md b/design-notes/README.md
new file mode 100644
index 0000000..c93a6b1
--- /dev/null
+++ b/design-notes/README.md
@@ -0,0 +1,10 @@
+# Design Notes
+
+**These are working engineering notes, not official documentation.**
+
+They record measurements, dead ends, corrections, and open questions produced while designing
+SkyWalking AI Sessionizer against real agent-runtime data. They are deliberately unpolished and are not part
+of the published documentation set.
+
+Official documentation lives in [`../docs/`](../docs/) and is indexed by
+[`../docs/menu.yml`](../docs/menu.yml).
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..a7e7c20
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,15 @@
+# Welcome
+
+**This is the official documentation of SkyWalking AI Sessionizer. You're welcome to join us.**
+
+From here you can learn all about the architecture, the conversation model it builds, and how to
+collect from a supported agent runtime.
+
+- [Concepts and Designs](en/concepts-and-designs/overview.md). Start here to understand what
+ SkyWalking AI Sessionizer is and the model it assembles.
+- [Adapters](en/adapters/claude-code.md). How a specific agent runtime maps onto the model, and what
+ it can and cannot supply.
+
+We're always looking for help improving our documentation and codebase, so please don't hesitate to
+[file an issue](https://github.com/apache/skywalking-ai-sessionizer/issues/new) if you see any problem.
+Or better yet, submit your own contribution to help make it better.
diff --git a/docs/en/adapters/claude-code-glossary.md b/docs/en/adapters/claude-code-glossary.md
new file mode 100644
index 0000000..ce4dc66
--- /dev/null
+++ b/docs/en/adapters/claude-code-glossary.md
@@ -0,0 +1,127 @@
+# Claude Code Glossary
+
+What Claude Code calls the things the [project glossary](../concepts-and-designs/glossary.md) names,
+and where each one appears in its data.
+
+The dialect is **`claude-code/1`**. That names the vocabulary, not the adapter: `claude-code-local`
+says how records were acquired, the dialect says how they are read. A push receiver for the same
+runtime would be a different adapter and the same dialect.
+
+This table is generated from the code — `asz glossary` prints it — so it cannot drift from the
+mapping it describes. A test fails if the project's vocabulary grows a name with no entry here.
+
+```sh
+asz glossary this table
+asz conversation ID talk · run · llm.call · tool
+asz conversation -terms native ID talk · promptId · message.id · tool_use + tool_result
+asz conversation -terms both ID run (promptId)
+```
+
+**59 terms: 36 that Claude Code names, 23 this project derives.** That ratio is worth watching. If
+everything had a native name the model would be a rename of one product rather than a model of any;
+if nothing did, it would have no evidence under it.
+
+---
+
+## Named by Claude Code
+
+Each row is a field the adapter actually reads. A rename in Claude Code changes this table and the
+extraction together, because they sit in the same file.
+
+| model | Claude Code | where | note |
+| --- | --- | --- | --- |
+| `agent.call` | `Agent, Skill or Workflow tool_use` | an assistant record | |
+| `agent.launch_ack` | `toolUseResult.status == "async_launched"` | a tool result | an acknowledgement, not a result |
+| `batch` | `toolUseResult.runId` | a workflow launch result | also the name of the directory the batch's records are filed under |
+| `call` | `message.id` | record | not requestId, which a client-fabricated record can reuse |
+| `cancels` | `toolUseResult.interrupted` | a tool result | |
+| `child` | `toolUseResult.agentId` | a launch result, a journal record | |
+| `context.injection` | `attachment` | an attachment record | |
+| `continues` | `logicalParentUuid` | a compact_boundary record | the only link back; a boundary's own parentUuid is null |
+| `control.command` | `system/local_command` | a system record | |
+| `control.interrupt` | `toolUseResult.interrupted` | a tool result | |
+| `control.permission` | `permission-mode` | a record type | |
+| `epoch.boundary` | `system/compact_boundary` | a system record | |
+| `epoch.summary` | `isCompactSummary` | a user record | |
+| `error.api` | `system/api_error` | a system record | |
+| `external` | `origin.kind == "human"` | record | also a run whose records state no origin at all - a locally typed command |
+| `follows` | `logicalParentUuid` | a compact_boundary record | |
+| `id` | `uuid` | record | |
+| `llm.call` | `message.id` | assistant records sharing one | one call is several records; 2.09 on average |
+| `message.assistant` | `text block` | an assistant record | |
+| `message.external` | `origin.kind == "human"` | a user record | also a queued_command attachment whose commandMode is "prompt" |
+| `message.synthetic` | `message.model == "<synthetic>"` | an assistant record | the client wrote it; no model produced it |
+| `notification` | `origin.kind == "task-notification"` | record | |
+| `parent` | `parentUuid` | record | |
+| `redacted` | `redacted_thinking` | a content block | |
+| `reports` | `<task-notification>` | a user record's content | |
+| `result_of` | `tool_use_id` | a tool_result block | also stated redundantly as sourceToolAssistantUUID |
+| `run` | `promptId` | record | one loop per distinct promptId |
+| `runtime.notification` | `<task-notification>` | a user record's content | |
+| `session` | `sessionId` | record, and the transcript filename | |
+| `starts` | `toolUseResult.agentId, or a run journal` | a launch result | |
+| `stream` | `agentId` | a child record | the parent lineage is the session transcript itself |
+| `summarizes` | `parentUuid` | an isCompactSummary record | |
+| `thinking` | `thinking block` | an assistant record | |
+| `time` | `timestamp` | record | |
+| `tool` | `tool_use + tool_result` | two records | one step here, two blocks there |
+| `trigger` | `origin.kind` | record, and attachment.origin.kind | |
+
+## Derived by this project
+
+Claude Code has no word for any of these. That is an answer, not a gap: saying so is more useful
+than a plausible word that would send a reader looking for a field that does not exist.
+
+| model | what it is |
+| --- | --- |
+| `agent.output` | the last model response in a child stream |
+| `available` | the bytes are in a landed record |
+| `conflict` | sources disagree |
+| `conversation` | supplied, never inferred; defaults to the session id |
+| `ends_with` | the last model response in a child stream |
+| `epoch` | the span between two compact_boundary records |
+| `exact_ambiguous` | an identifier matched several; nothing chose one |
+| `exact_unique` | an identifier matched exactly one candidate |
+| `hash_only` | only a digest of the bytes was kept |
+| `in_segment` | an activity window chosen for commit |
+| `input_of` | unavailable; the serialized provider request is not written locally |
+| `omitted` | deliberately not carried |
+| `retries` | unavailable; retry identity is not recorded |
+| `segment` | an activity window chosen for commit |
+| `size_only` | only the length was kept |
+| `strong_inference` | no identifier, but a well-founded structural argument |
+| `talk` | one readable interaction; derived from where a run was triggered |
+| `truncated` | part of the bytes, with the full length reported |
+| `turn.duration` | unavailable on the claude-vscode entrypoint |
+| `unavailable` | the runtime never wrote it, or the source was pruned |
+| `unknown` | no record of the run states a trigger |
+| `unresolved` | no usable evidence |
+| `weak_inference` | plausible only |
+
+---
+
+## What Claude Code records and the model does not carry
+
+The mapping runs both ways, and this direction matters for anyone going back to the source.
+
+| field | why it is not carried |
+| --- | --- |
+| `requestId` | identifies a provider attempt, which nothing resolves against. A client-fabricated record can reuse a real one, so grouping by it would put invented content inside a real call. It is the join key to captured provider bodies and will return when those do. |
+| `agentId` | equals the stream name for a child stream; carrying it would store the same value twice. |
+| `sessionId` | the landed file already says which session it belongs to. |
+| `isSidechain` | absent on 28.7% of parent-lineage records, so it cannot say which stream a record belongs to. The file can. |
+| `cwd`, `gitBranch`, `slug`, `version`, `userType` | environment, not conversation. Still in the landed record for anyone who wants it. |
+| `thinking.signature` | 13.3% of the corpus, wrapping 0.1 MB of reasoning text. A provider verifies it; a reader cannot read it. |
+
+## What Claude Code does not supply at all
+
+Reported `unavailable` rather than approximated. A second adapter might do better on any of these.
+
+| | |
+| --- | --- |
+| tool execution timing | only `WebFetch.durationMs`, `WebSearch.durationSeconds` and `Agent.totalDurationMs`. Everything else has none, and a timestamp gap includes model and harness latency. |
+| turn duration | absent on the `claude-vscode` entrypoint — 13 of 15 sessions sampled. |
+| a title or summary | for a session, a talk, or anything else. |
+| retry identity | a retried attempt is not linked to the one it replaced. |
+| the serialized provider request | never written locally, so model-context membership is a cross-check on the reconstruction rather than a statement about the wire. |
+| its own record schema | not published, and it changed during this project. |
diff --git a/docs/en/adapters/claude-code.md b/docs/en/adapters/claude-code.md
new file mode 100644
index 0000000..54fd2bc
--- /dev/null
+++ b/docs/en/adapters/claude-code.md
@@ -0,0 +1,338 @@
+# Claude Code Adapter
+
+How the [Unified Conversation Model](../concepts-and-designs/unified-conversation-model.md) maps onto
+Claude Code. Every mapping below is stated with the evidence that supports it and, where a concept
+cannot be supplied, said to be unavailable rather than approximated.
+
+**Verified against** Claude Code 2.1.220 – 2.1.251. No earlier version was available for measurement,
+so nothing here is confirmed below 2.1.220.
+
+## Collection
+
+The adapter reads local files. It requires **no configuration, no environment variables, and no
+cooperation from Claude Code** — and because it reads what is already on disk, it works on
+conversation history that predates its installation.
+
+```text
+~/.claude/projects/<slugified-cwd>/<session-id>.jsonl main stream
+~/.claude/projects/<slugified-cwd>/<session-id>/subagents/agent-<agent-id>.jsonl
+~/.claude/projects/<slugified-cwd>/<session-id>/subagents/agent-<agent-id>.meta.json
+~/.claude/projects/<slugified-cwd>/<session-id>/subagents/workflows/<wf-id>/agent-<agent-id>.jsonl
+~/.claude/projects/<slugified-cwd>/<session-id>/subagents/workflows/<wf-id>/journal.jsonl
+~/.claude/projects/<slugified-cwd>/<session-id>/workflows/wf_<run-id>.json
+~/.claude/projects/<slugified-cwd>/<session-id>/workflows/scripts/<label>-wf_<run-id>.js
+```
+
+A workflow script carries its run id in the filename, which is what makes it collectable
+despite living outside its session's directory. Split on the **last** `-wf_`: a run id itself
+contains hyphens (`wf_afa31e47-f6c`), so a greedy match starts at the first occurrence and
+swallows any later one.
+
+OpenTelemetry is an optional second channel that adds measurement only — see
+[OTLP](#otlp-optional-second-channel).
+
+### Discovery is session-first
+
+A directory under `projects/` is named after the working directory Claude Code was launched in, with
+`/`, `.` and space all collapsed to `-`. That transformation is **not reversible** — the real path is
+recoverable only from record content.
+
+More importantly, **a session's files are not confined to one such directory**. Claude Code files a
+workflow run's *script* under whatever working directory the agent had at the time. Measured on a
+real corpus, 7 of 61 sessions span more than one directory for this reason, one of them across six.
+The conversation data itself - main transcript, child streams, journals, manifests - stays together;
+it is the script that travels.
+
+Discovery therefore scans every directory for entries whose name is a session id — both `.jsonl`
+files and directories — and **groups by session id across directories**. A directory-first walk
+would attribute a session's scripts to a directory that holds nothing else of it.
+
+A directory is recorded as belonging to a session only once it has yielded a collectable source.
+Otherwise a directory containing nothing we collect could disqualify the whole session through an
+exclude pattern — which is why filtering keys on the session's *primary* directory, the one
+holding its main transcript.
+
+Two further rules:
+
+- `memory/` is a sibling directory inside a project directory and is **not** a session. Match session
+ directories by identifier shape, not by "is a directory".
+- **Pruning is real and not atomic.** Claude Code deletes transcripts — the great majority of session
+ ids known to its prompt history have none. A session directory whose main transcript has been
+ deleted while its child streams survive is a normal state, not corruption.
+
+## Object mapping
+
+| Model object | Claude Code | Notes |
+| --- | --- | --- |
+| **Conversation** | `sessionId` *(default)* | overridable when an application supplies an explicit id |
+| **Segment** | derived | an activity window chosen by the assembler; not a runtime concept |
+| **Session** | `sessionId` | 1:1 with Conversation under the default mapping |
+| **ExecutionStream** `main` | `<session-id>.jsonl` | the only stream that compacts |
+| **ExecutionStream** child | `agent-<agent-id>.jsonl` | shares the parent `sessionId` |
+| **Context epoch** | split by `system/compact_boundary` | main stream only |
+| **Talk** | derived from the input → output span | not a runtime concept |
+| **Run** | derived | not a runtime concept |
+
+### Conversation identity
+
+`sessionId` is the default conversation identity because **nothing on disk reliably links two
+sessions**. `bridgeSessionId` spans two session ids in only 1 of 20 observed cases and can be the
+empty string; the snake_case `session_id` field is entrypoint-gated and its apparent cross-session
+links trace to a single stale value. No heuristic over `cwd`, `gitBranch`, `slug` or title is sound.
+
+`/clear` starts a new `sessionId` and a new file, and is detectable at the **head of the successor
+file** as a fixed three-record chain: a root `user` record with `isMeta:true` carrying
+`<local-command-caveat>`, then a `user` record whose content is `<command-name>/clear</command-name>`,
+then `system/local_command`. The successor carries no pointer back to its predecessor.
+
+Applications that consider several `/clear`-separated sessions to be one piece of work must supply an
+explicit conversation id.
+
+### Execution streams
+
+A child agent **always shares the parent's `sessionId`** — measured across 2,697 subagent files with
+zero exceptions and zero files carrying more than one session id. It never gets a session of its own.
+The partition is clean: no sidechain records in main transcripts, no top-level `agentId` in main
+transcripts.
+
+| | main | child |
+| --- | --- | --- |
+| `sessionId` | S | **S — identical** |
+| `isSidechain` | absent | `true` |
+| top-level `agentId` | absent | `^a[0-9a-f]{16}$` |
+
+Nesting stops at depth 2: of 2,686 agents, 2,654 are depth 1 and 31 are depth 2, each carrying
+`parentAgentId`. No depth ≥ 3 exists.
+
+Child streams are structurally simpler than main: exactly one root per file, no duplicate record
+ids, **no compaction at all**, and no rewind forks. Their model context accumulates without
+interruption for the life of the stream.
+
+### Context epochs
+
+`system/compact_boundary` is an explicit, machine-readable epoch boundary — the model never has to
+infer one. It carries `compactMetadata` plus **`logicalParentUuid`**, a pointer to the last
+pre-compaction message that appears on no other record type anywhere.
+
+- The boundary's `uuid` is the `parentUuid` of the immediately following `isCompactSummary:true`
+ record, without exception.
+- The summary's timestamp is **always earlier** than its own boundary's, by 1 ms to 1,097 ms. Never
+ order an epoch boundary by timestamp.
+- `preservedMessages.allUuids` may contain ids that exist nowhere on disk — records held in context
+ but never written. Both those entries and `logicalParentUuid` must be typed unresolved-capable.
+- The preserved segment does not necessarily abut the boundary; resolve by id, never by proximity.
+
+## Step mapping
+
+| Model kind | Claude Code source | Quality |
+| --- | --- | --- |
+| `message.external` | `user` record, `origin.kind:"human"` | `exact_unique` |
+| `message.assistant` | assistant `text` block | `exact_unique` |
+| `message.synthetic` | `message.model == "<synthetic>"` | `exact_unique` |
+| `context.injection` | `attachment` records, 32 types | `exact_unique` |
+| `llm.call` | `(file, message.id)` group | `exact_unique` |
+| `thinking` | assistant `thinking` block — signature only | `unavailable` (content) |
+| `tool` | assistant `tool_use` block joined to its `tool_result` block; enriched by `toolUseResult` | `exact_unique` |
+| `agent.call` | `tool_use` named `Agent` / `Workflow` / Skill fork | `exact_unique` |
+| `agent.launch_ack` | `toolUseResult.status:"async_launched"` | `exact_unique` |
+| `agent.output` | final assistant output of the child stream | `exact_unique` |
+| `runtime.notification` | `<task-notification>` block | `exact_unique` |
+| `epoch.boundary` | `system/compact_boundary` | `exact_unique` |
+| `epoch.summary` | following `isCompactSummary:true` record | `exact_unique` |
+| `error.api` | `system/api_error` + `retryAttempt` | `exact_unique` |
+| `control.interrupt` | `[Request interrupted by user]` | `exact_unique` |
+| `control.permission` | denial strings, `permission-mode` records | `strong_inference` |
+| `control.command` | `system/local_command` | `exact_unique` |
+| `turn.duration` | `system/turn_duration` | `exact_unique` |
+
+One tool use is one step. The `tool_use` block supplies `name` and `input` — for `Bash`, the command
+itself — and the `tool_result` block supplies the result. The join is exact and one-to-one: corpus-wide
+every `(file, tool_use_id)` maps to exactly one result once duplicate records are removed. A provider
+call to its tools is one-to-many, up to 16.
+
+A second, fully redundant join exists: every tool result carries a top-level
+`sourceToolAssistantUUID`, and it matched the record holding the corresponding `tool_use` in 105,409
+of 105,409 cases. It is a free integrity check on the id join.
+
+`toolUseResult` enriches the result with structure the content block lacks — `stdout`/`stderr` split,
+`structuredPatch`, interruption flags — but almost only on the **main** stream: present on 100% of
+main-transcript tool results and 2.1% of child-stream ones. Everything read from it, including the
+spawn joins below, is therefore a main-stream mechanism.
+
+**`toolUseResult` is not always an object.** On roughly 7% of results it is a bare string. Decoding it
+into a struct fails, and because the failure covers the whole record, every identifier on that record
+is lost with it — the tool join, the prompt cycle, the parent link. It must be read as raw JSON and
+shape-checked before any field access.
+
+`timing` is `unavailable`. Claude Code writes no separate execution record locally, and inferring a
+duration from the gap between the request and result records would report queueing and model latency
+as tool time. Three tools are the exception and do report a real duration — `WebFetch.durationMs`,
+`WebSearch.durationSeconds`, `Agent.totalDurationMs`. A configured timeout is not a measurement.
+OTLP's `claude_code.tool.execution` span supplies the rest, which is Phase 2.
+
+**Absence of `is_error` does not mean success** — it is present on 85.5% of result blocks.
+
+### Provider calls
+
+**Group by `(file, message.id)` — never by `requestId`.** `message.id` is present on every assistant
+record; `requestId` is absent on some genuine calls, and a `<synthetic>` companion record can reuse a
+real call's `requestId`, which would put fabricated content inside a real provider call.
+
+A single response is split across several JSONL lines — 2.09 fragments per call corpus-wide, ranging
+1.59 to 2.51 per session. Line order is authoritative; timestamps are not, and ties occur. A call's
+fragments are **not contiguous**: on 24% of multi-fragment calls their own tool results sit between
+them, so grouping must scan the whole file by key rather than a run of lines.
+
+**Usage: take the last fragment in LINE ORDER. Never sum, and never use `stop_reason` to find it.**
+
+`stop_reason` is not a terminal marker. A main transcript stamps the same value on every fragment of a
+call; a subagent transcript stamps it only on the last. So a `stop_reason` test picks the *first*
+fragment on main and the last on a child. Only line order works on both.
+
+Summing is wrong for a different reason on each side. Main repeats the final usage on every fragment,
+so adding them multiplies the real number by the fragment count — 2.18× pooled, 9× worst case.
+Subagent partials climb `1, 2, 3 …`, where the last value is the whole count.
+
+About 10% of calls never report a `stop_reason` (0.02% main, 14.5% subagent). Two consequences:
+
+- Their usage block is **present and wrong** — an output count of one to five, a streaming stub. Usage
+ availability is gated on `stop_reason`, never on the field being present.
+- They are **not in flight**. Only 72 of 7,410 sit at end-of-file; the rest are mid-file with the
+ conversation continuing past them, and their tools did run. Nothing waits for them.
+
+`origin` is not in one place: it sits on the record and also on `attachment.origin`, and reading only
+the first loses 27.6% of all trigger markers. Its value is an open set — a third kind exists beyond
+`human` and `task-notification` — so a switch on exactly two values will meet something unexpected.
+
+### Agent spawn
+
+No single field spans the mechanisms, and they do very unequal amounts of work.
+
+| Mechanism | Parent → child join | Share |
+| --- | --- | --- |
+| **Workflow** | the `Workflow` result's `runId`, plus `journal.jsonl` `{type:"started", agentId}` naming each child | ~96% |
+| **Agent tool** | the parent's `toolUseResult.agentId` | |
+| **Skill fork** | the same field with `status:"forked"`; no separate pointer to the call, so the tool id comes from the result's own content block | |
+| **Nesting** | the child sidecar's `parentAgentId` | 31 children |
+| | *together* | ~4% |
+
+**`agent-<id>.meta.json` is not a general parent pointer.** It normally holds only
+`{agentType, spawnDepth}`. It names a parent for a *nested* child and nothing else, so it is a 3.3%
+edge case rather than a primary join. Without `parentAgentId` a child of a child is attributed to the
+main stream, flattening all 31 depth-2 spawns. `spawnDepth` is absent on one sidecar and must default
+to 1.
+
+**The journal answers one question:** which children belong to a run. It carries no timestamp, no
+name, no parent pointer and no tool id. The launch itself comes from the parent's result, which names
+the run. 10.24% of workflow children never get a terminal journal record, so "started with no result"
+is normal.
+
+**`toolUseResult.status` has three values.** `async_launched` is a launch acknowledgement and not a
+result. `forked` is a skill fork. `completed` is a **synchronous return that IS the result** and
+carries `agentId`.
+
+Completion arrives as a `<task-notification>`, and its two ids are not equally good.
+**`<task-id>` is a generic task handle** — an `agentId` only about 8% of the time, otherwise a
+background-command id, a workflow task id or a monitor id. Joining on it unconditionally matches the
+wrong thing most of the time. `<tool-use-id>` is exact and is the primary key. A single notification
+can carry several `<task-id>` elements.
+
+Two cautions. The `<tool-use-id>` is **not always resolvable in the file it lands in** — a nested
+child notifies the main session while the call that started it lives in an agent file — so resolution
+must search the session's whole file set. And `<status>` is **absent entirely** on a large class of
+notifications; requiring it will fail.
+
+The workflow journal **leads the filesystem**: a `started`, even a `result`, can be journalled before
+the child transcript exists. That is normal for a live session, not corruption. Once the run is over
+it is instead evidence of a pruned or lost file.
+
+**A child with no parent at all exists** — two in the corpus. It is a state to record, not an
+assumption to assert.
+
+## Reconstruction rules
+
+Three rules, each required for correctness rather than efficiency.
+
+**1. Drop duplicate records by `(file, uuid)` before anything else.** Record ids are not unique within
+a file: 1,533 repeated ids among 318,199, in 11 of 62 main transcripts and **0 of 2,908 subagent
+transcripts**, always with multiplicity exactly 2 — so one pass is enough.
+
+Keep the **first** occurrence, and not only for position. The later copy is the worse one: its
+`promptId` is rewritten on 538 of 1,533 pairs, its `parentUuid` differs on 40, and where both carry
+`toolUseResult` the first is larger on 430 pairs and smaller on **zero** — 43.4% of captured tool
+output would be discarded, with `stdout` blanked to `""` on 314 pairs. The `message` object itself is
+byte-identical on every pair, which is why this looks harmless and is not. Nothing distinguishes an
+original from a copy except line order.
+
+These duplicate blocks are not noise. Each is a pre-compaction re-emission of **exactly** the set of
+records lying off the following boundary's ancestry — which independently establishes that
+**context = chain ancestry ∪ off-chain siblings**, and distinguishes parallel-tool siblings (in
+context) from abandoned branches (dropped).
+
+**2. Assemble by `(message.id, line order)`, never by walking `parentUuid`.** The `parentUuid` graph is
+a **DAG, not a chain**: parallel tool dispatch forks it, so an ancestor walk traverses one branch and
+silently drops the other `tool_use` and its result.
+
+> Ancestor-walk reconstruction produces a message list containing a `tool_use` with no matching
+> `tool_result` — a body the Messages API would reject — on **39% of provider calls**. Line-order
+> reconstruction is balanced in 2,622 of 2,623 tool-bearing files.
+
+Every fork has exactly two children. Treat a fork as a topological hint, not a branch to choose.
+
+**3. Always iterate the full `content` array.** A line usually carries one block but sometimes carries
+several, mostly on `user` records — including `[image, text]`, where reading `content[0]` renders a
+base64 image as the user's prompt.
+
+## What this adapter cannot supply
+
+| | Why |
+| --- | --- |
+| **Reasoning text** | Claude Code sends `thinking.display:"omitted"`, so the provider returns only a signature. Raw-body capture redacts it again. `unavailable` — not recoverable by any local mechanism. |
+| **Serialized request** | system prompt, tool schemas and cache annotations are absent from transcripts. `input_manifest` is `truncated`, never `full`, with **inferred** message membership. |
+| **Injected preamble** | the instruction block prepended to the first user message has no transcript record. Its *data* survives as `attachment` records; its rendered form does not. |
+| **Per-call duration and cost** | not written to transcripts. Available via OTLP. |
+| **`tool.execution`** | no local record; the call and result are observable, the execution is not. |
+| **Retry attempt identity** | derivable only for failures that received an HTTP response; transport failures carry no request id. |
+
+The injected preamble is a **fixed** cost — roughly 8 KB without a project instruction file, up to
+~40 KB with one — so its share of a conversation falls as the conversation grows. It is not a
+proportional loss.
+
+Because the mapping from injected records to request blocks is **beta-gated and varies between client
+versions**, and the active beta list is not recorded, a transcript-only reconstruction of the exact
+request message list is not possible. Verifying model-context continuity against what was actually
+sent requires captured provider bodies.
+
+## OTLP (optional second channel)
+
+Claude Code exports OpenTelemetry when `CLAUDE_CODE_ENABLE_TELEMETRY=1` and the relevant exporter
+variables are set. It adds **measurement only**:
+
+- per-call `duration_ms` and `cost_usd`, tool durations and error types
+- `tool_decision` with a composite `source` (`hook:` / `rule:` / `mode:`)
+
+It contributes **no structure**. Events carry no agent identity — `query_source` is normalised to
+`main | subagent | auxiliary`, and the child-completion event carries no agent id at all. Agent
+lineage exists only on spans, which require an additional beta flag; several span names present in
+the binary are unreachable in shipped builds.
+
+Content defaults are also restrictive: prompts and responses are redacted unless explicitly enabled,
+content is truncated, and reasoning is always redacted.
+
+## Local data hazards
+
+Behaviours that will break a naive reader:
+
+- **Session ids are not reliably lowercase.** Joins must be byte-exact and never case-folded.
+- **Lines can be very large** — the longest measured is 979,632 bytes, and files reach 59 MB. A
+ scanner with a 64 KB token limit fails silently.
+- **`cwd` and `gitBranch` are per-record**, not session constants; one session showed 52 distinct
+ `cwd` values. Only `sessionId`, `entrypoint` and `userType` are reliable per-file constants.
+- **A large share of lines carry no record id at all** — metadata-only types that are not
+ context-bearing. A parser assuming every line has one will fail.
+- **Some files contain no conversation records at all**, holding only metadata types. Treating every
+ `.jsonl` as a session produces empty conversations.
+- **`tool_use_id` is not always prefixed `toolu_`** — server-side tools use a different prefix.
+- **The corpus is live.** Files appear and grow during a scan; counts are snapshots.
+- **`toolUseResult` is a main-stream enrichment.** On workflow children only the error-string form
+ survives, and the key is *absent* rather than null.
diff --git a/docs/en/concepts-and-designs/conversation-assembly.md b/docs/en/concepts-and-designs/conversation-assembly.md
new file mode 100644
index 0000000..fd1de2c
--- /dev/null
+++ b/docs/en/concepts-and-designs/conversation-assembly.md
@@ -0,0 +1,141 @@
+# Conversation Assembly
+
+Assembly turns landed records into the structure defined by the
+[Unified Conversation Model](unified-conversation-model.md), and appends the result to an
+append-only chain of immutable rounds.
+
+It reads the **derived index**, not the payloads. Structure resolution needs identifiers, not
+content: removing duplicate records needs record ids, grouping a provider call needs message ids,
+joining a tool needs tool-use ids, joining a spawn needs agent and run ids. Message text is read only
+when a conversation is rendered, and only for the records that appear in it.
+
+```sh
+asz parse # assemble every session, append a round to each chain
+asz parse SESSION # one session
+asz conversation ID # fold the chain and show the structure
+asz verify # check landed data and every round chain
+```
+
+## The pipeline
+
+Eight stages. The order is forced: each depends on the one before it.
+
+| Stage | What it establishes |
+| --- | --- |
+| 1. Remove duplicate records | nothing downstream is correct first; the first copy wins |
+| 2. Partition streams | one ordered lineage per agent, from the file it was written in |
+| 3. Group provider calls | by message id, in line order |
+| 4. Join tools | request and result become one step |
+| 5. Join child agents | which call started which child stream |
+| 6. Cut context epochs | only from an explicit reset record |
+| 7. Build Talks and Runs | the conversation a person reads |
+| 8. Propose segments | activity windows that can be committed |
+
+Six rules in here are the opposite of the obvious choice, and each is the opposite because a
+measurement said so.
+
+**A record type is not what a record is.** Every tool result in the corpus sits on a record whose
+type is `user`. Only a small fraction of `user` records are things a person said. Classification
+reads the trigger and the content blocks, never the type alone.
+
+**Keep the first copy of a repeated record, not the last.** A runtime re-writes a block of history
+just before it resets model context. The later copy is the worse one: it has a rewritten prompt
+cycle, and in many cases its captured tool output has been blanked. Keeping the last both moves
+records away from their true position and destroys data.
+
+**Group a provider call by message id, in line order.** Never by walking the parent pointer forward,
+which drops a tool use on nearly one call in five; and never by request id, because a record the
+client fabricated can carry a real call's request id.
+
+**Take usage from the last fragment in line order.** Not from the fragment with a stop reason — a
+parent lineage stamps the same stop reason on every fragment — and never by adding fragments up,
+which multiplies the real number by the fragment count.
+
+**A Talk starts on input from outside the agent, or on a cycle with no stated trigger at all.** A
+background agent finishing and the parent resuming is mechanically a new prompt cycle, but nobody
+said anything; it is the same interaction continuing. A locally typed command is the reverse case —
+a person acting with no trigger recorded — so both are Talk starts and everything between them is
+not.
+
+**Membership is resolved by walking backward.** A model response carries no cycle id but reaches one
+through its containment parents, which is single-valued. Line proximity agrees in the simple case and
+disagrees exactly where it matters.
+
+## What comes out
+
+The containment tree carries direct ownership only. Every node has at most one parent.
+
+```text
+session
+ └ stream
+ └ epoch
+ └ talk
+ └ run
+ ├ llm.call
+ │ ├ message.assistant · thinking
+ │ └ tool
+ ├ message.external · context.injection
+ ├ agent.call → agent.launch_ack
+ └ runtime.notification
+```
+
+Everything else is a **typed relation** carrying its own correlation quality: `starts`, `reports`,
+`ends_with`, `result_of`, `follows`, `summarizes`, `in_segment`. Cross-stream flow is never containment,
+which is what stops a rendered conversation repeating every subagent's work inside its parent.
+
+A Segment is a relation rather than a parent. It is a time window and a session outlives many of
+them, so a session cannot sit under a segment in a tree where every node has one parent.
+
+## The round chain
+
+A round is an immutable delta. The conversation is the fold of every round from the first to the
+latest.
+
+```text
+data/_conversations/<conversation-id>/
+ conversation.state the mutable head pointer
+ rounds/r000001-<digest>.sf immutable, read-only
+ rounds/r000002-<digest>.sf
+```
+
+Later evidence — a tool result that arrives after its call, a child transcript that appears after its
+spawn — produces a new revision in a **later** round, never an edit to an earlier one. That is what
+lets a round be digested, archived or shipped the moment it is written.
+
+Three rules govern the fold, and each exists because its opposite loses information:
+
+1. **Last writer wins, per id, whole entity.** Not field by field: a partial merge cannot express
+ "this is now absent", so a correction that removes something could never be recorded.
+2. **Absence means unchanged.** An entity missing from round 7 is exactly what round 4 published.
+ Removal is explicit, and an unresolved reference that gets resolved is superseded with that state
+ rather than deleted — absence cannot say that a gap existed and closed.
+3. **Order is chain order, not timestamp order.** Timestamps come from the runtime and can run
+ backwards; the chain cannot.
+
+Rounds are linked by digest, not by filename: round N names the digest of round N−1. A round carries
+no wall-clock time, so the same landed evidence and the same parser version reproduce the same bytes
+and the same digest. Anything mutable or temporal lives in `conversation.state`, outside every
+digest.
+
+A viewer folds the same chain, but not necessarily the same way: it may want the state as of an
+earlier round, or one entity's history across rounds, or to render rounds as they arrive. That is a
+later concern. What the chain guarantees is that the rounds hold enough to answer any of them.
+
+`asz verify` checks four things. Three are digest properties — no gap in the round numbers, each
+round names its predecessor's digest, each round's digest matches its bytes. The fourth is that
+consumed landed sequences are contiguous across rounds, which catches skipped evidence that no digest
+would reveal.
+
+## Qualification
+
+Nothing is presented as observed execution unless it was observed.
+
+Every join carries a correlation quality: `exact_unique`, `exact_ambiguous`, `strong_inference`,
+`weak_inference`, `unresolved`, `conflict`. An exact identifier does not guarantee a unique match —
+where several candidates share a key the relation stays `exact_ambiguous` and the assembler does not
+choose one.
+
+What could not be resolved is carried as data, with a denominator. An assembler that drops what it
+could not resolve presents a partial conversation as a complete one. An entry is promoted to terminal
+only on terminal evidence — a pruned source, a finished run — never because it has stayed open for a
+number of rounds, which is a statement about how often we looked rather than about the data.
diff --git a/docs/en/concepts-and-designs/glossary.md b/docs/en/concepts-and-designs/glossary.md
new file mode 100644
index 0000000..3eff1d6
--- /dev/null
+++ b/docs/en/concepts-and-designs/glossary.md
@@ -0,0 +1,181 @@
+# Glossary
+
+Every word this project uses with a fixed meaning, and what each one is **not**. It is
+runtime-agnostic throughout: no agent product's vocabulary appears here. For what one runtime calls
+these things, see the [Claude Code glossary](../adapters/claude-code-glossary.md).
+
+Where a word is easy to confuse with a neighbour, the entry says so — those pairs are where most
+misreadings start.
+
+---
+
+## The two boundaries
+
+Everything else is defined relative to these.
+
+**Conversation** — the durable ownership boundary, and the unit of storage, analysis and export.
+Its identity is **supplied, never inferred**: an adapter reports it or an application provides it.
+Grouping by person, account, timestamp proximity or session id is not permitted.
+*Not:* a person, an account, a session, a trace, or a time range.
+
+**ExecutionStream** (usually just **stream**) — the ordered continuity boundary. Model-message
+continuity is evaluated inside one and never across two. A child agent always creates or continues a
+distinct stream.
+*Not:* timestamp order, and not a sequence of prompts.
+
+## Structure
+
+**Segment** — an activity window that can be committed on its own. It sits above Session and cuts
+**across** every stream beneath it, so it is a time window rather than a branch of the tree.
+*Not:* a turn, a Talk, or a new conversation.
+
+**Session** — the source-runtime context a record came from, carried as provenance. One conversation
+may contain several; a session belongs to exactly one conversation.
+*Not:* an agent, a stream, or a conversation. It is **not** a grouping key.
+
+**Context epoch** (usually **epoch**) — the lifetime of one model context within a stream. A stream
+that never resets has exactly one. A reset is taken only from an explicit boundary record.
+*Not:* a token-count threshold, and never inferred from a message-list mismatch.
+
+**Talk** — one readable interaction: an input from outside the agent, the work that followed, and
+the output. A Talk starts on external input, or on a run whose records state no trigger at all.
+Everything a Talk delegated stays inside it.
+*Not:* an exact provider request, and not one per prompt.
+
+**Run** — one loop the agent performed in response to one trigger. A Run averages thirteen provider
+calls, so it is emphatically **not** a single model call. The agent does not start its own: every
+Run is triggered from outside the loop, by input or by the runtime reporting a child has finished.
+
+**Step** — one observed occurrence inside a Run. The leaves of the tree.
+*Not:* an inferred event. A step that cannot be observed is reported unavailable.
+
+## Step kinds
+
+| kind | is |
+| --- | --- |
+| `message.external` | input originating outside the agent, typically a person |
+| `message.assistant` | agent output visible to the requester |
+| `message.synthetic` | an assistant-role record **the client fabricated** — a connection loss, a provider error — surfaced inline so the requester sees it. Never counted as agent output. |
+| `context.injection` | material the harness put into model context: instructions, memory, catalogues, reminders |
+| `llm.call` | one provider attempt: its ordered input and its response |
+| `thinking` | reasoning content attached to a response |
+| `tool` | one tool use — the request, what ran, and what came back, as **one** step |
+| `agent.call` | a request to start or continue a child agent |
+| `agent.launch_ack` | acknowledgement that a child started. **Not** its result. |
+| `agent.output` | the child's final output, owned by the **child** stream |
+| `runtime.notification` | the runtime telling the parent a child completed |
+| `epoch.boundary` | an explicit model-context reset |
+| `epoch.summary` | the carried-forward summary that reset produced |
+| `error.api` | a provider error |
+| `control.interrupt` | an externally initiated interruption |
+| `control.permission` | a permission decision or mode change |
+| `control.command` | a runtime command invoked in-line |
+| `turn.duration` | a runtime-reported duration for a completed turn |
+
+**A tool use is one step, not three.** The request and the result are separate records, often far
+apart, but they pair one to one and a reader thinks of them as a single event. A tool whose result
+never arrived carries `result: unavailable` — it is not dropped and not given an empty result.
+
+## Relations
+
+The tree carries **containment only**: every node has at most one parent. Everything else is a
+typed relation carrying its own correlation quality. Cross-stream flow is never containment.
+
+| relation | reads as |
+| --- | --- |
+| `starts` | an agent call → the child stream it created |
+| `reports` | a notification → the child that finished |
+| `ends_with` | a child stream → the step carrying its final output |
+| `result_of` | a record → the tool use whose result it carried |
+| `follows` | an epoch → the epoch it continues after a reset |
+| `summarizes` | a summary → the reset that produced it |
+| `in_segment` | a Talk → the window that will commit it |
+| `retries` | an attempt → the attempt it replaced |
+| `cancels` | an interruption → the operation it ended |
+| `input_of` | a record → the provider call whose input it was part of |
+
+Each name is a verb, so an edge reads as a sentence from its start to its end.
+
+## What a record carries
+
+The role names a record's identifiers play. A runtime's own field names stop at its adapter; these
+are what everything above it sees.
+
+| role | is |
+| --- | --- |
+| `id` | this record's own identity |
+| `parent` | its containment parent |
+| `call` | the provider call it is a fragment of |
+| `run` | the agent loop it belongs to |
+| `batch` | the group of children it was started with. A join key; nothing in the model corresponds to it. |
+| `stream` | the lineage it was written in |
+| `continues` | the record a new model context resumes from, across a reset |
+| `tool` | the tool use this record is about, where the runtime says so outside the content |
+| `child` | the child agent stream this record names |
+| `time` | when the runtime says it happened |
+| `trigger` | what started the run: `external`, `notification`, or `unknown` |
+
+## Qualification
+
+Nothing is presented as observed unless it was observed.
+
+**Correlation quality** — on every join between two records:
+
+`exact_unique` · `exact_ambiguous` · `strong_inference` · `weak_inference` · `unresolved` ·
+`conflict`
+
+An exact identifier does **not** guarantee a unique match. Where several candidates share a key the
+relation stays `exact_ambiguous` and nothing chooses one. A `conflict` is never overwritten by a
+later agreeing observation.
+
+**Claim state** — how a claim was arrived at: `observed_replayable` · `observed_report_only` ·
+`proposed` · `unavailable`.
+
+**Content state** — for any referenced payload: `available` · `redacted` · `omitted` · `hash_only` ·
+`size_only` · `truncated` · `unavailable`.
+
+## Storage
+
+**Landed record** — a source record copied into the storage zone, byte-for-byte. Write-once and
+read-only: temp file, fsync, rename, `chmod 0444`. Never appended to.
+
+**Delta** — one file of landed records, covering a contiguous run of a source. A source becomes many
+deltas over time.
+
+**Cursor** — how far collection got in one source, committed **after** the data lands. That order
+makes a crash repeat data rather than lose it.
+
+**Sequence** — a landed file's number, monotonic across every stream in a session. It is what lets
+progress be tracked with a single watermark.
+
+**Watermark** — how far a stage has got, as a sequence. They trail each other:
+`assembled ≤ indexed ≤ landed`.
+
+**Index** — a derived lookup structure over landed records. Holds identifiers, never content.
+**Disposable**: delete it and it rebuilds; a schema change discards rather than migrates. Nothing
+downstream may treat it as authoritative.
+
+**Round** — one immutable, digest-chained delta of conversation structure. Round N names round N−1's
+digest. A round carries no wall-clock time, so the same evidence reproduces the same bytes.
+
+**Chain** — a conversation's rounds in order.
+
+**Fold** — applying every round in order. `fold(rounds 1..N)` **is** the conversation as of round N;
+there is no other state. Last writer wins per entity; absence means unchanged; removal is an
+explicit tombstone; order is chain order and never timestamp order.
+
+**Revision** — the round number that produced a version of an entity. Derived from chain position,
+not counted per entity, so a round can be re-derived without replaying the chain.
+
+## Adapters
+
+**Adapter** — what turns one runtime's data into landed records. Named for the runtime **and** the
+collection posture, because the posture changes what can be promised about completeness.
+
+**Transport** — how records arrive: reading local files, or receiving a push.
+
+**Dialect** — how records are interpreted: one runtime's schema. A push receiver and a local reader
+for the same runtime share a dialect and nothing else.
+
+**Glossary** — what one dialect calls each thing named here, so a reader can see either vocabulary.
+An empty entry means the runtime has no word for it, which is an answer rather than a gap.
diff --git a/docs/en/concepts-and-designs/overview.md b/docs/en/concepts-and-designs/overview.md
new file mode 100644
index 0000000..f742c3b
--- /dev/null
+++ b/docs/en/concepts-and-designs/overview.md
@@ -0,0 +1,53 @@
+# Overview
+
+SkyWalking AI Sessionizer is conversation-level observability, measurement and export for long-lived AI
+agents. It assembles fragmented agent telemetry — transcripts, traces, logs, provider request bodies,
+tool events and subagent metadata — into one durable conversation structure.
+
+## The problem
+
+Agent runtimes record a single user-visible conversation as many unrelated artifacts. That
+conversation can outlive one process, reactivate after a long idle period, and contain several
+concurrent agent execution lineages. Trace-level inspection cannot answer:
+
+- What did the whole conversation do?
+- Which input, model response, tool result or child-agent result led to the next model call?
+- Did that call preserve the previous message history, or start a new context?
+- Which sessions and agents contributed to the same conversation?
+- What should be measured, stored, previewed and exported as one unit?
+
+## The approach
+
+SkyWalking AI Sessionizer fixes two boundaries and derives everything else from them:
+
+- **Conversation** is the durable aggregation and ownership boundary. Its identity is supplied by the
+ application or adapter — never inferred from a person, an account, or timestamp proximity.
+- **ExecutionStream** is the ordered continuity boundary. Model-message continuity is evaluated within
+ one stream and one context epoch, never across them.
+
+See the [Unified Conversation Model](unified-conversation-model.md) for the full structure.
+
+## Evidence discipline
+
+The project's central rule is that **nothing is presented as observed unless it was observed**.
+
+Every material claim, statistic, diagram edge and rendered annotation carries a qualification, and
+every correlation between two records carries a resolution state — `exact_unique`,
+`exact_ambiguous`, `strong_inference`, `unresolved` or `conflict`. An exact identifier with several
+candidates stays ambiguous; the assembler must never silently choose one.
+
+Where a runtime cannot supply something, the adapter reports it as `unavailable` rather than
+approximating it. A missing model input, an unrecorded parent, or an unobservable causal link is
+never manufactured to make a tree look complete.
+
+## Status
+
+Pre-alpha. The conversation model and the Claude Code data mapping are defined and evidence-backed;
+the collector, assembler, storage, export and preview are not yet implemented.
+
+## Non-goals
+
+- Grouping work by human or principal identity.
+- Inventing missing model input, sessions, parentage or causal links.
+- Re-exporting the original discrete traces and logs as the final product.
+- Acting as a general observability backend or product UI.
diff --git a/docs/en/concepts-and-designs/unified-conversation-model.md b/docs/en/concepts-and-designs/unified-conversation-model.md
new file mode 100644
index 0000000..5c810dd
--- /dev/null
+++ b/docs/en/concepts-and-designs/unified-conversation-model.md
@@ -0,0 +1,248 @@
+# Unified Conversation Model
+
+SkyWalking AI Sessionizer assembles fragmented agent telemetry into one durable conversation structure. This
+document defines that structure. It is **runtime-agnostic**: every concept here must be expressible
+by any adapter, and no concept assumes a particular agent product. Runtime-specific mappings live
+under [Adapters](../adapters/claude-code.md).
+
+## Why a model is needed
+
+Agent runtimes expose traces, logs, transcripts, provider request bodies, tool events and subagent
+metadata as separate records. A single user-visible conversation can outlive one process, reactivate
+after a long idle period, and contain several concurrent agent execution lineages.
+
+Trace-level inspection alone cannot answer: what did the whole conversation do; which input, model
+response, tool result or child-agent result led to the next model call; did that call preserve the
+previous message history or start a new context; which agents contributed; and what should be
+measured, stored and exported as one unit.
+
+The model answers those by fixing two boundaries:
+
+- **Conversation** is the durable aggregation and ownership boundary.
+- **ExecutionStream** is the ordered continuity boundary.
+
+Everything else is defined relative to those two.
+
+## Hierarchy
+
+```text
+Conversation durable identity · ownership boundary
+ └ Segment activity window · the COMMIT unit
+ └ Session observed source provenance
+ ├ ExecutionStream main ordered parent-agent lineage
+ │ └ Context epoch × N model-context lifetime
+ │ └ Talk × N one interaction from outside → run → output
+ │ ├ Input
+ │ └ Run the agent loop
+ │ └ Step × N the leaves — see "Step layer"
+ └ ExecutionStream child × N independent context per child agent
+ └ Talk (typically 1)
+ └ Run → Step × N
+```
+
+`Segment` sits above `Session` and **cuts across every stream beneath it**: it is a time window
+chosen for commit, not a branch of the stream tree. A segment is committed only once all of its
+streams are safe to freeze.
+
+`Context epoch` exists **only where a runtime can reset model context in place**. A stream with no
+compaction always has exactly one epoch, and its message history accumulates without interruption.
+
+A `Talk` **begins** where input arrives from outside the agent. It is not one input. A runtime that
+accepts a message while the agent is still working delivers it into the cycle already running, and
+that message belongs to the interaction it interrupted, not to a new one. So a Talk carries every
+input that arrived before it ended.
+
+Measured over 361 talks drawn from 43 real conversations: 75.3% carry one input, 15.8% carry two,
+5.3% carry three, and 2.5% carry four or more. Only 24% carry exactly one input and one reply. A
+reader that assumes one input per Talk is wrong about a quarter of them.
+
+The same holds on the way out. The agent speaks between tool calls, so a Talk usually holds several
+assistant messages and only the last is the reply. Anything that renders a Talk as a single exchange
+has to say which of them it means.
+
+### Object definitions
+
+| Object | Is | Is **not** |
+| --- | --- | --- |
+| **Conversation** | stable semantic branch; the unit of storage, analysis and export | a person, an account, a session, a trace, or a time range |
+| **Segment** | independently committable activity window | a turn, a Talk, or a new conversation |
+| **Session** | source-runtime context carried as provenance | an agent, a stream, or a conversation |
+| **ExecutionStream** | ordered main, child, auxiliary or judge lineage within one session | timestamp order, or a prompt sequence |
+| **Context epoch** | the lifetime of one model context within a stream | a token-count threshold |
+| **Talk** | one interaction that began outside the agent | one input, or an exact provider request |
+| **Run** | one agent loop inside a Talk | a single model call |
+| **Step** | one observed occurrence inside a run | an inferred event |
+
+### Identity rules
+
+- **Conversation identity is supplied, never inferred.** An adapter reports it, or an application
+ supplies it. Grouping by person, account, timestamp proximity or session id is not permitted.
+- **A session is provenance, not a grouping key.** One conversation may contain several sessions; a
+ session belongs to exactly one conversation.
+- **A child agent always creates or continues a distinct execution stream.** It creates a new session
+ only when the source runtime explicitly reports a different session identifier. Visual nesting,
+ agent identity and timestamps never manufacture a session.
+- **A conversation id survives cold reactivation** while its durable identity mapping exists. A TTL
+ purge starts a new generation.
+
+## Step layer
+
+Steps are the leaves of the tree — the observed occurrences inside a run. Every step carries its own
+provenance and qualification (see below); a step that cannot be observed is reported as unavailable
+rather than synthesised.
+
+| Family | Kind | Meaning |
+| --- | --- | --- |
+| **Message** | `message.external` | input originating outside the agent (typically human) |
+| | `message.assistant` | agent output visible to the requester |
+| | `message.synthetic` | an assistant-role record **fabricated by the client**, not returned by the provider — connection loss, provider error, quota or auth failure, surfaced inline so the requester sees it |
+| **Context** | `context.injection` | material the harness injected into model context — instructions, memory, catalogues, reminders, environment state |
+| **Model** | `llm.call` | one provider attempt: ordered input manifest plus response |
+| | `thinking` | reasoning content associated with a model response |
+| **Tool** | `tool` | one tool use: the request, what ran, and what came back — see below |
+| **Agent** | `agent.call` | a request to start or continue a child agent |
+| | `agent.launch_ack` | acknowledgement that a child started — **not** its result |
+| | `agent.output` | the child's final output, owned by the **child** stream |
+| | `runtime.notification` | the runtime informing the parent that a child completed |
+| **Epoch** | `epoch.boundary` | an explicit model-context reset |
+| | `epoch.summary` | the carried-forward summary produced by that reset |
+| **Control** | `error.api` | a provider error, with retry state where available |
+| | `control.interrupt` | an externally initiated interruption |
+| | `control.permission` | a permission decision or mode change |
+| | `control.command` | a runtime command invoked in-line |
+| | `turn.duration` | a runtime-reported duration for a completed turn |
+
+**A tool use is one step, not three.** It carries `name`, `input` (for a shell tool, the command
+itself), and `result`, each referencing the record it came from:
+
+```text
+tool
+ ├ name "Bash"
+ ├ input { command: "…", description: "…" } ← the request
+ ├ result { output, is_error, … } ← what came back
+ └ timing duration, where the runtime reports it
+```
+
+Request and result are separate records in most runtimes, often far apart in the stream, but they
+pair one-to-one and a reader thinks of them as a single event: *it ran this, and got that back*.
+Splitting them into sibling steps triples the node count and forces every consumer to rejoin what
+the model already knows belongs together.
+
+Two states must stay expressible rather than being hidden. A tool use whose result never
+arrived - interrupted, or the session ended - carries `result: unavailable`; it is not dropped, and
+it is not given an empty result. And where a runtime does not report execution timing separately
+from record timestamps, `timing` is `unavailable` rather than inferred from the gap between records.
+
+Four ownership rules govern this layer:
+
+1. **`message.synthetic` must never be counted as agent output.** It carries the assistant role and
+ sits in the ordered stream like any response, but no model produced it. Counted as
+ `message.assistant` it corrupts output statistics and attributes text to the agent that the agent
+ never wrote. It is also positive signal — it marks precisely where a conversation was interrupted
+ — so it is separated rather than discarded.
+2. **`context.injection` is first-class, not decoration.** Injected material shapes a response as much
+ as the human turn does, and in some runtimes it is the *only* record of an input.
+3. **`agent.output` belongs to the child stream.** The parent receives a compact boundary that
+ *references* the child output; it never absorbs the child's messages or tools. This is what
+ prevents duplication when a conversation is rendered or exported.
+4. **`agent.launch_ack` is not a result.** For asynchronous delegation the canonical order is
+ `agent.call → agent.launch_ack → child stream → agent.output → runtime.notification → parent's
+ next llm.call`.
+
+## Model-context continuity
+
+Within one stream and one context epoch, adjacent eligible provider calls are expected to satisfy:
+
+```text
+next_request.messages =
+ previous_request.messages
+ + assistant(previous_response)
+ + new_input_messages
+```
+
+Four properties of this check matter:
+
+- Comparison is **recursive semantic equality, not byte equality**. Cache annotations migrate between
+ requests and must be excluded from the comparison, or the invariant is false on every adjacent pair.
+- It applies **within one stream and one epoch only**. Auxiliary, retry and reset transitions are
+ excluded, and it never crosses streams.
+- An explicit epoch boundary is the **only** admissible evidence of a context reset. It is never
+ inferred from token counters or from a prefix mismatch alone.
+- The strength of the check depends entirely on the evidence available. Where a runtime exposes the
+ serialized request, this verifies what was actually sent. Where it does not, the same equation
+ evaluated over a reconstructed message list is a **structural cross-check on the reconstruction**,
+ not a statement about the wire. Adapters must report which of the two applies.
+
+## Evidence and qualification
+
+Every material claim, statistic, diagram edge and rendered annotation carries or inherits a
+qualification. Nothing is presented as observed execution unless it was observed.
+
+**Claim states**
+
+| State | Meaning |
+| --- | --- |
+| `observed_replayable` | derived from retained evidence that can be replayed |
+| `observed_report_only` | observed, but the underlying evidence is not retained |
+| `proposed` | a design target, not yet produced by an implementation |
+| `unavailable` | the source cannot supply it |
+
+**Correlation quality** — every join between two records:
+
+| State | Meaning |
+| --- | --- |
+| `exact_unique` | an exact identifier match with exactly one candidate |
+| `exact_ambiguous` | an exact identifier match with several candidates — **never silently resolved** |
+| `strong_inference` | no identifier, but a well-founded structural argument |
+| `weak_inference` | plausible only |
+| `unresolved` | no usable evidence |
+| `conflict` | sources disagree |
+
+An exact identifier does not guarantee a unique match. Where several candidates share a key, the
+relation stays `exact_ambiguous` and the assembler must not choose one.
+
+**Content state** — for any referenced payload: `available`, `redacted`, `omitted`, `hash_only`,
+`size_only`, `truncated`, `unavailable`.
+
+## Ownership versus causality
+
+The tree carries **direct semantic ownership only**. Every node has at most one containment parent.
+
+Everything else — provider input membership, spawn, asynchronous delivery, retry, join, cancellation
+and compaction lineage — is a **sparse typed relation** between nodes, each carrying its own
+correlation quality and source references. Cross-stream flow is never expressed as containment.
+
+Display order is a deterministic projection. It does not prove causality, and it must not be used as
+evidence for it.
+
+## Adapter contract
+
+An adapter supplies as much of the model as its runtime exposes, and reports the rest as
+`unavailable`. It must never invent identity, parentage or causal links.
+
+**Minimum for a useful adapter**
+
+| Requirement | Notes |
+| --- | --- |
+| Conversation identity | explicit; or a documented, stated default mapping |
+| Session identity | as observed from the runtime |
+| Execution-stream identity | main and child lineages distinguishable |
+| Ordered steps | with a stable per-source ordinal |
+| Tool call ↔ result join | with correlation quality |
+| Child spawn and return | with correlation quality |
+
+**Strongly recommended**
+
+Context-epoch boundaries; ordered provider input manifests; injected-context records; retry and
+attempt identity.
+
+An adapter that cannot expose ordered provider input must report model-context coverage as
+`unavailable`. Timing and cache counters do not prove model-input membership.
+
+## Non-goals
+
+- Grouping work by human or principal identity.
+- Inventing missing model input, sessions, parentage or causal links.
+- Re-exporting the original discrete traces and logs as the final product.
+- Acting as a general observability backend or product UI.
+- Making an unavailable field look like an observed execution-tree node.
diff --git a/docs/menu.yml b/docs/menu.yml
new file mode 100644
index 0000000..7208b73
--- /dev/null
+++ b/docs/menu.yml
@@ -0,0 +1,36 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+catalog:
+ - name: Welcome
+ path: /readme
+ - name: Concepts and Designs
+ catalog:
+ - name: Overview
+ path: /en/concepts-and-designs/overview
+ - name: Unified Conversation Model
+ path: /en/concepts-and-designs/unified-conversation-model
+ - name: Conversation Assembly
+ path: /en/concepts-and-designs/conversation-assembly
+ - name: Glossary
+ path: /en/concepts-and-designs/glossary
+ - name: Adapters
+ catalog:
+ - name: Claude Code
+ path: /en/adapters/claude-code
+ - name: Claude Code Glossary
+ path: /en/adapters/claude-code-glossary
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..a8d6389
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module github.com/apache/skywalking-ai-sessionizer
+
+go 1.25
+
+require gopkg.in/yaml.v3 v3.0.1
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..a62c313
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,4 @@
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/adapters/claudecode/claudecode.go b/internal/adapters/claudecode/claudecode.go
new file mode 100644
index 0000000..2bdffcf
--- /dev/null
+++ b/internal/adapters/claudecode/claudecode.go
@@ -0,0 +1,119 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package claudecode implements the claude-code-local adapter: a pull
+// collector that reads Claude Code's own on-disk files.
+//
+// It requires no configuration of Claude Code, no environment variables and no
+// cooperation from the running agent. Because it reads what is already on disk,
+// it also works on conversation history that predates its installation.
+package claudecode
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// Name is the adapter identifier used in config and in landed headers.
+const Name = "claude-code-local"
+
+// Version is the adapter's contract version, recorded in landed headers so a
+// bundle can be interpreted against the adapter that produced it.
+const Version = "0.1.0"
+
+// sessionIDRe matches a Claude Code session identifier.
+//
+// Case is significant: real corpora contain uppercase session ids, so every
+// comparison must be byte-exact and never case-folded.
+var sessionIDRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
+
+// agentIDRe matches a Claude Code agent identifier.
+var agentIDRe = regexp.MustCompile(`^a[0-9a-f]{16}$`)
+
+// IsSessionID reports whether name is a session identifier.
+func IsSessionID(name string) bool { return sessionIDRe.MatchString(name) }
+
+// IsAgentID reports whether name is an agent identifier.
+func IsAgentID(name string) bool { return agentIDRe.MatchString(name) }
+
+// runIDRe validates the run id extracted from a script filename.
+var runIDRe = regexp.MustCompile(`^wf_[A-Za-z0-9][A-Za-z0-9_-]*$`)
+
+// ScriptRunID returns the workflow run id embedded in a script filename.
+//
+// Claude Code writes workflow scripts as "<label>-wf_<run-id>.js". A run id
+// itself contains hyphens (wf_afa31e47-f6c), so a greedy pattern would start at
+// the FIRST "-wf_" and swallow any later one - taking the label as part of the
+// id whenever the label happens to contain that sequence. Splitting on the LAST
+// occurrence is what makes the boundary unambiguous.
+func ScriptRunID(name string) (string, bool) {
+ base, ok := strings.CutSuffix(name, ".js")
+ if !ok {
+ return "", false
+ }
+ i := strings.LastIndex(base, "-wf_")
+ if i < 0 {
+ return "", false
+ }
+ id := base[i+1:]
+ if !runIDRe.MatchString(id) {
+ return "", false
+ }
+ return id, true
+}
+
+// Slugify converts a working directory to the source directory name Claude
+// Code files it under.
+//
+// The transformation collapses '/', '.' and ' ' to '-'. It is therefore lossy
+// and NOT reversible: "-a-b" could have come from "/a/b", "/a.b" or "/a b".
+// Forward slugification is deterministic, so configuration slugifies the user's
+// path to match; nothing ever attempts the inverse. The real working directory
+// is recoverable only from record content.
+func Slugify(dir string) string {
+ return strings.Map(func(r rune) rune {
+ switch r {
+ case '/', '.', ' ':
+ return '-'
+ }
+ return r
+ }, dir)
+}
+
+// ResolveSourceRoot determines where Claude Code keeps its project data.
+//
+// Resolution order: an explicit override, then CLAUDE_CONFIG_DIR, then
+// XDG_CONFIG_HOME, then the default. Claude Code honours both environment
+// variables, so the location must never be hardcoded.
+func ResolveSourceRoot(override string) (string, error) {
+ if override != "" {
+ return filepath.Abs(override)
+ }
+ if dir := os.Getenv("CLAUDE_CONFIG_DIR"); dir != "" {
+ return filepath.Join(dir, "projects"), nil
+ }
+ if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
+ return filepath.Join(dir, "claude", "projects"), nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".claude", "projects"), nil
+}
diff --git a/internal/adapters/claudecode/claudecode_test.go b/internal/adapters/claudecode/claudecode_test.go
new file mode 100644
index 0000000..58cb3b8
--- /dev/null
+++ b/internal/adapters/claudecode/claudecode_test.go
@@ -0,0 +1,289 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode_test
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+)
+
+func TestSlugify(t *testing.T) {
+ // '/', '.' and ' ' all collapse to '-', which is why the transform is not
+ // reversible and why configuration only ever slugifies forward.
+ cases := map[string]string{
+ "/Users/w/github/skywalking-horizon-ui": "-Users-w-github-skywalking-horizon-ui",
+ "/private/tmp/agentsessionizer-v04.psix": "-private-tmp-agentsessionizer-v04-psix",
+ "/Users/w/Library/Application Support/X": "-Users-w-Library-Application-Support-X",
+ }
+ for in, want := range cases {
+ if got := claudecode.Slugify(in); got != want {
+ t.Errorf("Slugify(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestSessionIDCaseSensitive(t *testing.T) {
+ // Real corpora contain uppercase session ids, so matching must accept them
+ // while comparisons stay byte-exact.
+ upper := "41090DAB-113C-432A-8594-520FA2A7DA4A"
+ if !claudecode.IsSessionID(upper) {
+ t.Errorf("uppercase session id rejected: %s", upper)
+ }
+ if claudecode.IsSessionID("memory") {
+ t.Error("memory must not be treated as a session id")
+ }
+ if claudecode.IsSessionID("not-a-uuid") {
+ t.Error("non-uuid accepted as session id")
+ }
+}
+
+// buildTree writes a synthetic source tree covering every discovery case.
+func buildTree(t *testing.T) string {
+ t.Helper()
+ root := t.TempDir()
+ write := func(rel, body string) {
+ p := filepath.Join(root, rel)
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ const sess = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
+ const agent1 = "a1111111111111111"
+ const agent2 = "a2222222222222222"
+
+ // Main transcript and a direct child, under project A.
+ write("-proj-a/"+sess+".jsonl", "{\"type\":\"user\",\"uuid\":\"u1\"}\n")
+ write("-proj-a/"+sess+"/subagents/agent-"+agent1+".jsonl", "{\"type\":\"user\"}\n")
+ write("-proj-a/"+sess+"/subagents/agent-"+agent1+".meta.json", `{"agentType":"general-purpose","spawnDepth":1}`)
+ // A workflow run: child stream, journal and manifest.
+ write("-proj-a/"+sess+"/subagents/workflows/wf_r1/agent-"+agent2+".jsonl", "{\"type\":\"user\"}\n")
+ write("-proj-a/"+sess+"/subagents/workflows/wf_r1/journal.jsonl", "{\"type\":\"started\",\"agentId\":\""+agent2+"\"}\n")
+ write("-proj-a/"+sess+"/workflows/wf_r1.json", `{"runId":"wf_r1","status":"completed"}`)
+
+ // The SAME session also has a directory under project B, which is what
+ // happens when a child agent runs in a different working directory.
+ write("-proj-b/"+sess+"/subagents/agent-a3333333333333333.jsonl", "{\"type\":\"user\"}\n")
+
+ // Noise that must be ignored.
+ write("-proj-a/memory/notes.md", "not a session")
+ write("-proj-a/not-a-uuid.jsonl", "{}")
+ return root
+}
+
+func TestDiscoverIsSessionFirst(t *testing.T) {
+ root := buildTree(t)
+ sessions, err := claudecode.Discover(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(sessions) != 1 {
+ t.Fatalf("got %d sessions, want 1 (session-first grouping should merge across dirs)", len(sessions))
+ }
+ s := sessions[0]
+ if len(s.Dirs) != 2 {
+ t.Errorf("got %d source dirs, want 2: %v", len(s.Dirs), s.Dirs)
+ }
+
+ count := map[claudecode.SourceKind]int{}
+ for _, src := range s.Sources {
+ count[src.Kind]++
+ }
+ want := map[claudecode.SourceKind]int{
+ claudecode.SrcMainTranscript: 1,
+ claudecode.SrcAgentTranscript: 3, // direct + workflow + the one under project B
+ claudecode.SrcAgentMeta: 1,
+ claudecode.SrcJournal: 1,
+ claudecode.SrcWorkflowManifest: 1,
+ }
+ for k, w := range want {
+ if count[k] != w {
+ t.Errorf("kind %d: got %d, want %d", k, count[k], w)
+ }
+ }
+}
+
+func TestDiscoverExcludesMemoryDir(t *testing.T) {
+ root := buildTree(t)
+ sessions, err := claudecode.Discover(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, s := range sessions {
+ if s.ID == "memory" {
+ t.Fatal("memory directory was treated as a session")
+ }
+ }
+}
+
+// --- tailing ---
+
+func writeFile(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestTailStopsAtLastCompleteLine(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "s.jsonl")
+ // The third line has no terminating newline: it is an in-flight write and
+ // must not be consumed, or we land a record the writer has not finished.
+ writeFile(t, p, "{\"a\":1}\n{\"a\":2}\n{\"a\":3")
+
+ cur := storage.NewCursor(storage.CursorAppend, "s.jsonl")
+ ch, err := claudecode.TailAppend(p, cur, 1<<20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ch.Lines) != 2 {
+ t.Fatalf("got %d lines, want 2 (partial tail must be left behind)", len(ch.Lines))
+ }
+ if ch.NewOrd != 2 {
+ t.Errorf("NewOrd = %d, want 2", ch.NewOrd)
+ }
+ // Two 8-byte lines consumed; the 6-byte partial third line is left behind.
+ if ch.NewOffset != 16 {
+ t.Errorf("NewOffset = %d, want 16", ch.NewOffset)
+ }
+}
+
+func TestTailResumesAndNumbersFromCursor(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "s.jsonl")
+ writeFile(t, p, "{\"a\":1}\n{\"a\":2}\n")
+
+ cur := storage.NewCursor(storage.CursorAppend, "s.jsonl")
+ ch, err := claudecode.TailAppend(p, cur, 1<<20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ applyChunk(t, cur, p, ch)
+
+ // Append two more lines; ord must continue from the cursor, not restart.
+ writeFile(t, p, "{\"a\":1}\n{\"a\":2}\n{\"a\":3}\n{\"a\":4}\n")
+ ch2, err := claudecode.TailAppend(p, cur, 1<<20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ch2.Lines) != 2 {
+ t.Fatalf("got %d lines, want 2 (only the new tail)", len(ch2.Lines))
+ }
+ if ch2.Lines[0].Ord != 3 || ch2.Lines[1].Ord != 4 {
+ t.Errorf("ords = %d,%d; want 3,4 - ord must continue from the cursor",
+ ch2.Lines[0].Ord, ch2.Lines[1].Ord)
+ }
+}
+
+func TestTailDetectsTruncation(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "s.jsonl")
+ writeFile(t, p, "{\"a\":1}\n{\"a\":2}\n")
+ cur := storage.NewCursor(storage.CursorAppend, "s.jsonl")
+ ch, err := claudecode.TailAppend(p, cur, 1<<20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ applyChunk(t, cur, p, ch)
+
+ writeFile(t, p, "{\"a\":1}\n") // shorter than the cursor offset
+ if _, err := claudecode.TailAppend(p, cur, 1<<20); err == nil {
+ t.Fatal("expected a conflict on truncation, got nil")
+ }
+}
+
+func TestTailDetectsRewriteBehindCursor(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "s.jsonl")
+ writeFile(t, p, "{\"a\":1}\n{\"a\":2}\n")
+ cur := storage.NewCursor(storage.CursorAppend, "s.jsonl")
+ ch, err := claudecode.TailAppend(p, cur, 1<<20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ applyChunk(t, cur, p, ch)
+
+ // Same length, different content behind the cursor, plus new data.
+ writeFile(t, p, "{\"a\":9}\n{\"a\":8}\n{\"a\":3}\n")
+ _, err = claudecode.TailAppend(p, cur, 1<<20)
+ if err == nil {
+ t.Fatal("expected a conflict on rewrite behind the cursor, got nil")
+ }
+ var ce *claudecode.ConflictError
+ if !asConflict(err, &ce) {
+ t.Fatalf("expected ConflictError, got %T: %v", err, err)
+ }
+}
+
+func TestTailSourceGone(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "gone.jsonl")
+ cur := storage.NewCursor(storage.CursorAppend, "gone.jsonl")
+ _, err := claudecode.TailAppend(p, cur, 1<<20)
+ if err == nil {
+ t.Fatal("expected ErrSourceGone")
+ }
+ // Pruning is normal - Claude Code deletes transcripts - so this must be a
+ // distinguishable state, not a generic failure.
+ if !isSourceGone(err) {
+ t.Fatalf("expected ErrSourceGone, got %v", err)
+ }
+}
+
+func TestTailNoProgressWhenUnchanged(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "s.jsonl")
+ writeFile(t, p, "{\"a\":1}\n")
+ cur := storage.NewCursor(storage.CursorAppend, "s.jsonl")
+ ch, err := claudecode.TailAppend(p, cur, 1<<20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ applyChunk(t, cur, p, ch)
+
+ ch2, err := claudecode.TailAppend(p, cur, 1<<20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ch2.Lines) != 0 {
+ t.Errorf("got %d lines from an unchanged source, want 0", len(ch2.Lines))
+ }
+}
+
+func applyChunk(t *testing.T, cur *storage.Cursor, path string, ch *claudecode.Chunk) {
+ t.Helper()
+ fi, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ d, err := claudecode.TailDigestAt(path, int64(ch.NewOffset))
+ if err != nil {
+ t.Fatal(err)
+ }
+ cur.Offset, cur.Ord, cur.LastUUID, cur.TailSHA256 = ch.NewOffset, ch.NewOrd, ch.LastUUID, d
+ cur.Size, cur.MTime = fi.Size(), fi.ModTime().Unix()
+ _ = time.Now
+}
diff --git a/internal/adapters/claudecode/collect.go b/internal/adapters/claudecode/collect.go
new file mode 100644
index 0000000..ed83b96
--- /dev/null
+++ b/internal/adapters/claudecode/collect.go
@@ -0,0 +1,463 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/index"
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
+)
+
+// Collector lands Claude Code sources into a storage zone.
+type Collector struct {
+ SourceRoot string
+ Zone *storage.Zone
+ MaxDelta int64
+ Now func() time.Time
+}
+
+// pass carries the state of one session's collection.
+//
+// The index lives here rather than on Collector because it is per-session
+// mutable state: holding it on the shared struct would make two concurrent
+// CollectAll calls race on it, silently interleaving one session's entries into
+// another's index.
+//
+// Indexing rides along with landing because the collector already holds every
+// byte it writes; a separate pass would re-read the whole corpus to learn
+// nothing new.
+type pass struct {
+ ix *index.Index
+ state *storage.SessionState
+ st *Stats
+ now time.Time
+}
+
+// maxDrainRounds bounds how many windows one source may land in a single pass,
+// so a source that is growing faster than we read cannot starve the others.
+const maxDrainRounds = 64
+
+// Stats summarises one collection pass.
+type Stats struct {
+ Sessions int
+ SourcesSeen int
+ SourcesLanded int
+ Records int
+ Bytes int64
+ SourcesGone int
+ Conflicts int
+ // Busy counts sessions skipped because another collector holds their lock.
+ Busy int
+ // Pending counts sources that still had data when the per-pass drain limit
+ // was reached. A non-zero value means the pass was NOT complete.
+ Pending int
+ // Indexed is the total index entries across every session touched.
+ Indexed int
+ // Reindexed counts entries recovered by re-reading landed files because the
+ // index had fallen behind. A non-zero value means a previous pass was
+ // interrupted after landing but before the index was written.
+ Reindexed int
+ Errors []error
+}
+
+// Complete reports whether the pass collected everything available.
+func (s *Stats) Complete() bool {
+ return s.Pending == 0 && s.Conflicts == 0 && len(s.Errors) == 0
+}
+
+// New returns a Collector with sensible defaults.
+func New(sourceRoot string, zone *storage.Zone, maxDelta int64) *Collector {
+ if maxDelta <= 0 {
+ maxDelta = 8 << 20
+ }
+ return &Collector{SourceRoot: sourceRoot, Zone: zone, MaxDelta: maxDelta, Now: time.Now}
+}
+
+// CollectAll discovers sessions and lands whatever is new in each.
+func (c *Collector) CollectAll(filter func(Session) bool) (*Stats, error) {
+ sessions, warnings, err := DiscoverWithWarnings(c.SourceRoot)
+ if err != nil {
+ return nil, err
+ }
+ st := &Stats{}
+ // An unreadable source directory hides sources; recording it as an error
+ // keeps the pass from reporting a clean, complete-looking result.
+ st.Errors = append(st.Errors, warnings...)
+ for _, s := range sessions {
+ if filter != nil && !filter(s) {
+ continue
+ }
+ st.Sessions++
+ if err := c.collectSession(s, st); err != nil {
+ st.Errors = append(st.Errors, fmt.Errorf("session %s: %w", s.ID, err))
+ }
+ }
+ return st, nil
+}
+
+// collectSession processes one session single-threaded.
+//
+// Single-threaded is required, not incidental: the landed sequence must be
+// monotonic across every stream in the session, which is what lets the
+// assembler track progress with one watermark instead of one per stream.
+func (c *Collector) collectSession(s Session, st *Stats) error {
+ sessionDir := c.Zone.SessionDir(s.ID)
+
+ // One collector per session. Without this two processes allocate the same
+ // sequence numbers to different content, and the assembler's watermark
+ // skips whichever it reaches second.
+ lock, err := storage.LockSession(sessionDir)
+ if err != nil {
+ if errors.Is(err, storage.ErrSessionBusy) {
+ st.Busy++
+ return nil
+ }
+ return err
+ }
+ defer func() { _ = lock.Unlock() }()
+
+ indexDir := c.Zone.IndexDir(s.ID)
+ ixState, err := storage.LoadIndexState(c.Zone.IndexStatePath(s.ID), s.ID)
+ if err != nil {
+ return err
+ }
+ statePath := c.Zone.SessionStatePath(s.ID)
+ state, err := storage.LoadSessionState(statePath, s.ID)
+ if err != nil {
+ return err
+ }
+ if err := state.RecoverNextSeq(sessionDir); err != nil {
+ return err
+ }
+
+ var ix *index.Index
+ if ixState.Schema == index.Schema {
+ if loaded, ok, lerr := index.Load(indexDir, s.ID); lerr == nil && ok {
+ ix = loaded
+ }
+ }
+ if ix == nil {
+ ix = index.New(s.ID)
+ ixState = storage.NewIndexState(s.ID)
+ }
+
+ // Close any gap between what has landed and what the index covers.
+ //
+ // Cursors commit per source while the index is written once per session, so
+ // a crash between them advances the cursors without recording their
+ // entries. Nothing would re-land, so the gap would be permanent - and the
+ // index would claim, via indexed_seq, to describe data it does not hold.
+ // Re-reading the landed files is the only way to close it.
+ reindexed := false
+ if landedTo := state.NextSeq - 1; ixState.IndexedSeq < landedTo {
+ n, rerr := RebuildIndex(c.Zone, s.ID, ix, ixState.IndexedSeq)
+ if rerr != nil {
+ return rerr
+ }
+ if n > 0 {
+ st.Reindexed += n
+ }
+ ixState.IndexedSeq = landedTo
+ reindexed = true
+ }
+
+ p := &pass{ix: ix, state: state, st: st, now: c.Now()}
+ now := p.now
+ changed := false
+
+ // Two distinct sources must never share a landing directory. A session's
+ // files are spread across several source directories, so in principle the
+ // same agent id or workflow run id could appear under two of them; landing
+ // both into one stream would interleave unrelated records behind a single
+ // cursor, and the two would then overwrite each other's position on every
+ // pass. Detect it and refuse rather than corrupt.
+ claimed := make(map[string]string, len(s.Sources))
+
+ for _, src := range s.Sources {
+ st.SourcesSeen++
+
+ _, cursorPath, _ := c.cursorPaths(src)
+ if prev, dup := claimed[cursorPath]; dup && prev != src.Rel {
+ st.Conflicts++
+ st.Errors = append(st.Errors, fmt.Errorf(
+ "claudecode: %s and %s both map to %s; refusing to interleave them",
+ prev, src.Rel, cursorPath))
+ continue
+ }
+ claimed[cursorPath] = src.Rel
+
+ // Drain a source rather than landing one window per pass. A single
+ // window would leave the documented "-once" backfill silently
+ // incomplete: a 59 MB transcript needs eight passes at the default
+ // budget, and the pass would still report success.
+ var landedAny bool
+ for round := 0; ; round++ {
+ landed, more, err := c.collectSource(src, p)
+ if err != nil {
+ st.Errors = append(st.Errors, err)
+ break
+ }
+ landedAny = landedAny || landed
+ if !more {
+ break
+ }
+ if round >= maxDrainRounds {
+ st.Pending++
+ break
+ }
+ }
+ if landedAny {
+ changed = true
+ st.SourcesLanded++
+ }
+ }
+
+ // A recovered index must be persisted even when no source landed this pass;
+ // otherwise the rebuild is discarded and repeats on every round forever.
+ if changed || reindexed {
+ ixState.Schema = index.Schema
+ ixState.IndexedSeq = state.NextSeq - 1
+ ixState.Entries = len(ix.Entries)
+ ixState.Blocks = len(ix.Blocks)
+ ixState.Strings = ix.Strings.Len()
+ if err := ix.Write(indexDir); err != nil {
+ return err
+ }
+ if err := ixState.Save(c.Zone.IndexStatePath(s.ID), now); err != nil {
+ return err
+ }
+ st.Indexed += ixState.Entries
+ }
+
+ state.LastScan = now.UTC().Format(time.RFC3339Nano)
+ return state.Save(statePath, now)
+}
+
+// cursorPaths returns the landing directory and cursor path for a source.
+func (c *Collector) cursorPaths(src Source) (dir, cursorPath, prefix string) {
+ switch src.Kind {
+ case SrcMainTranscript, SrcAgentTranscript:
+ dir = c.Zone.StreamDir(src.Session, src.Stream)
+ prefix = "transcript"
+ case SrcAgentMeta:
+ dir = c.Zone.StreamDir(src.Session, src.Stream)
+ prefix = "meta"
+ case SrcJournal:
+ dir = c.Zone.RunDir(src.Session, src.RunID)
+ prefix = "journal"
+ case SrcWorkflowManifest:
+ dir = c.Zone.RunDir(src.Session, src.RunID)
+ prefix = "manifest"
+ case SrcWorkflowScript:
+ dir = c.Zone.RunDir(src.Session, src.RunID)
+ prefix = "script"
+ default:
+ return "", "", ""
+ }
+ // "<kind>.cursor" rather than "<kind>-cursor": a landed file is
+ // "<kind>-<ts>-<seq>.jsonl", so a hyphen would make the cursor share a
+ // prefix with the data it tracks and turn any prefix scan into a bug.
+ return dir, filepath.Join(dir, prefix+".cursor"), prefix
+}
+
+func (c *Collector) collectSource(src Source, p *pass) (landed, more bool, err error) {
+ dir, cursorPath, prefix := c.cursorPaths(src)
+ if dir == "" {
+ return false, false, fmt.Errorf("claudecode: unknown source kind for %s", src.Rel)
+ }
+
+ kind := storage.CursorSnapshot
+ if src.Kind.Append() {
+ kind = storage.CursorAppend
+ }
+ cur, err := storage.LoadCursor(cursorPath, kind, src.Rel)
+ if err != nil {
+ return false, false, err
+ }
+ if cur.State == storage.CursorConflict {
+ p.st.Conflicts++
+ return false, false, nil // a conflict is sticky until resolved
+ }
+
+ if src.Kind.Append() {
+ return c.landAppend(src, cur, cursorPath, dir, prefix, p)
+ }
+ landed, err = c.landSnapshot(src, cur, cursorPath, dir, prefix, p)
+ return landed, false, err
+}
+
+func (c *Collector) landAppend(src Source, cur *storage.Cursor, cursorPath, dir, prefix string,
+ p *pass) (bool, bool, error) {
+ state, st, now := p.state, p.st, p.now
+
+ chunk, err := TailAppend(src.Path, cur, c.MaxDelta)
+ switch {
+ case errors.Is(err, ErrSourceGone):
+ landed, gerr := c.markGone(cur, cursorPath, st, now)
+ return landed, false, gerr
+ case err != nil:
+ var ce *ConflictError
+ if errors.As(err, &ce) {
+ cur.State = storage.CursorConflict
+ st.Conflicts++
+ _ = cur.Save(cursorPath, now)
+ return false, false, err
+ }
+ return false, false, err
+ }
+ if len(chunk.Lines) == 0 {
+ return false, false, nil
+ }
+
+ seq := state.Take()
+ name := storage.LandedName(prefix, storage.Stamp(now), seq)
+ hdr := &sessiondata.Header{
+ H: 1, Seq: seq, At: now.UTC().Format(time.RFC3339Nano),
+ Kind: src.Kind.RecordKind(), Adapter: Name + "/" + Version, Dialect: Dialect,
+ Src: src.Rel, Session: src.Session, Stream: src.Stream, Batch: src.RunID,
+ }
+
+ var written int64
+ err = storage.WriteAtomic(filepath.Join(dir, name), storage.PermLanded, func(w io.Writer) error {
+ rw, err := sessiondata.NewWriter(w, hdr)
+ if err != nil {
+ return err
+ }
+ for row, ln := range chunk.Lines {
+ // Conversion happens HERE, once, while the source is being read.
+ // Nothing above this line ever sees a Claude Code shape.
+ rec := Convert(src, ln.Ord, ln.Off, ln.Bytes)
+ if err := rw.Write(rec); err != nil {
+ return err
+ }
+ written += int64(len(ln.Bytes))
+ // The index is built from the CONVERTED record, so the source is
+ // parsed once and the index has no dialect in it.
+ e, blocks := index.FromRecord(p.ix, hdr, rec, uint32(seq), uint32(row+1))
+ p.ix.Append(e, blocks...)
+ }
+ return rw.Close()
+ })
+ if err != nil {
+ return false, false, err
+ }
+
+ // Land before committing the cursor. A crash between the two re-lands the
+ // same records next pass, which is safe because the assembler must already
+ // drop duplicate records by (file, uuid) - Claude Code itself writes them.
+ // The reverse order would lose data instead.
+ digest, err := TailDigestAt(src.Path, int64(chunk.NewOffset))
+ if err != nil {
+ // The source vanished between landing and the digest. The delta is
+ // already durable, so record the position we reached rather than
+ // discarding a successful write.
+ if !os.IsNotExist(err) {
+ return true, false, err
+ }
+ }
+
+ cur.Dev, cur.Ino = chunk.Dev, chunk.Ino
+ cur.Offset, cur.Ord = chunk.NewOffset, chunk.NewOrd
+ cur.LastUUID, cur.TailSHA256 = chunk.LastUUID, digest
+ cur.Size, cur.MTime = chunk.Size, chunk.MTime
+ cur.LastSeq, cur.State = seq, storage.CursorActive
+
+ st.Records += len(chunk.Lines)
+ st.Bytes += written
+ return true, chunk.More, cur.Save(cursorPath, now)
+}
+
+func (c *Collector) landSnapshot(src Source, cur *storage.Cursor, cursorPath, dir, prefix string,
+ p *pass) (bool, error) {
+ state, st, now := p.state, p.st, p.now
+
+ data, err := os.ReadFile(src.Path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return c.markGone(cur, cursorPath, st, now)
+ }
+ return false, err
+ }
+ body := trimTrailingNewline(data)
+ if len(body) == 0 {
+ return false, nil
+ }
+ sum := sha256.Sum256(body)
+ digest := hex.EncodeToString(sum[:])
+ if digest == cur.ContentSHA256 {
+ return false, nil // unchanged
+ }
+
+ seq := state.Take()
+ name := storage.LandedName(prefix, storage.Stamp(now), seq)
+ hdr := &sessiondata.Header{
+ H: 1, Seq: seq, At: now.UTC().Format(time.RFC3339Nano),
+ Kind: src.Kind.RecordKind(), Adapter: Name + "/" + Version, Dialect: Dialect,
+ Src: src.Rel, Session: src.Session, Stream: src.Stream, Batch: src.RunID,
+ }
+ err = storage.WriteAtomic(filepath.Join(dir, name), storage.PermLanded, func(w io.Writer) error {
+ rw, err := sessiondata.NewWriter(w, hdr)
+ if err != nil {
+ return err
+ }
+ rec := Convert(src, 1, 0, body)
+ if err := rw.Write(rec); err != nil {
+ return err
+ }
+ e, blocks := index.FromRecord(p.ix, hdr, rec, uint32(seq), 1)
+ p.ix.Append(e, blocks...)
+ return rw.Close()
+ })
+ if err != nil {
+ return false, err
+ }
+
+ info, _ := statSource(src.Path)
+ cur.ContentSHA256 = digest
+ cur.Size, cur.MTime = info.size, info.mtime
+ cur.LastSeq, cur.State = seq, storage.CursorActive
+
+ st.Records++
+ st.Bytes += int64(len(body))
+ return true, cur.Save(cursorPath, now)
+}
+
+func (c *Collector) markGone(cur *storage.Cursor, cursorPath string, st *Stats, now time.Time) (bool, error) {
+ st.SourcesGone++
+ if cur.State == storage.CursorSourceGone {
+ return false, nil
+ }
+ cur.State = storage.CursorSourceGone
+ return false, cur.Save(cursorPath, now)
+}
+
+func trimTrailingNewline(b []byte) []byte {
+ for len(b) > 0 && (b[len(b)-1] == '\n' || b[len(b)-1] == '\r') {
+ b = b[:len(b)-1]
+ }
+ return b
+}
diff --git a/internal/adapters/claudecode/collect_test.go b/internal/adapters/claudecode/collect_test.go
new file mode 100644
index 0000000..1395d74
--- /dev/null
+++ b/internal/adapters/claudecode/collect_test.go
@@ -0,0 +1,187 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode_test
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+)
+
+func mk(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+const (
+ tSess = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
+ tAgent = "a1234567890abcdef"
+)
+
+// TestCollideStreamsAreRefused guards against two distinct sources landing into
+// one stream. Interleaving them behind a single cursor would corrupt both, and
+// they would fight over the cursor position on every pass.
+func TestCollideStreamsAreRefused(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ mk(t, filepath.Join(src, "-proj-a", tSess, "subagents", "agent-"+tAgent+".jsonl"), "{\"uuid\":\"a\"}\n")
+ mk(t, filepath.Join(src, "-proj-b", tSess, "subagents", "agent-"+tAgent+".jsonl"), "{\"uuid\":\"b\"}\n")
+
+ col := claudecode.New(src, storage.NewZone(zone), 0)
+ st, err := col.CollectAll(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if st.Conflicts == 0 {
+ t.Error("expected a conflict when two sources map to one stream")
+ }
+ var found bool
+ for _, e := range st.Errors {
+ if strings.Contains(e.Error(), "refusing to interleave") {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("expected an interleave refusal; errors = %v", st.Errors)
+ }
+}
+
+// TestCollectLandsAllSixKinds exercises the whole shape end to end.
+func TestCollectLandsAllSixKinds(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ base := filepath.Join(src, "-proj-a", tSess)
+ mk(t, filepath.Join(src, "-proj-a", tSess+".jsonl"), "{\"uuid\":\"m1\"}\n")
+ mk(t, filepath.Join(base, "subagents", "agent-"+tAgent+".jsonl"), "{\"uuid\":\"c1\"}\n")
+ mk(t, filepath.Join(base, "subagents", "agent-"+tAgent+".meta.json"), `{"agentType":"general-purpose","spawnDepth":1}`)
+ mk(t, filepath.Join(base, "subagents", "workflows", "wf_r1", "agent-a2222222222222222.jsonl"), "{\"uuid\":\"w1\"}\n")
+ mk(t, filepath.Join(base, "subagents", "workflows", "wf_r1", "journal.jsonl"), "{\"type\":\"started\"}\n")
+ mk(t, filepath.Join(base, "workflows", "wf_r1.json"), `{"runId":"wf_r1"}`)
+ // Claude Code writes the workflow script under whatever cwd the agent had;
+ // the run id is embedded in the filename.
+ mk(t, filepath.Join(base, "workflows", "scripts", "my-flow-wf_r1.js"), "export const meta = {}\n")
+
+ col := claudecode.New(src, storage.NewZone(zone), 0)
+ st, err := col.CollectAll(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(st.Errors) != 0 {
+ t.Fatalf("unexpected errors: %v", st.Errors)
+ }
+ if st.SourcesLanded != 7 {
+ t.Errorf("landed %d sources, want 7", st.SourcesLanded)
+ }
+
+ z := storage.NewZone(zone)
+ for _, dir := range []string{
+ z.StreamDir(tSess, storage.StreamMain),
+ z.StreamDir(tSess, tAgent),
+ z.StreamDir(tSess, "a2222222222222222"),
+ z.RunDir(tSess, "wf_r1"),
+ } {
+ if _, err := os.Stat(dir); err != nil {
+ t.Errorf("expected landing directory %s: %v", dir, err)
+ }
+ }
+}
+
+// TestSnapshotLandsOnlyOnChange verifies a rewritten source lands a new version
+// only when its content actually changes - otherwise a manifest that is
+// rewritten with identical content would land a copy on every pass forever.
+func TestSnapshotLandsOnlyOnChange(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ manifest := filepath.Join(src, "-proj-a", tSess, "workflows", "wf_r1.json")
+ mk(t, filepath.Join(src, "-proj-a", tSess+".jsonl"), "{\"uuid\":\"m1\"}\n")
+ mk(t, manifest, `{"runId":"wf_r1","status":"running"}`)
+
+ col := claudecode.New(src, storage.NewZone(zone), 0)
+ if _, err := col.CollectAll(nil); err != nil {
+ t.Fatal(err)
+ }
+ countManifests := func() int {
+ ents, _ := os.ReadDir(storage.NewZone(zone).RunDir(tSess, "wf_r1"))
+ n := 0
+ for _, e := range ents {
+ if strings.HasPrefix(e.Name(), "manifest-") {
+ n++
+ }
+ }
+ return n
+ }
+ if got := countManifests(); got != 1 {
+ t.Fatalf("after first pass: %d manifests, want 1", got)
+ }
+
+ // Unchanged content must not land again.
+ if _, err := col.CollectAll(nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := countManifests(); got != 1 {
+ t.Errorf("unchanged manifest landed again: %d versions", got)
+ }
+
+ // A real rewrite lands a new immutable version beside the old one.
+ mk(t, manifest, `{"runId":"wf_r1","status":"completed"}`)
+ if _, err := col.CollectAll(nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := countManifests(); got != 2 {
+ t.Errorf("changed manifest: %d versions, want 2", got)
+ }
+}
+
+// TestSourceGoneIsNotAnError covers pruning, which Claude Code does routinely.
+func TestSourceGoneIsNotAnError(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ main := filepath.Join(src, "-proj-a", tSess+".jsonl")
+ mk(t, main, "{\"uuid\":\"m1\"}\n")
+
+ col := claudecode.New(src, storage.NewZone(zone), 0)
+ if _, err := col.CollectAll(nil); err != nil {
+ t.Fatal(err)
+ }
+ // The landed delta must survive its source being deleted.
+ if err := os.Remove(main); err != nil {
+ t.Fatal(err)
+ }
+ st, err := col.CollectAll(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(st.Errors) != 0 {
+ t.Errorf("pruning must not be an error: %v", st.Errors)
+ }
+ ents, _ := os.ReadDir(storage.NewZone(zone).StreamDir(tSess, storage.StreamMain))
+ var kept bool
+ for _, e := range ents {
+ if strings.HasPrefix(e.Name(), "transcript-") {
+ kept = true
+ }
+ }
+ if !kept {
+ t.Error("landed data must outlive its pruned source")
+ }
+}
diff --git a/internal/adapters/claudecode/convert.go b/internal/adapters/claudecode/convert.go
new file mode 100644
index 0000000..fe16afd
--- /dev/null
+++ b/internal/adapters/claudecode/convert.go
@@ -0,0 +1,417 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "strings"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/index"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/model"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
+)
+
+// Convert turns one Claude Code source record into a Session Data record.
+//
+// This is the only function that reads Claude Code's shape. Everything above it
+// - the index, assembly, the structure chain, a reader - sees parts and role
+// names and never a runtime field.
+//
+// Three rules govern what happens to bytes:
+//
+// - Understood content becomes a part.
+// - Content the dialect does not recognise becomes an `unknown` part that
+// KEEPS THE BYTES. A later version of this dialect can interpret it without
+// the source, which is what makes converting on read safe.
+// - Content deliberately not carried is named in Dropped with its size, so
+// the loss is stated rather than silent.
+func Convert(src Source, ord, off uint64, payload []byte) *sessiondata.Record {
+ sum := sha256.Sum256(payload)
+ rec := &sessiondata.Record{
+ Ord: ord, Off: off,
+ Sha: hex.EncodeToString(sum[:])[:12],
+ Bytes: len(payload),
+ }
+
+ // A manifest and a script are both written by the harness, and saying so is
+ // what lets a reader tell them from anything a person or a model produced.
+ rec.From = sessiondata.FromRuntime
+ // A script is JavaScript. Nothing here can describe it, so it stays bytes.
+ if src.Kind == SrcWorkflowScript {
+ rec.Parts = []sessiondata.Part{rawPart(payload, "the source is a program, not data")}
+ return rec
+ }
+ // A workflow manifest is one JSON document rather than a stream of records:
+ // structured, readable, and not prose. It still names the workflow it ran,
+ // so it is decoded far enough to take that before the document travels
+ // whole.
+ if src.Kind == SrcWorkflowManifest {
+ var d indexRecord
+ if err := json.Unmarshal(payload, &d); err == nil {
+ rec.Label = d.WorkflowName
+ rec.Batch = firstOf(d.RunID, src.RunID)
+ }
+ rec.Parts = []sessiondata.Part{{
+ Kind: sessiondata.PartData, Data: json.RawMessage(payload),
+ State: model.ContentAvailable, Bytes: len(payload),
+ }}
+ return rec
+ }
+
+ var d indexRecord
+ if err := json.Unmarshal(payload, &d); err != nil {
+ // A record that will not parse is still evidence. Its position is known
+ // and its bytes are kept; nothing about it is guessed.
+ rec.Parts = []sessiondata.Part{rawPart(payload, "the record is not valid JSON")}
+ return rec
+ }
+
+ tur, hasTUR := decodeToolResult(d.ToolUseResult)
+ rec.ID, rec.Parent = d.UUID, d.ParentUUID
+ rec.Call, rec.Run = d.Message.ID, d.PromptID
+ rec.Continues = d.LogicalParentUUID
+ rec.Time = d.Timestamp
+ rec.Trigger = triggerName(triggerOf(&d))
+ tool, child := linksOf(&d, &tur, src)
+ rec.Tool, rec.Child = tool, child
+ rec.Batch = tur.RunID
+ if rec.Batch == "" {
+ rec.Batch = src.RunID
+ }
+ rec.StartedBy = d.ParentAgentID
+ rec.Label = firstOf(d.AITitle, d.Description, d.WorkflowName)
+ rec.Flags = flagNames(flagsOf(&d, &tur, hasTUR, src))
+ rec.From = producerOf(&d, src)
+ if u := d.Message.Usage; u != nil {
+ rec.Usage = &sessiondata.Usage{
+ Input: u.Input, Output: u.Output,
+ CacheRead: u.CacheRead, CacheWrite: u.CacheWrite,
+ }
+ }
+
+ rec.Parts, rec.Dropped = partsOf(&d, &tur, hasTUR, payload)
+ if len(rec.Parts) == 0 {
+ // A record with no message and no attachment still said something - a
+ // journal announcing a child, a manifest, an artefact the host keeps for
+ // itself. It is structured and readable, just not prose, so it travels
+ // as data rather than as something unrecognised.
+ rec.Parts = []sessiondata.Part{{
+ Kind: sessiondata.PartData, Data: json.RawMessage(payload),
+ State: model.ContentAvailable, Bytes: len(payload),
+ }}
+ }
+ return rec
+}
+
+// partsOf breaks a record's content into parts.
+func partsOf(d *indexRecord, tur *toolResult, hasTUR bool, payload []byte) ([]sessiondata.Part, []sessiondata.Drop) {
+ var parts []sessiondata.Part
+ var dropped []sessiondata.Drop
+
+ // An attachment carries its text outside the message, in a shape that
+ // differs per attachment type. What is readable becomes text; the rest is
+ // kept whole rather than picked over.
+ if d.Type == "attachment" {
+ text, obj := attachmentContent(payload)
+ p := sessiondata.Part{Kind: sessiondata.PartText, Text: text,
+ State: model.ContentAvailable, Bytes: len(text)}
+ if text == "" {
+ // Most attachment types are a set of fields rather than a sentence -
+ // names added and removed, a count, a list. They are still injected
+ // context and a reader still wants them, so they travel as data
+ // rather than as something we failed to recognise.
+ p = sessiondata.Part{Kind: sessiondata.PartData, Data: obj,
+ State: model.ContentAvailable, Bytes: len(obj)}
+ }
+ parts = append(parts, p)
+ return parts, dropped
+ }
+
+ content := d.Message.Content
+ if len(content) == 0 {
+ return parts, dropped
+ }
+ // Content is sometimes a bare string rather than a list of blocks.
+ if content[0] == '"' {
+ var text string
+ if err := json.Unmarshal(content, &text); err == nil {
+ parts = append(parts, sessiondata.Part{
+ Kind: sessiondata.PartText, Text: text,
+ State: model.ContentAvailable, Bytes: len(text),
+ })
+ return parts, dropped
+ }
+ }
+
+ var blocks []json.RawMessage
+ if err := json.Unmarshal(content, &blocks); err != nil {
+ return append(parts, rawPart(content, "the content is neither text nor a list of blocks")), dropped
+ }
+ for _, raw := range blocks {
+ p, drop := partOf(raw, tur, hasTUR)
+ parts = append(parts, p)
+ if drop != nil {
+ dropped = append(dropped, *drop)
+ }
+ }
+ return parts, dropped
+}
+
+// blockOf is the shape of one content block, decoded far enough to classify it.
+type blockOf struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ Thinking string `json:"thinking"`
+ Signature string `json:"signature"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Input json.RawMessage `json:"input"`
+ ToolUseID string `json:"tool_use_id"`
+ Content json.RawMessage `json:"content"`
+ IsError *bool `json:"is_error"`
+ Source struct {
+ MediaType string `json:"media_type"`
+ Data string `json:"data"`
+ } `json:"source"`
+}
+
+// partOf converts one content block.
+func partOf(raw json.RawMessage, tur *toolResult, hasTUR bool) (sessiondata.Part, *sessiondata.Drop) {
+ var b blockOf
+ if err := json.Unmarshal(raw, &b); err != nil {
+ return rawPart(raw, "the block is not an object"), nil
+ }
+ switch b.Type {
+ case "text":
+ return sessiondata.Part{Kind: sessiondata.PartText, Text: b.Text,
+ State: model.ContentAvailable, Bytes: len(b.Text)}, nil
+
+ case "thinking", "redacted_thinking":
+ // Reasoning is usually withheld: the block arrives with a signature and
+ // no text at all. Calling that "available" claims we have something we
+ // do not, which is the one thing this model is not allowed to do.
+ state := model.ContentAvailable
+ switch {
+ case b.Type == "redacted_thinking":
+ state = model.ContentRedacted
+ case b.Thinking == "":
+ state = model.ContentUnavailable
+ }
+ p := sessiondata.Part{Kind: sessiondata.PartReasoning, Text: b.Thinking,
+ State: state, Bytes: len(b.Thinking)}
+ if b.Signature == "" {
+ return p, nil
+ }
+ // A signature is material a provider verifies when a block is handed
+ // back to it. No reader can use it, and it is the second largest thing
+ // in the corpus - 13.3% of all bytes, wrapping reasoning text that
+ // totals 0.1 MB. It is named here rather than silently discarded.
+ return p, &sessiondata.Drop{
+ What: "reasoning signature", Bytes: len(b.Signature),
+ Why: "a provider verifies it; a reader cannot read it",
+ }
+
+ case "tool_use", "server_tool_use":
+ return sessiondata.Part{Kind: sessiondata.PartCall, ID: b.ID, Name: b.Name,
+ Data: b.Input, State: model.ContentAvailable, Bytes: len(b.Input)}, nil
+
+ case "tool_result":
+ p := sessiondata.Part{Kind: sessiondata.PartResult, Of: b.ToolUseID,
+ State: model.ContentAvailable}
+ p.Text, p.Bytes = resultText(b.Content)
+ if b.IsError != nil {
+ p.Failed = b.IsError
+ }
+ // A result whose content is neither a string nor text blocks - an image,
+ // a reference to another tool's output - reads as empty otherwise. The
+ // raw content travels instead of being described, because the source it
+ // came from can be pruned and this would be the only copy.
+ if p.Text == "" && len(b.Content) > 0 {
+ p.Data = b.Content
+ p.Bytes = len(b.Content)
+ }
+ // The runtime also gives a structured form of the same result on the
+ // parent lineage - stdout and stderr split apart, a patch, a status.
+ // It is a different view of the same output, so it travels beside the
+ // text rather than replacing it.
+ if hasTUR && tur.raw != nil {
+ p.Data = tur.raw
+ }
+ return p, nil
+
+ case "image":
+ return sessiondata.Part{Kind: sessiondata.PartMedia, Media: b.Source.MediaType,
+ Data: mustJSON(b.Source.Data), State: model.ContentAvailable,
+ Bytes: len(b.Source.Data)}, nil
+ }
+ // Anything else keeps its bytes. A block type that did not exist when this
+ // was written must not be lost because of it.
+ return rawPart(raw, "block type "+quote(b.Type)+" is not one this dialect describes"), nil
+}
+
+// resultText pulls the readable text out of a tool result's content, which is
+// sometimes a string and sometimes a list of blocks.
+func resultText(content json.RawMessage) (string, int) {
+ if len(content) == 0 {
+ return "", 0
+ }
+ if content[0] == '"' {
+ var s string
+ if err := json.Unmarshal(content, &s); err == nil {
+ return s, len(s)
+ }
+ }
+ var blocks []blockOf
+ if err := json.Unmarshal(content, &blocks); err == nil {
+ var out []string
+ for _, b := range blocks {
+ if b.Text != "" {
+ out = append(out, b.Text)
+ }
+ }
+ if len(out) > 0 {
+ joined := strings.Join(out, "\n")
+ return joined, len(joined)
+ }
+ }
+ return "", len(content)
+}
+
+// attachmentContent returns an attachment's prose, or its whole object when it
+// has none.
+//
+// There are dozens of attachment types and their shapes differ. Two fields
+// carry prose; the rest carry structure - names added and removed, a count, a
+// list of skills. Rather than guess at each shape, the prose is taken where it
+// exists and the object travels whole where it does not.
+func attachmentContent(payload []byte) (string, json.RawMessage) {
+ var d struct {
+ Attachment json.RawMessage `json:"attachment"`
+ }
+ if err := json.Unmarshal(payload, &d); err != nil {
+ return "", nil
+ }
+ var a struct {
+ Text string `json:"text"`
+ Content json.RawMessage `json:"content"`
+ }
+ if err := json.Unmarshal(d.Attachment, &a); err != nil {
+ return "", d.Attachment
+ }
+ if a.Text != "" {
+ return a.Text, d.Attachment
+ }
+ if len(a.Content) > 0 && a.Content[0] == '"' {
+ var s string
+ if err := json.Unmarshal(a.Content, &s); err == nil {
+ return s, d.Attachment
+ }
+ }
+ return "", d.Attachment
+}
+
+// rawPart keeps bytes the dialect could not describe.
+func rawPart(b []byte, why string) sessiondata.Part {
+ return sessiondata.Part{
+ Kind: sessiondata.PartUnknown, Data: mustJSON(string(b)),
+ Text: why, State: model.ContentAvailable, Bytes: len(b),
+ }
+}
+
+func mustJSON(v any) json.RawMessage {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return nil
+ }
+ return b
+}
+
+func quote(s string) string { return `"` + s + `"` }
+
+// firstOf returns the first value that is not empty.
+func firstOf(vals ...string) string {
+ for _, v := range vals {
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+// producerOf says who wrote a record.
+//
+// A record's own type is a poor guide: every tool result in the corpus sits on
+// a record typed "user", and only a small share of those are a person. What
+// matters downstream is who produced it.
+func producerOf(d *indexRecord, src Source) sessiondata.From {
+ switch src.Kind {
+ case SrcAgentMeta, SrcJournal, SrcWorkflowManifest, SrcWorkflowScript:
+ return sessiondata.FromRuntime
+ }
+ switch d.Type {
+ case "assistant":
+ return sessiondata.FromAgent
+ case "system", "attachment":
+ return sessiondata.FromRuntime
+ case "user":
+ return sessiondata.FromExternal
+ }
+ return sessiondata.FromRuntime
+}
+
+// triggerName renders a trigger in the model's vocabulary.
+func triggerName(t index.Trigger) string {
+ switch t {
+ case index.TriggerExternal:
+ return model.TriggerExternal
+ case index.TriggerNotification:
+ return model.TriggerNotification
+ case index.TriggerOther:
+ return model.TriggerUnknown
+ }
+ return ""
+}
+
+// flagNames renders the flags a record carries.
+var flagNames = func(f index.Flags) []string {
+ var out []string
+ for _, m := range []struct {
+ bit index.Flags
+ name string
+ }{
+ {index.FlagSynthetic, "synthetic"},
+ {index.FlagEpochBoundary, "context_reset"},
+ {index.FlagEpochSummary, "reset_summary"},
+ {index.FlagStopReason, "finished"},
+ {index.FlagExternalInput, "external_input"},
+ {index.FlagInjection, "injected"},
+ {index.FlagChildResult, "child_result"},
+ {index.FlagError, "error"},
+ {index.FlagLaunchAck, "launch_ack"},
+ {index.FlagTurnDuration, "turn_duration"},
+ {index.FlagCommand, "command"},
+ {index.FlagNotice, "notice"},
+ } {
+ if f.Has(m.bit) {
+ out = append(out, m.name)
+ }
+ }
+ return out
+}
diff --git a/internal/adapters/claudecode/discover.go b/internal/adapters/claudecode/discover.go
new file mode 100644
index 0000000..465943b
--- /dev/null
+++ b/internal/adapters/claudecode/discover.go
@@ -0,0 +1,370 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
+)
+
+// SourceKind identifies one of the six file kinds a session is spread over.
+type SourceKind int
+
+const (
+ // SrcMainTranscript is <session-id>.jsonl - the parent execution stream.
+ SrcMainTranscript SourceKind = iota
+ // SrcAgentTranscript is a child execution stream, direct or workflow-spawned.
+ SrcAgentTranscript
+ // SrcAgentMeta is a child's sidecar. Written once at spawn and never updated.
+ SrcAgentMeta
+ // SrcJournal is a workflow run's journal. Append-only, and it leads the
+ // filesystem: a started record can precede the child transcript's existence.
+ SrcJournal
+ // SrcWorkflowManifest is a workflow run manifest. Rewritten as the run
+ // progresses, so it is tracked by content digest rather than by position.
+ SrcWorkflowManifest
+ // SrcWorkflowScript is a workflow run's script source. Claude Code files it
+ // under whatever working directory the agent had at the time, so it is the
+ // one artifact that genuinely lives outside its session's own directory.
+ SrcWorkflowScript
+)
+
+// Append reports whether the source only ever grows.
+func (k SourceKind) Append() bool {
+ switch k {
+ case SrcMainTranscript, SrcAgentTranscript, SrcJournal:
+ return true
+ }
+ return false
+}
+
+// RecordKind maps a source kind to the landed envelope kind.
+func (k SourceKind) RecordKind() sessiondata.Kind {
+ switch k {
+ case SrcAgentMeta:
+ return sessiondata.KindAgentMeta
+ case SrcJournal:
+ return sessiondata.KindJournal
+ case SrcWorkflowManifest:
+ return sessiondata.KindWorkflowManifest
+ case SrcWorkflowScript:
+ return sessiondata.KindWorkflowScript
+ default:
+ return sessiondata.KindTranscript
+ }
+}
+
+// Source is one collectable file belonging to a session.
+type Source struct {
+ Kind SourceKind
+ Path string // absolute
+ Rel string // relative to the adapter source root; retained in the landed header
+ Session string
+ Stream string // "main" or an agent id; empty for journal and manifest
+ RunID string // workflow run id, for journal and manifest
+}
+
+// Session is one Claude Code session and every file belonging to it.
+//
+// Dirs records every source directory the session appears under. A session's
+// files are NOT confined to one: when a child agent runs in a different working
+// directory its stream is filed under that directory's slug instead. In a real
+// corpus 7 of 61 sessions spanned more than one, one of them six.
+type Session struct {
+ ID string
+ Dirs []string
+ // Primary is the source directory holding this session's main transcript -
+ // effectively the directory Claude Code was launched in. It is empty when
+ // the main transcript has been pruned, which happens routinely.
+ //
+ // Filtering keys on Primary rather than on Dirs: a session's child streams
+ // can be filed under unrelated directories (a scratchpad, a subdirectory),
+ // and letting one of those disqualify the whole session would discard a real
+ // conversation because of where a subagent happened to run.
+ Primary string
+ Sources []Source
+}
+
+// Discover enumerates sessions under root.
+//
+// Discovery is session-first: it scans every source directory for entries whose
+// name is a session id - both .jsonl files and directories - and groups them by
+// session id ACROSS directories. A directory-first walk misses most child
+// streams for roughly one session in nine.
+func Discover(root string) ([]Session, error) {
+ sessions, warnings, err := DiscoverWithWarnings(root)
+ if err != nil {
+ return nil, err
+ }
+ // Warnings are non-fatal by construction: a partially readable corpus still
+ // yields the sessions it can. Callers that need to report them use
+ // DiscoverWithWarnings.
+ if len(warnings) > 0 {
+ return sessions, errors.Join(warnings...)
+ }
+ return sessions, nil
+}
+
+// DiscoverWithWarnings is Discover, separating unreadable directories from a
+// fatal failure. A partially readable corpus still yields the sessions it can.
+func DiscoverWithWarnings(root string) (sessions []Session, warnings []error, err error) {
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ byID := map[string]*Session{}
+ // A directory is recorded only once it has actually yielded a source.
+ // Claude Code writes workflow *scripts* under whatever cwd the agent had at
+ // the time, producing session directories that contain nothing we collect;
+ // recording those as session directories let an empty stub disqualify an
+ // entire real conversation through an exclude pattern.
+ get := func(id string) *Session {
+ s, ok := byID[id]
+ if !ok {
+ s = &Session{ID: id}
+ byID[id] = s
+ }
+ return s
+ }
+ add := func(id, dir string, srcs ...Source) {
+ if len(srcs) == 0 {
+ return
+ }
+ s := get(id)
+ if !contains(s.Dirs, dir) {
+ s.Dirs = append(s.Dirs, dir)
+ }
+ s.Sources = append(s.Sources, srcs...)
+ }
+
+ for _, projEntry := range entries {
+ if !projEntry.IsDir() {
+ continue
+ }
+ projName := projEntry.Name()
+ projPath := filepath.Join(root, projName)
+ items, err := os.ReadDir(projPath)
+ if err != nil {
+ // A directory that vanished mid-scan is normal - the corpus is
+ // live. Anything else (permissions, I/O) hides sources, so it is
+ // reported rather than silently producing a short result.
+ if !os.IsNotExist(err) {
+ warnings = append(warnings, fmt.Errorf("claudecode: read %s: %w", projName, err))
+ }
+ continue
+ }
+ for _, it := range items {
+ name := it.Name()
+ switch {
+ case !it.IsDir() && strings.HasSuffix(name, ".jsonl"):
+ id := strings.TrimSuffix(name, ".jsonl")
+ if !IsSessionID(id) {
+ continue
+ }
+ add(id, projName, Source{
+ Kind: SrcMainTranscript, Path: filepath.Join(projPath, name),
+ Rel: filepath.Join(projName, name), Session: id, Stream: storage.StreamMain,
+ })
+ case it.IsDir():
+ // Non-session directories live here too - "memory" is a sibling of
+ // the session directories and is not a session.
+ if !IsSessionID(name) {
+ continue
+ }
+ add(name, projName, discoverSessionDir(projPath, projName, name)...)
+ }
+ }
+ }
+
+ out := make([]Session, 0, len(byID))
+ for _, s := range byID {
+ for _, src := range s.Sources {
+ if src.Kind == SrcMainTranscript {
+ s.Primary = filepath.Dir(src.Rel)
+ break
+ }
+ }
+ sort.Slice(s.Sources, func(i, j int) bool { return s.Sources[i].Rel < s.Sources[j].Rel })
+ sort.Strings(s.Dirs)
+ out = append(out, *s)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+ return out, warnings, nil
+}
+
+// discoverSessionDir enumerates the sources beneath one <project>/<session>/ directory.
+func discoverSessionDir(projPath, projName, session string) []Source {
+ var out []Source
+ base := filepath.Join(projPath, session)
+ relBase := filepath.Join(projName, session)
+
+ // subagents/: direct child streams and their sidecars.
+ subDir := filepath.Join(base, "subagents")
+ if items, err := os.ReadDir(subDir); err == nil {
+ for _, it := range items {
+ name := it.Name()
+ if it.IsDir() {
+ if name == "workflows" {
+ out = append(out, discoverWorkflowAgents(subDir, filepath.Join(relBase, "subagents"), session)...)
+ }
+ continue
+ }
+ if agentID, ok := agentTranscript(name); ok {
+ out = append(out, Source{
+ Kind: SrcAgentTranscript, Path: filepath.Join(subDir, name),
+ Rel: filepath.Join(relBase, "subagents", name), Session: session, Stream: agentID,
+ })
+ continue
+ }
+ if agentID, ok := agentMeta(name); ok {
+ out = append(out, Source{
+ Kind: SrcAgentMeta, Path: filepath.Join(subDir, name),
+ Rel: filepath.Join(relBase, "subagents", name), Session: session, Stream: agentID,
+ })
+ }
+ }
+ }
+
+ // workflows/: run manifests, and scripts/ beneath it.
+ wfDir := filepath.Join(base, "workflows")
+ if items, err := os.ReadDir(wfDir); err == nil {
+ for _, it := range items {
+ name := it.Name()
+ if it.IsDir() {
+ if name == "scripts" {
+ out = append(out, discoverScripts(filepath.Join(wfDir, name),
+ filepath.Join(relBase, "workflows", "scripts"), session)...)
+ }
+ continue
+ }
+ if !strings.HasPrefix(name, "wf_") || !strings.HasSuffix(name, ".json") {
+ continue
+ }
+ out = append(out, Source{
+ Kind: SrcWorkflowManifest, Path: filepath.Join(wfDir, name),
+ Rel: filepath.Join(relBase, "workflows", name), Session: session,
+ RunID: strings.TrimSuffix(name, ".json"),
+ })
+ }
+ }
+ return out
+}
+
+// discoverScripts enumerates workflow scripts, keying each to its run.
+func discoverScripts(dir, rel, session string) []Source {
+ items, err := os.ReadDir(dir)
+ if err != nil {
+ return nil
+ }
+ var out []Source
+ for _, it := range items {
+ if it.IsDir() {
+ continue
+ }
+ runID, ok := ScriptRunID(it.Name())
+ if !ok {
+ continue
+ }
+ out = append(out, Source{
+ Kind: SrcWorkflowScript, Path: filepath.Join(dir, it.Name()),
+ Rel: filepath.Join(rel, it.Name()), Session: session, RunID: runID,
+ })
+ }
+ return out
+}
+
+// discoverWorkflowAgents enumerates workflow-spawned children and run journals.
+func discoverWorkflowAgents(subDir, relSub, session string) []Source {
+ var out []Source
+ wfRoot := filepath.Join(subDir, "workflows")
+ runs, err := os.ReadDir(wfRoot)
+ if err != nil {
+ return nil
+ }
+ for _, run := range runs {
+ if !run.IsDir() {
+ continue
+ }
+ runID := run.Name()
+ runPath := filepath.Join(wfRoot, runID)
+ relRun := filepath.Join(relSub, "workflows", runID)
+ items, err := os.ReadDir(runPath)
+ if err != nil {
+ continue
+ }
+ for _, it := range items {
+ name := it.Name()
+ if it.IsDir() {
+ continue
+ }
+ switch {
+ case name == "journal.jsonl":
+ out = append(out, Source{
+ Kind: SrcJournal, Path: filepath.Join(runPath, name),
+ Rel: filepath.Join(relRun, name), Session: session, RunID: runID,
+ })
+ default:
+ if agentID, ok := agentTranscript(name); ok {
+ out = append(out, Source{
+ Kind: SrcAgentTranscript, Path: filepath.Join(runPath, name),
+ Rel: filepath.Join(relRun, name), Session: session, Stream: agentID,
+ })
+ } else if agentID, ok := agentMeta(name); ok {
+ out = append(out, Source{
+ Kind: SrcAgentMeta, Path: filepath.Join(runPath, name),
+ Rel: filepath.Join(relRun, name), Session: session, Stream: agentID,
+ })
+ }
+ }
+ }
+ }
+ return out
+}
+
+func agentTranscript(name string) (string, bool) {
+ if !strings.HasPrefix(name, "agent-") || !strings.HasSuffix(name, ".jsonl") {
+ return "", false
+ }
+ id := strings.TrimSuffix(strings.TrimPrefix(name, "agent-"), ".jsonl")
+ return id, IsAgentID(id)
+}
+
+func agentMeta(name string) (string, bool) {
+ if !strings.HasPrefix(name, "agent-") || !strings.HasSuffix(name, ".meta.json") {
+ return "", false
+ }
+ id := strings.TrimSuffix(strings.TrimPrefix(name, "agent-"), ".meta.json")
+ return id, IsAgentID(id)
+}
+
+func contains(xs []string, v string) bool {
+ for _, x := range xs {
+ if x == v {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/adapters/claudecode/glossary.go b/internal/adapters/claudecode/glossary.go
new file mode 100644
index 0000000..c9a1d0a
--- /dev/null
+++ b/internal/adapters/claudecode/glossary.go
@@ -0,0 +1,133 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode
+
+import "github.com/apache/skywalking-ai-sessionizer/pkg/model"
+
+// Dialect names this runtime's vocabulary and the version of the mapping below.
+//
+// It is separate from the adapter name. `claude-code-local` says how records
+// were acquired; this says how they are read. A push receiver for the same
+// runtime would be a different adapter and the same dialect.
+const Dialect = "claude-code/1"
+
+// glossary is what Claude Code calls the things the model names.
+//
+// It lives beside the extraction code on purpose. Every entry with a Native
+// value corresponds to a field the converter reads, so a rename in the
+// runtime changes both together, and a test checks that the model's whole
+// vocabulary is accounted for here.
+//
+// An empty Native is not a gap. A Talk, a Segment, a correlation quality - the
+// runtime records none of them, and saying so is more useful than a plausible
+// word that would send a reader looking for a field that does not exist.
+var glossary = model.NewGlossary(Dialect,
+ // ---- the roles a record plays ----
+ model.Term{Unified: model.RoleID, Native: "uuid", Where: "record"},
+ model.Term{Unified: model.RoleParent, Native: "parentUuid", Where: "record"},
+ model.Term{Unified: model.RoleCall, Native: "message.id", Where: "record",
+ Note: "not requestId, which a client-fabricated record can reuse"},
+ model.Term{Unified: model.RoleRun, Native: "promptId", Where: "record",
+ Note: "on user records only; a model response reaches one through its parents"},
+ model.Term{Unified: model.RoleBatch, Native: "toolUseResult.runId", Where: "a workflow launch result",
+ Note: "also the name of the directory the batch's records are filed under"},
+ model.Term{Unified: model.RoleStream, Native: "agentId", Where: "a child record, and its filename",
+ Note: "main has no name of its own; it is the session's own transcript"},
+ model.Term{Unified: model.RoleContinues, Native: "logicalParentUuid", Where: "a compact_boundary record",
+ Note: "the only link back; a boundary's own parentUuid is null"},
+ model.Term{Unified: model.RoleTool, Native: "tool_use_id", Where: "a tool_result block, a notification, a sidecar"},
+ model.Term{Unified: model.RoleChild, Native: "toolUseResult.agentId", Where: "a launch result, a journal record"},
+ model.Term{Unified: model.RoleTime, Native: "timestamp", Where: "record"},
+ model.Term{Unified: model.RoleTrigger, Native: "origin.kind", Where: "record, and attachment.origin.kind"},
+
+ // ---- structure the model derived; the runtime records none of it ----
+ model.Term{Unified: model.KindConversation, Note: "supplied, never inferred; defaults to the session id"},
+ model.Term{Unified: model.KindSegment, Note: "an activity window chosen for commit"},
+ model.Term{Unified: model.KindSession, Native: "sessionId", Where: "record, and the transcript filename"},
+ model.Term{Unified: model.KindStream, Native: "agentId", Where: "a child record",
+ Note: "the parent lineage is the session transcript itself"},
+ model.Term{Unified: model.KindEpoch, Note: "the span between two compact_boundary records"},
+ model.Term{Unified: model.KindTalk, Note: "one readable interaction; derived from where a run was triggered"},
+ model.Term{Unified: model.KindRun, Native: "promptId", Where: "record",
+ Note: "one loop per distinct promptId"},
+
+ // ---- steps ----
+ model.Term{Unified: model.KindMessageExternal, Native: "origin.kind == \"human\"", Where: "a user record",
+ Note: "also a queued_command attachment whose commandMode is \"prompt\""},
+ model.Term{Unified: model.KindMessageAssistant, Native: "text block", Where: "an assistant record"},
+ model.Term{Unified: model.KindMessageSynthetic, Native: "message.model == \"<synthetic>\"", Where: "an assistant record",
+ Note: "the client wrote it; no model produced it"},
+ model.Term{Unified: model.KindContextInjection, Native: "attachment", Where: "an attachment record"},
+ model.Term{Unified: model.KindLLMCall, Native: "message.id", Where: "assistant records sharing one",
+ Note: "one call is several records; 2.09 on average"},
+ model.Term{Unified: model.KindThinking, Native: "thinking block", Where: "an assistant record"},
+ model.Term{Unified: model.KindTool, Native: "tool_use + tool_result", Where: "two records",
+ Note: "one step here, two blocks there"},
+ model.Term{Unified: model.KindAgentCall, Native: "Agent, Skill or Workflow tool_use", Where: "an assistant record"},
+ model.Term{Unified: model.KindAgentLaunchAck, Native: "toolUseResult.status == \"async_launched\"", Where: "a tool result",
+ Note: "an acknowledgement, not a result"},
+ model.Term{Unified: model.KindAgentOutput, Note: "the last model response in a child stream"},
+ model.Term{Unified: model.KindRuntimeNotification, Native: "<task-notification>", Where: "a user record's content"},
+ model.Term{Unified: model.KindEpochBoundary, Native: "system/compact_boundary", Where: "a system record"},
+ model.Term{Unified: model.KindEpochSummary, Native: "isCompactSummary", Where: "a user record"},
+ model.Term{Unified: model.KindErrorAPI, Native: "system/api_error", Where: "a system record"},
+ model.Term{Unified: model.KindControlInterrupt, Native: "toolUseResult.interrupted", Where: "a tool result"},
+ model.Term{Unified: model.KindControlPermission, Native: "permission-mode", Where: "a record type"},
+ model.Term{Unified: model.KindControlCommand, Native: "system/local_command", Where: "a system record"},
+ model.Term{Unified: model.KindTurnDuration, Native: "system/turn_duration", Where: "a system record",
+ Note: "the runtime measured it; it is not the gap between two record timestamps"},
+ model.Term{Unified: model.KindControlNotice, Native: "system/away_summary, bridge_status, informational, model_refusal_fallback",
+ Where: "a system record", Note: "anything the runtime says about the session itself"},
+
+ // ---- relations ----
+ model.Term{Unified: model.RelStarts, Native: "toolUseResult.agentId, or a run journal", Where: "a launch result"},
+ model.Term{Unified: model.RelReports, Native: "<task-notification>", Where: "a user record's content"},
+ model.Term{Unified: model.RelEndsWith, Note: "the last model response in a child stream"},
+ model.Term{Unified: model.RelResultOf, Native: "tool_use_id", Where: "a tool_result block",
+ Note: "also stated redundantly as sourceToolAssistantUUID"},
+ model.Term{Unified: model.RelFollows, Native: "logicalParentUuid", Where: "a compact_boundary record"},
+ model.Term{Unified: model.RelSummarizes, Native: "parentUuid", Where: "an isCompactSummary record"},
+ model.Term{Unified: model.RelInSegment, Note: "an activity window chosen for commit"},
+ model.Term{Unified: model.RelRetries, Note: "unavailable; retry identity is not recorded"},
+ model.Term{Unified: model.RelCancels, Native: "toolUseResult.interrupted", Where: "a tool result"},
+ model.Term{Unified: model.RelInputOf, Note: "unavailable; the serialized provider request is not written locally"},
+
+ // ---- qualification; all of it is this project's judgement ----
+ model.Term{Unified: model.ExactUnique, Note: "an identifier matched exactly one candidate"},
+ model.Term{Unified: model.ExactAmbiguous, Note: "an identifier matched several; nothing chose one"},
+ model.Term{Unified: model.StrongInference, Note: "no identifier, but a well-founded structural argument"},
+ model.Term{Unified: model.WeakInference, Note: "plausible only"},
+ model.Term{Unified: model.Unresolved, Note: "no usable evidence"},
+ model.Term{Unified: model.Conflict, Note: "sources disagree"},
+
+ model.Term{Unified: model.ContentAvailable, Note: "the bytes are in a landed record"},
+ model.Term{Unified: model.ContentRedacted, Native: "redacted_thinking", Where: "a content block"},
+ model.Term{Unified: model.ContentOmitted, Note: "deliberately not carried"},
+ model.Term{Unified: model.ContentHashOnly, Note: "only a digest of the bytes was kept"},
+ model.Term{Unified: model.ContentSizeOnly, Note: "only the length was kept"},
+ model.Term{Unified: model.ContentTruncated, Note: "part of the bytes, with the full length reported"},
+ model.Term{Unified: model.ContentUnavailable, Note: "the runtime never wrote it, or the source was pruned"},
+
+ model.Term{Unified: model.TriggerExternal, Native: "origin.kind == \"human\"", Where: "record",
+ Note: "also a run whose records state no origin at all - a locally typed command"},
+ model.Term{Unified: model.TriggerNotification, Native: "origin.kind == \"task-notification\"", Where: "record"},
+ model.Term{Unified: model.TriggerUnknown, Note: "no record of the run states a trigger"},
+)
+
+// Glossary returns what Claude Code calls the things the model names.
+func Glossary() *model.Glossary { return glossary }
diff --git a/internal/adapters/claudecode/glossary_test.go b/internal/adapters/claudecode/glossary_test.go
new file mode 100644
index 0000000..5000ab0
--- /dev/null
+++ b/internal/adapters/claudecode/glossary_test.go
@@ -0,0 +1,97 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode_test
+
+import (
+ "testing"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/model"
+)
+
+// Every name the model can put in its output must be in the glossary, even if
+// only to say the runtime has no word for it.
+//
+// The failure this prevents is silent: a reader asks for native terms, meets a
+// name with no entry, and is shown the unified one with nothing saying a native
+// name was missing rather than absent. Adding a node kind without a term is the
+// easy way to cause it, so the check is here rather than in a review.
+func TestGlossaryCoversTheWholeVocabulary(t *testing.T) {
+ g := claudecode.Glossary()
+ var missing []string
+ for _, name := range model.Vocabulary() {
+ if _, ok := g.Lookup(name); !ok {
+ missing = append(missing, name)
+ }
+ }
+ if len(missing) > 0 {
+ t.Errorf("%d name(s) have no term:\n %v", len(missing), missing)
+ }
+}
+
+// A term that names a runtime field must say where to find it, or a reader is
+// told a word and given nowhere to look.
+func TestNativeTermsSayWhereToLook(t *testing.T) {
+ for _, term := range claudecode.Glossary().Terms() {
+ if term.Native != "" && term.Where == "" {
+ t.Errorf("%q maps to %q but does not say where it appears", term.Unified, term.Native)
+ }
+ if term.Native == "" && term.Where != "" {
+ t.Errorf("%q has no native name but claims a location", term.Unified)
+ }
+ }
+}
+
+// Roughly half of what the model reports is its own work, and a reader should
+// be able to see which half. If everything had a native name the model would be
+// a rename of one runtime rather than a model of any.
+func TestDerivedTermsAreVisiblyDerived(t *testing.T) {
+ var derived, native int
+ for _, term := range claudecode.Glossary().Terms() {
+ if term.Derived() {
+ derived++
+ if term.Note == "" {
+ t.Errorf("%q is derived but says nothing about what it is", term.Unified)
+ }
+ } else {
+ native++
+ }
+ }
+ if derived == 0 || native == 0 {
+ t.Fatalf("derived=%d native=%d; expected both", derived, native)
+ }
+ t.Logf("%d terms: %d named by the runtime, %d derived here", derived+native, native, derived)
+}
+
+// Asking for a native name must never return nothing.
+func TestNativeFallsBackToTheModelName(t *testing.T) {
+ g := claudecode.Glossary()
+ if got := g.Native(model.KindTalk); got != model.KindTalk {
+ t.Errorf("a derived kind rendered as %q, want the model's own name", got)
+ }
+ if got := g.Native(model.RoleRun); got != "promptId" {
+ t.Errorf("run rendered as %q, want promptId", got)
+ }
+ if got := g.Native("something-that-does-not-exist"); got != "something-that-does-not-exist" {
+ t.Errorf("an unknown name rendered as %q", got)
+ }
+ var nilG *model.Glossary
+ if got := nilG.Native(model.KindTalk); got != model.KindTalk {
+ t.Errorf("a nil glossary rendered %q; a reader with no glossary must still get a name", got)
+ }
+}
diff --git a/internal/adapters/claudecode/helpers_test.go b/internal/adapters/claudecode/helpers_test.go
new file mode 100644
index 0000000..b533084
--- /dev/null
+++ b/internal/adapters/claudecode/helpers_test.go
@@ -0,0 +1,27 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode_test
+
+import (
+ "errors"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+)
+
+func asConflict(err error, target **claudecode.ConflictError) bool { return errors.As(err, target) }
+func isSourceGone(err error) bool { return errors.Is(err, claudecode.ErrSourceGone) }
diff --git a/internal/adapters/claudecode/index.go b/internal/adapters/claudecode/index.go
new file mode 100644
index 0000000..08d9866
--- /dev/null
+++ b/internal/adapters/claudecode/index.go
@@ -0,0 +1,372 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/index"
+)
+
+// indexRecord is the subset of a Claude Code record the index needs.
+//
+// Only identifiers and shape are decoded. Message text, tool inputs and results
+// are deliberately left in the payload: structure resolution needs ids, not
+// content, and decoding content would cost far more than it buys.
+type indexRecord struct {
+ Type string `json:"type"`
+ UUID string `json:"uuid"`
+ ParentUUID string `json:"parentUuid"`
+ Timestamp string `json:"timestamp"`
+ PromptID string `json:"promptId"`
+ AgentID string `json:"agentId"`
+ Subtype string `json:"subtype"`
+
+ // LogicalParentUUID appears on context-reset boundaries and on no other
+ // record type. It is the explicit pointer to the last message before the
+ // reset, which is what makes an epoch observed rather than guessed. A
+ // boundary's own parentUuid is null on every boundary measured, so this is
+ // the only link back.
+ LogicalParentUUID string `json:"logicalParentUuid"`
+ IsCompactSummary bool `json:"isCompactSummary"`
+ IsMeta bool `json:"isMeta"`
+
+ // AITitle, Description and WorkflowName are the three places a name appears:
+ // on a title record, on a child's sidecar, and on a workflow manifest.
+ AITitle string `json:"aiTitle"`
+ Description string `json:"description"`
+ WorkflowName string `json:"workflowName"`
+ RunID string `json:"runId"`
+ // Result is present on a run journal's result records, and holds what a
+ // child returned. It is NOT in the child's own transcript.
+ Result json.RawMessage `json:"result"`
+
+ // ParentAgentID appears on a nested child's sidecar. Without it a child of a
+ // child is attributed to the session's main stream, which flattens the whole
+ // nesting.
+ ParentAgentID string `json:"parentAgentId"`
+
+ Origin origin `json:"origin"`
+
+ Message struct {
+ ID string `json:"id"`
+ Model string `json:"model"`
+ StopReason *string `json:"stop_reason"`
+ Content json.RawMessage `json:"content"`
+ Usage *struct {
+ Input int `json:"input_tokens"`
+ Output int `json:"output_tokens"`
+ CacheRead int `json:"cache_read_input_tokens"`
+ CacheWrite int `json:"cache_creation_input_tokens"`
+ } `json:"usage"`
+ } `json:"message"`
+
+ // ToolUseResult must stay raw. It is an OBJECT most of the time and a bare
+ // string the rest of the time, so decoding it into a struct fails on those
+ // records - and a failed decode of the whole record loses every identifier
+ // on it, not just this field.
+ ToolUseResult json.RawMessage `json:"toolUseResult"`
+
+ Attachment struct {
+ Type string `json:"type"`
+ CommandMode string `json:"commandMode"`
+ Origin origin `json:"origin"`
+ } `json:"attachment"`
+}
+
+// origin says what caused a prompt cycle. It is carried on the record itself and
+// also, for a large share of cases, on the attachment inside it.
+type origin struct {
+ Kind string `json:"kind"`
+}
+
+// toolResult is the object form of a tool result's runtime enrichment.
+//
+// raw keeps the whole object, because the readable half is stdout and stderr
+// and the rest is a per-tool schema - 49 distinct key sets across the corpus -
+// that normalisation cannot flatten without losing it.
+type toolResult struct {
+ raw json.RawMessage
+ AgentID string `json:"agentId"`
+ ToolUseID string `json:"toolUseId"`
+ Status string `json:"status"`
+ RunID string `json:"runId"`
+ TranscriptDir string `json:"transcriptDir"`
+ TaskID string `json:"taskId"`
+}
+
+// decodeToolResult reads the enrichment only when it is an object.
+func decodeToolResult(raw json.RawMessage) (toolResult, bool) {
+ var t toolResult
+ if len(raw) == 0 || raw[0] != '{' {
+ return t, false
+ }
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return t, false
+ }
+ t.raw = raw
+ return t, true
+}
+
+// Launch statuses a tool result can report.
+const (
+ statusAsyncLaunched = "async_launched" // a child started; this is NOT its result
+ statusForked = "forked" // a skill fork; the child has no sidecar pointer
+ statusCompleted = "completed" // a synchronous return; this IS the result
+)
+
+// syntheticModel is the model name on an assistant-role record the CLIENT
+// produced - a connection loss, a provider error, a quota or authentication
+// failure - rather than one a provider returned.
+//
+// The test is the model name, not a missing request id: about a quarter of these
+// records carry a request id, and a genuine call exists that carries none, so
+// request id misclassifies in both directions.
+const syntheticModel = "<synthetic>"
+
+// triggerOf reads what caused this record's prompt cycle.
+//
+// The marker sits on the record and, for a substantial share, on the attachment
+// inside it instead. Reading only the record misses more than a quarter of them.
+// The value is an open set - a third kind exists beyond the two obvious ones -
+// so anything unrecognised maps to "other" rather than being treated as absent.
+func triggerOf(d *indexRecord) index.Trigger {
+ kind := d.Origin.Kind
+ if kind == "" {
+ kind = d.Attachment.Origin.Kind
+ }
+ switch kind {
+ case "":
+ return index.TriggerNone
+ case "human":
+ return index.TriggerExternal
+ case "task-notification":
+ return index.TriggerNotification
+ }
+ return index.TriggerOther
+}
+
+// flagsOf reduces the record's shape to the bits a lookup needs.
+func flagsOf(d *indexRecord, tur *toolResult, hasTUR bool, src Source) index.Flags {
+ var f index.Flags
+ if d.Type == "assistant" {
+ if d.Message.Model == syntheticModel {
+ f |= index.FlagSynthetic
+ }
+ // A stop reason says the provider finished, which is what makes the usage
+ // numbers on this call real. It does NOT identify the last fragment: a
+ // main transcript stamps the same stop reason on every fragment of a call.
+ // Only line order finds the last one.
+ if d.Message.StopReason != nil && *d.Message.StopReason != "" {
+ f |= index.FlagStopReason
+ }
+ }
+ if d.Type == "system" {
+ // Every subtype is accounted for. An unrecognised one becomes a notice
+ // rather than nothing, because a system record always says something
+ // about the session and dropping it loses that silently.
+ switch d.Subtype {
+ case "compact_boundary":
+ f |= index.FlagEpochBoundary
+ case "api_error":
+ f |= index.FlagError
+ case "turn_duration":
+ f |= index.FlagTurnDuration
+ case "local_command":
+ f |= index.FlagCommand
+ default:
+ f |= index.FlagNotice
+ }
+ }
+ if d.IsCompactSummary {
+ f |= index.FlagEpochSummary
+ }
+ if src.Kind == SrcJournal && len(d.Result) > 0 {
+ // A run journal records what each child returned. Measured across the
+ // corpus, 2,353 of 2,436 of these appear NOWHERE else - not in the
+ // child's own transcript - so this is the only copy.
+ f |= index.FlagChildResult
+ }
+ if hasTUR {
+ switch tur.Status {
+ case statusAsyncLaunched:
+ // The acknowledgement is not the result. Counting it as one records an
+ // empty output for every asynchronous delegation.
+ f |= index.FlagLaunchAck
+ case statusCompleted:
+ // A synchronous return. This one IS the child's result, and it repeats
+ // the child's own output, so a renderer has to drop the copy rather
+ // than show the same work twice.
+ f |= index.FlagChildResult
+ }
+ }
+
+ // An attachment is material the harness put into model context. One kind is
+ // not: a queued command the person typed, which exists ONLY as an attachment
+ // and is otherwise lost.
+ //
+ // The command mode is what separates them, and both halves of it are needed.
+ // Matching the attachment type alone reads completion-notification blobs as
+ // things the user typed, and in some sessions there are more of those than
+ // genuine prompts.
+ //
+ // Everything else is injected. That is the DEFAULT rather than a list,
+ // because a list has to be right about types that do not exist yet. An
+ // earlier version of this code kept one, and it was wrong in both
+ // directions at once: 635 records across 13 real attachment types fell
+ // through it and were dropped from the conversation entirely, while 5 types
+ // on the list appear nowhere in the corpus. Defaulting to injection cannot
+ // lose a record; at worst it over-reports one, and a reader is told what it
+ // was either way.
+ if d.Type == "attachment" {
+ if d.Attachment.Type == "queued_command" && d.Attachment.CommandMode == "prompt" {
+ f |= index.FlagExternalInput
+ } else {
+ f |= index.FlagInjection
+ }
+ }
+ if d.Type == "user" && d.Origin.Kind == "human" && !d.IsMeta {
+ f |= index.FlagExternalInput
+ }
+ if d.IsMeta {
+ // A meta record is text the harness wrote, placed in the user role. It is
+ // injected context, not something a person said.
+ f |= index.FlagInjection
+ f &^= index.FlagExternalInput
+ }
+ if src.Kind == SrcAgentMeta || src.Kind == SrcJournal {
+ f &^= index.FlagExternalInput
+ }
+ return f
+}
+
+// linksOf extracts the two cross-record links that are not content blocks.
+//
+// Several mechanisms start a child agent and no single field covers them, so
+// each is read where it lives:
+//
+// Workflow the launch result names a run; the run's journal names every
+// child it starts. This is by far the largest source - most
+// children on disk are reached this way and no other.
+// Agent tool the parent's result names the child. The status says whether it
+// is an acknowledgement or a completed return.
+// Skill fork only the parent's result names the child.
+// Nesting a child's sidecar names its parent agent. Without it a child of
+// a child is attributed to the main stream.
+//
+// Completion is a further path: a notification names the call it completes and
+// the task that finished, and it can land in a different file from that call,
+// so both ids are extracted here and resolved across the whole session.
+//
+// It returns strings; the caller interns them, so this stays a statement about
+// Claude Code alone.
+func linksOf(d *indexRecord, tur *toolResult, src Source) (anchor, spawn string) {
+ switch src.Kind {
+ case SrcAgentMeta:
+ // The sidecar is not a general parent pointer: it names a parent only for
+ // a nested child. For everything else it holds only the agent's type and
+ // depth, and the join has to come from the parent side or the journal.
+ spawn = d.AgentID
+ if spawn == "" {
+ spawn = src.Stream
+ }
+ anchor = tur.ToolUseID
+ case SrcJournal:
+ // A journal record announces a child before its transcript exists, so an
+ // unresolved reference here is expected for a while rather than an error.
+ spawn = d.AgentID
+ default:
+ anchor, spawn = tur.ToolUseID, tur.AgentID
+ }
+
+ if a, s := notificationIDs(d.Message.Content); a != "" || s != "" {
+ if anchor == "" {
+ anchor = a
+ }
+ if spawn == "" {
+ spawn = s
+ }
+ }
+ return anchor, spawn
+}
+
+// ParentAgent returns the agent a nested child's sidecar names as its parent.
+func ParentAgent(payload []byte) string {
+ var d indexRecord
+ if err := json.Unmarshal(payload, &d); err != nil {
+ return ""
+ }
+ return d.ParentAgentID
+}
+
+// notificationIDs pulls the call id and the task id out of a completion
+// notification.
+//
+// The call id is the primary key. The task id is only sometimes an agent id -
+// it is a generic handle whose meaning depends on what was launched, so joining
+// on it unconditionally matches the wrong thing most of the time. It is returned
+// only when it has an agent id's shape.
+//
+// The status element is deliberately not read: it is missing entirely on a large
+// class of notifications, so anything requiring it would fail on them.
+func notificationIDs(content json.RawMessage) (anchor, spawn string) {
+ if len(content) == 0 || content[0] != '"' {
+ return "", "" // structured content carries no notification
+ }
+ var text string
+ if err := json.Unmarshal(content, &text); err != nil {
+ return "", ""
+ }
+ if !strings.Contains(text, "<task-notification>") {
+ return "", ""
+ }
+ anchor = between(text, "<tool-use-id>", "</tool-use-id>")
+ // A single notification can name several tasks. Only one that looks like an
+ // agent identifier is usable as a stream name.
+ rest := text
+ for {
+ id := between(rest, "<task-id>", "</task-id>")
+ if id == "" {
+ break
+ }
+ if IsAgentID(id) {
+ spawn = id
+ break
+ }
+ i := strings.Index(rest, "</task-id>")
+ if i < 0 {
+ break
+ }
+ rest = rest[i+len("</task-id>"):]
+ }
+ return anchor, spawn
+}
+
+func between(s, openTag, closeTag string) string {
+ i := strings.Index(s, openTag)
+ if i < 0 {
+ return ""
+ }
+ rest := s[i+len(openTag):]
+ j := strings.Index(rest, closeTag)
+ if j < 0 {
+ return ""
+ }
+ return strings.TrimSpace(rest[:j])
+}
diff --git a/internal/adapters/claudecode/matcher.go b/internal/adapters/claudecode/matcher.go
new file mode 100644
index 0000000..611cf94
--- /dev/null
+++ b/internal/adapters/claudecode/matcher.go
@@ -0,0 +1,144 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode
+
+import (
+ "path"
+ "strings"
+)
+
+// Matcher decides which sessions to collect.
+//
+// Patterns are evaluated per SESSION, never per directory. A session's files
+// can be spread across several source directories, so excluding a directory
+// would otherwise silently cut streams out of a session that is still being
+// collected - losing part of a conversation while appearing to succeed.
+type Matcher struct {
+ include []pattern
+ exclude []pattern
+}
+
+// pattern is a compiled matcher entry.
+//
+// subtree records that the source pattern ended in "/**". It must be captured
+// before slugification, because slugifying "/private/tmp/**" would collapse the
+// separator and destroy the suffix.
+type pattern struct {
+ base string
+ subtree bool
+}
+
+// NewMatcher compiles include and exclude patterns.
+//
+// An entry beginning with "/" is treated as a real working directory and is
+// slugified forward to match a source directory name. Anything else is matched
+// as a glob against the source directory name directly. Slugification is only
+// ever applied in this direction: the transform is lossy, so the inverse is
+// never attempted.
+func NewMatcher(include, exclude []string) *Matcher {
+ return &Matcher{include: compile(include), exclude: compile(exclude)}
+}
+
+func compile(pats []string) []pattern {
+ out := make([]pattern, 0, len(pats))
+ for _, p := range pats {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ var sub bool
+ if rest, ok := strings.CutSuffix(p, "/**"); ok {
+ p, sub = rest, true
+ }
+ if strings.HasPrefix(p, "/") {
+ p = Slugify(p)
+ }
+ out = append(out, pattern{base: p, subtree: sub})
+ }
+ return out
+}
+
+// Match reports whether a session should be collected.
+//
+// Matching keys on the session's PRIMARY directory - where its main transcript
+// lives - not on every directory it touches. A session's child streams are
+// filed under whatever working directory the child ran in, so a single
+// scratchpad or subdirectory must not exclude the whole conversation. Excluding
+// "/private/tmp/**" means "do not collect sessions that ARE scratchpad
+// sessions", not "drop any session that ever touched a scratchpad".
+//
+// When the main transcript has been pruned there is no primary directory. The
+// session is then judged on all of its directories together, and is only
+// excluded when every one of them matches - so an orphaned child stream
+// belonging to a real project is still collected.
+func (m *Matcher) Match(s Session) bool {
+ if m == nil {
+ return true
+ }
+ dirs := s.Dirs
+ if s.Primary != "" {
+ dirs = []string{s.Primary}
+ }
+
+ excluded := len(dirs) > 0
+ for _, dir := range dirs {
+ if !matchAny(m.exclude, dir) {
+ excluded = false
+ break
+ }
+ }
+ if excluded {
+ return false
+ }
+
+ if len(m.include) == 0 {
+ return true
+ }
+ for _, dir := range dirs {
+ if matchAny(m.include, dir) {
+ return true
+ }
+ }
+ return false
+}
+
+func matchAny(pats []pattern, dir string) bool {
+ for _, p := range pats {
+ if p.match(dir) {
+ return true
+ }
+ }
+ return false
+}
+
+// match applies one compiled pattern to a source directory name.
+//
+// For a subtree pattern the directory must either be the base itself or lie
+// beneath it. Beneath is tested as base+"-" rather than a bare prefix, so
+// "/private/tmp/**" does not also swallow "/private/tmpfoo": source directory
+// names are flat, with path separators already collapsed to '-', so '-' is the
+// only boundary available.
+func (p pattern) match(dir string) bool {
+ if p.subtree {
+ return dir == p.base || strings.HasPrefix(dir, p.base+"-")
+ }
+ if ok, err := path.Match(p.base, dir); err == nil && ok {
+ return true
+ }
+ return p.base == dir
+}
diff --git a/internal/adapters/claudecode/matcher_test.go b/internal/adapters/claudecode/matcher_test.go
new file mode 100644
index 0000000..d2f642f
--- /dev/null
+++ b/internal/adapters/claudecode/matcher_test.go
@@ -0,0 +1,116 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode_test
+
+import (
+ "testing"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+)
+
+func TestMatcherIsSessionScoped(t *testing.T) {
+ // A real conversation whose main transcript lives in a project directory,
+ // with a child stream filed under a scratchpad. Excluding /private/tmp must
+ // NOT discard it: two real sessions on a live corpus have exactly this shape.
+ realSession := claudecode.Session{
+ ID: "s1",
+ Primary: "-Users-w-github-proj",
+ Dirs: []string{"-Users-w-github-proj", "-private-tmp-scratch"},
+ }
+ m := claudecode.NewMatcher(nil, []string{"/private/tmp/**"})
+ if !m.Match(realSession) {
+ t.Error("a scratchpad child stream must not exclude the whole session")
+ }
+
+ // A session that IS a scratchpad session - its main transcript lives there.
+ scratch := claudecode.Session{
+ ID: "s2",
+ Primary: "-private-tmp-scratch",
+ Dirs: []string{"-private-tmp-scratch"},
+ }
+ if m.Match(scratch) {
+ t.Error("a session whose main transcript is under an excluded path must be excluded")
+ }
+
+ // Pruned main transcript: judged on all directories, excluded only if every
+ // one matches, so an orphaned child stream of a real project survives.
+ orphanReal := claudecode.Session{
+ ID: "s3",
+ Dirs: []string{"-Users-w-github-proj", "-private-tmp-scratch"},
+ }
+ if !m.Match(orphanReal) {
+ t.Error("a pruned session with a real directory must still be collected")
+ }
+ orphanScratch := claudecode.Session{ID: "s4", Dirs: []string{"-private-tmp-scratch"}}
+ if m.Match(orphanScratch) {
+ t.Error("a pruned session with only excluded directories must be excluded")
+ }
+}
+
+func TestMatcherExcludeGlob(t *testing.T) {
+ m := claudecode.NewMatcher(nil, []string{"/private/tmp/**"})
+ cases := map[string]bool{ // dir -> want match (i.e. collected)
+ "-Users-w-github-proj": true,
+ "-private-tmp-scratch": false, // beneath /private/tmp
+ "-private-tmp": false, // the directory itself
+ // /private/tmpfoo is a DIFFERENT directory, so "/private/tmp/**" must
+ // not swallow it. Once separators are collapsed, '-' is the only
+ // boundary available to tell the two apart.
+ "-private-tmpfoo": true,
+ "-Users-w-private-tmp": true, // not rooted at /private/tmp
+ }
+ for dir, want := range cases {
+ got := m.Match(claudecode.Session{ID: "s", Primary: dir, Dirs: []string{dir}})
+ if got != want {
+ t.Errorf("dir %q: Match = %v, want %v", dir, got, want)
+ }
+ }
+}
+
+func TestMatcherIncludeAcceptsRealPaths(t *testing.T) {
+ // A real path is slugified forward to match a source directory name.
+ // The inverse is never attempted, because slugification is lossy.
+ m := claudecode.NewMatcher([]string{"/Users/w/github/skywalking-horizon-ui"}, nil)
+ if !m.Match(claudecode.Session{ID: "s", Primary: "-Users-w-github-skywalking-horizon-ui", Dirs: []string{"-Users-w-github-skywalking-horizon-ui"}}) {
+ t.Error("a real path should match its slugified directory name")
+ }
+ if m.Match(claudecode.Session{ID: "s", Primary: "-Users-w-github-other", Dirs: []string{"-Users-w-github-other"}}) {
+ t.Error("non-matching directory was included")
+ }
+}
+
+func TestMatcherIncludeGlob(t *testing.T) {
+ m := claudecode.NewMatcher([]string{"-Users-w-github-skywalking-*"}, nil)
+ if !m.Match(claudecode.Session{ID: "s", Primary: "-Users-w-github-skywalking-python", Dirs: []string{"-Users-w-github-skywalking-python"}}) {
+ t.Error("glob should match")
+ }
+ if m.Match(claudecode.Session{ID: "s", Primary: "-Users-w-github-other", Dirs: []string{"-Users-w-github-other"}}) {
+ t.Error("glob matched something it should not")
+ }
+}
+
+func TestMatcherEmptyIncludesEverything(t *testing.T) {
+ m := claudecode.NewMatcher(nil, nil)
+ if !m.Match(claudecode.Session{ID: "s", Primary: "anything", Dirs: []string{"anything"}}) {
+ t.Error("an empty matcher must collect everything")
+ }
+ var nilM *claudecode.Matcher
+ if !nilM.Match(claudecode.Session{ID: "s"}) {
+ t.Error("a nil matcher must collect everything")
+ }
+}
diff --git a/internal/adapters/claudecode/regress_test.go b/internal/adapters/claudecode/regress_test.go
new file mode 100644
index 0000000..49ebcaf
--- /dev/null
+++ b/internal/adapters/claudecode/regress_test.go
@@ -0,0 +1,466 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+ "github.com/apache/skywalking-ai-sessionizer/internal/index"
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
+)
+
+// landedSeqs returns every landed sequence number under a session, so a
+// duplicate is detectable.
+func landedSeqs(t *testing.T, sessionDir string) []string {
+ t.Helper()
+ var out []string
+ _ = filepath.Walk(sessionDir, func(p string, fi os.FileInfo, err error) error {
+ if err != nil || fi.IsDir() || !strings.HasSuffix(p, ".sd") {
+ return nil
+ }
+ name := filepath.Base(p)
+ if i := strings.LastIndex(name, "-"); i >= 0 {
+ out = append(out, strings.TrimSuffix(name[i+1:], ".sd"))
+ }
+ return nil
+ })
+ return out
+}
+
+// TestSeqSurvivesMidSessionCrash is the regression for the worst defect found:
+// session.state is written once per session while landed files commit per
+// source, so a crash mid-session left the counter behind the filesystem and
+// reissued sequence numbers. Because the assembler tracks progress with one
+// monotonic watermark, a reissued sequence is never read - silent data loss.
+func TestSeqSurvivesMidSessionCrash(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ main := filepath.Join(src, "-proj-a", tSess+".jsonl")
+ agent := filepath.Join(src, "-proj-a", tSess, "subagents", "agent-"+tAgent+".jsonl")
+ mk(t, main, "{\"uuid\":\"m1\"}\n")
+ mk(t, agent, "{\"uuid\":\"c1\"}\n")
+
+ z := storage.NewZone(zone)
+ col := claudecode.New(src, z, 0)
+ if _, err := col.CollectAll(nil); err != nil {
+ t.Fatal(err)
+ }
+
+ // Simulate the crash: roll session.state back to what is on disk when the
+ // process dies after landing but before the end-of-session save.
+ statePath := z.SessionStatePath(tSess)
+ stale, err := storage.LoadSessionState(statePath, tSess)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stale.NextSeq = 1
+ if err := os.WriteFile(statePath, []byte(fmt.Sprintf(
+ "schema 1\nsession %s\nnext_seq 1\nliveness unknown\n", tSess)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // New data on both streams; the collector must not reuse seqs 1 and 2.
+ mk(t, main, "{\"uuid\":\"m1\"}\n{\"uuid\":\"m2\"}\n")
+ mk(t, agent, "{\"uuid\":\"c1\"}\n{\"uuid\":\"c2\"}\n")
+ if _, err := col.CollectAll(nil); err != nil {
+ t.Fatal(err)
+ }
+
+ seqs := landedSeqs(t, z.SessionDir(tSess))
+ seen := map[string]int{}
+ for _, s := range seqs {
+ seen[s]++
+ }
+ for s, n := range seen {
+ if n > 1 {
+ t.Errorf("sequence %s used by %d landed files - the assembler watermark would drop all but one", s, n)
+ }
+ }
+ if len(seqs) != 4 {
+ t.Errorf("got %d landed files (%v), want 4", len(seqs), seqs)
+ }
+}
+
+// TestConcurrentCollectorsDoNotCollide covers two collectors sharing a zone.
+func TestConcurrentCollectorsDoNotCollide(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ mk(t, filepath.Join(src, "-proj-a", tSess+".jsonl"), "{\"uuid\":\"m1\"}\n")
+ mk(t, filepath.Join(src, "-proj-a", tSess, "subagents", "agent-"+tAgent+".jsonl"), "{\"uuid\":\"c1\"}\n")
+
+ z := storage.NewZone(zone)
+ var wg sync.WaitGroup
+ for i := 0; i < 2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ col := claudecode.New(src, z, 0)
+ _, _ = col.CollectAll(nil)
+ }()
+ }
+ wg.Wait()
+
+ seen := map[string]int{}
+ for _, s := range landedSeqs(t, z.SessionDir(tSess)) {
+ seen[s]++
+ }
+ for s, n := range seen {
+ if n > 1 {
+ t.Errorf("concurrent collectors both issued sequence %s (%d files)", s, n)
+ }
+ }
+}
+
+// TestOncePassDrainsLargeSource is the regression for Chunk.More being computed
+// and never read: a single pass landed one budget-sized window and reported a
+// clean, complete-looking result.
+func TestOncePassDrainsLargeSource(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ var b strings.Builder
+ const lines = 400
+ for i := 0; i < lines; i++ {
+ fmt.Fprintf(&b, "{\"uuid\":\"u%d\",\"pad\":\"%s\"}\n", i, strings.Repeat("x", 200))
+ }
+ mk(t, filepath.Join(src, "-proj-a", tSess+".jsonl"), b.String())
+
+ // A budget far smaller than the source, so a single window is nowhere near enough.
+ col := claudecode.New(src, storage.NewZone(zone), 4096)
+ st, err := col.CollectAll(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if st.Records != lines {
+ t.Errorf("landed %d of %d records in one pass - the pass under-collected", st.Records, lines)
+ }
+ if !st.Complete() {
+ t.Errorf("pass reported incomplete: pending=%d errors=%v", st.Pending, st.Errors)
+ }
+}
+
+// TestOversizedLineDoesNotStall covers a single source line larger than the
+// byte budget, which previously returned zero lines with no error, forever.
+func TestOversizedLineDoesNotStall(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ big := fmt.Sprintf("{\"uuid\":\"big\",\"pad\":\"%s\"}\n", strings.Repeat("y", 50_000))
+ mk(t, filepath.Join(src, "-proj-a", tSess+".jsonl"), big+"{\"uuid\":\"after\"}\n")
+
+ col := claudecode.New(src, storage.NewZone(zone), 1024) // budget far below one line
+ st, err := col.CollectAll(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if st.Records != 2 {
+ t.Errorf("landed %d records, want 2 - an oversized line stalled the source", st.Records)
+ }
+}
+
+// TestStubDirectoryDoesNotVetoSession is the regression for the discovery half
+// of the exclusion bug: a session directory containing nothing collectable was
+// still recorded as a source directory, letting it disqualify the whole session.
+func TestStubDirectoryDoesNotVetoSession(t *testing.T) {
+ src := t.TempDir()
+ mk(t, filepath.Join(src, "-Users-me-proj", tSess+".jsonl"), "{\"uuid\":\"m1\"}\n")
+ // Claude Code files workflow scripts under whatever cwd the agent had; we
+ // never collect scripts, so this directory yields nothing.
+ mk(t, filepath.Join(src, "-private-tmp-scratch", tSess, "workflows", "scripts", "x.js"), "//\n")
+
+ sessions, err := claudecode.Discover(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(sessions) != 1 {
+ t.Fatalf("got %d sessions, want 1", len(sessions))
+ }
+ if got := sessions[0].Dirs; len(got) != 1 || got[0] != "-Users-me-proj" {
+ t.Errorf("Dirs = %v; a directory yielding no sources must not be recorded", got)
+ }
+ m := claudecode.NewMatcher(nil, []string{"/private/tmp/**"})
+ if !m.Match(sessions[0]) {
+ t.Error("an empty stub directory disqualified a real session")
+ }
+}
+
+// TestScriptRunIDParse pins the filename rule Claude Code uses for workflow
+// scripts. It is anchored to the end so a label containing "-wf_" cannot
+// shadow the real run id.
+func TestScriptRunIDParse(t *testing.T) {
+ cases := map[string]string{
+ "terminal-card-mappers-wf_afa31e47-f6c.js": "wf_afa31e47-f6c",
+ "oauth2-provider-facts-wf_7e2f3f7a-e04.js": "wf_7e2f3f7a-e04",
+ "a-wf_decoy-then-wf_real123.js": "wf_real123",
+ "plain.js": "",
+ "no-extension-wf_abc": "",
+ }
+ for name, want := range cases {
+ got, ok := claudecode.ScriptRunID(name)
+ if want == "" {
+ if ok {
+ t.Errorf("%s: expected no run id, got %q", name, got)
+ }
+ continue
+ }
+ if !ok || got != want {
+ t.Errorf("%s: got %q (ok=%v), want %q", name, got, ok, want)
+ }
+ }
+}
+
+// TestScriptCollectedIntoItsRunDirectory covers the case that motivated
+// collecting scripts at all: Claude Code files them under whatever working
+// directory the agent had, so they are the one artifact that genuinely lives
+// outside its session's own directory. Collecting them reunifies the session.
+func TestScriptCollectedIntoItsRunDirectory(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ // Main transcript in the real project directory.
+ mk(t, filepath.Join(src, "-proj-a", tSess+".jsonl"), "{\"uuid\":\"m1\"}\n")
+ mk(t, filepath.Join(src, "-proj-a", tSess, "workflows", "wf_r1.json"), `{"runId":"wf_r1"}`)
+ // Script filed under an unrelated directory, for the same session and run.
+ mk(t, filepath.Join(src, "-proj-elsewhere", tSess, "workflows", "scripts", "flow-wf_r1.js"),
+ "export const meta = { name: 'flow' }\n")
+
+ col := claudecode.New(src, storage.NewZone(zone), 0)
+ st, err := col.CollectAll(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(st.Errors) != 0 {
+ t.Fatalf("unexpected errors: %v", st.Errors)
+ }
+
+ runDir := storage.NewZone(zone).RunDir(tSess, "wf_r1")
+ ents, err := os.ReadDir(runDir)
+ if err != nil {
+ t.Fatalf("run directory not created: %v", err)
+ }
+ var haveScript, haveManifest bool
+ for _, e := range ents {
+ switch {
+ case strings.HasPrefix(e.Name(), "script-") && strings.HasSuffix(e.Name(), ".sd"):
+ haveScript = true
+ case strings.HasPrefix(e.Name(), "manifest-") && strings.HasSuffix(e.Name(), ".sd"):
+ haveManifest = true
+ }
+ }
+ if !haveScript {
+ t.Errorf("script from another directory was not collected into %s", runDir)
+ }
+ if !haveManifest {
+ t.Errorf("manifest not collected into %s", runDir)
+ }
+}
+
+// TestScriptPayloadIsKeptWhole checks that a non-JSON source - a
+// JavaScript file - still round trips. The envelope wraps it as a string so the
+// landed file stays parseable, with the bytes recoverable exactly.
+func TestScriptPayloadIsKeptWhole(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ const body = "export const meta = {\n name: 'flow',\n}\n"
+ mk(t, filepath.Join(src, "-proj-a", tSess+".jsonl"), "{\"uuid\":\"m1\"}\n")
+ mk(t, filepath.Join(src, "-proj-a", tSess, "workflows", "scripts", "flow-wf_r1.js"), body)
+
+ col := claudecode.New(src, storage.NewZone(zone), 0)
+ if _, err := col.CollectAll(nil); err != nil {
+ t.Fatal(err)
+ }
+ runDir := storage.NewZone(zone).RunDir(tSess, "wf_r1")
+ ents, _ := os.ReadDir(runDir)
+ var landed string
+ for _, e := range ents {
+ if strings.HasPrefix(e.Name(), "script-") && strings.HasSuffix(e.Name(), ".sd") {
+ landed = filepath.Join(runDir, e.Name())
+ }
+ }
+ if landed == "" {
+ t.Fatal("no landed script file")
+ }
+ f, err := os.Open(landed)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ r, err := sessiondata.NewReader(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec, err := r.Next()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // A workflow script is JavaScript. The dialect cannot break it into parts,
+ // so it keeps the bytes whole in an unknown part rather than describing it
+ // or dropping it - which is the rule that makes converting on read safe for
+ // anything a dialect does not understand.
+ if len(rec.Parts) != 1 || rec.Parts[0].Kind != sessiondata.PartUnknown {
+ t.Fatalf("script converted to %d part(s): %+v", len(rec.Parts), rec.Parts)
+ }
+ var got string
+ if err := json.Unmarshal(rec.Parts[0].Data, &got); err != nil {
+ t.Fatal(err)
+ }
+ if got != strings.TrimRight(body, "\n") {
+ t.Errorf("script bytes not preserved:\n got %q\nwant %q", got, strings.TrimRight(body, "\n"))
+ }
+}
+
+// TestIndexGapIsClosedAfterInterruptedPass is the regression for a silent,
+// permanent data gap.
+//
+// Cursors commit per source while the index is written once per session. A
+// crash between them advances the cursors without recording their entries, and
+// because nothing re-lands, the index would stay short forever while
+// indexed_seq claimed to cover everything.
+func TestIndexGapIsClosedAfterInterruptedPass(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ main := filepath.Join(src, "-proj-a", tSess+".jsonl")
+ mk(t, main, "{\"uuid\":\"m1\"}\n{\"uuid\":\"m2\"}\n")
+
+ z := storage.NewZone(zone)
+ col := claudecode.New(src, z, 0)
+ if _, err := col.CollectAll(nil); err != nil {
+ t.Fatal(err)
+ }
+
+ // Simulate the crash: the landed files and cursors survive, the index does
+ // not. This is exactly the on-disk state after a kill between the two.
+ if err := os.RemoveAll(z.IndexDir(tSess)); err != nil {
+ t.Fatal(err)
+ }
+
+ // Land more data. Without gap recovery the index would hold only m3,
+ // silently missing m1 and m2 which no longer re-land.
+ mk(t, main, "{\"uuid\":\"m1\"}\n{\"uuid\":\"m2\"}\n{\"uuid\":\"m3\"}\n")
+ st, err := col.CollectAll(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if st.Reindexed == 0 {
+ t.Error("expected the interrupted pass to be detected and re-indexed")
+ }
+
+ ix, ok, err := index.Load(z.IndexDir(tSess), tSess)
+ if err != nil || !ok {
+ t.Fatalf("index missing after recovery: ok=%v err=%v", ok, err)
+ }
+ for _, want := range []string{"m1", "m2", "m3"} {
+ if _, found := ix.EntryByRecord(want); !found {
+ t.Errorf("record %q missing from the index after recovery", want)
+ }
+ }
+}
+
+// TestIndexRebuildsFromLandedFiles covers a wholesale loss of the index - the
+// case that makes it safe to treat the index as derived and disposable.
+func TestIndexRebuildsFromLandedFiles(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ base := filepath.Join(src, "-proj-a", tSess)
+ mk(t, filepath.Join(src, "-proj-a", tSess+".jsonl"), "{\"uuid\":\"m1\"}\n")
+ mk(t, filepath.Join(base, "subagents", "agent-"+tAgent+".jsonl"), "{\"uuid\":\"c1\"}\n")
+ mk(t, filepath.Join(base, "subagents", "workflows", "wf_r1", "journal.jsonl"),
+ "{\"type\":\"started\",\"agentId\":\""+tAgent+"\"}\n")
+
+ z := storage.NewZone(zone)
+ col := claudecode.New(src, z, 0)
+ if _, err := col.CollectAll(nil); err != nil {
+ t.Fatal(err)
+ }
+ before, ok, err := index.Load(z.IndexDir(tSess), tSess)
+ if err != nil || !ok {
+ t.Fatal("no index after first pass")
+ }
+ wantEntries := len(before.Entries)
+
+ // Throw the index away entirely and rebuild it from the landed files alone.
+ if err := os.RemoveAll(z.IndexDir(tSess)); err != nil {
+ t.Fatal(err)
+ }
+ rebuilt := index.New(tSess)
+ n, err := claudecode.RebuildIndex(z, tSess, rebuilt, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != wantEntries {
+ t.Errorf("rebuilt %d entries, want %d", n, wantEntries)
+ }
+ for _, want := range []string{"m1", "c1"} {
+ if _, found := rebuilt.EntryByRecord(want); !found {
+ t.Errorf("record %q missing from the rebuilt index", want)
+ }
+ }
+ // Stream attribution must survive a rebuild that has only the landed files.
+ if got := rebuilt.Stream(tAgent); len(got) == 0 {
+ t.Error("child stream lost its attribution on rebuild")
+ }
+}
+
+// TestRecoveredIndexPersistsWithNothingNewLanded covers the steady state, which
+// is the common one: a watch loop finds a session whose index is behind and
+// whose sources have not changed.
+//
+// Persisting the index only when a source lands makes that case rebuild the
+// index on every pass and throw it away every time - the recovery never sticks,
+// the work repeats forever, and indexed_seq on disk keeps claiming coverage the
+// stored index does not have.
+func TestRecoveredIndexPersistsWithNothingNewLanded(t *testing.T) {
+ src, zone := t.TempDir(), t.TempDir()
+ main := filepath.Join(src, "-proj-a", tSess+".jsonl")
+ mk(t, main, "{\"uuid\":\"m1\"}\n{\"uuid\":\"m2\"}\n")
+
+ z := storage.NewZone(zone)
+ col := claudecode.New(src, z, 0)
+ if _, err := col.CollectAll(nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.RemoveAll(z.IndexDir(tSess)); err != nil {
+ t.Fatal(err)
+ }
+
+ // The source is untouched, so this pass lands nothing.
+ st, err := col.CollectAll(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if st.Records != 0 {
+ t.Fatalf("the pass landed %d records; the test needs a pass that lands nothing", st.Records)
+ }
+ if st.Reindexed == 0 {
+ t.Fatal("the missing index was not detected")
+ }
+
+ ix, ok, err := index.Load(z.IndexDir(tSess), tSess)
+ if err != nil || !ok {
+ t.Fatalf("the rebuilt index was not written: ok=%v err=%v", ok, err)
+ }
+ if len(ix.Entries) != 2 {
+ t.Fatalf("index holds %d entries, want 2", len(ix.Entries))
+ }
+
+ // And it must stick: a third pass has nothing left to recover.
+ third, err := col.CollectAll(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if third.Reindexed != 0 {
+ t.Errorf("re-indexed %d entries again; the recovery was discarded", third.Reindexed)
+ }
+}
diff --git a/internal/adapters/claudecode/reindex.go b/internal/adapters/claudecode/reindex.go
new file mode 100644
index 0000000..ae56f6d
--- /dev/null
+++ b/internal/adapters/claudecode/reindex.go
@@ -0,0 +1,98 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/index"
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
+)
+
+// RebuildIndex indexes every landed file with a sequence above afterSeq,
+// appending to ix.
+//
+// This is the recovery path, not the normal one. Indexing normally rides along
+// with landing, where the bytes are already in hand. But cursors commit per
+// source while the index is written once per session, so a crash between them
+// advances the cursors without recording their entries - and because nothing
+// re-lands, the gap would be permanent and silent. Re-reading the landed files
+// is the only way to close it.
+//
+// Passing afterSeq = 0 rebuilds the whole index from scratch.
+func RebuildIndex(z *storage.Zone, session string, ix *index.Index, afterSeq uint64) (int, error) {
+ files, err := storage.LandedFiles(z, session)
+ if err != nil {
+ return 0, err
+ }
+ var indexed int
+ for _, lf := range files {
+ if lf.Seq <= afterSeq {
+ continue
+ }
+ n, err := indexLandedFile(ix, lf)
+ if err != nil {
+ return indexed, err
+ }
+ indexed += n
+ }
+ return indexed, nil
+}
+
+// indexLandedFile indexes one landed file.
+func indexLandedFile(ix *index.Index, lf storage.LandedFile) (int, error) {
+ f, err := os.Open(lf.Path)
+ if err != nil {
+ return 0, err
+ }
+ defer f.Close()
+
+ r, err := sessiondata.NewReader(f)
+ if err != nil {
+ // A landed file whose header will not read describes an unknown amount
+ // of the conversation. Continuing would produce an index that silently
+ // omits it, and an assembly that looks complete.
+ return 0, fmt.Errorf("claudecode: landed file %s is unreadable: %w", lf.Path, err)
+ }
+ hdr := r.Header()
+ if hdr.Stream == "" {
+ hdr.Stream = lf.Stream
+ }
+ if hdr.Batch == "" {
+ hdr.Batch = lf.RunID
+ }
+
+ var n int
+ for row := uint32(1); ; row++ {
+ rec, err := r.Next()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return n, fmt.Errorf("claudecode: %s row %d: %w", lf.Path, row, err)
+ }
+ e, blocks := index.FromRecord(ix, &hdr, rec, uint32(lf.Seq), row)
+ ix.Append(e, blocks...)
+ n++
+ }
+ return n, nil
+}
diff --git a/internal/adapters/claudecode/tail.go b/internal/adapters/claudecode/tail.go
new file mode 100644
index 0000000..c594a92
--- /dev/null
+++ b/internal/adapters/claudecode/tail.go
@@ -0,0 +1,261 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package claudecode
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "syscall"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+)
+
+// ErrSourceGone means the source file no longer exists.
+//
+// This is an expected state, not a failure: Claude Code prunes transcripts, and
+// our landed deltas deliberately outlive them.
+var ErrSourceGone = errors.New("claudecode: source gone")
+
+// ConflictError means the source changed underneath the cursor. Collection
+// stops rather than resuming from a position that no longer means what it did.
+type ConflictError struct {
+ Path string
+ Reason string
+}
+
+func (e *ConflictError) Error() string {
+ return fmt.Sprintf("claudecode: conflict on %s: %s", e.Path, e.Reason)
+}
+
+// Line is one source record with its position in the source file.
+type Line struct {
+ Ord uint64
+ Off uint64
+ Bytes []byte
+}
+
+// Chunk is what one tail pass consumed.
+type Chunk struct {
+ Lines []Line
+ NewOffset uint64
+ NewOrd uint64
+ LastUUID string
+ // More reports that the byte budget was reached and the source has further
+ // complete lines waiting, so the caller should drain again.
+ More bool
+
+ // Dev, Ino and Size are the identity observed when the data was READ.
+ // Re-stat'ing the path after landing would hide a rotation that happened
+ // during the pass into the cursor, defeating the next pass's check.
+ Dev, Ino uint64
+ Size int64
+ MTime int64
+}
+
+// statInfo carries the identity and size checks a cursor is validated against.
+type statInfo struct {
+ dev, ino uint64
+ size int64
+ mtime int64
+}
+
+func statSource(path string) (statInfo, error) {
+ fi, err := os.Stat(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return statInfo{}, ErrSourceGone
+ }
+ return statInfo{}, err
+ }
+ st, ok := fi.Sys().(*syscall.Stat_t)
+ if !ok {
+ return statInfo{size: fi.Size(), mtime: fi.ModTime().Unix()}, nil
+ }
+ return statInfo{
+ dev: uint64(st.Dev), ino: uint64(st.Ino),
+ size: fi.Size(), mtime: fi.ModTime().Unix(),
+ }, nil
+}
+
+// TailAppend reads new complete lines from an append-only source.
+//
+// It advances only to the last complete newline; an unterminated tail is an
+// in-flight write and is deliberately left for the next pass. Reading it would
+// land a record the writer has not finished producing.
+func TailAppend(path string, cur *storage.Cursor, maxBytes int64) (*Chunk, error) {
+ info, err := statSource(path)
+ if err != nil {
+ return nil, err
+ }
+
+ fresh := cur.Ino == 0 && cur.Offset == 0
+ if !fresh {
+ // A different inode under the same path means the file was replaced,
+ // not appended to.
+ if cur.Ino != 0 && info.ino != 0 && cur.Ino != info.ino {
+ return nil, &ConflictError{Path: path, Reason: "inode changed (file rotated or replaced)"}
+ }
+ if info.size < int64(cur.Offset) {
+ return nil, &ConflictError{Path: path, Reason: fmt.Sprintf("truncated: size %d < offset %d", info.size, cur.Offset)}
+ }
+ }
+
+ f, err := os.Open(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, ErrSourceGone
+ }
+ return nil, err
+ }
+ defer f.Close()
+
+ // Nothing new: return before verifying the tail digest. Verifying on every
+ // pass would re-read a 1 MiB window for every one of several thousand
+ // unchanged sources. A rewrite is only actionable when we are about to read
+ // past it, so the check belongs on the path that actually reads.
+ if info.size == int64(cur.Offset) {
+ return &Chunk{NewOffset: cur.Offset, NewOrd: cur.Ord, LastUUID: cur.LastUUID,
+ Dev: info.dev, Ino: info.ino, Size: info.size, MTime: info.mtime}, nil
+ }
+
+ if !fresh && cur.TailSHA256 != "" {
+ sum, err := tailDigest(f, int64(cur.Offset))
+ if err != nil {
+ return nil, err
+ }
+ if sum != cur.TailSHA256 {
+ return nil, &ConflictError{Path: path, Reason: "tail digest mismatch (source rewritten behind cursor)"}
+ }
+ }
+
+ buf, err := readWindow(f, int64(cur.Offset), info.size, maxBytes)
+ if err != nil {
+ return nil, err
+ }
+
+ // Consume only up to the last complete newline in the window.
+ end := bytes.LastIndexByte(buf, '\n')
+ if end < 0 && int64(len(buf)) >= maxBytes && int64(cur.Offset)+int64(len(buf)) < info.size {
+ // The window is full and there is more file beyond it, so this single
+ // line is larger than the byte budget. Reading only the budget would
+ // stall this source forever, silently, with no error - so grow past the
+ // budget to find the line's end. The budget bounds throughput, not the
+ // maximum record we are willing to store.
+ buf, err = readWindow(f, int64(cur.Offset), info.size, 0)
+ if err != nil {
+ return nil, err
+ }
+ end = bytes.LastIndexByte(buf, '\n')
+ }
+ if end < 0 {
+ // Still no complete line: an in-flight write. Nothing safe to land yet.
+ return &Chunk{NewOffset: cur.Offset, NewOrd: cur.Ord, LastUUID: cur.LastUUID,
+ Dev: info.dev, Ino: info.ino, Size: info.size, MTime: info.mtime}, nil
+ }
+ consumed := buf[:end+1]
+
+ ch := &Chunk{NewOrd: cur.Ord, LastUUID: cur.LastUUID,
+ Dev: info.dev, Ino: info.ino, Size: info.size, MTime: info.mtime}
+ off := cur.Offset
+ ord := cur.Ord
+ for _, raw := range bytes.SplitAfter(consumed, []byte("\n")) {
+ if len(raw) == 0 {
+ continue
+ }
+ ord++
+ body := bytes.TrimSuffix(raw, []byte("\n"))
+ ch.Lines = append(ch.Lines, Line{Ord: ord, Off: off, Bytes: body})
+ if u := extractUUID(body); u != "" {
+ ch.LastUUID = u
+ }
+ off += uint64(len(raw))
+ }
+ ch.NewOffset = off
+ ch.NewOrd = ord
+ ch.More = int64(off) < info.size
+ return ch, nil
+}
+
+// readWindow reads at most maxBytes starting at from.
+func readWindow(f *os.File, from, size, maxBytes int64) ([]byte, error) {
+ want := size - from
+ if maxBytes > 0 && want > maxBytes {
+ want = maxBytes
+ }
+ buf := make([]byte, want)
+ n, err := f.ReadAt(buf, from)
+ if err != nil && !errors.Is(err, io.EOF) {
+ return nil, err
+ }
+ return buf[:n], nil
+}
+
+// tailDigest hashes the bytes immediately before offset.
+//
+// A window rather than the whole consumed prefix: re-hashing [0,offset) would
+// mean re-reading tens of megabytes every pass. The window is sized above the
+// largest observed transcript line so it always spans a record boundary.
+func tailDigest(f *os.File, offset int64) (string, error) {
+ if offset == 0 {
+ return "", nil
+ }
+ start := offset - storage.TailWindow
+ if start < 0 {
+ start = 0
+ }
+ buf := make([]byte, offset-start)
+ if _, err := f.ReadAt(buf, start); err != nil && !errors.Is(err, io.EOF) {
+ return "", err
+ }
+ sum := sha256.Sum256(buf)
+ return hex.EncodeToString(sum[:]), nil
+}
+
+// TailDigestAt computes the tail digest for a file at a given offset.
+func TailDigestAt(path string, offset int64) (string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ return tailDigest(f, offset)
+}
+
+var uuidKey = []byte(`"uuid":"`)
+
+// extractUUID pulls a record's uuid without a full JSON parse.
+//
+// Many record types carry no uuid at all, so an empty result is normal. The
+// value is diagnostic only - position is authoritative, never this.
+func extractUUID(line []byte) string {
+ i := bytes.Index(line, uuidKey)
+ if i < 0 {
+ return ""
+ }
+ rest := line[i+len(uuidKey):]
+ j := bytes.IndexByte(rest, '"')
+ if j < 0 {
+ return ""
+ }
+ return string(rest[:j])
+}
diff --git a/internal/assemble/assemble.go b/internal/assemble/assemble.go
new file mode 100644
index 0000000..883e54a
--- /dev/null
+++ b/internal/assemble/assemble.go
@@ -0,0 +1,356 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package assemble turns a session's derived index into conversation
+// structure.
+//
+// It reads the index, not the payloads. Structure resolution needs identifiers,
+// not content: removing duplicate records needs record ids, grouping a provider
+// call needs message ids, joining a tool needs tool-use ids, joining a spawn
+// needs agent and run ids. None of it reads message text. Payloads are read
+// only when the result is rendered, and only for the records that appear in it.
+//
+// The whole session is assembled every time. That is what makes the output
+// reproducible: the same index always produces the same entities, so a round
+// can be re-derived rather than only trusted. Assembly resolves against a few
+// megabytes of index and takes milliseconds, so rebuilding costs little.
+package assemble
+
+import (
+ "encoding/json"
+ "fmt"
+ "sort"
+ "time"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/index"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/model"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessionflow"
+)
+
+// Options configures one assembly.
+type Options struct {
+ // Conversation is supplied, never inferred. An adapter reports it, or an
+ // application supplies it. Grouping by person, account, timestamp proximity
+ // or session id is not permitted, so this has no default here; the caller
+ // applies its adapter's documented mapping.
+ Conversation string
+ Session string
+
+ // IdleGap is how long a session must be quiet before a segment becomes a
+ // candidate for commit. A candidate is not a commit: about a third of long
+ // gaps fall in the middle of a turn, so the gates in stage 8 decide.
+ IdleGap time.Duration
+
+ // ThroughSeq bounds assembly to landed sequences at or below it.
+ //
+ // Without it, assembly reads whatever the index happens to hold and the
+ // caller lowers its declared watermark afterwards - so a round could contain
+ // nodes and references drawn from evidence its own header, and its own input
+ // digest, do not cover. Concurrent collection makes that likely rather than
+ // theoretical: the index grows while assembly walks it.
+ //
+ // Zero means no bound, which is only correct when nothing else is writing.
+ ThroughSeq uint64
+}
+
+// DefaultIdleGap is the quiet period that makes a segment a close candidate.
+//
+// Ten minutes is where the measurement puts the worst trade: about 39% of gaps
+// that long fall inside a Talk rather than between two, against 22% at twenty
+// minutes. It stays the default because the gates in stage 8 are what actually
+// decide, and a shorter gap proposes more candidates for them to judge. It is
+// carried in the round's policy version, so a chain built under one gap can
+// never be extended under another.
+const DefaultIdleGap = 10 * time.Minute
+
+// Stats records what assembly saw. Every count carries its denominator, because
+// "3 unresolved" means nothing and "3 of 8,045 tool uses" means something.
+type Stats struct {
+ Entries int
+ Duplicates int
+ // Beyond counts indexed records above the round's watermark, which this
+ // assembly deliberately did not read.
+ Beyond int
+
+ Streams int
+ Epochs int
+ Talks int
+ Runs int
+ Steps int
+
+ ProviderCalls int
+ CallFragments int
+ CallsWithoutEnd int // never received a terminal fragment: usage is unavailable
+ SyntheticMessages int
+
+ ToolUses int
+ ToolsResolved int
+ ToolsUnmatched int
+
+ Spawns int
+ SpawnsResolved int
+ ChildrenOrphan int
+ Notifications int
+ NotifyUnmatched int
+
+ SegmentsOpen int
+ SegmentsCandidate int
+}
+
+// Result is one assembly of a whole session.
+type Result struct {
+ Nodes []sessionflow.Node
+ Relations []sessionflow.Relation
+ Unresolved []sessionflow.Unresolved
+ Stats Stats
+
+ // ThroughSeq is the highest landed sequence the assembly covered. It is what
+ // the round header records, and what tells the next round where to resume.
+ ThroughSeq uint64
+}
+
+// runKey scopes an agent loop to the stream it was written in.
+type runKey struct {
+ stream uint32
+ run uint32
+}
+
+// builder holds the state the eight stages share.
+type builder struct {
+ ix *index.Index
+ opt Options
+
+ nodes map[string]*sessionflow.Node
+ rels map[string]*sessionflow.Relation
+ unres map[string]*sessionflow.Unresolved
+ stats Stats
+
+ // canonical is the session's entries in landed order with replayed copies
+ // removed. Every stage works from this, never from Entries directly.
+ canonical []int32
+
+ streams []*streamInfo
+ byStream map[uint32]*streamInfo
+ calls []*providerCall
+ tools []*toolUse
+ talks []*talk
+ segments []*segment
+
+ // spawnEdges are resolved in stage 5 and written after stage 7, because the
+ // nodes they connect do not exist until then.
+ spawnEdges []spawnEdge
+
+ // container maps a landed record, by (seq, row), to the node that holds it.
+ container map[[2]uint32]string
+
+ toolByID map[uint32]*toolUse
+ epochAt map[[2]uint32]string
+ talkByID map[string]*talk
+
+ // talkOfRun maps a run to the Talk that owns it. A Talk owns the run that
+ // started it and every run that followed from the work it delegated.
+ //
+ // The key is (stream, run) and not the run alone. A run id is NOT unique
+ // across a session: a child stream is written under an id that also appears
+ // in the parent, and one was observed in three different child streams.
+ // Keying on the id alone puts a child's records inside the parent's turn and
+ // leaves the child with no run of its own.
+ talkOfRun map[runKey]string
+ // runNode maps a run to its node id, keyed the same way.
+ runNode map[runKey]string
+ // runAt is each run's first landed position, which is what orders it.
+ runAt map[runKey]sessionflow.Ref
+}
+
+// Session assembles one session into conversation structure.
+func Session(ix *index.Index, opt Options) (*Result, error) {
+ if opt.Conversation == "" {
+ return nil, fmt.Errorf("assemble: conversation identity is supplied, never inferred")
+ }
+ if opt.IdleGap == 0 {
+ opt.IdleGap = DefaultIdleGap
+ }
+ b := &builder{
+ ix: ix, opt: opt,
+ nodes: map[string]*sessionflow.Node{},
+ rels: map[string]*sessionflow.Relation{},
+ unres: map[string]*sessionflow.Unresolved{},
+ byStream: map[uint32]*streamInfo{},
+ talkOfRun: map[runKey]string{},
+ runNode: map[runKey]string{},
+ runAt: map[runKey]sessionflow.Ref{},
+ }
+ ix.Build()
+
+ // The order is forced: each stage depends on the one before it.
+ b.stage1Canonical() // duplicates removed
+ b.stage2Streams() // lineages partitioned
+ b.stage3Calls() // provider calls grouped
+ b.stage4Tools() // tool uses joined to their results
+ b.stage5Spawns() // children joined to the calls that started them
+ b.stage6Epochs() // context resets cut
+ b.stage7Talks() // talks and runs built, steps placed
+ b.stage8Segments() // commit windows proposed
+ b.emitSteps() // leaves written, now that their containers are known
+
+ return b.result(), nil
+}
+
+// result flattens the builder into sorted, reproducible output.
+//
+// Sorting by id is what makes two assemblies of the same index produce the same
+// bytes. Map iteration order in Go is deliberately random, so emitting in map
+// order would make every round differ from the last for no reason.
+func (b *builder) result() *Result {
+ r := &Result{Stats: b.stats}
+ for _, n := range b.nodes {
+ r.Nodes = append(r.Nodes, *n)
+ }
+ sort.Slice(r.Nodes, func(i, j int) bool { return r.Nodes[i].ID < r.Nodes[j].ID })
+ for _, v := range b.rels {
+ r.Relations = append(r.Relations, *v)
+ }
... 15327 lines suppressed ...