@1agents/session-reader 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Read Plane(读平面)** —— 跨智能体会话的发现、逐轮解析、按 `pwd` 聚合与蒸馏。
4
4
 
5
- 读平面与执行平面解耦:不依赖任何常驻守护进程、不写库、不改会话,**以本地原始会话文件为唯一准绳**,纯 Node.js 标准库(`fs/promises`、`path`、`os`、`readline`)实现。
5
+ 读平面与执行平面解耦:不依赖任何常驻守护进程、不写库、不改会话,**以本地原始会话文件为唯一准绳**,纯 Node.js 标准库(`fs/promises`、`path`、`os`、`readline`、`zlib`)实现。
6
6
 
7
7
  ## 覆盖的智能体
8
8
 
@@ -11,9 +11,24 @@
11
11
  | `antigravity` | `~/.gemini/antigravity/brain/<uuid>/.system_generated/logs/transcript.jsonl`(+ 同级 `implementation_plan.md` / `walkthrough.md` 等产出物) | `run_command` 的 `Cwd`;缺失时由所操作文件向上找 `.git` |
12
12
  | `claude` | `~/.claude/projects/<slug>/<session-id>.jsonl` | 条目自带的 `cwd` 字段(目录 slug 只用作快速预筛) |
13
13
  | `codex` | `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl` | `session_meta` / `turn_context` 的 `cwd` |
14
+ | `dsh`(DeepSeek Harness) | `~/.dsh/sessions/<slug>/session-<uuid>/session.v2.jsonl.zstd`(**zstd 分帧压缩**) | 开篇 `session` 记录的 `cwd` |
15
+ | `grok` | `~/.grok/sessions/<percent-encoded cwd>/<uuid>/chat_history.jsonl`(+ 同目录 `events.jsonl` / `rewind_points.jsonl` / `updates.jsonl` / `summary.json` / `goal/*.md`) | `summary.json` 的 `info.cwd`;目录名本身就是 cwd 的百分号编码,可反解 |
14
16
 
15
17
  所有会话被归一为同一组 `TurnEvent`:`user` / `assistant` / `thinking` / `tool_call` / `tool_result`。
16
18
 
19
+ 两家新来的各自带一个别人没有的麻烦,都在解析层吃掉了:
20
+
21
+ - **dsh 的 zstd 是「一次 flush 一帧」追加出来的**,整个文件是若干完整帧首尾相接。Node 的
22
+ `zstdDecompressSync` 和 `createZstdDecompress` 都只解第一帧就停——不报错,只是安静地
23
+ 少给你 99% 的会话。`src/util/zstd.ts` 按 RFC 8878 的帧头逐帧走位(不解压就能算出每帧
24
+ 边界),整文件读全;正在写入的半截尾帧丢掉而不是毒化整个会话。
25
+ - **grok 的 `chat_history.jsonl` 里一个时间戳都没有**。时间、工具成败、后台回执分散在同目录
26
+ 的侧文件里:`events.jsonl` 的 `tool_started`/`tool_completed`(按 `tool_call_id` 对上,给
27
+ 出耗时与 `outcome`)、`rewind_points.jsonl` 的 `prompt_index → created_at`(用户消息的
28
+ 确切时刻)、`updates.jsonl` 的 ACP 流(助理消息与思考的时刻,**按文本本身对上**而不是按
29
+ 位置猜;对不上就宁可不给时间戳)。token 记账也只有 `updates.jsonl` 有,没有这个文件的
30
+ 会话就如实不报 token。
31
+
17
32
  ## 安装
18
33
 
19
34
  ```bash
@@ -22,18 +37,18 @@ npm install -g @1agents/session-reader # 作为 1session 命令
22
37
  npx @1agents/session-reader list # 不安装直接用
23
38
  ```
24
39
 
25
- 要求 Node.js >= 22.5(依赖内置的 `node:sqlite`)。
40
+ 要求 Node.js >= 22.15(`node:sqlite` 建索引,`node:zlib` 的 zstd 读 dsh 的压缩会话;zstd 是 22.15 才进 `node:zlib` 的)。
26
41
  唯一的运行时依赖是自家的 [`@1agents/dreammate-network`](https://github.com/scottzx/dreammate-network)
27
42
  ——L0 协议定义,12 kB,本身零依赖——`npm install` 会自动带上,不需要单独装。
28
43
 
29
- ## 内置 skill:一条命令装到三家智能体
44
+ ## 内置 skill:一条命令装到五家智能体
30
45
 
31
- `1session` 自带一个 skill(`skills/1session/`),装进三家智能体各自的 skills 目录后,
46
+ `1session` 自带一个 skill(`skills/1session/`),装进各家智能体自己的 skills 目录后,
32
47
  它们在用户问起"上次/之前/那个报错"时会自己想起来调这个 CLI,而不需要你每次手动贴命令。
33
48
 
34
49
  ```bash
35
- 1session skill install # 链接到三家(未安装的智能体会跳过)
36
- 1session skill status # 看三家各自是什么状态
50
+ 1session skill install # 链接到五家(未安装的智能体会跳过)
51
+ 1session skill status # 看五家各自是什么状态
37
52
  1session skill uninstall # 撤掉
38
53
  ```
39
54
 
@@ -42,11 +57,13 @@ npx @1agents/session-reader list # 不安装直接用
42
57
  | claude | `~/.claude/skills/1session` |
43
58
  | codex | `~/.codex/skills/1session` |
44
59
  | antigravity | `~/.gemini/antigravity/skills/1session`(**不是** `~/.gemini/skills`,那是 gemini-cli 的位) |
60
+ | grok | `~/.grok/skills/1session` |
61
+ | dsh | `~/.dsh/skills/1session` |
45
62
 
46
- 三家的格式完全一致(`<dir>/<name>/SKILL.md` + YAML frontmatter),所以装的是同一份文件。
63
+ 五家的格式完全一致(`<dir>/<name>/SKILL.md` + YAML frontmatter),所以装的是同一份文件。
47
64
 
48
65
  默认建**符号链接**而不是拷贝:下次 `npm i -g @1agents/session-reader@latest` 升级后,
49
- 三家看到的 skill 自动就是新的,不用记着重装。Claude Code 实测会跟随符号链接并热加载。
66
+ 各家看到的 skill 自动就是新的,不用记着重装。Claude Code 实测会跟随符号链接并热加载。
50
67
  如果某家的加载器不认符号链接(表现是 `status` 显示已链接、但智能体里看不到这个 skill),
51
68
  用 `1session skill install --copy` 换成拷贝——代价是升级后要重跑一次安装,`status`
52
69
  会把"拷贝与当前包不一致"显式标出来。
@@ -79,7 +96,7 @@ npm run build && node dist/bin/1session.js <command>
79
96
  | `1session search <query> [--scope <path>\|cwd\|global] [--since 24h] [--limit n] [--kind k1,k2] [--regex] [--case] [--context n] [--max-hits n] [--include-self] [--json]` | 跨会话全文检索:命中带 `T<轮次> · E<事件号>` 句柄 + 上下文片段(默认当前 pwd 子树,见 `--scope`) |
80
97
  | `1session index [<id>] [--all] [--scope <path>\|cwd\|global] [--force] [--since 30d]` | 建立 / 刷新索引;`--all` 全库回填 |
81
98
  | `1session graph <id> [--json]`(别名 `related`) | 会话之间的引用关系 + 每条边的证据 |
82
- | `1session skill install\|status\|uninstall [--agent a,b] [--copy] [--force] [--dry-run]` | 把内置 skill 装进三家智能体的 skills 目录(见上) |
99
+ | `1session skill install\|status\|uninstall [--agent a,b] [--copy] [--force] [--dry-run]` | 把内置 skill 装进五家智能体的 skills 目录(见上) |
83
100
 
84
101
  全局开关 `--no-index` 绕过索引直读源文件。
85
102
 
@@ -106,13 +123,15 @@ npm run build && node dist/bin/1session.js <command>
106
123
 
107
124
  (`--workspace <path>` 是路径形式的旧拼法,仍然可用,优先于 `--scope`。)
108
125
 
109
- **路径从哪来**(三家各写各的,读之前先归一):
126
+ **路径从哪来**(各家各写各的,读之前先归一):
110
127
 
111
128
  | Provider | 项目路径 | 时间 |
112
129
  | --- | --- | --- |
113
130
  | claude | 目录名 = cwd 的 slug(`~/.claude/projects/-Users-me-proj/`),每行 JSONL 另带 `cwd` | `updatedAt` = 文件 mtime;`createdAt` = 首行 `timestamp` |
114
131
  | codex | 目录只按日期分(`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`),路径只在 `session_meta` / `turn_context` 的 `payload.cwd` 里 | `updatedAt` = mtime;`createdAt` = `session_meta.timestamp`,兜底解析文件名里的时间戳 |
115
132
  | antigravity | transcript 里没有 cwd,但 IDE 自己维护着项目↔会话的关系:`~/.gemini/antigravity/conversations/<id>.db` 的 `trajectory_metadata_blob` 里存着打开的目录(protobuf 字段 1.1,`file://` URI;1.2 是外层 workspace 根,1.4 是 git 分支)。读它,读不到才退回从工具参数里猜 | `updatedAt` = mtime;`createdAt` = 首个 step 的 `created_at` |
133
+ | dsh | 目录名是 cwd 的有损编码(`-` 同时代表 `/` 和字面 `-`),只当参考;真 cwd 在开篇 `session` 记录的 `cwd` 里。`scanRef` 只解压文件头 64KB——zstd 帧自带边界,取前缀就等于取会话前缀 | `updatedAt` = mtime;`createdAt` = `session.createdAt`(epoch 毫秒) |
134
+ | grok | 目录名是 cwd 的**百分号编码**,`decodeURIComponent` 就能无损还原,所以带 `--scope` 时可以在开文件之前就筛掉不相干的会话;`summary.json` 的 `info.cwd` 是正式答案 | `updatedAt` / `createdAt` 直接取 `summary.json`;候选发现时的 mtime 取 `chat_history.jsonl` 与 `summary.json` 里较新的那个 |
116
135
 
117
136
  排序和 `--since` 都只用 mtime,`listCandidates()` 一次 `stat` 就够,不必打开文件 —— 打开文件(拿标题、cwd、创建时间)才是贵的那一步,所以它走索引缓存。
118
137
 
@@ -126,7 +145,7 @@ Antigravity 的 626 个会话里,本机有 14 个至今没有路径 —— 不
126
145
  - 第一次全量解析本机 625 个会话约 **10s**,之后每次 `list` 约 **0.5s**;
127
146
  - `--no-index` 仍然可以绕开索引直读源文件,两条路的结果应当逐条一致(可以 `diff` 验证)。
128
147
 
129
- 升级到这一版后,索引会因为 `PARSER_VERSION` 提升而自动重建一次(antigravity 的 workspace 口径变了),无需手动清库;想主动做可以跑 `1session index --all --global`。
148
+ 升级到这一版后,索引会因为 `PARSER_VERSION` / `EXTRACTOR_VERSION` 提升而自动重建一次(新增 grok / dsh 两家,写文件工具表也跟着补了 `search_replace` 与 `run_terminal_command`),无需手动清库;想主动做可以跑 `1session index --all --global`。
130
149
 
131
150
  `<session-id>` 支持完整 id、id 前缀(≥6 位)或原始文件路径。
132
151
 
@@ -330,7 +349,7 @@ overview 的「末态」恰恰是最易腐的一段:
330
349
  - **`--since` 的精度**:预筛用文件 mtime(可能因同步/复制而失真),`--digest` 会在整篇解析后用真实轮次时间戳再过滤一次。
331
350
  - **Claude 标题**:`list` 只读文件头,标题取首个用户请求;`inspect`/`digest`/`workspace --digest` 会整篇解析,此时优先使用会话自身的 `custom-title` / `ai-title`。
332
351
  - **`search` 没有扫描上限**。早先它受发现层的扫描预算限制,一次查询其实只看最近 ~60 个会话/每个智能体,却把结果报告得像查全了——"静默不全"比慢危险。现在 `--limit` 只管返回几个会话,不再兼任"最多检查几个会话";满足 `--workspace` / `--since` / `--provider` 的会话一个不漏。默认每个会话最多列 5 处命中,`totalMatches` 给的是真实总数。
333
- - **文件归属分三级溯源**(见上文 provenance)。`observed` 来自显式写工具(`Write`/`Edit`/`write_to_file`/`replace_file_content`/`apply_patch`)与 provider 自己记的 `FileChange`;`derived` 是从 shell 命令里解析出来的(`>`/`>>`、`tee`、`cp`/`mv`、`scp`、`sed -i`、python 的 `write_text`/`open(w)`)。没有后者,纯靠 shell 干活的智能体(如 codex 全程走 `exec` 沙箱)会显示成"一个文件都没改过"。这是启发式,可能漏也可能多报,所以每行都写明是哪条规则提取的。
352
+ - **文件归属分三级溯源**(见上文 provenance)。`observed` 来自显式写工具(`Write`/`Edit`/`write_to_file`/`replace_file_content`/`apply_patch`/grok 的 `search_replace`)与 provider 自己记的 `FileChange`;`derived` 是从 shell 命令里解析出来的(`>`/`>>`、`tee`、`cp`/`mv`、`scp`、`sed -i`、python 的 `write_text`/`open(w)`)。没有后者,纯靠 shell 干活的智能体(如 codex 全程走 `exec` 沙箱)会显示成"一个文件都没改过"。这是启发式,可能漏也可能多报,所以每行都写明是哪条规则提取的。
334
353
  - **轮次边界按"其后最近开始的那一轮"归属**。`task_started` 总比该轮第一个事件早几秒(12:41:06 vs 12:41:13),按"落在窗口内"匹配会全部落空。
335
354
  - **统计优先用各家自己记的结构化数据,而不是我们推断**。codex 的 `event_msg/item_completed` 里有 `FileChange`(带 diff)、`CommandExecution`(带 pid/cwd)、`task_started/task_complete`(轮次边界 + 耗时)与 `token_usage_record`;claude 每条都带 `gitBranch`/`model`/`usage`;antigravity 的产物、上传、后台任务分别在 brain 目录、`.user_uploaded/` 与 `.system_generated/{tasks,messages}/`。统计里 `filesByProvenance` 给出这次的 `observed / derived` 分项计数。
336
355
  - **轮次边界按时间对齐,不按下标**。codex 原生边界(12 个)比用户消息(15 条)少,按下标取耗时会错位。
@@ -27,8 +27,8 @@ const USAGE = `1session — cross-agent session Read Plane
27
27
  1session graph <session-id> [--json] 会话之间的引用关系
28
28
  1session serve [--port 7777] [--host 127.0.0.1] [--token <t>] [--no-report]
29
29
  起 HTTP Service,把本机会话接入 DreamMate Network
30
- 1session skill install|status|uninstall [--agent claude,codex,antigravity]
31
- [--copy] [--force] [--dry-run] [--json] 装到三家智能体的 skills 目录
30
+ 1session skill install|status|uninstall [--agent claude,codex,antigravity,grok,dsh]
31
+ [--copy] [--force] [--dry-run] [--json] 装到五家智能体的 skills 目录
32
32
  1session search <query> [--scope <path>|cwd|global] [--since 24h] [--limit n] [--provider name]
33
33
  [--kind user,assistant,thinking,tool_call,tool_result]
34
34
  [--regex] [--case] [--context n] [--max-hits n]
@@ -42,7 +42,8 @@ search 的每条命中带 T<轮次> · E<事件号>,可直接拼成 1session t
42
42
  --scope 取当前目录的相对路径或绝对路径,按子树匹配:--scope .. 含同级项目,
43
43
  --scope ~ 含 home 下全部;--global(= --scope global,= --scope /)跨全部项目。
44
44
 
45
- Providers: antigravity (~/.gemini/antigravity/brain), claude (~/.claude/projects), codex (~/.codex/sessions).
45
+ Providers: antigravity (~/.gemini/antigravity/brain), claude (~/.claude/projects),
46
+ codex (~/.codex/sessions), dsh (~/.dsh/sessions), grok (~/.grok/sessions).
46
47
  `;
47
48
  /** Flags that never take a value, so they cannot swallow a positional. */
48
49
  const BOOLEAN_FLAGS = new Set([
@@ -0,0 +1,2 @@
1
+ import { type ProviderAdapter } from './provider.js';
2
+ export declare const dshAdapter: ProviderAdapter;
@@ -0,0 +1,298 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { readJsonl } from '../util/jsonl.js';
5
+ import { readZstdJsonl } from '../util/zstd.js';
6
+ import { canonicalizePath } from '../util/paths.js';
7
+ import { looksLikeInstructions, oneLine, stripPromptEnvelope } from '../util/text.js';
8
+ import { toolArgsOf } from './provider.js';
9
+ import { emptyProviderStats, } from '../types.js';
10
+ /** DeepSeek Harness keeps one directory per session under a slugified cwd. */
11
+ const SESSIONS_DIR = path.join(os.homedir(), '.dsh', 'sessions');
12
+ const SESSION_DIR = /^session-([0-9a-fA-F-]{36})$/;
13
+ /** Written compressed; the plain spelling is read too, in case it ever is. */
14
+ const TRANSCRIPTS = ['session.v2.jsonl', 'session.v2.jsonl.zstd'];
15
+ /** The harness appends the status of a failed shell command to its output. */
16
+ const EXIT_CODE = /\[exit code:\s*(\d+)\]\s*$/i;
17
+ /** The harness injects catalogues and instructions as user messages. */
18
+ function isRealUser(source) {
19
+ return (source?.kind ?? 'user') === 'user';
20
+ }
21
+ function textOf(blocks) {
22
+ return (blocks ?? [])
23
+ .map((block) => block?.text ?? '')
24
+ .filter(Boolean)
25
+ .join('\n');
26
+ }
27
+ function stamp(time) {
28
+ return typeof time === 'number' && time > 0 ? new Date(time).toISOString() : undefined;
29
+ }
30
+ function parseArguments(value) {
31
+ if (typeof value === 'string') {
32
+ try {
33
+ return toolArgsOf(JSON.parse(value)) ?? { input: value };
34
+ }
35
+ catch {
36
+ return { input: value };
37
+ }
38
+ }
39
+ return toolArgsOf(value);
40
+ }
41
+ /** Dispatches on the extension so both spellings read the same way. */
42
+ function readLines(file, headBytes) {
43
+ if (file.endsWith('.zstd')) {
44
+ return readZstdJsonl(file, { ...(headBytes ? { headBytes } : {}) });
45
+ }
46
+ // A plain transcript is line-oriented, so a head read is a line budget.
47
+ return readJsonl(file, headBytes ? { maxLines: 200 } : {});
48
+ }
49
+ /** `sessions/<slugified cwd>/session-<uuid>/session.v2.jsonl[.zstd]`. */
50
+ async function transcriptPath(dir) {
51
+ for (const name of TRANSCRIPTS) {
52
+ const full = path.join(dir, name);
53
+ if (await fs.stat(full).then((stat) => stat.isFile()).catch(() => false))
54
+ return full;
55
+ }
56
+ return undefined;
57
+ }
58
+ export const dshAdapter = {
59
+ provider: 'dsh',
60
+ async listCandidates() {
61
+ let projects;
62
+ try {
63
+ projects = await fs.readdir(SESSIONS_DIR);
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ const found = [];
69
+ for (const project of projects) {
70
+ const dir = path.join(SESSIONS_DIR, project);
71
+ for (const entry of await fs.readdir(dir).catch(() => [])) {
72
+ // The directory is `session-<uuid>`; the id is the uuid, so a short id
73
+ // means the same thing here as it does for every other provider.
74
+ const id = SESSION_DIR.exec(entry)?.[1];
75
+ if (!id)
76
+ continue;
77
+ const file = await transcriptPath(path.join(dir, entry));
78
+ if (!file)
79
+ continue;
80
+ const stat = await fs.stat(file).catch(() => undefined);
81
+ if (!stat)
82
+ continue;
83
+ found.push({ id, path: file, mtimeMs: stat.mtimeMs, sizeBytes: stat.size });
84
+ }
85
+ }
86
+ return found.sort((a, b) => b.mtimeMs - a.mtimeMs);
87
+ },
88
+ async scanRef(candidate) {
89
+ let title;
90
+ let workspace;
91
+ let createdAt;
92
+ // Frames are self-delimiting, so a head read decodes to a head of the
93
+ // session — enough for the opening record and the title that follows it.
94
+ for await (const raw of readLines(candidate.path, 64 * 1024)) {
95
+ const line = raw;
96
+ if (line.type === 'session') {
97
+ createdAt ??= stamp(line.createdAt);
98
+ if (typeof line.cwd === 'string')
99
+ workspace ??= canonicalizePath(line.cwd);
100
+ continue;
101
+ }
102
+ const data = line.data ?? {};
103
+ // The harness titles a session from its first prompt; that title wins.
104
+ if (line.type === 'session/title' && typeof data.title === 'string')
105
+ title = data.title;
106
+ if (!title && line.type === 'user/message' && isRealUser(data.source)) {
107
+ const text = stripPromptEnvelope(textOf(data.content));
108
+ if (text && !looksLikeInstructions(text))
109
+ title = oneLine(text, 120);
110
+ }
111
+ }
112
+ return {
113
+ id: candidate.id,
114
+ provider: 'dsh',
115
+ path: candidate.path,
116
+ // Spread rather than assign: the index stores an absent column as absent,
117
+ // so an explicit `undefined` would make a cached ref differ from a parsed
118
+ // one — and a session can genuinely be opened without ever being titled.
119
+ ...(title ? { title } : {}),
120
+ ...(workspace ? { workspace } : {}),
121
+ ...(createdAt ? { createdAt } : {}),
122
+ updatedAt: new Date(candidate.mtimeMs).toISOString(),
123
+ sizeBytes: candidate.sizeBytes,
124
+ };
125
+ },
126
+ async parse(candidate) {
127
+ const turns = [];
128
+ const stats = emptyProviderStats();
129
+ const tokens = { input: 0, output: 0, total: 0 };
130
+ let cacheRead = 0;
131
+ let title;
132
+ let firstPrompt;
133
+ let workspace;
134
+ let createdAt;
135
+ let updatedAt;
136
+ // An explicit `timestamp: undefined` is not the same object as one without
137
+ // the key, and the index stores absent columns as absent — so a session read
138
+ // back from it would stop deep-equalling a fresh parse.
139
+ const push = ({ timestamp, ...turn }) => {
140
+ turns.push({
141
+ ...turn,
142
+ ...(timestamp ? { timestamp } : {}),
143
+ index: turns.length,
144
+ id: `${candidate.id}#${turns.length}`,
145
+ });
146
+ };
147
+ const count = (key) => {
148
+ stats.extras[key] = (stats.extras[key] ?? 0) + 1;
149
+ };
150
+ for await (const raw of readLines(candidate.path)) {
151
+ const line = raw;
152
+ if (line.type === 'session') {
153
+ createdAt ??= stamp(line.createdAt);
154
+ if (typeof line.cwd === 'string')
155
+ workspace ??= canonicalizePath(line.cwd);
156
+ if (line.delegationDepth)
157
+ stats.extras.delegationDepth = line.delegationDepth;
158
+ continue;
159
+ }
160
+ const timestamp = stamp(line.time);
161
+ if (timestamp)
162
+ updatedAt = timestamp;
163
+ const data = line.data ?? {};
164
+ switch (line.type) {
165
+ case 'session/title':
166
+ if (typeof data.title === 'string')
167
+ title = data.title;
168
+ break;
169
+ case 'model/selection':
170
+ case 'request/context': {
171
+ const model = data.model;
172
+ if (model && !stats.models.includes(model))
173
+ stats.models.push(model);
174
+ break;
175
+ }
176
+ case 'turn/start':
177
+ stats.turnBoundaries.push({
178
+ id: String(data.turn ?? stats.turnBoundaries.length + 1),
179
+ ...(timestamp ? { startedAt: timestamp } : {}),
180
+ completed: false,
181
+ });
182
+ break;
183
+ case 'turn/end': {
184
+ const id = String(data.turn ?? '');
185
+ const match = stats.turnBoundaries.find((boundary) => boundary.id === id && !boundary.completed);
186
+ const reason = data.reason?.kind ?? 'completed';
187
+ // `interrupted` and `aborted` end a turn without finishing it.
188
+ if (match && reason === 'completed') {
189
+ match.completed = true;
190
+ if (timestamp)
191
+ match.endedAt = timestamp;
192
+ if (match.startedAt && timestamp) {
193
+ match.durationMs = Math.max(0, Date.parse(timestamp) - Date.parse(match.startedAt));
194
+ }
195
+ }
196
+ if (reason !== 'completed')
197
+ count(`turn:${reason}`);
198
+ break;
199
+ }
200
+ case 'user/message': {
201
+ const source = data.source;
202
+ if (!isRealUser(source)) {
203
+ count(`injected:${source?.kind ?? 'unknown'}`);
204
+ break;
205
+ }
206
+ const text = stripPromptEnvelope(textOf(data.content));
207
+ if (!text || looksLikeInstructions(text))
208
+ break;
209
+ firstPrompt ??= oneLine(text, 120);
210
+ push({ kind: 'user', text, timestamp });
211
+ break;
212
+ }
213
+ case 'assistant/message': {
214
+ const message = (data.message ?? {});
215
+ const model = message.source?.model;
216
+ if (model && !stats.models.includes(model))
217
+ stats.models.push(model);
218
+ const usage = data.usage;
219
+ if (usage) {
220
+ tokens.input += usage.inputTokens ?? 0;
221
+ tokens.output += usage.outputTokens ?? 0;
222
+ cacheRead += usage.cacheReadTokens ?? 0;
223
+ }
224
+ for (const block of message.content ?? []) {
225
+ // `tool-call` blocks restate the `tool/call` records that follow;
226
+ // taking both would double every tool call in the timeline.
227
+ if (block.type === 'reasoning' && block.text?.trim()) {
228
+ push({ kind: 'thinking', text: block.text, timestamp });
229
+ }
230
+ else if (block.type === 'text' && block.text?.trim()) {
231
+ push({ kind: 'assistant', text: block.text, timestamp });
232
+ }
233
+ }
234
+ break;
235
+ }
236
+ case 'tool/call':
237
+ push({
238
+ kind: 'tool_call',
239
+ toolName: data.name,
240
+ toolArgs: parseArguments(data.arguments),
241
+ timestamp,
242
+ });
243
+ break;
244
+ case 'tool/result': {
245
+ const message = (data.message ?? {});
246
+ const result = (message.content ?? []).find((block) => block.type === 'tool-result');
247
+ const text = textOf(result?.content);
248
+ const exit = EXIT_CODE.exec(text.trimEnd());
249
+ push({
250
+ kind: 'tool_result',
251
+ toolResult: text,
252
+ isError: result?.isError === true,
253
+ timestamp,
254
+ ...(exit ? { exitCode: Number(exit[1]) } : {}),
255
+ });
256
+ break;
257
+ }
258
+ case 'approval/asked':
259
+ count('approvals');
260
+ break;
261
+ case 'subagent/descriptor':
262
+ count('subagents');
263
+ break;
264
+ case 'step/start':
265
+ count('steps');
266
+ break;
267
+ default:
268
+ break;
269
+ }
270
+ }
271
+ return {
272
+ ref: {
273
+ id: candidate.id,
274
+ provider: 'dsh',
275
+ path: candidate.path,
276
+ ...(title || firstPrompt ? { title: title ?? firstPrompt } : {}),
277
+ ...(workspace ? { workspace } : {}),
278
+ ...(createdAt ? { createdAt } : {}),
279
+ updatedAt: updatedAt ?? new Date(candidate.mtimeMs).toISOString(),
280
+ sizeBytes: candidate.sizeBytes,
281
+ },
282
+ turns,
283
+ artifacts: [],
284
+ stats: {
285
+ ...stats,
286
+ ...(tokens.input || tokens.output
287
+ ? {
288
+ tokens: {
289
+ ...tokens,
290
+ total: tokens.input + tokens.output,
291
+ ...(cacheRead ? { cacheRead } : {}),
292
+ },
293
+ }
294
+ : {}),
295
+ },
296
+ };
297
+ },
298
+ };
@@ -0,0 +1,7 @@
1
+ import { type ProviderAdapter } from './provider.js';
2
+ /**
3
+ * The directory name is the percent-encoded cwd, so it decodes back exactly —
4
+ * unlike Claude's lossy slug, this is an answer rather than a prefilter.
5
+ */
6
+ export declare function workspaceFromProjectDir(name: string): string;
7
+ export declare const grokAdapter: ProviderAdapter;