Skip to content

Project T3's activity stream onto mindwalk's Trace model #5

Closed

Description

@Dillpickleschmidt

Part of #1

Question

Define the projection from a T3 thread's activities to mindwalk's model.Trace — the ordered stream of file-touch events its scenes and playback deck consume.

This is the core of the port. Everything downstream renders whatever this produces.

Read first: .repos/mindwalk/internal/model for the Trace / Event / Target / Mark shapes, and internal/adapter/claudecode/adapter.go + internal/adapter/codex/adapter.go for how they populate them from JSONL.

T3 side: OrchestrationThreadActivity (packages/contracts/src/orchestration.ts:315) gives id, tone, kind, payload, turnId, sequence, createdAt. Ordering is turnId + sequence, continuous across resumes and compactions — one thread is one trace, no stitching.

Known mappings:

  • context_compaction is already a canonical item type → mindwalk's ◇ compaction mark maps 1:1.
  • collab_agent_tool_call → mindwalk's ○ subagent-launch mark.

Settled by research — this runs server-side

The "server-side or client-side" question is closed. projectActivityPayload (ActivityPayloadProjection.ts:167-201) replaces data with a six-key whitelist — item, command, files, toolCallId, kind, rawOutput — before anything reaches a client. toolName, input, rawInput, locations, and state are all dropped at the wire. A client-side classifier would require widening a whitelist that exists deliberately for wire-size reasons.

So this projection reads the event store directly, server-side, before the whitelist, alongside the citymap service. No wire changes needed. See Classify read and search tool calls per provider adapter for the full analysis and the per-adapter payload shapes.

Reuse, do not reinvent: packages/shared/src/toolActivity.ts already has classifyToolAction returning command | read | file_change | search | other plus a path walker. Widen that into classifyFileTouch rather than adding a sixth path-walker.

Per-provider notes from the research:

  • Claude — { toolName, input, result }; classify by tool name. Input key casing unverified (Capture live tool-call payloads for Cursor/Grok and OpenCode #11).
  • Codex — no read/grep tool; everything is command_execution. But commandActions[]: { type: "read" | "listFiles" | "search", path, query } is already in every payload and unused. Best source of the five.
  • Cursor / Grok (ACP) — use data.kind. Note ACP maps kind: "search" to web_search (AcpCoreRuntimeEvents.ts:55-57), colliding codebase greps with web fetches; needs disambiguating.
  • OpenCode — { tool, state }, shape unverified (Capture live tool-call payloads for Cursor/Grok and OpenCode #11).

Settled by Close the agent-lenses data gap — the main trace excludes subagent work

Once Persist the subagent attribution edge lands, this projection drops any item carrying a parentToolCallId. Mindwalk's Main lens is the root session file only and never merges subagent work; T3 today inlines it, unattributed.

This is a real behavior change, not cosmetic: fewer lit buildings, fewer edits, different regressionRate and churnFiles, and the trace goes quiet during a heavy Task. That is what mindwalk shows. Subagent work is reachable through its lens instead — see Project the agent graph and per-agent traces, which reuses this same projection restricted to one launch's subtree.

Threads recorded before the edge ships, and providers with no attribution, keep their subagent work inlined. That inconsistency is accepted and invisible in practice: such a thread has no lens to look inconsistent against.

Still to decide

  • On-demand or persisted. The trace is derived; caching it has the same invalidation questions as the citymap.
  • Tool calls that touch no file. Mindwalk keeps them as cool events on the histogram, so dropping them changes the timeline's shape and breaks the faithful-port rule. Confirm they survive.
  • "Weak" targets. Mindwalk's builder skips target.Weak when adding ghost files (internal/citymap/builder.go:47). Work out what makes a target weak and what the T3 equivalent is.

Provider scope — narrowed

Capture live tool-call payloads was closed as out of scope: the dev does not use Cursor, Grok, or OpenCode (real history is 18 claudeAgent threads, 2 codex, zero of the rest). This ticket is no longer blocked on it.

Implement and verify against Claude and Codex, whose shapes are both confirmed without needing a live capture:

  • Claude — classify by data.toolName; path at data.input.file_path (snake_case, confirmed against real session logs).
  • Codex — no read/grep tool; everything is command_execution. Read commandActions[]: { type: "read" | "listFiles" | "search", path, query }, already persisted and currently unused. Best source of the five.

Keep the seam provider-agnostic. ACP (Cursor/Grok) and OpenCode stay implemented-but-unverified: use data.kind for ACP and { tool, state } for OpenCode as documented in docs/internals/research/provider-tool-call-classification.md, and fall back to "touched, kind unknown" rather than throwing or silently dropping when a shape does not match. Adding a provider later should be adding a mapping, not reworking the classifier.

Note ACP maps kind: "search" to web_search, colliding codebase greps with web fetches — handle it in the mapping even though it is unverified, since the research established it from source.

Do not gate correctness on the unverified providers: no test should assert ACP or OpenCode behaviour that has not been observed.

Activity

  1. Dillpickleschmidt commented on Aug 1, 2026

    @Dillpickleschmidt
    OwnerAuthor

    Answer

    The projection runs server-side over projection_thread_activities.payload_json (full, unstripped), and ships as one endpoint GET /api/mindwalk/threads/:threadId returning { trace, citymap } — mirroring mindwalk's /snapshot, which is what its own UI fetches (App.tsx:183).

    One endpoint, because the two artifacts are mutually dependent and the ticket didn't note it: the citymap needs the trace's target paths for ghost buildings, and the trace needs the citymap for Target.fileId and Stats.FilesInRepo. Mindwalk resolves this with a strict order in a single request (server.go:834-841): build the trace with paths only → build the citymap with the trace → backfill fileIds → recompute stats with the real file count. We copy that. The 752 KB gzipped citymap also wants sending once, not twice.

    Copy mindwalk's classifier verbatim; leave toolActivity.ts alone

    The ticket says in bold "reuse, do not reinvent — widen classifyToolAction rather than adding a sixth path-walker." Both halves of that premise are wrong, and the measured data settles it decisively.

    The research it rests on states toolActivity.ts is "already shared between web and mobile." It isn't. classifyToolAction is not exported; it's a private helper whose only consumer in the repo is AcpRuntimeModel.ts:362, server-side, generating a readable title string for Cursor/Grok activities at ingest. Zero client consumers. Reusing it would mean growing a single provider's ingest-path helper into a cross-provider measurement engine — a dependency pointing the wrong way, since it's handed one live in-flight tool call, not a stored activity.

    The tool census across all 21 threads decides it outright:

    tool calls
    Bash 2,400
    Edit 487
    Read 367
    Write 191
    Agent 8
    Grep / Glob 0
    • Bash is 66% of every tool call, and this setup emits no Grep/Glob at all. So the ~250 lines of path scraping are load-bearing, not optional: without them search would be ~0, the hit/"seen" touch level would never light, and the histogram would be one giant exec bar.
    • T3 files a Read under itemType dynamic_tool_call. T3's own classifier would have called it other and missed every read in the repo. Branching on data.toolName is what saves it.

    So: port actionFor, targetsFor, the scraping helpers (extractPaths, commandReadPaths, parsePathHits, normalizePath, repoPathExists), the verify/search/read command matchers, and ComputeStats verbatim. Two classifiers in the repo is correct — different questions, different consumers, different stages, neither constraining the other. One source of truth (payload_json), two readers.

    Claude's payload already is mindwalk's shape

    Verified against real rows: { toolName, input, result: { content, is_error } }. So the Claude adapter is close to a one-liner, and file_path is snake_case — exactly what targetsFor expects. The ticket's "input key casing unverified (#11)" is now verified, no live capture needed. is_error is structural, so Observability.Errors is "exact" for Claude. Agent (8 calls) is already covered by mindwalk's "Task" || "Agent" subagent check.

    Codex's commandActions[] confirmed present and typed: { command, name, path, type: "read" | "unknown" } — with type: "unknown" falling through to the scraping path, exactly as needed.

    Everything T3-specific lives in a thin per-provider adapter reshaping an activity into { tool, input, result } — the same boundary mindwalk already has (internal/adapter/ over a shared adapter.go). Adding OpenCode later is one more adapter.

    Which activities become events

    One event per tool call, folded at tool.completed. Measured on the largest thread (8,011 activities): tool.completed is 1,283 rows / 15.4 MB, while tool.updated is 2,564 rows / 16.6 MB — so a SQL filter skips over half the bytes before parsing.

    • tool.denied → an event with isError: true, no targets. Matches what mindwalk renders for a rejected tool in Claude's log, and denials are genuinely interesting on the timeline.
    • Unfinished calls (interrupted turns) → dropped. No result, no error flag, nothing touched.
    • Tool calls touching no file → kept, as the ticket requires. They're the histogram bars and the errorRate denominator.
    • All of T3's own bookkeeping — approval.*, runtime.*, checkpoint.captured, turn.plan.updated, task.*, context-window.updated → dropped. Mindwalk's Event means "the agent used a tool"; folding these in would inflate the histogram against a denominator mindwalk never had. approval.requested and runtime.error are the tempting two — recorded as fog, as future mark types rather than events.

    Marks — one correction

    • Subagent ← collab_agent_tool_call. Clean 1:1; both a mark and an event, as mindwalk does for Task.
    • Compaction — the ticket says this comes from the context_compaction canonical item type. It doesn't. Non-tool item types are dropped before ever becoming activities (ProviderRuntimeIngestion.ts:640). It actually arrives as a thread.state.changed event with state: "compacted", stored under activity kind context-compaction (:574-593). Still 1:1, different source.
    • User messages → merged by createdAt, tiebreaking a message before an activity at the same instant. Turn-anchoring was the first instinct and is unavailable: user messages are always stored turnId: null (decider.ts:795) — only assistant messages get one — and the messages table has no sequence column, while activities' sequence is the provider's session sequence. createdAt is the only shared key, and it's safe here: adapters stamp activities with the T3 server's own clock (nowIso) at observation, and messages with command.createdAt from the same process. One clock, no skew.
    • Mindwalk's injected user-message filter has nothing to do — T3's message list is real chat only. Dropped.

    Weak targets

    weak means "guessed out of prose" — the safety valve on the scraping. A weak target is discarded if the file doesn't exist, never creates a ghost, drags Observability.Reads to "estimated", and is skipped when the trail picks a file. Copying the scraping carries all of that over: scraped from text → weak; typed field → not weak.

    One deviation: Codex's commandActions is not weak. Mindwalk marks every Codex target weak because shell strings are all it has; T3 has a typed discriminator with an absolute path from Codex's own sandbox parser. Marking that weak would make every Codex thread report "estimated" read confidence — precisely the lie the field exists to prevent, since it's meant to separate a real signal from a blind spot. Same rule, better data. Cost: Codex traces now produce ghosts for read-but-absent files, which mindwalk never shows.

    Since Bash is 66% of calls and all Bash targets are weak, Observability.Reads will read "estimated" on essentially every Claude thread. That is honest and correct.

    On-demand or persisted → in-memory cache keyed on the thread's activity count

    The ticket frames this as "the same invalidation questions as the citymap." It isn't, and that's the answer. The citymap's key is fuzzy — commit + dirty can't see an edit to an already-dirty file, which is why it needs a 30s TTL and why that hole is live fog on the map. The trace's input is an append-only log for one thread: rows are never updated or deleted, so the row count is a perfect fingerprint. Same count, same trace, guaranteed.

    Exact invalidation, an index-covered COUNT(*), no TTL, no migration, no second source of truth. Live-watch is coming and will poll this endpoint; a count key makes a no-change poll nearly free instead of re-parsing 15 MB.

    Root and degradation

    Root is worktreePath ?? workspaceRoot — identical to the citymap, or nothing lights up.

    On citymap build failure, return an empty citymap and the trace anyway, not a 500. This is mindwalk's own behaviour (server.go:835) and it isn't hypothetical: six threads have /home/dylank as workspace root, which is not a git repo, so the citymap there falls through to walking the entire home directory — the case most likely to fail or take forever. You still get the timeline, stats, marks and playback deck; you just lose the buildings.

    Mindwalk already degrades correctly if the root vanishes, and copying inherits it: normalizePath is pure string math and never touches disk; repoPathExists fails closed so weak targets simply drop; the citymap falls back to empty. Three independent degradations, free.

    Investigated and dismissed: whether a thread can be left pointing at a deleted worktree. It can't, through T3. worktreeCleanup.ts + useThreadActions.ts:354-362 couple removal to thread deletion — getOrphanedWorktreePathForThread only returns a path when no other thread shares it, and removal runs after the thread is deleted. The invariant is "a worktree goes only when its last thread goes." Deliberate, correct, not a T3 bug. A user can still remove one by hand from T3's terminal, but the degradation above covers that for free.

    Lens parameter from day one

    The projection takes a lens: main (drop items carrying parentToolCallId) / agent(launchId) (keep only that launch's subtree). Both are no-ops today — Persist the subagent attribution edge hasn't shipped, so no item carries the field, making main everything and agent empty. Both correct.

    When that lands, both light up with zero restructuring, and Project the agent graph and per-agent traces becomes "call the same function with a different lens" rather than a refactor — the same bet the map already made for 3D Diff.

    "Weak" targets, resolved (the ticket's third open item)

    Answered above: weak is mindwalk's heuristic-confidence flag, it comes free with the verbatim copy, and commandActions is the one T3-specific exception.

  2. Dillpickleschmidt commented on Aug 1, 2026

    @Dillpickleschmidt
    OwnerAuthor

    Resolved — see the resolution comment above. Build lands in Land the trace projection and snapshot endpoint.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions