- name
- mcode-trajectory-studio
- description
- Inspect and visualize MiniMax Code session trajectories from the local runtime's read-only SQLite projection. Use when the user asks what happened in a session, why a task was slow or expensive, how many turns or tool calls a run took, which tools failed, how much wall-clock time went to the model versus tools, where context was spent, when compaction ran, which sub-agents were dispatched, or wants an interactive timeline of a session. Trigger phrases include "轨迹", "trajectory", "这个会话花了多久", "为什么这么慢", "调用了哪些工具", "session 时长/轮次", "看看某个 session".
- license
- Apache-2.0
- metadata
- {"author":"weekbin","version":"0.1.3"}
# MiniMax Code Trajectory Studio
Read-only trajectory inspection for local MiniMax Code sessions. The Plugin reads the
runtime's own SQLite projection and never writes to session storage.
## Pick the detail level first
| Level | Returns | When |
|---|---|---|
| `summary` (default) | roles, turn IDs, timings, token usage, tool **names** and statuses | Any question about duration, cost, turns, failures, structure |
| `full` | additionally message text, thinking, tool **arguments**, tool **results** | Only after the user explicitly asks to see content |
Never widen to `full` on your own. The user's session content is theirs; ask before returning it.
## Tools
1. `trajectory_list` — find a session. Returns metadata only, newest first. Filter by `agent`,
`kind`, `parentSessionId`, or `sinceMs`.
**`parentSessionId` reaches the sub-agent sessions a session dispatched**, and `"root"` gives
the top-level ones. Every row carries `parentSessionId`, so you will often see a parent that has
children and then want to look inside them — filter rather than paging the whole machine and
picking them out, which on a busy machine is several calls over hundreds of rows to find what
one call returns. It composes with `agent` and `kind`, so "the explore sub-agents of this
session" is one call. An id that matches nothing returns an empty page; a blank or omitted one
is no filter at all. `agent` is an **exact, case-sensitive** match against the agent names this
machine actually used (`Explore` does not match a session stored as `explore`) — read
the `agents` list on `trajectory_list`'s reply (`{name, count}` entries) rather than guessing.
It is **not narrowed by the page's filters**, so it is still complete on a reply whose
`sessions` list is empty — which is what tells you a filter value was wrong rather
than that the machine is quiet. Each `count` is how many sessions match that name under
this reply's own `includeArchived`, so it is the `total` you get after filtering on it,
not a machine-wide tally. Archived sessions are hidden unless you
pass `includeArchived: true`.
`sinceMs` is a lower bound; pair it with **`untilMs`** for a range, because "the session from
March" is a range and a lower bound alone answers it with everything since March. Both take
**epoch milliseconds**, and the exact shape is on every row: `updatedAtMs` is the same clock, so
read the format off any reply rather than guessing it. To turn a calendar day into the pair,
compute local midnight of that day and of the next one and pass them as `sinceMs`/`untilMs` —
`untilMs` is inclusive, so the day's own sessions are inside the range rather than excluded by
an off-by-one at midnight.
**`total` is how many sessions the filter matches, and `nextCursor` is how to reach the rest.**
A single page is a slice, not the machine: a busy one holds hundreds of sessions and the page holds
20. When looking for an older session — "last week's", "the one where I touched the parser" — pass
`nextCursor` back as `cursor` until it is `null` rather than concluding from page one that no such
session exists. `sinceMs` cannot help here: it only moves the window forward.
**Do not page these tools by position.** There is no `offset` argument, and the reason is not
tidiness: the list is newest-first by the same column the runtime rewrites when it writes to a
session, so a walk by row number silently drops any session that moves while you are walking and
still reports that it finished. Hand back the cursor verbatim. Every page of one walk describes the
same instant, `walkPinnedAtMs`, so `total` and the rows agree; a session written to after page one is
newer than that instant, leaves both the rows and the count, and is found by starting the walk again.
**A cursor belongs to the walk that produced it** — the same query and the same filters. Handing
one back after changing `agent`, `kind`, `parentSessionId`, `sinceMs`, `untilMs` or
`includeArchived` returns page one of the *new* question with a `note` saying so, rather than a
page that looks like a continuation and is not. Changing `limit` is fine: it decides how much is
carried, not which rows match.
2. `trajectory_summary` — the dsh `sessionStats` equivalent for one session: `turns`, `steps`,
`llmMs`, `toolMs`, `decodeMs`, `thinkingMs`, `inputTokens`/`cacheReadTokens`/`totalTokens`,
`toolCalls`/`toolFailures`, `compactions`, `subagentTasks`, `assets`, and a `sources` breakdown.
Omit `sessionId` for the most recently updated session. A non-zero `unreadableRows` means these
totals describe fewer records than `trajectory_get` lists — say so if the two disagree.
Check `warnings` before reading `subagentTasks`, `backgroundTasks` or `assets`: those are
counts, so a total that could not be computed still arrives as `0`, and the warning is what
separates "none ran" from "not counted". `toolMs` is `null` only when it is unavailable.
**`errorMessage` is present only when the run stopped badly**, and it is the reason — the
status word alone is not one. A session that failed on its first turn has one message row
and `turns: 1`, so its numbers all read as "it started and did nothing" while the whole
story is in this field: `ENOSPC: no space left on device, write`,
`Mandatory V2 Agent prompt is missing: …`, `BYOK provider … upstream error: …`,
`Local runtime restarted before this turn completed.` **When someone asks why a session
failed, read this before reading anything else** — it is the same text `trajectory_list`
and `trajectory_search` publish on the row, so you never have to page back to the list to
find out.
**`objective` is what the run was actually asked to do, and it is not the title restated.**
The runtime records it in its own table and it was unreachable from here. On a measured
machine the two share almost no vocabulary in **13 of 15** goal-bearing sessions — one is
titled 同步本地与远程分支代码 and was asked to 继续深挖当前插件的方方面面可能存在的问题; another is
titled 查询项目前后端架构 and was asked to 整理并落地一套更好的动画效果集成方案. A reader who infers the
task from the title is wrong two times out of three, with nothing on any reply to say so.
**`turnsFailedOrAborted` is how a session that looks clean admits it is not.** A turn that
fails at intake writes **no message row at all**, so neither `turns` nor `errorMessage` nor
`warnings` moves when one dies: on a measured machine 7 sessions carry 14 such turns and
every one of them reads as a finished run with no error. `trajectory_ingress` is where to
look; `ingressAvailable: false` means this runtime records no intake, and the two counts
are then `null` rather than `0`.
**Absent is the common case** (618 of 633 sessions record no goal) and is never the title:
`goalAvailable: false` means this runtime has no such table, which is a different answer
again. `goalTokensUsed` / `goalTimeUsedSeconds` are what the goal itself cost.
**`assets` and `changedTurns` are different facts and neither stands in for the other.**
`assets` counts files the session *delivered or attached*; `changedTurns` counts its turns that
*recorded edits*. Measured on a machine where 351 sessions carry change records, **329 of them
delivered zero assets** — so `assets: 0` is the usual answer for a session that rewrote a
repository, and where both exist the two counts differ 19 times out of 22, in both directions.
**Never read `assets: 0` as "the session did nothing."** Check `changedTurns`; when the question
is about files, pick the tool by which of the two is meant. `changesAvailable: false` means the
runtime records no change sets at all, so `changedTurns: 0` there means "not recorded", and the
warning says so as well. **`unmatchedTurns` is the third number and it is not a `warning`:**
how many of those change sets name a turn no message row carries. Both numbers are correct and
they do not have to reconcile with `trajectory_turns` — see entry 5. Subtract it before paging
by `turnId`.
3. `trajectory_turns` — **the progress view.** One row per turn: `turnId`, `count` (records in
that turn), `llmMs`, `failureCount` (tool calls in that turn that failed), `firstMs` (when the
turn started) and output tokens.
**`firstMs` is what makes this a timeline.** The rows are in chronological order, so before it
existed you could see that turn 40 came after turn 39 and could not say when either happened —
dating one turn meant paging `trajectory_get` until a record carried a timestamp, which on a
large session is paging the whole session. Use it to answer "what happened last", "what did I do
on Tuesday", "which turn was after the deploy". `null` means the source records no time at all
(an artifact with no timestamps), which is different from `0`.
**`failureCount` is how you find what went wrong.** `trajectory_summary`'s `toolFailures` is a
session-wide number with no way to drill into: without the per-turn count, "which turn broke"
meant paging the whole session and inspecting `toolCalls[].ok` on every record — twenty-two pages
and 1.4 seconds on a 21992-record session. Page the turns, keep the ones where it is above zero,
then read those with `trajectory_get`'s `turnId`. It reconciles exactly: summed over every turn it
equals `trajectory_summary`'s `toolFailures`, on the same terms as `count`/`llmMs` below. A turn
that made no tool calls reads `0`; `null` means the source does not record failures at all (the
artifact path), which is a different answer from "nothing failed". Reach for this instead of paging `trajectory_get` to work
out how a run broke down — that costs one call per thousand records where this costs one call in
total. `total` is how many turns the session has, so a one-turn page is not mistaken for a
one-turn run. Summed over every turn, `count`/`llmMs`/`outputTokens` equal the session's
`events`/`llmMs`/`decodeTokens` from `trajectory_summary`; both `outputTokens` and its alias
`decodeTokens` are on each row so the reconciliation does not trip over a name.
**Check `source`.** A session the projection has not indexed yet is folded from its own
`messages.jsonl`, and there `llmMs`/`outputTokens` are `null` — the artifact recorded no
duration and no usage, which is not the same as a turn that cost nothing. That path also
has no `trajectory_summary` to reconcile against, since that tool answers
`session_not_indexed` for such a session.
4. `trajectory_assets` — **which files, not how many.** `trajectory_summary` reports `assets: N`;
this is the list behind that count — `path`, `name`, `role`, `sourceTag`, `msgId`,
`artifactId` — one 2 ms query rather than a thousand event records scanned for paths that are
already columns. Most sessions produced none, so `total: 0` with `available: true` is an ordinary
answer. **Check `available` before reading an empty list**: `available: false` with a `note` means
this runtime exposes no assets table, or the query failed, so the empty list means "not listed",
not "none exist". `assetType` is null for most real rows — do not read a type into a null, and do
not infer one from the extension. `path` is folded to `~`.
5. `trajectory_changes` — **what the run actually changed on disk, turn by turn.** The runtime
records a change set per turn: every file added, modified or deleted, the line counts, and in
`detailLevel: "full"` the unified diff. This is what answers "what did that session do to my
code" — the transcript only holds what the agent *said* it did, which is the shape it intended
rather than the shape that landed. Recorded for sessions that edited a repo and absent for the
rest, so `total: 0` with `available: true` is ordinary; **`available: false` with a `note` means
this runtime records no change sets, so the empty list means "not recorded"**. Files inside the
workspace are stored relative to it and come back absolute; `path` narrows to a substring of the
resolved path, and `pathMatchedNothing` says a filter found nothing rather than leaving you to
guess. **Read `scratchFiles`** on the turn, or `matchedScratchFiles` for the session: more than half
the entries in a real busy turn are temp-dir
apply scripts, and a reader who is not told will read an 82-file turn as 82 repository edits.
`status` and `revertedAtMs` are carried through as recorded and carry no signal on any runtime
measured so far. `diff` is capped per file with `diffTruncated` saying when it was cut.
**`changedTurns` counts change sets, not turns of the transcript, and they do not have to
agree.** The runtime writes the two tables independently, so a change set can name a turn no
message row carries — 307 of 1,417 on a measured machine, 11.8% of every file entry — and on
7 of 351 sessions `changedTurns` is **larger** than the number of turns `trajectory_turns`
lists (worst case 165 against 2). **`unmatchedTurns` on either reply is how many of those
name a turn the transcript lacks**, and `turnInTranscript` on each row says the same per turn.
They are real edits and stay in the list; use `changedTurns - unmatchedTurns` as the number
of change sets you can page `trajectory_get` by `turnId` for. A `trajectory_get` by one of
the unmatched ids returns no events and says why — `sessionTotal` carries the session's own
record count, so an empty `total` there means "that turn is not in the transcript", not "the
session is empty".
6. `trajectory_ingress` — **the turns the runtime took in, including the ones the transcript
never saw.** One row per accepted turn: when it was accepted, in what order, what claimed
it (`claimSource`), how long it sat in the queue (`queuedMs`), and how it ended (`status`).
**Reach for this whenever a session reads as clean but should not.** On a measured
machine 14 turns that **failed or aborted** appear in no message row at all, across 7
sessions that every other tool reports with no error, no `errorMessage` and
`warnings: []` — the transcript genuinely does not contain them, so nothing else can
surface them. `failedOrAborted` is the count that ended badly;
`missingFromTranscript` is the count the projection does not carry, and the two answer
different questions — 13 turns on that machine *completed* and are equally invisible, so
`inTranscript: false` alone does not mean "it died".
**There is no reason field, because the runtime records none**: the table has sixteen
columns and none says why a turn failed. This tool says that a turn died and when, never
why, and a `reason` here would be a guess wearing a field name.
`claimSource` is the only record of *who asked*: 668 of 2,466 turns on that machine came
from a thread goal, and 1,603 carry no claim at all — reported as `null`, never attributed
to the user. Most sessions record none, so `total: 0` with `available: true` is ordinary;
`available: false` means this runtime cannot tell and `failedOrAborted` is `null`, not `0`.
`status` narrows to one recorded status while `total` stays the session's own count.
7. `trajectory_find` — find records in **one session** whose text contains a query. Scoped to a
session because that is where it is cheap: the same search across the whole machine measures
seconds, while inside one session it is tens of milliseconds. The query is **literal** — `%` and
`_` are matched as characters, not wildcards. Every hit carries the record's `index` in the same
numbering `trajectory_get` pages in, so **read the hit with `offset: hit.index`** rather than
paging to find it; `field` says whether the text was the message (`content`), the thinking, or
neither (`other`), and on `other` the excerpt is taken from the tool call too, with
`matchedToolCall` naming which half held the needle — `args` is what the model wrote, `result`
is what came back. A shell result usually echoes the command line, so both halves often hold the
same term; the argument wins there, because it is the half that says what was being done. **The parameter is `detailLevel`; `full` is what carries message text** — there is no other
way to ask for it, and the default `summary` returns records with `contentLength` and no `content`
at all. Any other value (`'nope'`, `'FULL'`, `detail`, `full: true`) is ignored and the reply comes
back at `summary` with a `note` saying so; a guess that is not caught by the note looks exactly
like a session that said nothing. `snippet` comes only in `full` detail, like every other message body,
and it is quoted **around the match** on both sources — not from the start of the record, so on
a long message the word you searched for is in it. `unindexedCaseMatch: true` means the session's rows were also folded in
JavaScript, because the needle holds a non-ASCII letter with more than one case form (`ş`/`Ş`,
`é`/`É`) and the indexed `LIKE` folds ASCII only. It costs far more than an indexed lookup on a
big session, and it is not an error — it is the reason a match exists that the index could not
see. It is absent otherwise, so its presence is the report. A needle with no case variants
(`轨迹`, plain ASCII) never pays for the scan.
A session the indexer has not reached yet is searched in its `messages.jsonl` artifact, and the
reply's `source` says which store answered. `field`, `snippet` and `matchedToolCall` mean the
same thing there as they do on the projection path, so a hit is not called `content` merely
for coming off disk — the excerpt and the field describe the same text on either path. On that
path `total` is `null` by design — the
fold stops at the page boundary rather than paying for a whole-file count — so **`nextOffset`,
not `total`, is how you know whether more matches exist**. Page until it is `null` there too.
8. `trajectory_get` — page through records in insert order. Use `offset`/`limit`; narrow with
`turnId` **or `role`**, and pass `detailLevel: 'full'` for message text. Nothing else returns `content` or
`thinking`: `summary` gives `contentLength` and omits both, so a reply you did not ask `full` for
is not evidence the session contained no words. A value outside `summary`/`full` is ignored and the
reply carries a `note` naming the level actually used. Read `nextOffset` and keep paging when you need the whole session. **A long session
holds tens of thousands of records, so to read its *end* — "what happened at the end", "how did
it finish" — do not page there: `trajectory_summary`'s `events` equals this tool's `total`, so the
last page is one call at `offset: summary.events - limit`. That identity only holds unfiltered;
under a `turnId` filter `total` is what matched, not the session.**
**`role` is the filter to reach for before `full`.** Measured on the machine this was written
against: `assistant` holds 112,290 of 116,304 rows and `user` holds **2,388** — 2.1%. One session
carried 5,272 assistant records and **9** user ones. Asking "what did this conversation actually
ask for, and how far did it get" without the filter meant walking all 5,343 and discarding:
**27 calls and 24.6 MiB** for nine messages, at `full` detail, which is the level you are told
not to widen to on your own. The same call with `role: 'user'` is **one call, 5.5 KiB, 5 ms.**
`role: null` asks for the records carrying **no** role — a real group of 1,626 on that machine —
and is *not* the same as omitting the argument. `total` is always what matched the filter, so a
walk that reaches `nextOffset: null` has seen every record of that role and no others. Each record carries
`inputKind` — `human`, `injected`, or `unknown`, and `null` on every non-user role — so you can
tell a person's own words from context the harness injected. `unknown` means the runtime did not
classify it; do not fold it into `injected`. Each record also carries `failureCount` and a
`toolCalls` array where each entry has `name`, `ok`, `status`, the measured `durationMs` (null
when the runtime recorded no task for that call), `taskId`, `agentName`, `childSessionId`, and —
in `full` detail — `args` and `result`.
`hasOutput` says whether the runtime recorded an output file for that call, so
`trajectory_task_output` has something to read; it is about the *recording*, not about
whether the call succeeded, and `taskId` is `null` on any call the runtime did not run
as a background task — which is most calls in some sessions and none in others.
9. `trajectory_search` — full-text search over session ids, titles, agent names, statuses, session
kinds, workspace paths, and **the per-session `purpose`**. A query is matched as a phrase,
case-insensitively, inside one field. So `Spec review`
matches that title and not a session whose title merely holds both words apart; `Documents`
matches a `workspaceDir` containing it even though the reader typed a capital.
**`purpose` is why this can be a content search at all.** The runtime writes the objective it
dispatched a sub-agent with, and the index carries it — so a word from that objective matches a
session whose *title* never mentions it. On a 633-session machine, 439 rows had one and 194 had
none: the ones that do not are top-level sessions, and they are matched on their metadata alone.
So a query drawn from a sub-agent's brief will find it, and the same query will not find a
top-level session that merely discussed the same thing. That is the limit of the index, not a
failed search, and the distinction is worth stating when a content query returns nothing.
**A run's own `objective` is searched too, on a channel that is not the index.** The runtime's
FTS table has no objective column at all, so this is a separate literal scan of the goal table,
reported honestly as such: `objectivesIndexed` says whether it ran, rows it found carry
`objectiveMatched: true` and the matching `objective` text, and `objectiveMatchedSessionIds`
lists them. On a measured machine **every goal match was invisible to FTS** — five ordinary
Chinese queries found 13 goal-matching sessions between them and the index found **none** of
them, 0 of 15 goal-bearing sessions being reachable by a word from their own objective. So a
query drawn from what a run was *asked* finds it even when its title says nothing about it.
Objective rows ride on the page but never in `total` or the cursor: the walk is an index walk,
and a cursor whose meaning depended on which source matched would be unusable. It also works
with **no FTS5 at all**, which is when a machine can least afford to lose the field.
**These queries are not selective.** The index covers exactly the columns people reach for, so
an agent name or a common word can still match most of a machine — on 614 sessions `idle`
matched 587 and `mavis` 187. `total` is the match count and `nextCursor` is the cursor for the
rest, so **pass it back as `cursor` until it is null before saying how many sessions match**. A
first page of 200 is a page, not an answer, and without `total` it was indistinguishable from one.
Page this tool by cursor, never by row position — see the same warning on `trajectory_list`.
**Check `ftsAvailable` and `note` before concluding anything from an empty result.** There are
four reasons for `sessions: []`, and only one of them is "nothing matches":
- FTS5 missing from the bundled SQLite (22.13.0–22.18.x, and every 23.x) — `ftsAvailable: false`
with a `note`. That is "search is off", not "nothing matches".
- the query failed — `ftsAvailable: true`, a `note` saying the search failed, and the result
means "not searched". Report the failure; do not present it as an absence of matches.
- this build's index does not carry the fields a match needs — a `note` saying so. Nothing
was searched, and **retrying will not help**, because the cause is the schema rather than
this call. Say the runtime cannot search, do not report a failure and do not retry.
- the query ran and matched nothing — no `note`, and only then is an empty list a finding.
10. `trajectory_tasks` — background tasks and sub-agent dispatches owned by a session, with status,
Ver no GitHub