diff --git a/README.md b/README.md index a786dee..1f7c5cb 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Self-drive Route Planner](plugins/hanzijie/self-drive-route-planner/) | **Official plugin** for planning driving routes with place search, route alternatives, demo mode, and Xiaohongshu 3:4 itinerary cards | [HanZijie](https://github.com/HanZijie) | | [Git Commit Tree](plugins/microbiosis/git-tree/) | Inspect a local Git repo's commit history with a swim-lane graph, branches/tags, commit detail, and per-file change stats; persisted filter preferences and optional auto-refresh | [Microbiosis](https://github.com/Microbiosis) | | [灵签易占 / Chinese Divination](plugins/weekbin/chinese-divination/) | Cast hexagrams with the Plum Blossom method (time, number, three-coin, or daily), read the result through the classical body/use rules, and browse all sixty-four hexagrams with a ganzhi almanac | [weekbin](https://github.com/weekbin) | +| [Session Trajectory](plugins/avatasia/mmc-trajectory/) | Browse a conversation's model trajectory: messages, reasoning, tool calls and results, Token usage, and per-turn timing | [avatasia](https://github.com/avatasia) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index bc725e1..a78adcc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -66,6 +66,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [自驾规划](plugins/hanzijie/self-drive-route-planner/README.zh-CN.md) | 【官方插件】规划自驾路线、地点搜索、候选算路与小红书 3:4 行程图;支持演示模式 | [HanZijie](https://github.com/HanZijie) | | [Git 提交树](plugins/microbiosis/git-tree/README.zh-CN.md) | 查看本机 Git 仓库的提交历史:泳道提交图、分支/标签、提交详情与文件改动统计,支持筛选偏好持久化与可选自动刷新 | [Microbiosis](https://github.com/Microbiosis) | | [灵签易占](plugins/weekbin/chinese-divination/README.zh-CN.md) | 梅花易数起卦与解卦:时间、数字、铜钱摇卦、每日一卦四法起卦,按体用生克断吉凶,另备六十四卦全文与干支历法 | [weekbin](https://github.com/weekbin) | +| [会话轨迹](plugins/avatasia/mmc-trajectory/README.zh-CN.md) | 按轮次浏览一段会话的模型轨迹:消息流、思考过程、工具调用与结果、token 用量与每轮耗时 | [avatasia](https://github.com/avatasia) |
预览:Token 用量看板 diff --git a/plugins/avatasia/mmc-trajectory/.minimax-plugin/plugin.json b/plugins/avatasia/mmc-trajectory/.minimax-plugin/plugin.json new file mode 100644 index 0000000..18f9edd --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/.minimax-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "name": "mmc-trajectory", + "displayName": "会话轨迹", + "version": "1.0.0", + "description": "按轮次浏览当前会话的模型轨迹:消息流、思考过程、工具调用与结果、token 与耗时统计。", + "author": "avatasia", + "icon": "icon.png", + "category": "Other", + "exampleQueries": ["打开会话轨迹"], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/avatasia/mmc-trajectory/LICENSE b/plugins/avatasia/mmc-trajectory/LICENSE new file mode 100644 index 0000000..6b00177 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 avatasia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/avatasia/mmc-trajectory/README.md b/plugins/avatasia/mmc-trajectory/README.md new file mode 100644 index 0000000..81a7ce7 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/README.md @@ -0,0 +1,133 @@ +# Session Trajectory + +English | [简体中文](README.zh-CN.md) + +Browse the model trajectory of a MiniMax Code conversation: messages, reasoning, tool calls and their results, Token usage, and per-turn timing. + +Author: [avatasia](https://github.com/avatasia) · Version: `1.0.0` + +## Install and use + +Copy this entire directory into your active MiniMax Code data directory, including the hidden `.minimax-plugin` directory. By default, `` is the `.minimax` directory in your home folder (`~/.minimax`), so plugins go in `~/.minimax/plugins/`. If you have configured a different data directory, use that directory instead: + +```text +/plugins/mmc-trajectory/.minimax-plugin/plugin.json +``` + +The author directory is only used to group contributions in the community repository; the installed path does not include it. Restart a MiniMax Code version that supports MiniApps, confirm the plugin is recognized and enabled, and open "会话轨迹" (Session Trajectory) from the `@` menu, or ask the Agent to open it. + +The page follows the most recent conversation by default and re-reads it every 2.5 seconds, so it stays live while you work; it pauses while the page is hidden. Each read is validated with an HTTP `ETag`: when the session file has not grown, the service answers `304 Not Modified` with no body and the page keeps what it already rendered. An idle tab therefore costs a few bytes per poll instead of the whole trajectory. Use the session picker to switch to any other conversation. No API key and no dependency installation are required — the package is dependency-free and needs no build step. + +## What it shows + +The ledger groups records by turn, following the `turn_id` recorded in the session file. Each record is classified as user, answer, narration, thinking, tool call, tool result, system, or compaction, and each turn header carries that turn's own Token counts and elapsed time. Selecting a record opens a detail panel with summary, raw JSON, tool arguments, tool result, and reasoning, plus a button that hands the record to the Agent's chat input. + +**The type chips are the only filter over kinds.** One thing is being filtered — which record kinds are visible — and it is one array in the client, written from exactly three controls: the eight kind buttons, 全部, and 清除筛选. An earlier version also had a row of layer buttons (turn / thinking / call) sitting above the list as presets over those same chips. They were removed, because any preset a reader outgrows becomes a button that contradicts the chips it was supposed to summarise: untick 问答 while leaving 用户 ticked, and the turn button goes dark and claims the conversation is hidden when in fact it was narrowed. One control that cannot drift is worth more than three convenient ones that can. Two further controls cut across kinds instead of over them: a time range, which bounds the ledger to a wall-clock window, and an anomaly filter, whose rules pick out records that are wrong — a failed tool call, a compaction, a turn that never got an answer, an answer that came back empty — rather than records of a particular kind. Neither rewrites the kind array, so every kind chip still answers exactly one question. + +Naming kinds directly costs one extra click and reaches everything a preset did — tick 用户 *and* 答复 for the conversation, untick both for its absence, and any scattered subset in between, such as only the user's questions or only system records. + +A kind is whatever the filter can act on, which is the rule that decided 压缩 being a kind of its own rather than a `system` record with a different title. Filed under `system`, a context checkpoint could not be kept or dropped on its own and unticking 系统 took every checkpoint with it — the same "one question, two answers" problem the layer buttons had, hiding in a second field instead of a second row of controls. + +The one control that is **not** a filter is 总览轴, in the window bar under the strip: it switches the strip between equal width and a real time axis. It was labelled 时长 and sat with the filters at the top of the page, which made it read as a filter on duration — it changes neither the rows nor any count on the page. Its label now names the strip and both axes, and it sits directly under the thing it changes. + +Unticking a kind drops those rows from the render entirely. No placeholder summary row is left behind, so nothing on screen can miscount what it covers, and a chip is the only way to bring them back. The kinds are independent, so unticking one never takes part of another with it, and each turn header recomputes from the rows that survive, which makes every count you see under a filter a real post-filter count. Nothing marks a turn as filtered: the figures moving is the signal, and a label repeating that on every turn is noise. + +Values that the session file does not record are rendered as `—`. Nothing is inferred or filled in: a tool call with no matching result has no duration, an assistant message carries no model attribution if the file omitted it, and a message with no Token usage shows no Token usage. + +Assistant text arrives in two roles and they are filed separately. **答复** is the answer a turn ended on, and it is not a judgement call: `stopReason` records why the model stopped, and `stop` means it finished, so that text is the delivered answer. **过程** is the text it wrote on the way to a tool call, where `stopReason` is `toolUse`. In an agentic session the two are nowhere near equal — 51 answers against 901 preambles in a typical working session — so filing them together buries the answers under several times their own volume. An assistant message is always displayed in one fixed order — **reasoning, then the answer, then the tool calls it asked for** — regardless of the order the blocks appear in the session file. Token usage stays with the answer; a message that produced reasoning but no answer shows its usage on the reasoning block, because that is all the message contains. + +**Two numbers that look like they should match, and what makes them match.** A turn either ends with +exactly one reply (`stopReason: stop`) or with none — measured over every prompted turn in a +working session, 54 closed, 5 stopped to ask the reader something, 4 were cut off. So the turn +count and the reply count are the same number seen twice, and the KPI card says which each one +means: the headline is the turns somebody typed into, and the line under it splits them into +closed, waiting on the reader, and cut off. Those parts sum to the headline. + +**问答 is a prompted turn that has not closed, not a turn of its own.** It is the turn whose last record +is the "waiting for the local user" result of `ask_user` or `request_feature_enable`. The reader's +answer never reaches the trajectory file as a record — the tool result in the asking turn only ever +says the questionnaire is waiting — so the turn that answers has no prompt of its own and is filed +as a continuation: still conversation, still one reply. An earlier version filed *that* turn as 问答, +which meant a turn could be labelled 问答 while the reader still had not answered, and turns holding +hundreds of tool calls were labelled a Q&A. There is no record and no filter called 问答 for the same +reason: nothing in the file describes one. + +**A message the reader sent while a turn was still running is filed as 追加, apart from 用户.** One turn can +hold several of them — the prompt that opened it, plus whatever arrived while the agent was still +working — and only the first counts as dialogue, so the message count and the turn count stop +disagreeing. Nothing in the file marks it: compared field by field, a 追加 is byte-identical to a typed +prompt in role, `stopReason`, api, model, provider, tokens and `producedBy`. The signal available is +position — measured across every turn that has a prompt, it sits at record 1 or 2 and never further +in — so this is an inference from ordering, not a fact the file asserts, and the detail panel says +so where it shows. + +One model call produces one row per block, so a rail down the left edge joins the rows that arrived together — a response split into reasoning, answer and tool calls reads as one unit. A response that produced a single row shows a dot instead. The grouping is also stated in each row's tooltip, so it is never conveyed by position alone. Tool results are not model responses, carry no rail, and are not joined to the call that caused them. + +When a session has been compacted, the runtime does not trim it in place: it rotates the previous `messages.jsonl` into `snapshots/`, opens a new generation, and starts a fresh active file. **The ledger still shows the whole conversation.** The app walks the generation chain the same way the runtime does — it opens the active file, reads the generation that file declares, and steps back one generation at a time through the parent each file names — and then concatenates every reachable generation oldest-first. A snapshot whose parent generation is not exactly one lower ends the chain rather than being stitched on, and a snapshot the chain never reaches (a fork leaves those behind) is reported as an orphan instead of being shown. + +A **上下文代** (context generation) picker appears once a session has more than one generation, so the ledger can be narrowed to any single slice; it defaults to **全部(默认)**, the whole lineage. Narrowing to an older generation says plainly that it is a frozen snapshot rather than live history, and the message tile then shows the session-wide total next to the slice. + +## Data & access + +**Files read.** The service resolves the session root in this order: the `MAVIS_HOME` environment variable, then `.minimax` in the home directory, giving `/v2/sessions`. Setting `MMC_TRAJECTORY_ROOT` replaces the data directory itself, for testing; `v2/sessions` and `v2/sqlite` are still resolved beneath it. From each session directory it reads `messages.jsonl` (the active generation), `manifest.json` (the session id and creation time), and `history-catalog.json` (the generation list, plus a committed byte length per artifact). Each snapshot reachable from the active generation is then read from `snapshots/`, bounded by the app's own 64 MB cap rather than by the length the catalog recorded, and only the first 64 KB of each candidate is needed to decide whether it belongs to the chain at all. A session that has never compacted has no `history-catalog.json` at all, and is read from its active file alone. It also opens `/v2/sqlite/runtime-state.sqlite` **read-only** to resolve which conversation is active and to read real session titles. + +Reads are bounded by `MAX_MESSAGE_BYTES` and `readHead` clamps each one to the last complete line, so a file that is being appended to is never parsed mid-line; any unparsable line is skipped and counted. The cap in force is sent to the page with the payload and rendered from there, rather than written as a number in two places that then drift apart. The session directory name is `base64url(sessionId)`, so a session id can be recovered from the filename alone; the service cross-checks that against `manifest.json` and the database before reading any file. + +Snapshot file names come out of `history-catalog.json`, which is data rather than code, so a name is only used when it is a plain basename with no path separator and no `..`, and the resolved path is then required to still sit inside the session directory. Both checks have to pass before anything is opened. + +**Files written.** None. The runtime never writes to disk. + +**Network.** None. The app makes no outbound requests, has no telemetry, and needs no credential configuration. + +**Processes spawned.** None. + +**State.** Cache state is kept in process memory only and is discarded on exit. Nothing is persisted between runs. + +Session titles and message text are your real local data — take care when sharing screenshots or your screen. + +Three details of the session format are worth knowing, because they change what you see: + +- Host-injected blocks such as `` are recorded with role `user` but are not user prompts. The service splits them off using the recorded `canonicalTextRange` and shows them as `系统` (system) records, so the ledger does not present injected context as something you typed. +- A `user` message whose text is only an injected block has no prompt, so sub-agent and background-task sessions legitimately show no user record. +- A `compactionSummary` message is the runtime's own context checkpoint — not something you or the model said. It gets its own type, 压缩 (compaction), rather than a `system` record with a different label, so you can keep or drop it on its own; that is also why the filter can see it. It holds no Token usage of its own, and reports the context size it replaced, so a long session shows where its earlier context went. Its detail panel also names the generation it opened, who produced it, and the revision it replaced. + +## Diagnostics + +The Node runtime also serves `GET /api/runtime`, a diagnostic route reporting what the process can observe about its own session identity: the Node version, whether the runtime database could be opened and which conversation it resolved to, and the basenames of the working and data directories. It returns names and structure only — every filesystem location is reduced to a bare filename, and no absolute path, process id or command-line argument appears in the response. An earlier version also returned the names of the environment variables and scanned them for anything shaped like a session id; those fields are gone, because handing the host process's environment to a page is a liability and the scan answered nothing this route still needs. + +The Host assigns the listening port at startup and logs it as `miniapp.runtime.listening`. This route is the reason the claims in the next section were established rather than assumed, and it is the first thing to check when a build of MiniMax Code changes how the runtime is spawned. + +## How the active session is chosen + +MiniMax Code does not tell a Mini App which conversation opened its page: the runtime context passed to `start(context)` has no session id, the Host bridge exposed to the page provides only `miniapp.message.append`, and the page URL carries no parameter. This app therefore infers the conversation and **always states how it decided**, in the page footer. + +The runtime database is asked first. Among conversations (`session_kind = 'conversation'` with no `purpose`, not archived), it prefers one holding a live turn lease in `local_runtime_session_locks`, then one with status `started`, then the most recently updated. Cron runs and background worker tasks are excluded by `session_kind`. + +If no conversation is running a turn, or the database cannot be read, the app falls back to the most recently written session file. If two or more conversations hold live turn leases at the same time, the app reports the ambiguity, names every candidate, and asks you to confirm instead of silently picking one. + +The runtime database is opened in read-only mode. If it is missing, unreadable, or its schema no longer matches, the app degrades to the file-based heuristic rather than failing. + +## Limits and known gaps + +- The app depends on undocumented internal formats: the `v2/sessions` directory layout and the runtime database schema. A client update can change either, which may break session identification or parsing. +- Session identification is an inference, not a binding. With one conversation active it is reliable; with none running it degrades to most-recently-written. +- A single `messages.jsonl` is read up to 64 MB. Larger sessions are truncated and the page says so. +- The ledger does not use virtual scrolling; every rendered row is a real element. The page therefore opens on the newest 200 records, and the window bar button switches to the whole session. The button names what pressing it will do, not the current state: 「显示全部」 at the default and 「显示最新 200 条」 once everything is showing, while the count beside it reports what is on screen. That full view is opt-in for a reason: on a 9,900-record session it is roughly 57,000 elements rebuilt every 2.5s poll, which was measured to freeze the page rather than be read. 「向上加载更早的 N 条」 widens the window in steps and a poll does not undo it. A sticky window bar under the overview strip offers that choice behind four explicit controls: switch the strip between equal width and a real time axis, load 200 earlier records, jump back to the latest 200, and show everything. "Show everything" builds every record in the session into the DOM at once and gets noticeably slower on large sessions; incoming records do not knock it back down to 200. +- `messageCount` and `turnCount` in the session picker are estimates derived from a bounded prefix of the file; exact values come from the selected session. +- Light theme token coverage is verified — every `--mcode-*` token the page consumes is defined for both themes — but its rendered appearance was not visually checked; the page was exercised under a dark system preference. + +## Tested environment + +- **MiniMax Code:** 3.1.1.178 (desktop, Windows build) +- **Operating system:** Windows 10.0.26200 (x64) +- **Node in the Mini App runtime:** v24.18.0 + +Verified manually in the client: publishing and startup, live polling during an active turn, session switching, type filtering, record inspection, and error states. A 19.6 MB session loads without truncation. Session resolution was cross-checked against the conversation title shown in the client and against the session's own manifest. + +**Not verified:** macOS and Linux, and the light theme's rendered appearance. Compatibility with unofficial or source builds is also unverified. + +Source layout: the page is `miniapp/client/index.html` and the Node service is `miniapp/node/server.mjs`. No build step and no third-party dependency is required; `miniapp/node/miniapp-api.ts` is a type declaration only and is never imported at runtime. + +## License + +[MIT](LICENSE). \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/README.zh-CN.md b/plugins/avatasia/mmc-trajectory/README.zh-CN.md new file mode 100644 index 0000000..bb26af5 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/README.zh-CN.md @@ -0,0 +1,121 @@ +# 会话轨迹 + +[English](README.md) | 简体中文 + +按轮次浏览一段 MiniMax Code 对话的模型轨迹:消息流、思考过程、工具调用与结果、token 消耗与每轮耗时。 + +作者:[avatasia](https://github.com/avatasia) · 版本:`1.0.0` + +## 安装与使用 + +把整个目录(含隐藏的 `.minimax-plugin`)复制到你的 MiniMax Code 活跃数据目录下。默认情况下 `` 是家目录下的 `.minimax`(即 `~/.minimax`),所以插件放在 `~/.minimax/plugins/`。如果你配置过其它数据目录,请用那个目录: + +```text +/plugins/mmc-trajectory/.minimax-plugin/plugin.json +``` + +作者目录只用于社区仓库里归类贡献,安装路径不包含它。重启一个支持 MiniApp 的 MiniMax Code 版本,确认插件已被识别并启用,从 `@` 菜单打开「会话轨迹」,或直接让 Agent 帮你打开。 + +页面默认跟随最近一段对话,每 2.5 秒重新读取一次,因此你工作时它会保持实时更新;页面不可见时会自动暂停。每次读取都通过 HTTP `ETag` 做校验:当会话文件没有增长时,服务端返回 `304 Not Modified` 且不带响应体,页面保留已渲染的内容 —— 因此空闲页签每次轮询只花几个字节,而不是整份轨迹。用会话选择器可以切换到其它任何对话。不需要 API key,也不需要装依赖 —— 本包零依赖、无构建步骤。 + +## 它展示什么 + +台账按 `turn_id` 把记录分组,轮次划分完全依据会话文件中记录的 `turn_id`。每条记录被归类为用户、答复、过程、思考、工具调用、工具结果、系统或压缩;每个轮次表头带该轮自己的 token 计数与耗时。选中任意记录会打开详情面板,含摘要、原始 JSON、工具入参、工具结果与思考内容,并提供一个把该记录内容送回 Agent 对话输入框的按钮。 + +**类型选项是唯一针对「类型」的筛选。** 被过滤的始终只有一件事——哪些记录类型可见——客户端里它是同一个数组,只由三个控件写入:八个类型按钮、「全部」和「清除筛选」。早期版本在列表上方还排了一行图层按钮(轮次 / 思考 / 调用),作为同一批类型选项的快捷预设,已经删除。任何读者能走偏的预设,最终都会变成一个与它本该概括的选项自相矛盾的按钮:只取消「过程」而保留「用户」,轮次按钮就熄灭并声称整个对话已隐藏,而实际上你只是收窄了它。一个不会跑偏的控件,比三个方便的控件更值钱。 另外两个控件并不在类型之上做筛选,而是横跨类型:时间区间把台账限制在一个墙上时钟窗口内;异常筛选挑出的是「出了问题的记录」—— 工具失败、上下文压缩、整轮没有答复、答复是空的 —— 而不是某一类记录。两者都不改写类型数组,所以每个类型选项始终只回答一个问题。 + +直接点名类型只多花一次点击,却能到达预设原本覆盖的全部范围:勾选「用户」**和**「答复」就是对话,两个都取消就是没有对话,中间任意散落的子集(只看用户提问、只看系统记录)同样可以。 + +**一种类型就是筛选能作用的那个单位。** 这条规则决定了「压缩」为什么是一个独立类型,而不是一条换了标签的「系统」记录:塞在「系统」底下时,一次上下文检查点无法被单独保留或丢弃,取消「系统」还会把所有检查点一起带走 —— 和刚拆掉的图层按钮是同一个「一个问题、两个答案」的毛病,只不过藏在一个第二字段里,而不是藏在一排第二控件里。 + +**唯一不是筛选的控件是「总览轴」**,它放在总览条正下方的窗口栏里,只切换总览条的横轴。它以前标着「时长」、待在页面顶部的筛选栏旁边,于是被读成了「按时长筛选」—— 它既不改台账里的行,也不改页面上的任何计数。现在它的标签直接写明总览条和两种轴,并待在它真正影响的那条带子下面。 + +取消一个类型即不渲染它的行,不留占位摘要行,因此屏幕上没有任何一行需要去概括它本该概括的内容,类型选项也是唯一的恢复途径。各类型彼此独立,取消一个不会顺带走另一个的任何部分;轮次表头按幸存下来的行重算,所以筛选后看到的计数就是筛选后的真实计数。表头上不再挂「筛选后」徽章 —— 数字自己变了就是信号,每个轮次都重复一遍标签反而是噪音。 + +会话文件没有记录的值一律渲染为 `—`,不做任何推断或补全:没有匹配结果的工具调用就没有耗时,会话文件没写模型信息的消息就不带模型归属,没有 token 统计的消息就不显示 token。 + +一条 assistant 消息始终按固定顺序展示 —— **思考、然后回答、然后它发起的工具调用** —— 与这些块在会话文件里的排列顺序无关。token 用量跟着回答走;只产生思考、没有回答的消息,用量显示在思考记录上,因为那就是该消息的全部内容。 + +**两个本来应该对上的数字,以及它们为什么能对上。** 一轮要么以恰好一条答复(`stopReason: stop`)收尾,要么没有; +在一段真在工作的会话里逐轮数下来,54 轮已收尾、5 轮停下来问你、4 轮被切断。所以轮次数和答复数本来就是同一个数字的两种说法, +KPI 卡片里各标清了各自是什么:大数字是你打过字的轮数,下面一行把它拆成已收尾、提问待答、中断。 +这几部分加起来正好是大数字。 + +**问答是一个停下来了、还没收尾的对话轮次,而不是单独的一轮。** 它是最后一条记录为 `ask_user` 或 `request_feature_enable` +的「等待本机用户回复」结果的那一轮。你的回答根本不会作为记录写进轨迹文件——提问那一轮的工具结果只会说「问卷等待中」—— +因此回答的那个轮次本身没有提示词,归为「续作」:还是对话,还是只收尾一次。早期版本把「那个」轮次标成了问答, +结果是你还没回答时就有轮次冒着「问答」,而几百次工具调用的轮次也被标成了一次问答。因为文件里根本没有描述这么一次交换的内容, +所以既没有叫「问答」的记录,也没有叫「问答」的筛选。 + +**你在一轮还没完时发过来的消息单独归为「追加」。** 一轮可以包含多条:打开它的那条提示词,以及助手忙着时你又发来的。只有第一条算对话轮次, +所以消息数和轮次数不再对不上。文件里没有任何标记:逐字段比对过,追加在 role、`stopReason`、api、model、provider、tokens、`producedBy` 上与打字消息完全一致。 +唯一可用的信号是位置:统计每个有提示词的轮次,提示词总在第 1 或第 2 条记录,从不更靠后。所以这是从顺序做的推断,不是文件声明的事实,详情面板在展示它的地方会写明。一次模型调用会拆成若干行,左侧的 tree 线把同一次返回的行连在一起 —— 一条被拆成思考、回答、工具调用的响应因此读起来是一个整体;只产生单行的响应则显示为一个圆点。同样的信息也写在每行的悬浮提示里,不会只靠位置表达。工具结果不是模型响应,没有连线,也不与引发它的那次调用相连。 + +会话被压缩之后,运行时并不是就地裁剪:它把原来的 `messages.jsonl` 轮转到 `snapshots/`,开启新一代上下文,再新建一份活跃文件。**台账仍然展示完整对话。** 应用用与运行时相同的方式走这条代链 —— 打开活跃文件、读出它自己声明的代号、再沿着每一代各自指名的父代一代一代往回走 —— 然后把每一代按从旧到新的顺序拼接起来。若某一代声明的父代不是恰好小一,代链就在此中断、不会把它接上;代链从未走到的快照(fork 会留下这种)会被当作孤立代报告,而不是展示出来。 + +当一个会话存在多代时,工具栏会出现「上下文代」选择器,用于把台账收窄到任意单独一代;其默认值是**全部(默认)**,也就是完整代链。收窄到旧代时会明确说明它是冻结的历史快照而不是实时记录,此时「消息」会并列显示全程条数。 + +## 数据与访问 + +**读取的文件。** 服务按以下顺序解析会话根目录:环境变量 `MAVIS_HOME`,其次是家目录下的 `.minimax`,最终得到 `/v2/sessions`。环境变量 `MMC_TRAJECTORY_ROOT` 覆盖的是数据目录本身,供测试使用;`v2/sessions` 与 `v2/sqlite` 仍在它下面解析。每个会话目录下读取 `messages.jsonl`(活跃那一代)、`manifest.json`(会话 id 与创建时间)、`history-catalog.json`(上下文代清单,以及每个 artifact 已提交的字节长度)。从活跃代出发能走到的每一份快照,随后只受应用自身的 64 MB 上界约束从 `snapshots/` 读取,不再以清单记录的长度为界;而判断某个候选是否属于这条代链,只需要读它的前 64 KB。从未发生过压缩的会话根本没有 `history-catalog.json`,此时只读它的活跃文件。另外**只读**打开 `/v2/sqlite/runtime-state.sqlite`,用于判定哪段对话正在运行并读取真实会话标题。 + +读取范围由 `MAX_MESSAGE_BYTES` 设上界,`readHead` 再把每次读取收口到最后一个完整换行,避免正在被追加写入的文件被解析到半行;无法解析的行会被跳过并计数。上限由服务端随 payload 一起下发、页面按实际值渲染,而不是在两边各写一个数字再各自漂移。会话目录名是 `base64url(sessionId)`,因此单凭文件名就能还原会话 id;服务在读取任何文件之前,会拿它与 `manifest.json`、数据库三方交叉校验。 + +快照文件名来自 `history-catalog.json`,那是数据而不是代码。因此一个文件名只有在「是不含路径分隔符与 `..` 的纯 basename」且「解析后的路径仍位于会话目录之内」这两条同时成立时才会被使用;两道检查都通过之后才会打开文件。 + +**写入的文件。** 无。运行时不写磁盘。 + +**网络。** 无。不发起任何出站请求,无遥测,不需要配置任何凭证。 + +**启动的子进程。** 无。 + +**状态。** 缓存只保留在进程内存中,退出即丢弃,运行之间不落盘任何东西。 + +会话标题与消息正文是你的真实本地数据 —— 分享截图或投屏时请留意。 + +有三处会话格式的细节值得知道,因为它们会直接改变你看到的内容: + +- Host 注入的块(例如 ``)在文件里记为 `user` 角色,但它不是你输入的提示词。服务依据文件中记录的 `canonicalTextRange` 把它们切出来,作为 `系统` 记录展示,因此台账不会把注入的上下文伪装成你说过的话。 +- 文本只有一个注入块的 `user` 消息其实没有提示词,所以 sub-agent 与后台任务的会话里看不到用户记录是正常现象,不是缺陷。 +- `compactionSummary` 消息是运行时自己打的上下文检查点,既不是你说的也不是模型说的。它是独立类型 `压缩`,而不是一条换了标签的 `系统` 记录 —— 正因如此你才能单独保留或丢弃它,筛选也才看得到它。它自身不带 token 用量,并透出它替换掉的那份上下文的规模 —— 于是长会话能看出早先的上下文去了哪里。它的详情页还会说明这一检查点开启了第几代、由谁生成、以及替换掉的是哪一个版本。 + +## 诊断 + +Node 运行时另外提供一个诊断路由 `GET /api/runtime`,报告该进程能观测到的自身会话身份信息:Node 版本、运行时数据库能否打开、最终解析出了哪个会话,以及工作目录与数据目录的 basename。它只返回名称与结构 —— 文件系统位置一律压缩成纯文件名,响应里不含任何绝对路径、进程 id 或命令行参数。早期版本还会返回环境变量名并扫描它们是否形似会话 id;这些字段已删除 —— 把宿主进程的环境暴露给页面本身就是风险,而那次扫描并没有回答这个路由现在仍然需要的任何问题。 + +监听端口由 Host 在启动时分配,并以 `miniapp.runtime.listening` 记录日志。这个路由正是下一节那些结论能被「坐实」而不是「假设」的原因;当 MiniMax Code 的某个版本改变了运行时的拉起方式时,它也是第一个该查的地方。 + +## 当前会话如何确定 + +MiniMax Code 不会告诉 Mini App 是哪段对话打开了它的页面:传给 `start(context)` 的运行时上下文里没有 session id,暴露给页面的 Host 桥接只有 `miniapp.message.append`,页面 URL 也不带任何参数。因此本应用采用推断,**并且始终在页脚说明它是怎么判断的**。 + +优先向运行时数据库询问。在所有对话(`session_kind = 'conversation'`、无 `purpose`、未归档)中,依次优先选择:在 `local_runtime_session_locks` 中持有活跃 turn 租约的、状态为 `started` 的、最后更新的那个。cron 任务与后台 worker 任务会被 `session_kind` 排除掉。 + +如果没有任何对话正在跑轮次,或数据库读不出来,应用会退回到最近写入的会话文件。如果同时有两段以上对话持有活跃 turn 租约,应用会明确报告这个歧义、列出全部候选,请你确认,而不是悄悄挑一个。 + +运行时数据库以只读模式打开。如果文件缺失、不可读,或表结构已经对不上,应用会降级为基于文件的启发式判断,而不是直接失败。 + +## 限制与已知缺口 + +- 本应用依赖未公开的内部格式:`v2/sessions` 目录结构与运行时数据库表结构。客户端一次更新就可能改动其中之一,导致会话识别或解析失效。 +- 会话识别是推断,不是绑定。只有一段对话在跑时它很可靠;没有对话在跑时会降级为「最近写入」。 +- 单个 `messages.jsonl` 最多读取 64 MB。更大的会话会被截断,页面会明确说明。 +- 台里没有用虚拟滚动,每一行都是真实的 DOM 元素。页面默认只展示最新 200 条,窗口条上的按钮可以切到全量;全量是可选的,因为它很贵 —— 在这个会话上约 9,900 条、约 5.7 万个元素,每 2.5 秒轮询重建一次,实测会直接卡死页面。总览条正下方的吸顶窗口栏提供三个显式控件:「总览轴」「向上加载更早的 200 条」,以及在最新 200 条与全量之间切换的那一个按钮(它写的是按下去会变成什么,默认为「显示全部」,全量时为「显示最新 200 条」;当前窗口由右边的计数条报告,两者回答的是不同问题)。「总览轴」只改总览条的横轴——等宽(每条记录一样宽,完全忽略时间)或真实时间轴(宽度等于该记录的真实耗时);它不筛选、不排序,台账里的行和页面上的任何计数都不变。「向上加载更早的 200 条」每点一次就多加一页,轮询不会把已经加宽的窗口重置回去;点切换按钮也不会自动滚动页面,读者正在看的那一行会留在原地。 +- 会话选择器里的 `messageCount` / `turnCount` 是从文件的有界前缀估算的;精确值以选中会话后展示的为准。 +- 浅色主题的 token 覆盖已验证(页面用到的每个 `--mcode-*` 变量在两套主题下都有定义),但渲染外观未做视觉检查;功能验收是在深色系统偏好下进行的。 + +## 测试环境 + +- **MiniMax Code:** 3.1.1.178(桌面版,Windows 构建) +- **操作系统:** Windows 10.0.26200(x64) +- **Mini App 运行时 Node:** v24.18.0 + +在客户端内手动验证过:发布与启动、活跃轮次期间的实时轮询、会话切换、类型筛选、记录检视、错误态展示。一个 19.6 MB 的会话可无截断加载。会话解析结果与客户端里显示的对话标题、以及会话自身的 manifest 做过交叉核对。 + +**未验证:** macOS 与 Linux,以及浅色主题的渲染外观;与官方发行版之外的或源码构建版本的兼容性同样未验证。 + +源码位置:页面是 `miniapp/client/index.html`,Node 服务是 `miniapp/node/server.mjs`。不需要构建步骤,不需要任何第三方依赖;`miniapp/node/miniapp-api.ts` 只是类型声明,运行时不会被导入。 + +## 许可证 + +[MIT](LICENSE)。 diff --git a/plugins/avatasia/mmc-trajectory/icon.png b/plugins/avatasia/mmc-trajectory/icon.png new file mode 100644 index 0000000..299a26c Binary files /dev/null and b/plugins/avatasia/mmc-trajectory/icon.png differ diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html new file mode 100644 index 0000000..c977097 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -0,0 +1,6391 @@ + + + + + + 会话轨迹 + + + + +
+ + +

+ + + + + + + +
+
+

关键指标

+ + +
+
+
+ + + +
+

轨迹总览

+
+ +
+ + + + + + + +
+
+

总览条是下方列表同一批记录的图形投影,不含列表之外的任何数字。关闭“时长”时每条记录占同样宽度,完全忽略时间;打开时按真实时间排布,并压缩中间的空闲间隙,其中工具调用是实测耗时,模型行是距上一条事件的等待。在总览上按住拖拽可把列表聚焦到某个时间区间,滚轮可缩放,右键拖拽可平移;双击、按 Esc 或点“清除区间”恢复完整列表。

+
+ +
+
+
+

轨迹

+ +
+ +
+
+
+ +
+
+

检视

+ +
+

尚未选择记录

+
+
+
+
+
+ + +
+

+
+
+ +
+ + + +
+
+ + + + \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/miniapp/miniapp.json b/plugins/avatasia/mmc-trajectory/miniapp/miniapp.json new file mode 100644 index 0000000..498d880 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/miniapp/miniapp.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard" + }, + "mcpEndpoints": [] +} diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/miniapp-api.ts b/plugins/avatasia/mmc-trajectory/miniapp/node/miniapp-api.ts new file mode 100644 index 0000000..7a0e0fd --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/miniapp-api.ts @@ -0,0 +1,109 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + readonly hostConnector?: HostConnectorClient; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise; +} diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs new file mode 100644 index 0000000..78d7bd1 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs @@ -0,0 +1,2194 @@ +// @ts-check + +import { open, readdir, readFile, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { basename, join, resolve, sep } from 'node:path'; +import { homedir } from 'node:os'; +import { createHash } from 'node:crypto'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + +const MAX_WALK_DEPTH = 4; +const MAX_SESSION_CANDIDATES = 400; +const MAX_SESSION_LIST = 30; +const MAX_MESSAGE_BYTES = 64 * 1024 * 1024; +/** + * Tools whose reply arrives through the tool rather than as a chat message. A turn that calls + * one of these is followed, sometimes after a compaction sits in between, by a turn that has + * no user message because the reader's answer never became one. + */ +const USER_INPUT_TOOLS = new Set(['ask_user', 'request_feature_enable']); +/** + * Suffix the runtime appends to a turn id when it writes that turn's compaction checkpoint. The + * checkpoint names the turn it interrupts, so stripping this is what puts the two back together. + */ +const COMPACTION_TURN_SUFFIX = ':compaction'; +// Enough of a file to read its opening line. A generation marker lives on the first row, so +// deciding whether a snapshot belongs to the lineage never needs more than this. +const GENERATION_PROBE_BYTES = 64 * 1024; +const LIST_PEEK_BYTES = 256 * 1024; +const MAX_FIELD_CHARS = 20000; +const MAX_SUMMARY_CHARS = 160; +const MAX_ARGUMENT_BYTES = 32000; +const MAX_LABEL_CHARS = 60; +const MAX_RAW_BYTES = 65536; +const LIST_CACHE_TTL_MS = 1000; +const SESSION_CACHE_TTL_MS = 5000; +const SESSION_CACHE_ENTRIES = 80; +const TRAJECTORY_CACHE_ENTRIES = 4; +const SESSION_STORE_TOKEN = ''; +const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }; +const ROLE_PATTERN = /"role"\s*:\s*"(user|assistant|toolResult|custom)"/g; +const TURN_PATTERN = /"turn_id"\s*:\s*"([^"]*)"/g; +const BLOCK_PATTERN = /"type"\s*:\s*"(toolCall|thinking|text)"/g; +const ABSOLUTE_STORE_PATTERN = /(?:[A-Za-z]:[\\/])?[^\s"'`<>|]{0,120}?[\\/]?\.minimax(?:[\\/][^\s"'`<>|]{0,160})?/g; + +/** + * Resolve the local session store. Never returns a location that is logged or returned to the browser. + * @param {Record} [env] + * @returns {{ root: string, sessionsRoot: string }} + */ +export function resolveSessionsRoot(env = process.env) { + const candidates = [env.MMC_TRAJECTORY_ROOT, env.MAVIS_HOME]; + let home = ''; + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim()) { + home = candidate.trim(); + break; + } + } + if (!home) home = join(homedir(), '.minimax'); + return { root: home, sessionsRoot: join(home, 'v2', 'sessions') }; +} + +/** + * Versioned `.history-mutation-*` copies also contain `messages.jsonl`; only real session folders count. + * @param {string} name + */ +export function isSessionDirName(name) { + return name.includes('-session_') && !name.startsWith('.'); +} + +/** + * @param {unknown} raw + * @param {number} fallback + * @param {number} min + * @param {number} max + */ +export function normalizeLimit(raw, fallback, min, max) { + const parsed = Number.parseInt(typeof raw === 'string' ? raw : '', 10); + if (!Number.isFinite(parsed)) return fallback; + if (parsed < min) return min; + if (parsed > max) return max; + return parsed; +} + +/** + * @param {string} value + * @param {number} max + */ +export function singleLine(value, max) { + const flattened = String(value ?? '').replace(/\s+/g, ' ').trim(); + return flattened.length > max ? flattened.slice(0, max) : flattened; +} + +/** + * @param {unknown} value + * @param {number} max + * @returns {{ text: string | null, truncated: boolean }} + */ +export function clipField(value, max) { + if (typeof value !== 'string' || value === '') return { text: null, truncated: false }; + if (value.length <= max) return { text: value, truncated: false }; + return { text: value.slice(0, max), truncated: true }; +} + +/** + * Replaces internal store locations so the browser never learns where sessions live. + * @param {readonly string[]} secrets + */ +export function createRedactor(secrets) { + const prefixes = [...new Set(secrets.filter((entry) => typeof entry === 'string' && entry.length > 0))] + .sort((a, b) => b.length - a.length); + if (prefixes.length === 0) return (value) => value; + return (value) => { + let out = String(value); + for (const prefix of prefixes) out = out.split(prefix).join(SESSION_STORE_TOKEN); + return out.includes('.minimax') ? out.replace(ABSOLUTE_STORE_PATTERN, SESSION_STORE_TOKEN) : out; + }; +} + +/** + * @param {string} text + * @returns {{ rows: Array>, skipped: number }} + */ +export function parseMessagesJsonl(text) { + /** @type {Array>} */ + const rows = []; + let skipped = 0; + let start = 0; + while (start <= text.length) { + let end = text.indexOf('\n', start); + if (end === -1) end = text.length; + const line = text.slice(start, end); + start = end + 1; + if (line.trim() === '') { + if (end >= text.length) break; + continue; + } + try { + const parsed = JSON.parse(line); + if (parsed && typeof parsed === 'object') rows.push(parsed); + else skipped += 1; + } catch { + skipped += 1; + } + if (end >= text.length) break; + } + return { rows, skipped }; +} + +/** + * Cheap single-pass counters used by the session list; values are approximate by design. + * @param {string} text + */ +export function countRolesInPrefix(text) { + const turnIds = new Set(); + const counts = { messages: 0, userMessages: 0, assistantMessages: 0, toolResults: 0, systemMessages: 0, toolCalls: 0, thinkingBlocks: 0, turns: 0 }; + for (const match of text.matchAll(/"message_id"\s*:/g)) counts.messages += 1; + for (const match of text.matchAll(ROLE_PATTERN)) { + if (match[1] === 'user') counts.userMessages += 1; + else if (match[1] === 'assistant') counts.assistantMessages += 1; + else if (match[1] === 'toolResult') counts.toolResults += 1; + else counts.systemMessages += 1; + } + for (const match of text.matchAll(TURN_PATTERN)) turnIds.add(match[1]); + for (const match of text.matchAll(BLOCK_PATTERN)) { + if (match[1] === 'toolCall') counts.toolCalls += 1; + else if (match[1] === 'thinking') counts.thinkingBlocks += 1; + } + counts.turns = turnIds.size; + return counts; +} + +/** + * @param {string} dirName + */ +export function decodeSessionIdFromDirName(dirName) { + const marker = '-session_'; + const index = dirName.indexOf(marker); + if (index < 0) return null; + const decoded = Buffer.from(dirName.slice(index + marker.length), 'base64url').toString('utf8'); + return /^[A-Za-z0-9_-]{4,128}$/.test(decoded) ? decoded : null; +} + +/** + * The active catalog length is the only trustworthy byte boundary while the file is being appended. + * @param {unknown} catalog + * @returns {number | null} + */ +export function activeCatalogBytes(catalog) { + if (!catalog || typeof catalog !== 'object') return null; + const artifacts = /** @type {{ artifacts?: unknown }} */ (catalog).artifacts; + if (!Array.isArray(artifacts)) return null; + let total = 0; + let found = false; + for (const entry of artifacts) { + if (!entry || typeof entry !== 'object') continue; + const artifact = /** @type {{ kind?: unknown, fileName?: unknown, byteLength?: unknown }} */ (entry); + if (artifact.kind !== 'active' || artifact.fileName !== 'messages.jsonl') continue; + const size = Number(artifact.byteLength); + if (Number.isFinite(size) && size >= 0) { + total = Math.max(total, Math.floor(size)); + found = true; + } + } + return found ? total : null; +} + +/** + * Whether our own read cap, and nothing else, cut a file short. + * + * The catalog-recorded sizes are a snapshot, and the active `messages.jsonl` keeps being appended + * to after the catalog was written — so "we read fewer bytes than the file now has" is not evidence + * that the file is too large. Reporting it as truncation is how a merely-active session ended up + * claiming the first 64 MB when it was nowhere near it. + * + * @param {number} size the file's size at read time + * @param {number} limit the bytes we were willing to read + * @returns {boolean} + */ +export function truncatedByCap(size, limit) { + if (!Number.isFinite(size) || !Number.isFinite(limit)) return false; + return size > limit; +} + +/** + * @param {string} filePath + * @param {number} limit + * @returns {Promise<{ text: string, size: number, read: number }>} + */ +export async function readHead(filePath, limit) { + const handle = await open(filePath, 'r'); + try { + const stat = await handle.stat(); + const size = stat.size; + const wanted = Math.max(0, Math.min(limit, size)); + const buffer = Buffer.allocUnsafe(wanted); + let filled = 0; + while (filled < wanted) { + const { bytesRead } = await handle.read(buffer, filled, wanted - filled, filled); + if (bytesRead <= 0) break; + filled += bytesRead; + } + const view = buffer.subarray(0, filled); + const lastNewline = view.lastIndexOf(0x0a); + const usable = lastNewline === -1 ? filled : lastNewline; + return { text: view.subarray(0, usable).toString('utf8'), size, read: filled }; + } finally { + await handle.close(); + } +} + +/** + * @param {string} dir + */ +async function readJsonIfPresent(dir, name) { + try { + const raw = await readFile(join(dir, name), 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +/** + * A basename we are willing to join onto a directory. The catalog is data on disk, and data + * does not get to choose which file gets opened, so anything carrying a separator, a dot + * segment or an unexpected extension is dropped rather than normalised. + */ +const SAFE_ARTIFACT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}\.jsonl$/; +const SAFE_REVISION = /^sha256:[0-9a-f]{64}$/; + +/** + * Which generation a row came from, carried on the row itself while a stitched lineage is + * being built. A symbol keeps it off `JSON.stringify` and out of every raw payload. + */ +export const ROW_GENERATION = Symbol('rowGeneration'); + +/** + * A finite, non-negative JSON number — or nothing. + * + * `Number()` is deliberately not used here: it turns `null` into 0 and `"7"` into 7, so a + * malformed catalog would come back as a confident-looking zero-byte generation rather than + * as a dropped entry. + * @param {unknown} value + */ +function nonNegativeInteger(value) { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null; +} + +/** + * Every context generation the runtime has recorded for a session. + * + * A compaction does not trim history in place: the runtime rotates the previous + * `messages.jsonl` into `snapshots/.jsonl` and starts a fresh active file, so after a + * compaction the active file holds only what came *after* the checkpoint. `artifacts[]` is + * the only place the earlier generations are still listed, so without this a compacted + * session silently reads as if all of its earlier messages never existed. + * + * @param {unknown} catalog + * @returns {Array<{ generation: number, kind: string, fileName: string, byteLength: number | null, messageCount: number | null, revision: string | null, active: boolean }>} + */ +export function parseCatalogGenerations(catalog) { + if (!catalog || typeof catalog !== 'object') return []; + const artifacts = /** @type {{ artifacts?: unknown }} */ (catalog).artifacts; + if (!Array.isArray(artifacts)) return []; + /** @type {Map} */ + const byGeneration = new Map(); + for (const entry of artifacts) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const artifact = /** @type {Record} */ (entry); + const generation = nonNegativeInteger(artifact.generation); + if (generation === null) continue; + const fileName = typeof artifact.fileName === 'string' ? artifact.fileName : ''; + if (!SAFE_ARTIFACT_NAME.test(fileName) || fileName.includes('..')) continue; + const kind = typeof artifact.kind === 'string' && artifact.kind.trim() ? artifact.kind.trim() : 'snapshot'; + const revision = typeof artifact.revision === 'string' && SAFE_REVISION.test(artifact.revision) ? artifact.revision : null; + // First entry wins: a duplicated generation is a malformed catalog, and picking the + // first one keeps the answer stable instead of depending on array order luck. + if (byGeneration.has(generation)) continue; + byGeneration.set(generation, { + generation, + kind, + fileName, + byteLength: nonNegativeInteger(artifact.byteLength), + messageCount: nonNegativeInteger(artifact.messageCount), + revision, + active: kind === 'active', + }); + } + return [...byGeneration.values()].sort((a, b) => a.generation - b.generation); +} + +/** + * @param {unknown} catalog + * @param {Array<{ generation: number, kind: string, fileName: string, byteLength: number | null, messageCount: number | null, revision: string | null, active: boolean }>} generations + * @returns {number | null} + */ +export function activeCatalogGeneration(catalog, generations) { + const declared = catalog && typeof catalog === 'object' + ? nonNegativeInteger(/** @type {{ activeGeneration?: unknown }} */ (catalog).activeGeneration) + : null; + if (declared !== null && generations.some((entry) => entry.generation === declared)) return declared; + const actives = generations.filter((entry) => entry.active); + if (actives.length > 0) return actives[actives.length - 1].generation; + return generations.length > 0 ? generations[generations.length - 1].generation : null; +} + +/** + * Resolves a catalog artifact to a readable file inside the session directory. + * + * Two independent checks stand between the catalog and `open()`: the name must match the + * safe-basename pattern, and the resolved path must still sit under the session directory. + * Either one alone would be enough to catch a hand-edited catalog; both are cheap, and this + * is the only place in the app that opens a file named by something other than the app. + * + * @param {string} dir + * @param {{ generation: number, kind: string, fileName: string, byteLength: number | null, messageCount: number | null, revision: string | null, active: boolean }} artifact + * @returns {string | null} + */ +export function resolveArtifactPath(dir, artifact) { + if (!artifact || typeof artifact.fileName !== 'string') return null; + if (!SAFE_ARTIFACT_NAME.test(artifact.fileName) || artifact.fileName.includes('..')) return null; + const root = resolve(dir); + const candidate = resolve(root, artifact.active ? artifact.fileName : join('snapshots', artifact.fileName)); + if (candidate !== root && !candidate.startsWith(root + sep)) return null; + return candidate; +} + +/** + * The generation marker a file opens with. + * + * A rotation is only believable because the file that opens it says so: the first row of + * every generation after the first is a `compactionSummary` carrying `history_artifact`, + * and that object names the generation it opened and the exact revision it replaced. The + * runtime reads the same marker to walk its lineage, and so does this app — a filename or + * a catalog entry is a claim, this is the receipt. + * + * @param {string} headText + * @returns {{ generation: number, parentGeneration: number | null, parentCompactionId: string | null, parentRevision: string | null } | null} + */ +export function readGenerationMarker(headText) { + const firstLine = headText.split('\n', 1)[0]; + if (!firstLine) return null; + /** @type {any} */ + let row; + try { + row = JSON.parse(firstLine); + } catch { + return null; + } + if (!row || typeof row !== 'object') return null; + const message = row.message; + if (!message || typeof message !== 'object' || message.role !== 'compactionSummary') return null; + const artifact = row.history_artifact; + if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) return null; + const generation = nonNegativeInteger(artifact.generation); + if (generation === null || generation === 0) return null; + const parent = artifact.parentSnapshot && typeof artifact.parentSnapshot === 'object' && !Array.isArray(artifact.parentSnapshot) + ? /** @type {Record} */ (artifact.parentSnapshot) + : null; + return { + generation, + parentGeneration: parent ? nonNegativeInteger(parent.generation) : null, + parentCompactionId: parent && typeof parent.compactionId === 'string' ? parent.compactionId : null, + parentRevision: parent && typeof parent.revision === 'string' && SAFE_REVISION.test(parent.revision) + ? parent.revision + : null, + }; +} + +/** + * Walks a session's generation chain the way the runtime does: start at the active file, + * take the generation it declares, then step back one generation at a time through the + * parent each file names. + * + * The catalog is used only to find candidate files. Every candidate still has to open and + * declare the generation it was reached for, so a catalog that lists a file belonging to a + * different lineage cannot smuggle it in, and a file whose parent generation is not exactly + * one lower ends the chain instead of being stitched on. Snapshots never reached are orphans + * — a fork leaves them behind — and are reported rather than shown. + * + * @param {{ generations: Array<{ generation: number, kind: string, fileName: string, byteLength: number | null, messageCount: number | null, revision: string | null, active: boolean }> }} catalog + * @param {(generation: number | null) => Promise<{ fileName: string, marker: { generation: number, parentGeneration: number | null } | null } | null>} probe `null` asks for the active file + */ +export async function walkLineage(catalog, probe) { + const byGeneration = new Map(); + for (const entry of catalog.generations) { + if (!entry.active && !byGeneration.has(entry.generation)) byGeneration.set(entry.generation, entry); + } + + const activeProbe = await probe(null); + if (activeProbe === null) return { chain: [], orphans: [] }; + + const activeGeneration = activeProbe.marker ? activeProbe.marker.generation : 0; + const chain = [{ generation: activeGeneration, fileName: activeProbe.fileName }]; + const reached = new Set([activeGeneration]); + + let expect = activeGeneration; + while (expect > 0) { + const want = expect - 1; + if (!byGeneration.has(want)) break; + const parent = await probe(want); + if (parent === null) break; + const declared = parent.marker ? parent.marker.generation : 0; + if (declared !== want) break; + if (parent.marker && parent.marker.parentGeneration !== null && parent.marker.parentGeneration !== want - 1) break; + if (reached.has(declared)) break; + reached.add(declared); + chain.unshift({ generation: declared, fileName: parent.fileName }); + expect = want; + } + + const orphans = catalog.generations + .filter((entry) => !entry.active && !reached.has(entry.generation)) + .map((entry) => ({ generation: entry.generation, fileName: entry.fileName })); + return { chain, orphans }; +} + +/** + * @param {string} dir + */ +export async function readSessionIdentity(dir) { + const manifest = await readJsonIfPresent(dir, 'manifest.json'); + const catalog = await readJsonIfPresent(dir, 'history-catalog.json'); + const manifestId = manifest && typeof manifest.sessionId === 'string' ? manifest.sessionId.trim() : ''; + const id = manifestId || decodeSessionIdFromDirName(basename(dir)) || basename(dir); + const createdAtMs = manifest && Number.isFinite(Number(manifest.createdAtMs)) ? Number(manifest.createdAtMs) : null; + const generations = parseCatalogGenerations(catalog); + return { + id, + createdAtMs, + catalogBytes: activeCatalogBytes(catalog), + generations, + activeGeneration: activeCatalogGeneration(catalog, generations), + }; +} + +/** + * Depth-capped walk that only descends into dated folders and never into a session folder twice. + * @param {string} sessionsRoot + */ +export async function collectSessionEntries(sessionsRoot) { + /** @type {Array<{ dir: string, sizeBytes: number, lastActiveAt: number }>} */ + const entries = []; + /** @param {string} dir @param {number} depth */ + const walk = async (dir, depth) => { + if (depth > MAX_WALK_DEPTH || entries.length >= MAX_SESSION_CANDIDATES) return; + let names; + try { + names = await readdir(dir); + } catch { + return; + } + for (const name of names) { + if (entries.length >= MAX_SESSION_CANDIDATES) return; + const child = join(dir, name); + if (isSessionDirName(name)) { + try { + const fileStat = await stat(join(child, 'messages.jsonl')); + entries.push({ dir: child, sizeBytes: fileStat.size, lastActiveAt: Math.floor(fileStat.mtimeMs) }); + } catch { + // A session folder without a readable messages file is simply not a candidate. + } + continue; + } + if (name.startsWith('.')) continue; + await walk(child, depth + 1); + } + }; + await walk(sessionsRoot, 0); + entries.sort((a, b) => b.lastActiveAt - a.lastActiveAt); + return entries.slice(0, MAX_SESSION_LIST); +} + +/** + * @param {unknown} value + * @returns {number | null} + */ +function finiteOrNull(value) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +/** + * The runtime database is the only place on this machine that knows which conversation is + * live. It is optional: a missing file, a missing `node:sqlite`, or any query failure must + * degrade to the filesystem heuristic rather than break the board. + * @param {string} home + */ +async function openRuntimeStateDb(home) { + try { + const { DatabaseSync } = await import('node:sqlite'); + const dbPath = join(home, 'v2', 'sqlite', 'runtime-state.sqlite'); + if (!existsSync(dbPath)) return null; + return new DatabaseSync(dbPath, { readOnly: true }); + } catch { + return null; + } +} + +/** @param {unknown} value */ +function toNumber(value) { + if (typeof value === 'bigint') return Number(value); + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +/** + * Resolve the conversation the user is actually in. + * + * The Host never tells the page which session opened it, so we ask the runtime database + * instead. Ranking, strongest signal first: + * 1. holds a live turn lease — an agent turn is running right now + * 2. status = 'started' — open and not finished + * 3. most recently updated — the fallback for a long-idle conversation + * + * Only real user conversations qualify. `session_kind = 'conversation'` with a NULL + * `purpose` excludes cron runs (`purpose = 'cron:...'`) and background worker/explore + * tasks (`purpose = 'local-background-task:bg_...'`), which are exactly the sessions that + * would otherwise win on file mtime while the user is looking at a different one. + * + * @param {string} home + * @returns {Promise<{ sessionId: string, title: string | null, relativeDir: string | null, status: string | null, leased: boolean } | null>} + */ +export async function resolveCurrentConversation(home) { + const db = await openRuntimeStateDb(home); + if (!db) return null; + const now = Date.now(); + const QUALIFY = "s.session_kind = 'conversation' and s.purpose is null and ifnull(s.archived, 0) = 0"; + try { + const row = db.prepare(` + select s.session_id as session_id, s.title as title, s.status as status, + s.history_relative_dir as history_relative_dir, + case when exists ( + select 1 from local_runtime_session_locks l + where l.session_id = s.session_id and l.expires_at_ms > ? + ) then 1 else 0 end as leased + from local_runtime_sessions s + where ${QUALIFY} + order by leased desc, + case when s.status = 'started' then 0 else 1 end asc, + s.updated_at_ms desc + limit 1 + `).get(now); + if (!row || typeof row.session_id !== 'string') return null; + + // More than one conversation holding a live lease means we genuinely cannot tell which + // one this page belongs to. Report it instead of silently picking by a stale timestamp. + const leasedRows = db.prepare(` + select s.session_id as session_id, s.title as title + from local_runtime_sessions s + where ${QUALIFY} + and exists (select 1 from local_runtime_session_locks l + where l.session_id = s.session_id and l.expires_at_ms > ?) + order by s.updated_at_ms desc + limit 5 + `).all(now); + const candidates = leasedRows + .filter((entry) => entry && typeof entry.session_id === 'string') + .map((entry) => ({ + sessionId: entry.session_id, + title: typeof entry.title === 'string' && entry.title.trim() ? entry.title.trim() : null, + })); + + return { + sessionId: row.session_id, + title: typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null, + relativeDir: typeof row.history_relative_dir === 'string' && row.history_relative_dir.trim() + ? row.history_relative_dir.trim() + : null, + status: typeof row.status === 'string' ? row.status : null, + leased: toNumber(row.leased) === 1, + ambiguous: candidates.length > 1, + candidates, + }; + } catch { + return null; + } finally { + // Every call opened its own handle and none of them closed it. Polling every 2.5s meant + // 20–30 live handles waiting on the GC, and dispose() left them open too. + closeQuietly(db); + } +} + +/** + * Titles for the session picker. Missing database simply means the caller keeps its + * filesystem-derived label. + * @param {string} home + * @param {readonly string[]} sessionIds + * @returns {Promise>} + */ +export async function readSessionTitles(home, sessionIds) { + const result = new Map(); + const db = await openRuntimeStateDb(home); + if (!db) return result; + try { + if (sessionIds.length === 0) return result; + const stmt = db.prepare('select session_id, title, session_kind from local_runtime_sessions where session_id = ?'); + for (const id of sessionIds) { + const row = stmt.get(id); + if (!row) continue; + result.set(id, { + title: typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null, + kind: typeof row.session_kind === 'string' ? row.session_kind : null, + }); + } + } catch { + // A title is a nicety; never let it fail the list. + } finally { + closeQuietly(db); + } + return result; +} + +/** + * Closing a read-only handle cannot fail in a way the caller can act on — the work it was + * opened for is already done or already abandoned. Swallowing keeps every call site from + * having to wrap its own error handling around cleanup. + * @param {{ close?: () => void } | null} db + */ +function closeQuietly(db) { + if (!db || typeof db.close !== 'function') return; + try { + db.close(); + } catch { + // Nothing to do: the handle is being released either way. + } +} + +function emptyTokens() { + return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }; +} + +/** + * @param {unknown} usage + * @returns {{ input: number, output: number, cacheRead: number, cacheWrite: number, totalTokens: number | null } | null} + */ +export function normalizeUsage(usage) { + if (!usage || typeof usage !== 'object' || Array.isArray(usage)) return null; + const source = /** @type {Record} */ (usage); + const input = finiteOrNull(source.input); + if (input === null && finiteOrNull(source.output) === null) return null; + return { + input: input ?? 0, + output: finiteOrNull(source.output) ?? 0, + cacheRead: finiteOrNull(source.cacheRead) ?? 0, + cacheWrite: finiteOrNull(source.cacheWrite) ?? 0, + totalTokens: finiteOrNull(source.totalTokens), + }; +} + +/** + * @param {unknown} block + * @returns {string | null} + */ +function blockText(block) { + if (!block || typeof block !== 'object') return typeof block === 'string' ? block : null; + const source = /** @type {Record} */ (block); + if (typeof source.text === 'string') return source.text; + return null; +} + +/** + * @param {unknown} content + * @returns {string | null} + */ +export function contentToPlainText(content) { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return null; + const parts = []; + for (const block of content) { + const text = blockText(block); + if (text !== null) parts.push(text); + } + return parts.length > 0 ? parts.join('\n') : null; +} + +/** + * Host-injected blocks are recorded with role "user" but are not user prompts. + * They must not be shown as if the user typed them. + */ +const INJECTED_PREFIX = /^\s*(?:<(?:system-reminder|async-audit|background-task-finished|media-output-reminder|mcode-tools-master-reminder|task-completion-reminder|environment_details)\b|\[runaway guard\])/; + +/** + * `canonicalTextRange` marks where the real prompt starts inside a message whose + * content is prefixed by injected blocks. Checked before the injection test because + * a real prompt is itself prefixed by ``. + * @param {Record} message + * @returns {{ start: number, end: number } | null} + */ +export function canonicalPromptRange(message) { + const range = message.canonicalTextRange; + if (!range || typeof range !== 'object' || Array.isArray(range)) return null; + const start = Number(/** @type {any} */ (range).startOffset); + if (!Number.isFinite(start) || start < 0) return null; + const rawEnd = Number(/** @type {any} */ (range).endOffset); + const end = Number.isFinite(rawEnd) && rawEnd > start ? rawEnd : null; + return { start, end: end ?? start }; +} + +/** + * Splits a "user" row into the injected prefix and the real prompt. + * @param {Record} message + * @param {string | null} plain + * @returns {{ prompt: string | null, injected: string | null }} + */ +export function splitUserMessage(message, plain) { + if (plain === null || plain.trim() === '') return { prompt: null, injected: null }; + const range = canonicalPromptRange(message); + if (range) { + const prompt = plain.slice(range.start, range.end).trim(); + const injected = plain.slice(0, range.start).trim(); + if (prompt) return { prompt, injected: injected || null }; + if (injected) return { prompt: null, injected }; + } + if (INJECTED_PREFIX.test(plain)) return { prompt: null, injected: plain }; + return { prompt: plain.trim(), injected: null }; +} + +/** + * @param {unknown} args + */ +function compactValue(value, max) { + if (value === null) return 'null'; + if (typeof value === 'string') return singleLine(value, max); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return `[${ + value.slice(0, 3).map((entry) => compactValue(entry, 24)).join(', ') + }${value.length > 3 ? ', …' : ''}]`; + if (typeof value === 'object') return '{…}'; + return 'null'; +} + +/** + * @param {string} name + * @param {unknown} args + */ +export function toolCallSummary(name, args) { + if (!args || typeof args !== 'object' || Array.isArray(args)) return `${name}{}`; + const entries = Object.entries(/** @type {Record} */ (args)).slice(0, 4); + if (entries.length === 0) return `${name}{}`; + const rendered = entries.map(([key, value]) => `${key}="${compactValue(value, 48)}"`); + return `${name}{${rendered.join(', ')}}`; +} + +/** + * Moves `raw` off every record into an index-keyed map. + * + * On a real session the untouched message is about two thirds of the payload, and the page + * reads it for exactly one record at a time: the inspector's raw tab, one clipboard copy, + * one handoff. Carrying it on every row pays that weight on every poll and buys nothing + * until a row is actually opened, so it leaves the bulk response and is fetched per record + * instead. + * + * `rawOmitted` deliberately stays on the row. Whether a message was too large to carry at + * all is a fact the list itself has to be able to state — it is one boolean per row, and + * it is what lets the inspector say "omitted" instead of silently showing something else. + * + * Records are carried per turn, not as one flat list, so this walks the turns. + * + * @param {Array>} turns + * @returns {Map} + */ +export function splitRawRecords(turns) { + /** @type {Map} */ + const raws = new Map(); + if (!Array.isArray(turns)) return raws; + for (const turn of turns) { + if (!turn || typeof turn !== 'object' || !Array.isArray(turn.records)) continue; + for (const record of turn.records) { + if (!record || typeof record !== 'object' || !('raw' in record)) continue; + const index = Number(record.index); + if (Number.isInteger(index) && index > 0) raws.set(index, record.raw); + delete record.raw; + } + } + return raws; +} + +/** + * Builds the frozen `/api/trajectory` payload from already-parsed rows. + * @param {{ session: { id: string, createdAtMs?: number | null }, rows: Array>, sizeBytes?: number, truncated?: boolean, redact?: (value: string) => string, generations?: Array>, generation?: number | null, orphans?: Array<{ generation: number, fileName: string }> }} input + */ +export function buildTrajectoryPayload(input) { + const { session, rows, sizeBytes = 0, truncated = false } = input; + const redact = input.redact ?? ((value) => value); + /** @type {Array>} */ + const records = []; + /** @type {Array>} */ + const turns = []; + const turnById = new Map(); + const toolCallsById = new Map(); + const stats = { + messages: rows.length, + userMessages: 0, + assistantMessages: 0, + toolCalls: 0, + toolResults: 0, + thinkingBlocks: 0, + injectedBlocks: 0, + compactions: 0, + turns: 0, + turnsWithPrompt: 0, + continuationTurns: 0, + qaTurns: 0, + openTurns: 0, + steers: 0, + compactionTurns: 0, + turnsWithoutPrompt: 0, + errors: 0, + tokens: emptyTokens(), + durationMs: null, + }; + + let startedAt = null; + let lastTimestamp = null; + let label = null; + let textTruncatedAny = false; + + /** @param {string | null} turnId */ + const ensureTurn = (turnId) => { + const key = turnId ?? ''; + let turn = turnById.get(key); + if (!turn) { + turn = { + id: turnId, + index: turns.length + 1, + startedAt: null, + endedAt: null, + durationMs: null, + toolCalls: 0, + errors: 0, + tokens: emptyTokens(), + records: [], + }; + turnById.set(key, turn); + turns.push(turn); + } + return turn; + }; + + for (const row of rows) { + const message = row && typeof row.message === 'object' && row.message !== null ? row.message : {}; + const source = /** @type {Record} */ (message); + const role = typeof source.role === 'string' ? source.role : 'unknown'; + const timestamp = finiteOrNull(source.timestamp); + const turnId = typeof row.turn_id === 'string' ? row.turn_id : null; + // A compactionSummary is written under ":compaction" — the id of the turn it + // interrupted, plus a suffix — while every row after it carries the bare id. Keying on the + // whole string therefore opened a turn for the checkpoint alone, and because a turn joins the + // list the moment it is first seen, that turn was emitted after the entire turn it belongs to: + // the checkpoint rendered past records that came later, and the numbering ran backwards + // (#1037 then #970). Keying the checkpoint on the bare id puts it back where it happened — + // inside the turn it interrupted, which is also the only place a context checkpoint is. + const turnKey = role === 'compactionSummary' + && turnId !== null + && turnId.length > COMPACTION_TURN_SUFFIX.length + && turnId.endsWith(COMPACTION_TURN_SUFFIX) + ? turnId.slice(0, -COMPACTION_TURN_SUFFIX.length) + : turnId; + const turn = ensureTurn(turnKey); + if (timestamp !== null) { + if (startedAt === null || timestamp < startedAt) startedAt = timestamp; + if (lastTimestamp === null || timestamp > lastTimestamp) lastTimestamp = timestamp; + if (turn.startedAt === null || timestamp < turn.startedAt) turn.startedAt = timestamp; + if (turn.endedAt === null || timestamp > turn.endedAt) turn.endedAt = timestamp; + } + + const raw = buildRawPayload(source, redact); + const usage = normalizeUsage(source.usage); + if (usage) { + stats.tokens.input += usage.input; + stats.tokens.output += usage.output; + stats.tokens.cacheRead += usage.cacheRead; + stats.tokens.cacheWrite += usage.cacheWrite; + if (usage.totalTokens !== null) stats.tokens.totalTokens += usage.totalTokens; + turn.tokens.input += usage.input; + turn.tokens.output += usage.output; + turn.tokens.cacheRead += usage.cacheRead; + turn.tokens.cacheWrite += usage.cacheWrite; + if (usage.totalTokens !== null) turn.tokens.totalTokens += usage.totalTokens; + } + if (source.isError === true) { + stats.errors += 1; + turn.errors += 1; + } + + const messageId = typeof row.message_id === 'string' ? row.message_id : null; + const base = { + id: messageId, + // The turn this record belongs to, which for a checkpoint is the turn it interrupted — not + // the suffixed id the file wrote. A record whose `turnId` names no turn is a contradiction + // the page cannot resolve. + turnId: turnKey, + role, + // Stamped when a lineage is stitched, so the page can group or filter by generation + // without having to re-derive it from timestamps. + generation: nonNegativeInteger(/** @type {any} */ (row)[ROW_GENERATION]), + timestamp, + // One model call = one responseId, and every row split out of that message repeats it, + // so the page can draw which rows arrived together. Tool results are not model + // responses and carry none. + responseId: typeof source.responseId === 'string' ? source.responseId : null, + relativeMs: null, + durationMs: null, + toolName: null, + toolCallId: null, + tokens: null, + model: typeof source.model === 'string' ? source.model : null, + provider: typeof source.provider === 'string' ? source.provider : null, + api: typeof source.api === 'string' ? source.api : null, + stopReason: typeof source.stopReason === 'string' ? source.stopReason : null, + isError: source.isError === true ? true : source.isError === false ? false : null, + raw: raw.value, + rawOmitted: raw.omitted, + textTruncated: false, + }; + + /** @param {Partial> & { kind: string, title: string, summary: string }} patch */ + const push = (patch) => { + const record = { + ...base, + index: records.length + 1, + relativeMs: timestamp === null || startedAt === null ? null : timestamp - startedAt, + text: null, + thinking: null, + arguments: null, + result: null, + error: null, + tokensBefore: null, + producedBy: null, + parentGeneration: null, + parentCompactionId: null, + parentRevision: null, + ...patch, + }; + if (record.textTruncated) textTruncatedAny = true; + records.push(record); + turn.records.push(record); + return record; + }; + + const content = source.content; + const blocks = Array.isArray(content) ? content : null; + + if (role === 'assistant') { + stats.assistantMessages += 1; + const texts = []; + const thinkingParts = []; + const callParts = []; + if (blocks) { + for (const block of blocks) { + if (!block || typeof block !== 'object') continue; + const item = /** @type {Record} */ (block); + if (item.type === 'text' && typeof item.text === 'string') texts.push(item.text); + else if (item.type === 'thinking' && typeof item.thinking === 'string') thinkingParts.push(item.thinking); + else if (item.type === 'toolCall') callParts.push(item); + } + } else if (typeof content === 'string') texts.push(content); + else if (typeof source.text === 'string') texts.push(source.text); + + const textSource = texts.length > 0 ? texts.join('\n') : null; + const textField = clipField(textSource === null ? null : redact(textSource), MAX_FIELD_CHARS); + const thinkingField = clipField(thinkingParts.length > 0 ? redact(thinkingParts.join('\n')) : null, MAX_FIELD_CHARS); + + let usageAttached = false; + const carrierUsage = () => { + if (usageAttached) return null; + usageAttached = true; + return usage; + }; + + // The ledger imposes one explicit order on an assistant message: the reasoning, then + // the answer, then the actions it asked for. The session writer emits them in this order + // today, but the page must not depend on that — an ordering this UI presents is an + // invariant we own, so state it outright rather than inferring it from array position. + /** @type {Array<'text' | 'thinking' | 'toolCall'>} */ + const segmentOrder = []; + const present = (kind) => ( + kind === 'text' ? textField.text !== null + : kind === 'thinking' ? thinkingField.text !== null + : callParts.length > 0 + ); + for (const kind of /** @type {const} */ (['thinking', 'text', 'toolCall'])) { + if (present(kind)) segmentOrder.push(kind); + } + + // Token usage belongs to the assistant message, and must follow the answer it was + // measured for — not the reasoning block that now precedes it. A message with no text + // keeps the usage on its first record, which is the rule that applied before. + const usageOwner = textField.text !== null ? 'text' : (segmentOrder[0] ?? null); + + for (const kind of segmentOrder) { + if (kind === 'text') { + if (textField.text === null) continue; + push({ + // The model says why it stopped, and that sentence is the whole test. `stop` + // means it finished, so this text is the answer the turn delivers. `toolUse` + // means it is about to call a tool, so this text is the commentary it wrote on + // the way there. A third value, `aborted`, marks a turn cut off part-way; its + // text never became an answer, so it stays with the commentary rather than + // being passed off as one. Filing all of them under one label buried the + // answers under several times their own volume. + kind: source.stopReason === 'stop' ? 'reply' : 'narration', + title: 'assistant', + summary: singleLine(textField.text, MAX_SUMMARY_CHARS), + text: textField.text, + textTruncated: textField.truncated, + tokens: kind === usageOwner ? carrierUsage() : undefined, + }); + continue; + } + if (kind === 'thinking') { + if (thinkingField.text === null) continue; + stats.thinkingBlocks += 1; + push({ + kind: 'thinking', + title: 'thinking', + summary: singleLine(thinkingField.text, MAX_SUMMARY_CHARS), + thinking: thinkingField.text, + textTruncated: thinkingField.truncated, + tokens: kind === usageOwner ? carrierUsage() : undefined, + }); + continue; + } + for (const call of callParts) { + stats.toolCalls += 1; + turn.toolCalls += 1; + const name = typeof call.name === 'string' ? call.name : 'unknown'; + const toolCallId = typeof call.id === 'string' ? call.id : null; + const prepared = prepareArguments(call.arguments, redact); + const record = push({ + kind: 'toolCall', + title: name, + summary: toolCallSummary(name, prepared.summaryArgs), + toolName: name, + toolCallId, + arguments: prepared.value, + argumentsOmitted: prepared.omitted, + tokens: kind === usageOwner ? carrierUsage() : undefined, + }); + if (toolCallId) toolCallsById.set(toolCallId, { record, toolCallId }); + } + } + // The test is this message's own segments, not the turn's record count. `turn.records` + // already holds everything earlier in the turn, so an assistant message carrying only + // `content: []` — a stopReason 'error', or a turn cut off before it said anything — added + // no row once any earlier message had produced one, while its usage still landed on the + // turn's Token total. The page then showed a number it could not attribute to any line. + if (segmentOrder.length === 0) { + push({ kind: source.stopReason === 'stop' ? 'reply' : 'narration', title: 'assistant', summary: '', text: null, tokens: carrierUsage() }); + } + continue; + } + + if (role === 'toolResult') { + stats.toolResults += 1; + const resultField = clipField(contentToPlainText(content) === null ? null : redact(String(contentToPlainText(content))), MAX_FIELD_CHARS); + const toolCallId = typeof source.toolCallId === 'string' ? source.toolCallId : null; + const toolName = typeof source.toolName === 'string' ? source.toolName : null; + const record = push({ + kind: 'toolResult', + title: toolName ?? 'toolResult', + summary: singleLine(resultField.text ?? '', MAX_SUMMARY_CHARS), + result: resultField.text, + textTruncated: resultField.truncated, + toolName, + toolCallId, + error: source.isError === true && resultField.text ? resultField.text : null, + }); + const pending = toolCallId ? toolCallsById.get(toolCallId) : null; + if (pending && !pending.result) pending.result = record; + continue; + } + + if (role === 'user') { + const plain = contentToPlainText(content) ?? (typeof source.text === 'string' ? source.text : null); + const split = splitUserMessage(source, plain); + + if (split.injected !== null) { + stats.injectedBlocks += 1; + const injectedField = clipField(redact(split.injected), MAX_FIELD_CHARS); + push({ + kind: 'system', + title: 'injected', + summary: singleLine(injectedField.text ?? '', MAX_SUMMARY_CHARS), + text: injectedField.text, + textTruncated: injectedField.truncated, + }); + } + + if (split.prompt !== null) { + // The first prompt of a turn is what opened it. Anything later arrived while the + // turn was still running — a steer, redirecting work already under way. + // + // Nothing in the session file marks this. Compared field by field, a steer is + // byte-identical to a typed prompt in role, stopReason, api, model, provider, tokens, + // producedBy, turnId and everything else; only id, timestamp and index differ. The + // signal available is position: measured across every turn that has one, its prompt + // sits at record 1 or 2 and never further in, so a user record past that point can + // only have been written mid-turn. That is an inference from ordering, not a fact + // the file asserts, and it is recorded as `steered` so the detail panel can say so. + const steered = turn.records.some((r) => r.kind === 'user'); + if (steered) stats.steers += 1; else stats.userMessages += 1; + if (label === null) label = singleLine(redact(split.prompt), MAX_LABEL_CHARS); + const promptField = clipField(redact(split.prompt), MAX_FIELD_CHARS); + push({ + kind: steered ? 'steer' : 'user', + title: 'user', + summary: singleLine(promptField.text ?? '', MAX_SUMMARY_CHARS), + text: promptField.text, + textTruncated: promptField.truncated, + steered, + }); + } + + if (split.prompt === null && split.injected === null) { + push({ kind: 'system', title: 'user', summary: '', text: null }); + } + continue; + } + + // A compaction is a context checkpoint written by the runtime, not something the user + // or the model said. It gets its own kind rather than riding inside `system` because + // the client filters on kind alone: filed under `system`, a checkpoint would be + // impossible to keep or drop on its own, and unticking 系统 would take every context + // checkpoint with it. `title` still says which of the `system` family it is. + if (role === 'compactionSummary') { + stats.compactions += 1; + const checkpoint = typeof source.summary === 'string' + ? source.summary + : contentToPlainText(content); + const checkpointField = clipField(checkpoint === null ? null : redact(checkpoint), MAX_FIELD_CHARS); + // `history_artifact` is the only thing on this row that says *what happened*: which + // generation it opened, who produced it, and which earlier revision it replaced. + const artifact = row.history_artifact && typeof row.history_artifact === 'object' && !Array.isArray(row.history_artifact) + ? /** @type {Record} */ (row.history_artifact) + : null; + const parent = artifact && artifact.parentSnapshot && typeof artifact.parentSnapshot === 'object' + ? /** @type {Record} */ (artifact.parentSnapshot) + : null; + push({ + kind: 'compaction', + title: 'compaction', + summary: singleLine(checkpointField.text ?? '', MAX_SUMMARY_CHARS), + text: checkpointField.text, + textTruncated: checkpointField.truncated, + tokensBefore: finiteOrNull(source.tokensBefore), + producedBy: artifact && typeof artifact.producedBy === 'string' ? artifact.producedBy : null, + generation: artifact && Number.isInteger(Number(artifact.generation)) ? Number(artifact.generation) : null, + parentGeneration: parent && Number.isInteger(Number(parent.generation)) ? Number(parent.generation) : null, + parentCompactionId: parent && typeof parent.compactionId === 'string' ? parent.compactionId : null, + parentRevision: parent && typeof parent.revision === 'string' && SAFE_REVISION.test(parent.revision) + ? parent.revision + : null, + }); + continue; + } + + const systemText = contentToPlainText(content) + ?? (typeof source.summary === 'string' ? source.summary : null) + ?? (typeof source.text === 'string' ? source.text : null); + const systemField = clipField(systemText === null ? null : redact(systemText), MAX_FIELD_CHARS); + push({ + kind: 'system', + title: 'system', + summary: singleLine(systemField.text ?? '', MAX_SUMMARY_CHARS), + text: systemField.text, + textTruncated: systemField.truncated, + }); + } + + for (const turn of turns) { + turn.durationMs = turn.startedAt === null || turn.endedAt === null ? null : turn.endedAt - turn.startedAt; + } + + for (const pending of toolCallsById.values()) { + const callRecord = pending.record; + const resultRecord = pending.result; + if (!resultRecord) continue; + const durationMs = callRecord.timestamp === null || resultRecord.timestamp === null + ? null + : resultRecord.timestamp - callRecord.timestamp; + callRecord.durationMs = durationMs; + resultRecord.durationMs = durationMs; + const preview = singleLine(resultRecord.result ?? '', 80); + if (preview) callRecord.summary = singleLine(`${callRecord.summary} → ${preview}`, MAX_SUMMARY_CHARS); + } + + stats.turns = turns.length; + // A raw turn total is the number most likely to be misread, so every turn is filed and the + // count is broken down. + // + // 对话 a turn somebody typed into — has a first-of-turn prompt + // 续作 no prompt, but it closed with a reply: the agent working after the reader + // answered a question, which arrives through the tool channel rather than + // as a message + // 压缩 a turn that opened a context generation; a checkpoint, not conversation + // 其他 neither a prompt nor a reply + // + // 问答 is not a turn class, which is what it was mistaken for. It is a prompted turn that + // did not close: it ends on the “is waiting for the local user” result of a tool that waits on + // the reader. Labelling the *next* turn instead — as an earlier version did — put the tag on + // a turn while the reader still had not answered, and on turns holding hundreds of records + // that were plainly work rather than a Q&A. + // + // The other thing worth saying: a turn either ends with exactly one reply (stopReason: stop) + // or with none. Measured over 63 prompted turns, 54 closed, 5 stopped on ask_user, 4 were cut + // off. So whether a turn finished is a fact about the data, and it is the fact the counts + // are built from. + let turnsWithPrompt = 0; + let continuationTurns = 0; + let qaTurns = 0; + let openTurns = 0; + let compactionTurns = 0; + for (const turn of turns) { + let hasPrompt = false; + let isCompaction = false; + let askedReader = false; + let closed = false; + for (const record of turn.records) { + // Injected blocks were demoted to system records, and a later prompt is filed as a + // steer, so a `user` record here is the prompt that opened the turn. + if (record.kind === 'user') hasPrompt = true; + if (record.kind === 'compaction') isCompaction = true; + if (record.kind === 'reply') closed = true; + if (record.kind === 'toolCall' && typeof record.toolName === 'string' + && USER_INPUT_TOOLS.has(record.toolName)) askedReader = true; + } + turn.askedReader = askedReader; + turn.closed = closed; + // A turn that *contains* a checkpoint is still judged on what it did. A checkpoint used to be + // a turn of its own, so "contains one" was the same as "is one"; now that the checkpoint + // rejoins the turn it interrupted, that would relabel every turn the runtime ever compacted + // as a checkpoint and drop all of them out of the prompted count. Only a turn that holds + // nothing but its checkpoint is one. + if (hasPrompt) { + turn.turnClass = 'prompt'; + turnsWithPrompt += 1; + if (!closed) openTurns += 1; + if (askedReader && !closed) qaTurns += 1; + } else if (closed) { + turn.turnClass = 'continuation'; + continuationTurns += 1; + } else if (isCompaction && turn.records.length === 1) { + // Only reachable when a checkpoint opens a turn the file never names — no work either side + // of it to attach to. + turn.turnClass = 'compaction'; + compactionTurns += 1; + } else { + turn.turnClass = 'none'; + stats.turnsWithoutPrompt += 1; + } + } + stats.turnsWithPrompt = turnsWithPrompt; + stats.continuationTurns = continuationTurns; + stats.qaTurns = qaTurns; + stats.openTurns = openTurns; + stats.compactionTurns = compactionTurns; + if (startedAt !== null && lastTimestamp !== null) stats.durationMs = lastTimestamp - startedAt; + const resolvedLabel = label ?? `会话 ${String(session.id).slice(4, 12)}`; + + const orphans = Array.isArray(input.orphans) ? input.orphans : []; + // The catalog can list a generation the lineage walk rejected: a snapshot left behind by a fork + // is on disk but is not part of this conversation. Summing those into `messagesAll` inflated the + // session-wide count, and listing them in `generations` offered the reader a pick that resolved + // to nothing. Both are fixed by reporting the chain rather than the catalog — the rejected ones + // are still disclosed through `orphans`. + const orphanFiles = new Set(orphans.map((orphan) => orphan.fileName)); + const generations = (Array.isArray(input.generations) ? input.generations : []) + .filter((entry) => !orphanFiles.has(entry.fileName)); + // `messages` counts what is on screen. When the whole lineage is stitched that already + // spans every generation; when the reader filtered to one, `messagesAll` keeps the + // session-wide number from silently shrinking to a single slice. + const messagesAll = generations.length > 1 + ? generations.reduce((sum, entry) => sum + (Number.isInteger(entry.messageCount) ? entry.messageCount : 0), 0) + : rows.length; + + return { + session: { + id: session.id, + label: resolvedLabel, + messageCount: rows.length, + turnCount: turns.length, + startedAt, + lastActiveAt: session.createdAtMs ?? startedAt, + sizeBytes, + truncated, + // The page prints this number when it reports truncation, so it is sent rather than + // restated in the markup where the two could drift apart again. + maxMessageBytes: MAX_MESSAGE_BYTES, + generation: input.generation ?? null, + generations, + orphans, + }, + stats: { ...stats, messagesAll }, + turns: turns.map((turn) => ({ + id: turn.id, + index: turn.index, + startedAt: turn.startedAt, + endedAt: turn.endedAt, + durationMs: turn.durationMs, + toolCalls: turn.toolCalls, + errors: turn.errors, + tokens: turn.tokens, + // prompt | continuation | compaction | none. + turnClass: turn.turnClass ?? 'none', + // Whether this turn asked the reader something and therefore has not closed yet, and + // whether it ended with the answer it owes. Together they explain every gap between the + // turn count and the reply count. + askedReader: turn.askedReader === true, + closed: turn.closed === true, + records: turn.records, + })), + }; +} + +/** + * Redacts decoded string values in place of the serialized form: rewriting serialized JSON would + * corrupt nested escaped strings (a manifest embedded in `details` re-parses as broken JSON). + * @param {any} value + * @param {(value: string) => string} redact + * @param {number} [depth] + */ +function redactStructure(value, redact, depth = 0) { + if (depth > 24) return null; + if (typeof value === 'string') return redact(value); + if (Array.isArray(value)) return value.map((entry) => redactStructure(entry, redact, depth + 1)); + if (value && typeof value === 'object') { + /** @type {Record} */ + const out = {}; + for (const [key, entry] of Object.entries(value)) { + out[key] = redactStructure(entry, redact, depth + 1); + } + return out; + } + return value; +} + +/** + * @param {Record} message + * @param {(value: string) => string} redact + */ +function buildRawPayload(message, redact) { + if (!message || typeof message !== 'object') return { value: null, omitted: true }; + let serialized = ''; + try { + serialized = JSON.stringify(message); + } catch { + return { value: null, omitted: true }; + } + if (typeof serialized !== 'string') return { value: null, omitted: true }; + if (Buffer.byteLength(serialized, 'utf8') > MAX_RAW_BYTES) return { value: null, omitted: true }; + return { value: serialized.includes('.minimax') ? redactStructure(message, redact) : message, omitted: false }; +} + +/** + * @param {unknown} args + * @param {(value: string) => string} redact + */ +function prepareArguments(args, redact) { + if (args === undefined || args === null) return { value: null, omitted: false, summaryArgs: null }; + if (typeof args !== 'object') return { value: String(args), omitted: false, summaryArgs: String(args) }; + let serialized = ''; + try { + serialized = JSON.stringify(args); + } catch { + return { value: null, omitted: true, summaryArgs: null }; + } + // The summary is truncated to one short line, so it can safely use the uncapped redacted copy. + const redacted = serialized.includes('.minimax') ? redactStructure(args, redact) : args; + if (Buffer.byteLength(serialized, 'utf8') > MAX_ARGUMENT_BYTES) { + return { value: null, omitted: true, summaryArgs: redacted }; + } + return { value: redacted, omitted: false, summaryArgs: redacted }; +} + +/** + * @param {any} response + * @param {number} status + * @param {unknown} payload + */ +function sendJson(response, status, payload) { + if (response.writableEnded || response.destroyed) return; + let body; + try { + body = JSON.stringify(payload); + } catch { + return; + } + try { + response.writeHead(status, { ...JSON_HEADERS, 'content-length': Buffer.byteLength(body, 'utf8') }); + response.end(body); + } catch { + // The client polls aggressively and aborts requests; a closed socket is not an error here. + } +} + +/** + * A weak validator derived only from cheap-to-read facts, so it can be computed *before* + * the expensive file read and still change whenever the payload would change. + * @param {string} material + * @returns {string} + */ +export function weakETag(material) { + return 'W/"' + createHash('sha1').update(material).digest('hex').slice(0, 20) + '"'; +} + +/** + * Matches an `If-None-Match` header against the current tag, per RFC 9110: `*` matches + * anything, and a list matches if any member matches (weak comparison ignores the `W/` prefix). + * @param {string | undefined | null} header + * @param {string} etag + * @returns {boolean} + */ +export function etagMatches(header, etag) { + if (typeof header !== 'string' || header === '') return false; + const target = etag.replace(/^W\//, ''); + return header.split(',').some((candidate) => { + const value = candidate.trim(); + if (value === '*') return true; + return value.replace(/^W\//, '') === target; + }); +} + +/** + * The cache key for one rendered trajectory. Every component comes from a `stat`, a small + * manifest/catalog file, or the already-resolved session binding — never from reading + * `messages.jsonl`, which is the whole point: a matching key proves the file did not grow. + * @param {{ dir: string, sizeBytes: number, lastActiveAt: number }} entry + * @param {{ catalogBytes?: number | null }} identity + * @param {any} binding + * @returns {string} + */ +export function trajectoryCacheKey(entry, identity, binding, filter) { + const bind = binding + ? [ + binding.sessionId ?? '', + binding.title ?? '', + binding.ambiguous ? '1' : '0', + binding.leased ? '1' : '0', + (binding.candidates ?? []).map((c) => `${c.sessionId}:${c.title}`).join(','), + ].join('|') + : 'file-mtime'; + // The stitched answer depends on every generation in the chain, not just the file that is + // being appended to, so the catalog's own numbers have to be part of the key. Without them a + // compaction that only rotated snapshots could serve a stale pre-rotation body. + const lineage = (identity.generations ?? []) + .map((g) => `${g.generation}:${g.fileName}:${g.byteLength ?? ''}`) + .join(','); + // The requested slice is part of the answer too: the same session stitched and unfiltered is + // two different bodies and must never share an entry. + const gen = filter === null || filter === undefined ? 'all' : String(filter); + return `${entry.dir}|gen=${gen}|${lineage}|${entry.sizeBytes}|${entry.lastActiveAt}|${identity.catalogBytes ?? ''}|${bind}`; +} + +/** + * Sends an already-serialized JSON body. Unlike {@link sendJson} it supports revalidation: + * `no-store` would suppress the 304 path, so revalidated responses advertise `no-cache` + * (storable, but must be revalidated) and carry the tag the page echoes back. + * @param {any} response + * @param {number} status + * @param {string} body + * @param {{ etag?: string, revalidate?: boolean }} [options] + */ +export function sendJsonBody(response, status, body, options) { + if (response.writableEnded || response.destroyed) return; + const headers = { ...JSON_HEADERS }; + if (options && options.revalidate) headers['cache-control'] = 'no-cache'; + if (options && options.etag) headers.etag = options.etag; + if (status === 304) { + // A 304 carries no body and must not advertise one. + try { + response.writeHead(status, headers); + response.end(); + } catch { + // Aborted by the client; see sendJson. + } + return; + } + headers['content-length'] = Buffer.byteLength(body, 'utf8'); + try { + response.writeHead(status, headers); + response.end(body); + } catch { + // The client polls aggressively and aborts requests; a closed socket is not an error here. + } +} + +/** @param {any} response */ +function sendNotFound(response) { + sendJson(response, 404, { error: 'not_found' }); +} + +/** @param {any} response @param {string} code @param {string} message */ +function sendError(response, status, code, message) { + sendJson(response, status, { error: code, message }); +} + +/** + * @param {MiniAppContext} context + */ +export async function start(context) { + const location = resolveSessionsRoot(); + const secrets = [location.sessionsRoot, location.root, join(homedir(), '.minimax')]; + const redact = createRedactor(secrets); + /** @type {Buffer | null} */ + let clientEntry = null; + let listCache = { at: 0, entries: null }; + /** @type {Map} */ + const descriptorCache = new Map(); + /** + * Serialized `/api/trajectory` payloads keyed by facts that are cheap to read, so an + * unchanged session is neither re-read from disk nor re-transferred on the next poll. + * + * `raw` lives here rather than in `body`: the untouched message is most of the session + * but is only ever read for one record at a time, so keeping it beside the body means + * the poll transfers the small payload and the record that gets opened still costs + * nothing extra. + * @type {Map }>} + */ + const trajectoryCache = new Map(); + + const loadClientEntry = async () => { + if (clientEntry) return clientEntry; + const buffer = await readFile(join(context.pluginRoot, 'miniapp', 'client', 'index.html')); + clientEntry = buffer; + return buffer; + }; + + const listEntries = async () => { + const now = Date.now(); + if (listCache.entries && now - listCache.at < LIST_CACHE_TTL_MS) return listCache.entries; + const entries = await collectSessionEntries(location.sessionsRoot); + listCache = { at: now, entries }; + return entries; + }; + + const describeSession = async (entry) => { + const cacheKey = `${entry.dir}|${entry.sizeBytes}|${entry.lastActiveAt}`; + const cached = descriptorCache.get(cacheKey); + if (cached && Date.now() - cached.at < SESSION_CACHE_TTL_MS) return cached.value; + const identity = await readSessionIdentity(entry.dir); + const cacheId = `${entry.dir}|${identity.id}`; + let peek = { messages: 0, userMessages: 0, assistantMessages: 0, toolResults: 0, toolCalls: 0, thinkingBlocks: 0, turns: 0 }; + let label = null; + let startedAt = identity.createdAtMs; + try { + const head = await readHead(join(entry.dir, 'messages.jsonl'), LIST_PEEK_BYTES); + peek = countRolesInPrefix(head.text); + label = findFirstUserLabel(head.text, redact); + const firstTimestamp = firstTimestampIn(head.text); + if (firstTimestamp !== null) startedAt = firstTimestamp; + } catch (error) { + context.logger.warn('miniapp.trajectory.read_error', { reason: 'peek_failed', code: errorCode(error) }); + } + const value = { + id: identity.id, + label: label ?? `会话 ${String(identity.id).slice(4, 12)}`, + messageCount: peek.messages, + startedAt, + lastActiveAt: entry.lastActiveAt, + sizeBytes: entry.sizeBytes, + turnCount: peek.turns, + cacheId, + }; + descriptorCache.set(cacheKey, { at: Date.now(), value }); + if (descriptorCache.size > SESSION_CACHE_ENTRIES) { + const oldest = descriptorCache.keys().next(); + if (!oldest.done) descriptorCache.delete(oldest.value); + } + return value; + }; + + /** + * Diagnostics: report what this Node process can actually observe about its own session + * identity. Names and structure only — no environment *values* are returned unless the + * value is itself a session id, which is the one fact this route exists to settle. + */ + const handleRuntime = async (response) => { + let diagError = null; + try { + let sqlite = { available: false, error: null, dbFile: null, exists: false, opened: false, queryError: null, resolvedSessionId: null, conversationCount: null }; + /** @type {any} */ + let mod = null; + try { + mod = await import('node:sqlite'); + sqlite.available = typeof mod.DatabaseSync === 'function'; + } catch (error) { + sqlite.error = String(/** @type {any} */ (error)?.message ?? error).slice(0, 200); + } + if (sqlite.available) { + // Name only — this route must never hand the browser a filesystem location. + sqlite.dbFile = basename(join(location.root, 'v2', 'sqlite', 'runtime-state.sqlite')); + const sqlitePath = join(location.root, 'v2', 'sqlite', 'runtime-state.sqlite'); + sqlite.exists = existsSync(sqlitePath); + let probe = null; + try { + probe = new mod.DatabaseSync(sqlitePath, { readOnly: true }); + const count = probe.prepare( + "select count(*) as n from local_runtime_sessions where session_kind = 'conversation' and purpose is null", + ).get(); + sqlite.conversationCount = count ? toNumber(count.n) : null; + } catch (error) { + sqlite.queryError = String(/** @type {any} */ (error)?.message ?? error).slice(0, 240); + } finally { + // A schema mismatch is the case this route exists to report, and it is exactly the case + // where `prepare` throws — so closing inside the try left the handle waiting for GC. + closeQuietly(probe); + } + const current = await resolveCurrentConversation(location.root); + sqlite.opened = Boolean(current); + sqlite.resolvedSessionId = current ? current.sessionId : null; + } + sendJson(response, 200, { + nodeVersion: process.version, + // Everything below is a basename, a count, or a flag. This route deliberately publishes no + // path and no process internals: `process.argv[0]` is the absolute path of the node + // executable, which on Windows carries the user profile directory, and pid/ppid/execArgv + // are internals the page has no use for. The page reads none of this; the route exists to + // answer "is the runtime binding alive", and `sqlite` plus `sessionRootExists` answer that. + sqlite, + cwdBasename: basename(process.cwd()) || null, + dataDirBasename: basename(context.dataDir) || null, + sessionRootExists: existsSync(location.sessionsRoot), + }); + } catch (error) { + diagError = String(/** @type {any} */ (error)?.stack ?? error).slice(0, 600); + sendJson(response, 200, { diagError }); + } + }; + + const handleSessions = async (response, url) => { const limit = normalizeLimit(url.searchParams.get('limit'), 20, 1, 50); + let entries; + try { + entries = await listEntries(); + } catch (error) { + context.logger.error('miniapp.trajectory.read_error', { reason: 'root_unreadable', code: errorCode(error) }); + sendError(response, 503, 'trajectory_unavailable', '未找到本地会话数据目录'); + return; + } + if (entries.length === 0) { + sendError(response, 503, 'trajectory_unavailable', '未找到本地会话数据目录'); + return; + } + const described = []; + const visible = entries.slice(0, limit); + // Real titles from the runtime database; sub-agent and cron sessions get a kind badge so + // the picker never presents them as if they were the user's conversation. + const titles = await readSessionTitles( + location.root, + visible.map((entry) => decodeSessionIdFromDirName(basename(entry.dir))).filter(Boolean), + ); + for (const entry of visible) { + try { + const descriptor = await describeSession(entry); + const meta = titles.get(decodeSessionIdFromDirName(basename(entry.dir))); + if (meta) { + if (meta.title) descriptor.label = meta.title; + descriptor.sessionKind = meta.kind; + } + described.push(descriptor); + } catch (error) { + context.logger.warn('miniapp.trajectory.read_error', { reason: 'describe_failed', code: errorCode(error) }); + } + } + sendJson(response, 200, { sessions: described.map(stripInternalFields) }); + }; + + /** + * Which session a trajectory request is about, plus everything that decides whether it + * has changed. The bulk payload and the per-record raw fetch both go through here, so + * the same query string can never be read as two different sessions. + * + * @param {URL} url + */ + const resolveTrajectoryRequest = async (url) => { + let entries; + try { + entries = await listEntries(); + } catch (error) { + context.logger.error('miniapp.trajectory.read_error', { reason: 'root_unreadable', code: errorCode(error) }); + return { ok: false, status: 503, code: 'trajectory_unavailable', message: '未找到本地会话数据目录' }; + } + if (entries.length === 0) { + return { ok: false, status: 503, code: 'trajectory_unavailable', message: '未找到本地会话数据目录' }; + } + const requested = url.searchParams.get('session'); + let entry = null; + let binding = null; + if (!requested || requested === 'latest') { + // Ask the runtime database which conversation is live before falling back to mtime: + // a worker or cron session often has the newest file even while the user reads another one. + const current = await resolveCurrentConversation(location.root); + if (current) { + entry = entries.find((candidate) => basename(candidate.dir).includes('session_') + && decodeSessionIdFromDirName(basename(candidate.dir)) === current.sessionId) ?? null; + if (!entry && current.relativeDir) { + // Not in the recent window — build the entry straight from the recorded path. That value + // is a database column rather than a name this app chose, so it earns the same + // containment check `resolveArtifactPath` gives catalog file names. + const root = resolve(location.sessionsRoot); + const dir = resolve(root, current.relativeDir.replace(/\\/g, '/')); + if (dir !== root && !dir.startsWith(root + sep)) { + context.logger.warn('miniapp.trajectory.read_error', { reason: 'session_dir_rejected' }); + entry = null; + } else { + try { + const fileStat = await stat(join(dir, 'messages.jsonl')); + entry = { dir, sizeBytes: fileStat.size, lastActiveAt: Math.floor(fileStat.mtimeMs) }; + } catch { + entry = null; + } + } + } + if (entry) binding = current; + } + if (!entry) entry = entries[0]; + } else { + const matched = []; + for (const candidate of entries) { + const identity = await readSessionIdentity(candidate.dir); + if (identity.id === requested || basename(candidate.dir) === requested) { + matched.push({ entry: candidate, identity }); + break; + } + } + if (matched.length === 0) { + return { ok: false, status: 404, code: 'session_not_found', message: '找不到该会话' }; + } + entry = matched[0].entry; + } + + const identity = await readSessionIdentity(entry.dir); + + // Which slice of history to show. The default is the whole lineage, because that is what + // the session actually contains: a compaction rotates the old messages into a snapshot + // rather than deleting them, so reading only the active file would show a conversation + // that stops existing at its first checkpoint. `?generation=N` narrows to one slice. + const generations = identity.generations; + const requestedGeneration = url.searchParams.get('generation'); + /** @type {number | null} */ + let filter = null; + if (requestedGeneration !== null && requestedGeneration !== '') { + const wanted = Number(requestedGeneration); + filter = Number.isInteger(wanted) && wanted >= 0 ? wanted : NaN; + if (!generations.some((candidate) => candidate.generation === filter)) { + return { ok: false, status: 404, code: 'generation_not_found', message: '找不到该上下文代' }; + } + } + + // Everything needed to decide "did this change?" is already in hand. Building the key + // before the read is what lets an unchanged session skip the read entirely. + return { ok: true, entry, binding, identity, generations, filter, cacheKey: trajectoryCacheKey(entry, identity, binding, filter) }; + }; + + const handleTrajectory = async (response, url) => { + const ctx = await resolveTrajectoryRequest(url); + if (!ctx.ok) { + sendError(response, ctx.status, ctx.code, ctx.message); + return; + } + const { entry, binding, identity, generations, filter, cacheKey } = ctx; + const etag = weakETag(cacheKey); + const requestEtag = response.req && response.req.headers ? response.req.headers['if-none-match'] : null; + const cached = trajectoryCache.get(cacheKey); + if (cached) { + if (etagMatches(requestEtag, etag)) { + sendJsonBody(response, 304, '', { etag, revalidate: true }); + return; + } + // No validator from the page (first load, or a fresh tab): reuse the body we already + // built instead of re-reading and re-parsing the files to rebuild the same bytes. + sendJsonBody(response, 200, cached.body, { etag, revalidate: true }); + return; + } + + // Walk the lineage from the active file outward. A small head of each candidate is enough + // to learn which generation it claims to be, so only the files that actually belong to the + // chain get parsed in full. + const artifactByFile = new Map(generations.map((candidate) => [candidate.fileName, candidate])); + // history-catalog.json is written by the runtime when it rotates generations, so a session + // that has never compacted does not have one and its `generations` list is empty. The active + // file is real regardless, and `probe` below already synthesises an artifact for it; reuse + // that same shape here instead of rejecting the whole session with artifact_path_rejected. + const activeArtifactFor = (fileName) => artifactByFile.get(fileName) + ?? (fileName === 'messages.jsonl' + ? { + fileName: 'messages.jsonl', + active: true, + generation: null, + kind: 'messages', + byteLength: null, + messageCount: null, + revision: null, + } + : null); + /** @type {Map} */ + const probed = new Map(); + const probe = async (generation) => { + const artifact = generation === null + ? { fileName: 'messages.jsonl', active: true, byteLength: identity.catalogBytes } + : generations.find((candidate) => candidate.generation === generation) ?? null; + if (artifact === null) return null; + if (generation !== null && artifact.active) return null; + const filePath = resolveArtifactPath(entry.dir, artifact); + if (filePath === null) return null; + if (probed.has(artifact.fileName)) return probed.get(artifact.fileName); + const head = await readHead(filePath, GENERATION_PROBE_BYTES); + const result = { fileName: artifact.fileName, marker: readGenerationMarker(head.text) }; + probed.set(artifact.fileName, result); + return result; + }; + + /** @type {{ chain: Array<{ generation: number, fileName: string }>, orphans: Array<{ generation: number, fileName: string }> }} */ + let lineage; + try { + lineage = await walkLineage({ generations }, probe); + } catch (error) { + context.logger.error('miniapp.trajectory.read_error', { reason: 'lineage_unreadable', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); + return; + } + if (lineage.chain.length === 0) { + sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); + return; + } + const activeFileName = lineage.chain[lineage.chain.length - 1].fileName; + const activeArtifact = activeArtifactFor(activeFileName); + + // Concatenate oldest → newest, which is the order the conversation actually happened in. + /** @type {Array>} */ + const rows = []; + let skippedTotal = 0; + let truncated = false; + let readBytes = 0; + for (const link of lineage.chain) { + const artifact = activeArtifactFor(link.fileName); + const filePath = artifact === null ? null : resolveArtifactPath(entry.dir, artifact); + if (filePath === null) { + context.logger.error('miniapp.trajectory.read_error', { reason: 'artifact_path_rejected', code: errorCode(null) }); + sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); + return; + } + // Bound the read by our own cap only. The catalog's recorded sizes are a snapshot and the + // active messages.jsonl keeps being appended to after the catalog was written, so using + // them as a read limit both dropped records that were really on disk and — because the read + // then stopped short of the file — made `head.read < head.size` report "file too large" for + // a file that was merely newer than its catalog entry. `readHead` already clamps to the real + // stat size, so MAX_MESSAGE_BYTES bounds memory on its own. + const limit = MAX_MESSAGE_BYTES; + let head; + try { + head = await readHead(filePath, limit); + } catch (error) { + context.logger.error('miniapp.trajectory.read_error', { reason: 'messages_unreadable', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); + return; + } + if (head.text === '') { + context.logger.warn('miniapp.trajectory.read_error', { reason: 'empty_messages' }); + } + // Truncated means our cap cut the read, nothing else. + if (truncatedByCap(head.size, limit)) truncated = true; + readBytes += head.read; + const parsed = parseMessagesJsonl(head.text); + skippedTotal += parsed.skipped; + const keep = filter === null || filter === link.generation; + for (const row of parsed.rows) { + if (keep) { + row[ROW_GENERATION] = link.generation; + rows.push(row); + } + } + } + if (skippedTotal > 0) { + context.logger.warn('miniapp.trajectory.parse_warn', { skipped: skippedTotal }); + } + + const shown = filter === null ? lineage.chain : lineage.chain.filter((link) => link.generation === filter); + const payload = buildTrajectoryPayload({ + session: { id: identity.id, createdAtMs: entry.lastActiveAt }, + rows, + sizeBytes: readBytes, + truncated, + redact, + generations, + generation: shown.length === 1 ? shown[0].generation : null, + orphans: lineage.orphans, + }); + payload.session.lastActiveAt = entry.lastActiveAt; + payload.session.activeGeneration = activeArtifact === null ? null : activeArtifact.generation; + payload.session.stitched = shown.length > 1; + if (binding) { + // Tell the page how this session was chosen so the footer can be honest about it. + payload.session.binding = binding.ambiguous ? 'ambiguous' : 'runtime-db'; + payload.session.leased = binding.leased; + if (binding.title) payload.session.label = binding.title; + if (binding.ambiguous) { + payload.session.ambiguousCandidates = binding.candidates.map((candidate) => ({ + id: candidate.sessionId, + label: candidate.title, + })); + } + } else { + payload.session.binding = 'file-mtime'; + payload.session.leased = false; + } + + // Last thing before serializing, and the only place `raw` is ever touched: from here on + // the body carries no message bodies at all. + const raws = splitRawRecords(payload.turns); + + let body; + try { + body = JSON.stringify(payload); + } catch { + sendJson(response, 500, { error: 'trajectory_unserializable', message: '会话数据暂时无法读取' }); + return; + } + trajectoryCache.set(cacheKey, { at: Date.now(), etag, body, raws }); + while (trajectoryCache.size > TRAJECTORY_CACHE_ENTRIES) { + const oldest = trajectoryCache.keys().next(); + if (oldest.done) break; + trajectoryCache.delete(oldest.value); + } + sendJsonBody(response, 200, body, { etag, revalidate: true }); + }; + + /** + * One record's untouched message, fetched only when something actually reads it. + * + * Served out of the same cache entry as the bulk body, so opening the inspector's raw + * tab costs one record rather than a session. A miss means this process restarted + * between the two calls; saying so is more honest than silently showing the normalized + * record in a panel labelled 原文. + */ + const handleRaw = async (response, url) => { + const ctx = await resolveTrajectoryRequest(url); + if (!ctx.ok) { + sendError(response, ctx.status, ctx.code, ctx.message); + return; + } + const wanted = Number.parseInt(url.searchParams.get('index') ?? '', 10); + if (!Number.isInteger(wanted) || wanted < 1) { + sendError(response, 400, 'record_index_required', '缺少记录序号'); + return; + } + const cached = trajectoryCache.get(ctx.cacheKey); + if (!cached) { + sendError(response, 503, 'trajectory_not_cached', '会话数据已释放,请重新加载'); + return; + } + if (!cached.raws.has(wanted)) { + sendError(response, 404, 'record_not_found', '找不到该记录'); + return; + } + sendJson(response, 200, { index: wanted, raw: cached.raws.get(wanted) }); + }; + + const handleDashboard = async (response) => { + try { + const entry = await loadClientEntry(); + if (response.writableEnded || response.destroyed) return; + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'content-length': entry.length, + 'cache-control': 'no-store', + }); + response.end(entry); + } catch (error) { + context.logger.error('miniapp.trajectory.read_error', { reason: 'client_entry_unreadable', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '页面资源暂时无法读取'); + } + }; + + const server = createServer((request, response) => { + const rawUrl = request.url ?? '/'; + response.on('error', () => undefined); + request.on('error', () => undefined); + if (request.method !== 'GET') { + sendNotFound(response); + return; + } + let url; + try { + url = new URL(rawUrl, 'http://miniapp.local'); + } catch { + sendNotFound(response); + return; + } + if (request.destroyed || response.destroyed) return; + if (url.pathname === '/dashboard') { + void handleDashboard(response).catch((error) => { + context.logger.warn('miniapp.request.failed', { route: 'dashboard', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '页面资源暂时无法读取'); + }); + return; + } + if (url.pathname === '/api/runtime') { + void handleRuntime(response).catch((error) => { + context.logger.error('miniapp.trajectory.read_error', { reason: 'runtime_diag_failed', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '运行时诊断暂时不可用'); + }); + return; + } + if (url.pathname === '/api/sessions') { + void handleSessions(response, url).catch((error) => { + context.logger.error('miniapp.trajectory.read_error', { reason: 'sessions_failed', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); + }); + return; + } + if (url.pathname === '/api/trajectory') { + void handleTrajectory(response, url).catch((error) => { + context.logger.error('miniapp.trajectory.read_error', { reason: 'trajectory_failed', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); + }); + return; + } + if (url.pathname === '/api/trajectory/raw') { + void handleRaw(response, url).catch((error) => { + context.logger.error('miniapp.trajectory.read_error', { reason: 'raw_failed', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '原文暂时无法读取'); + }); + return; + } + sendNotFound(response); + }); + + // Keep aborted polls quiet: half-open sockets from frequent client interrupts are normal here. + server.on('clientError', (_error, socket) => { + if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); + else socket.destroy(); + }); + server.on('error', (error) => { + context.logger.error('miniapp.runtime.error', { code: errorCode(error) }); + }); + server.keepAliveTimeout = 5000; + server.headersTimeout = 10000; + + await listen(server, context.listen.host, context.listen.port); + context.logger.info('miniapp.runtime.listening'); + + /** @type {Promise | null} */ + let disposal = null; + /** @type {() => Promise} */ + const dispose = () => { + if (disposal) return disposal; + context.signal.removeEventListener('abort', onAbort); + listCache = { at: 0, entries: null }; + descriptorCache.clear(); + trajectoryCache.clear(); + clientEntry = null; + disposal = close(server).then(() => undefined); + return disposal; + }; + const onAbort = () => { + void dispose().catch(() => undefined); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} + +/** @param {any} value */ +function stripInternalFields(value) { + return { + id: value.id, + label: value.label, + messageCount: value.messageCount, + startedAt: value.startedAt, + lastActiveAt: value.lastActiveAt, + sizeBytes: value.sizeBytes, + turnCount: value.turnCount, + sessionKind: value.sessionKind ?? null, + }; +} + +/** + * @param {string} text + * @param {(value: string) => string} redact + */ +function findFirstUserLabel(text, redact) { + for (const line of text.split('\n')) { + if (!line.includes('"role":"user"') && !line.includes('"role": "user"')) continue; + let parsed; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + const message = parsed && typeof parsed === 'object' ? parsed.message : null; + if (!message || message.role !== 'user') continue; + const plain = contentToPlainText(message.content) ?? (typeof message.text === 'string' ? message.text : null); + // Sub-agent sessions have only injected "user" rows; those must not become the label. + const prompt = splitUserMessage(message, plain).prompt; + if (!prompt) continue; + const label = singleLine(redact(prompt), MAX_LABEL_CHARS); + if (label) return label; + } + return null; +} + +/** + * @param {string} text + */ +function firstTimestampIn(text) { + const match = /"timestamp"\s*:\s*(\d{10,16})/.exec(text); + if (!match) return null; + const value = Number.parseInt(match[1], 10); + return Number.isFinite(value) ? value : null; +} + +/** + * @param {unknown} error + */ +function errorCode(error) { + if (error && typeof error === 'object' && typeof (/** @type {any} */ (error).code) === 'string') { + return /** @type {any} */ (error).code; + } + return 'unknown'; +} + +/** + * @param {any} server + * @param {string} host + * @param {number} port + */ +function listen(server, host, port) { + return new Promise((resolve, reject) => { + const onError = (/** @type {unknown} */ error) => reject(error); + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + resolve(undefined); + }); + }); +} + +/** + * @param {any} server + */ +function close(server) { + return new Promise((resolve) => { + if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); + server.close(() => resolve(undefined)); + }); +} \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/package.json b/plugins/avatasia/mmc-trajectory/package.json new file mode 100644 index 0000000..aab6b8d --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +}