@1agents/session-reader 0.3.0 → 0.5.0

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
@@ -22,7 +22,9 @@ npm install -g @1agents/session-reader # 作为 1session 命令
22
22
  npx @1agents/session-reader list # 不安装直接用
23
23
  ```
24
24
 
25
- 要求 Node.js >= 22.5(依赖内置的 `node:sqlite`),零运行时依赖。
25
+ 要求 Node.js >= 22.5(依赖内置的 `node:sqlite`)。
26
+ 唯一的运行时依赖是自家的 [`@1agents/dreammate-network`](https://github.com/scottzx/dreammate-network)
27
+ ——L0 协议定义,12 kB,本身零依赖——`npm install` 会自动带上,不需要单独装。
26
28
 
27
29
  ## 内置 skill:一条命令装到三家智能体
28
30
 
@@ -67,14 +69,14 @@ npm run build && node dist/bin/1session.js <command>
67
69
  | `1session list [--limit n] [--scope <path>\|cwd\|global] [--provider name] [--since 24h] [--json]` | 按最近更新列出各智能体的会话(默认当前 pwd 子树,见 `--scope`) |
68
70
  | `1session overview <session-id> [--json]` | **第 1 层**:统计卡片(轮次/文件/命令/提交/产物/上传/后台任务/token)+ 目标、口径修正、状态锚点、全量落盘 |
69
71
  | `1session turns <session-id> [--json]` | **第 2 层**:逐轮概要——时间、耗时、事件区间、文件/命令/失败数、用户说了什么、agent 回了什么 |
70
- | `1session turn <session-id> <n> [--event k] [--json]` | **第 3 层**:展开某一轮的全部事件;`--event` 定位单次工具调用,输出完整参数与**未截断**结果 |
72
+ | `1session turn <session-id> <n> [--event k\|a-b\|a,b,c] [--json]` | **第 3 层**:展开某一轮的全部事件;`--event` 定位工具调用,输出完整参数与**未截断**结果,支持 `491`、`491-493`、`491,495,502` |
71
73
  | `1session digest <session-id> [--focus marketing\|review\|full] [--json]` | 单会话蒸馏:目标、改动文件、命令、关键节点 |
72
74
  | `1session workspace [path] [--since 24h] [--limit n] [--digest] [--focus f] [--json]` | **按 pwd 跨智能体聚合**(默认 `.`);带 `--digest` 输出统一故事线 |
73
75
  | `1session jobs <id> [--json]` | 异步作业账本:状态 + **证据** + pid / host / log |
74
76
  | `1session commands <id> [--failed] [--host h] [--turn n] [--json]` | 命令账本:exit_code / 耗时 / cwd |
75
77
  | `1session files <id> [--group project\|runtime\|log\|all] [--json]` | 文件账本,按项目 / 运行态 / 日志分组 |
76
78
  | `1session errors <id> [--json]` | 失败命令,带 stderr 与"同前缀命令后续是否成功" |
77
- | `1session search <query> [--scope <path>\|cwd\|global] [--since 24h] [--limit n] [--kind k1,k2] [--regex] [--case] [--context n] [--max-hits n] [--json]` | 跨会话全文检索:命中轮次 + 上下文片段(默认当前 pwd 子树,见 `--scope`) |
79
+ | `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`) |
78
80
  | `1session index [<id>] [--all] [--scope <path>\|cwd\|global] [--force] [--since 30d]` | 建立 / 刷新索引;`--all` 全库回填 |
79
81
  | `1session graph <id> [--json]`(别名 `related`) | 会话之间的引用关系 + 每条边的证据 |
80
82
  | `1session skill install\|status\|uninstall [--agent a,b] [--copy] [--force] [--dry-run]` | 把内置 skill 装进三家智能体的 skills 目录(见上) |
@@ -156,9 +158,14 @@ T 3 2026-09-11T13:03:29 (283s) 事件 29–45 文件 1 · 命令 7 · 失败 0
156
158
  ```
157
159
 
158
160
  ```bash
159
- 1session turn 5575981e 1 --event 11 # 第 3 层:单次工具调用的完整输出
161
+ 1session turn 5575981e 1 --event 11 # 第 3 层:单次工具调用的完整输出
162
+ 1session turn 5575981e 1 --event 11-13 # 连着看:调用 + 结果 + 助理的判断
160
163
  ```
161
164
 
165
+ `--event` 收 `11`、`11-13`、`11,15,22` 及其混合,一次最多 50 条;区间会按会话长度裁剪,
166
+ 所以 `560-999` 就是「看到结尾」。读一次工具调用通常要连读它的结果和后面那条助理判断——
167
+ 一个进程读完,而不是起三次。
168
+
162
169
  第三层会自动补全被截断的内容:antigravity 的 `transcript.jsonl` 会把长输出截短(事件 #11 只存了 4092 字符),命令会回读 `.system_generated/steps/8/output.txt` 拿到完整的 6937 字节并标注 `[已从 steps/ 补全]`;补不回来时如实提示 `⚠️ steps/N/output.txt 不存在`。
163
170
 
164
171
  跨会话检索(`--kind` 可过滤轮次类型,只看用户说了什么 / 只看工具调用):
@@ -169,12 +176,22 @@ T 3 2026-09-11T13:03:29 (283s) 事件 29–45 文件 1 · 命令 7 · 失败 0
169
176
 
170
177
  ```text
171
178
  4 处命中,分布在 2 个会话
179
+ (已折叠 1 处本次检索自身留下的回声;--include-self 展开)
172
180
 
173
181
  ### antigravity 5575981e 2026-09-13T00:21:05Z → 2026-09-13T03:31:58Z (2 命中)
174
- #318 user /xhs-tech-card 结合 docs/xhs_sol_h3_spark_output/index.html,第一次实战经验贴。
175
- #441 user /mobile-xhs-publisher
182
+ T7 · E318 user /xhs-tech-card 结合 docs/xhs_sol_h3_spark_output/index.html,第一次实战经验贴。
183
+ T9 · E441 user /mobile-xhs-publisher
184
+ ↳ 1session turn 5575981e 7 --event 318
176
185
  ```
177
186
 
187
+ 每条命中前缀就是**下钻句柄** `T<轮次> · E<事件号>`——顺序照着 `turn <id> <T> --event <E>` 排,
188
+ 每个会话再附一条拼好的命令,从第 1 层到第 3 层不用再跑一趟 `turns` 人工比对事件区间。
189
+
190
+ **默认折叠本次检索自己留下的脚印**:调用方的会话是边跑边索引的,`1session search "X"` 这条
191
+ 命令本身(以及它打印出来的东西)会立刻成为 `X` 的第一条命中。判据只有一条——五分钟内写下的
192
+ `1session` 调用及其结果;只有活着的那个 transcript 才可能有「此刻」的时间戳,所以历史会话一条
193
+ 都不会被碰到。折叠了多少永远会打印出来,`--include-self` 可以全部展开。
194
+
178
195
  输出示例(上午 Antigravity 定方案 → 下午 Claude Code 落地实现,自动交织成一条时间线):
179
196
 
180
197
  ```markdown
@@ -204,6 +221,7 @@ import {
204
221
  summarizeTurns,
205
222
  turnDetail,
206
223
  eventDetail,
224
+ eventDetails,
207
225
  } from '@1agents/session-reader';
208
226
 
209
227
  const sessions = await findSessionsByWorkspace(process.cwd(), { since: '7d' });
@@ -215,8 +233,13 @@ const overview = buildOverview(session); // 第 1 层:overview.stats / ov
215
233
  const turns = summarizeTurns(session); // 第 2 层
216
234
  const detail = turnDetail(session, 3); // 第 3 层
217
235
  const full = await eventDetail(session, 11); // 第 3 层:未截断的单次工具输出
236
+ const around = await eventDetails(session, '11-13'); // 相邻几条一起读
237
+ hits[0]?.matches[0]?.turn; // 命中所在轮次,与 match.index 凑成 T·E 句柄
218
238
  ```
219
239
 
240
+ `searchSessions` 另接 `selfSessionId` 显式指明调用方(默认取 `SESSION_READER_CALLER_SESSION`);
241
+ 被判定为「调用方自己」的命中不会被删掉,而是带上 `self` / `suppressed` 标记返回,展不展示由调用方定。
242
+
220
243
  `aggregateWorkspaceSessions` 返回 `WorkspaceDigest`:`sessions` / `collaboratingAgents` /
221
244
  `unifiedTimeline`(按时间排序的跨智能体节点)/ `fileAttribution`(文件 → 谁在什么时候改的)/ `markdown`。
222
245
  适合直接喂给下游做小红书笔记、PRD、周报与 changelog,也可零成本包成 DeepSeek Harness(Cordis)插件或 MCP server。
@@ -265,6 +288,20 @@ const full = await eventDetail(session, 11); // 第 3 层:未截断的单次
265
288
 
266
289
  同样的原则贯穿每一处:**没有证据就不断言**。异步作业只在有完成回执时标 `completed`,否则一律 `unknown` 并写明依据;没有打印 exit code 的命令结果,`exitCode` 就是空,绝不补成 0。
267
290
 
291
+ ### 三级溯源回答不了的那一问:现在还成不成立
292
+
293
+ `observed / derived / candidate` 回答的是**这个事实怎么来的**,不是**它现在还对不对**。
294
+ overview 的「末态」恰恰是最易腐的一段:
295
+
296
+ > 凡是关于**当前状态**的问题(凭据、环境变量、装了哪个版本、文件还在不在),会话只能告诉你
297
+ > 它**最后一次为真是什么时候**——去核实。转录记下的是说过什么和退出码,不是效果。
298
+
299
+ 三种真实撞上过的情况:一条命令退出码为 0、无报错,但它读到的是空输入,写进配置的是个空值;
300
+ 某一轮的结论说「现在存了三份」,其中一份是只活在当时那个 shell 里的临时函数,从没落盘;
301
+ 「某某已配好」在当时是对的,三周后需要另外验证。会话忠实记录了意图和退出码,记不下效果——
302
+ 这是转录这种载体的边界,不是提取规则的疏漏,所以本模块不打算用更聪明的规则去补它,
303
+ 只把话说清楚。
304
+
268
305
  ## 轮次完成状态
269
306
 
270
307
  第二层每一轮带一个**有证据的状态**——只说这一轮有没有收尾,不说做成了什么:
@@ -402,6 +439,87 @@ $ 1session graph ca8325e1
402
439
 
403
440
  运行时捕获:若环境注入了 `SESSION_READER_CALLER_SESSION`,调用当下就直接落边(`observed / runtime:caller-env`),无需事后从历史里恢复。
404
441
 
442
+ ## `1session serve`:接入 DreamMate Network
443
+
444
+ 把本地 Read Plane 原样暴露成网络能力——`1session overview <id>` 成为 `sessions.read`。
445
+ **不重新实现索引与事实层**,只是换一个调用入口。不引第三方 HTTP 框架,只用 `node:http`。
446
+
447
+ ```bash
448
+ 1session serve # 默认 127.0.0.1:7777
449
+ 1session serve --host 100.x.x.x --token <t> # 暴露到 tailnet
450
+ ```
451
+
452
+ 启动时默认向本机 [node agent](https://github.com/scottzx/dreammate-node)(36908)报备,
453
+ `--no-report` 可关。**agent 没起时是静默 no-op**,不影响本服务——只是外部得靠约定端口
454
+ 碰运气找它,而不是探一个 36908 就看见。报备会如实声明可达性:`--host` 是回环就报
455
+ `localhost`(外部发现得了但连不上),否则报 `network`。
456
+
457
+ 端口 7777 是 [L0 协议](https://github.com/scottzx/dreammate-network) 的**约定端口**
458
+ (`DEFAULT_PORTS['session-registry']`),不是随手挑的:发现是 pull 的——
459
+ Control Plane 从 tailnet 拿到节点后,照着这张表探测 `/manifest` 与 `/health`。
460
+ 换成别的端口就探测不到了,得由服务自己 `POST /nodes/register` 告知。
461
+
462
+ ```
463
+ GET /manifest Node Manifest(dreammate-network node.schema.json)
464
+ GET /health
465
+ GET /v1/node 同 /manifest,本 Service 前缀下的同一份文档
466
+ GET /v1/sessions ?limit&scope&since&provider
467
+ GET /v1/sessions/:id 会话概要
468
+ GET /v1/sessions/:id/turns 逐轮概要
469
+ GET /v1/search ?q=&scope=&since=&limit=&provider=&kind=&regex=&case=
470
+ GET /v1/graph/:id 会话之间的引用关系
471
+ ```
472
+
473
+ 声明的能力:`sessions.list` `sessions.read` `sessions.turns` `sessions.search` `sessions.graph`。
474
+
475
+ 每个会话都带上网络内的地址:
476
+
477
+ ```
478
+ session://<node>/<runtime>/<session_id>
479
+ session://Scott-Mac.local/claude/87f7a60a-a86e-49c5-b711-e463156a5420
480
+ ```
481
+
482
+ **跨机实测**(Windows 节点读 Mac 的会话,全程没有 Control Plane 参与):
483
+
484
+ ```
485
+ scott-pc$ curl http://scott-mac.tailfb4720.ts.net:7777/v1/sessions?limit=3
486
+ { "node": "scott-mac",
487
+ "sessions": [ { "uri": "session://scott-mac/claude/87f7a60a-…", … } ] }
488
+ ```
489
+
490
+ **读取即落边。** 请求带 `X-Caller-Session: <调用方会话>` 时,读取当下就写入
491
+ `调用方 --references--> 目标`,与 CLI 的 `SESSION_READER_CALLER_SESSION` 是同一条路径。
492
+ 两端都必须是本地已索引的会话,否则静默跳过——悬空边比没有边更糟。
493
+
494
+ **节点身份由 [`@1agents/dreammate-node`](https://github.com/scottzx/dreammate-node) 统一提供**
495
+ (优先 `tailscale status --json` 的 `Self`,缓存 60s):它是每台机器的公共事实,
496
+ 本机所有服务读到同一份,不会各自生成 id 把一台机器裂成几个 Node。
497
+
498
+ ```
499
+ node_id nigVtDS1s521CNTRL ← tailscale ID,重启不变
500
+ name scott-mac ← DNSName 前缀
501
+ type macos ← 由 tailscale 的 OS 映射
502
+ base_url http://scott-mac.tailfb4720.ts.net:7777/v1 ← MagicDNS,跨机可直接用
503
+ ```
504
+
505
+ > ⚠️ 名字取 **DNSName** 而不是 HostName 是有原因的:iOS 设备的 HostName
506
+ > 全是 `localhost`(实测 11 个节点里只有 9 个唯一),几台手机接进来会产出
507
+ > 一模一样的 `session://localhost/yima/...`。DNSName 实测 11/11 唯一且可读。
508
+
509
+ 没装 / 没登录 tailscale 时**静默回退**到本地身份(hostname + 首次生成的 uuid,
510
+ 存在 `~/.1agents/node.json`)。回退身份的 name 不保证跨设备唯一,只适合单机自用。
511
+ `manifest.metadata.identity_source` 会如实报告身份来自 `tailscale` 还是 `local`。
512
+ `DREAMMATE_NODE_ID` / `DREAMMATE_NODE_NAME` 覆盖一切(容器或同机第二个实例用)。
513
+
514
+ **只读、且默认只监听 loopback。** 会话原文含源码、shell 历史和恰好滚过屏幕的密钥,
515
+ 所以走出本机必须是一个刻意动作:显式 `--host`,并且最好配 `--token`(`Authorization: Bearer`)。
516
+ 非 loopback 且无 token 时启动会告警。非 GET 一律 405。
517
+
518
+ > 协议定义来自 [`@1agents/dreammate-network`](https://github.com/scottzx/dreammate-network)(L0)。
519
+ > `src/serve/node.ts` 直接 import 它的类型,不再本地抄一份——单向依赖 L2 → L0 是允许的,
520
+ > 而共用同一份定义才谈得上「公共语言」。**schema 是唯一事实源,改类型先去改那个包。**
521
+ > `/manifest` 的 `metadata.protocol_version` 报告本服务遵循的协议版本。
522
+
405
523
  ## 测试
406
524
 
407
525
  ```bash
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { aggregateWorkspaceSessions } from '../src/aggregator.js';
5
5
  import { distillSession } from '../src/distiller.js';
6
6
  import { buildOverview } from '../src/overview.js';
7
- import { eventDetail, summarizeTurns, turnDetail } from '../src/turns.js';
7
+ import { eventDetails, summarizeTurns, turnDetail } from '../src/turns.js';
8
8
  import { commandLedger, displayPath, errorLedger, fileLedger, jobLedger } from '../src/ledger.js';
9
9
  import { listRecentSessions, loadSession } from '../src/resolver.js';
10
10
  import { searchSessions } from '../src/search.js';
@@ -15,7 +15,8 @@ const USAGE = `1session — cross-agent session Read Plane
15
15
  1session list [--limit <n>] [--scope <path>|cwd|global] [--provider <name>] [--since 24h] [--json]
16
16
  1session overview <session-id> [--json] 第 1 层:会话概要
17
17
  1session turns <session-id> [--json] 第 2 层:逐轮概要
18
- 1session turn <session-id> <n> [--event <k>] [--json] 第 3 层:单轮 / 单次工具调用明细
18
+ 1session turn <session-id> <n> [--event <k|a-b|a,b,c>] [--json]
19
+ 第 3 层:单轮 / 单次或多次工具调用明细
19
20
  1session jobs <session-id> [--json] 异步作业账本
20
21
  1session commands <session-id> [--failed] [--host h] [--turn n] [--json]
21
22
  1session files <session-id> [--group project|runtime|log|all] [--json]
@@ -24,11 +25,17 @@ const USAGE = `1session — cross-agent session Read Plane
24
25
  1session workspace [path] [--since 24h] [--limit <n>] [--digest] [--focus <f>] [--json]
25
26
  1session index [<session-id>] [--all] [--scope <path>|cwd|global] [--force] [--since 30d] 建立/刷新索引
26
27
  1session graph <session-id> [--json] 会话之间的引用关系
28
+ 1session serve [--port 7777] [--host 127.0.0.1] [--token <t>] [--no-report]
29
+ 起 HTTP Service,把本机会话接入 DreamMate Network
27
30
  1session skill install|status|uninstall [--agent claude,codex,antigravity]
28
31
  [--copy] [--force] [--dry-run] [--json] 装到三家智能体的 skills 目录
29
32
  1session search <query> [--scope <path>|cwd|global] [--since 24h] [--limit n] [--provider name]
30
33
  [--kind user,assistant,thinking,tool_call,tool_result]
31
- [--regex] [--case] [--context n] [--max-hits n] [--json]
34
+ [--regex] [--case] [--context n] [--max-hits n]
35
+ [--include-self] [--json]
36
+
37
+ search 的每条命中带 T<轮次> · E<事件号>,可直接拼成 1session turn <id> <T> --event <E>。
38
+ 默认折叠本次检索自身在活跃会话里留下的回声,--include-self 展开。
32
39
 
33
40
  全局:--no-index 绕过索引直读源文件。索引位于 ~/.1agents/session-reader/index.db
34
41
  list / search / index --all 默认只看当前 pwd 目录(含子目录)下的会话。
@@ -40,7 +47,7 @@ Providers: antigravity (~/.gemini/antigravity/brain), claude (~/.claude/projects
40
47
  /** Flags that never take a value, so they cannot swallow a positional. */
41
48
  const BOOLEAN_FLAGS = new Set([
42
49
  'json', 'failed', 'digest', 'regex', 'case', 'all', 'force', 'no-index', 'global',
43
- 'copy', 'dry-run',
50
+ 'copy', 'dry-run', 'no-report', 'include-self',
44
51
  ]);
45
52
  function parseArgs(argv) {
46
53
  const [command = 'help', ...rest] = argv;
@@ -88,6 +95,12 @@ const kindsOf = (value) => str(value)
88
95
  ?.split(',')
89
96
  .map((kind) => kind.trim())
90
97
  .filter(Boolean);
98
+ /**
99
+ * The drill-down handle a hit carries: `T9 · E221` reads straight into
100
+ * `1session turn <id> 9 --event 221`. Turn first, because that is the order
101
+ * the command wants it in.
102
+ */
103
+ const handle = (turn, event) => `T${turn || '?'} · E${event}`;
91
104
  const focusOf = (value) => {
92
105
  const focus = str(value);
93
106
  return focus === 'marketing' || focus === 'full' ? focus : 'review';
@@ -266,17 +279,24 @@ async function main() {
266
279
  }
267
280
  case 'turn': {
268
281
  const session = await load(positional[0]);
269
- const eventIndex = num(flags.event);
270
- if (eventIndex !== undefined) {
271
- const detail = await eventDetail(session, eventIndex);
272
- const body = detail.fullText ?? detail.text ?? detail.toolResult ?? '';
273
- print(json, detail, [
274
- `#${detail.index} ${detail.kind}${detail.toolName ? `(${detail.toolName})` : ''} ${detail.timestamp ?? ''}` +
275
- `${detail.truncated ? (detail.fullText ? ' [已从 steps/ 补全]' : ' [已截断]') : ''}`,
276
- ...(detail.toolArgs ? ['', '参数:', JSON.stringify(detail.toolArgs, null, 2)] : []),
277
- ...(body ? ['', '内容:', body] : []),
278
- ...(detail.truncationNote ? ['', `⚠️ ${detail.truncationNote}`] : []),
279
- ].join('\n'));
282
+ const spec = str(flags.event);
283
+ if (spec !== undefined) {
284
+ const details = await eventDetails(session, spec);
285
+ // A bare index keeps returning one object; a range or a list — which
286
+ // the caller had to ask for explicitly — returns the array.
287
+ const single = /^\s*\d+\s*$/.test(spec);
288
+ print(json, single ? details[0] : details, details
289
+ .map((detail) => {
290
+ const body = detail.fullText ?? detail.text ?? detail.toolResult ?? '';
291
+ return [
292
+ `#${detail.index} ${detail.kind}${detail.toolName ? `(${detail.toolName})` : ''} ${detail.timestamp ?? ''}` +
293
+ `${detail.truncated ? (detail.fullText ? ' [已从 steps/ 补全]' : ' [已截断]') : ''}`,
294
+ ...(detail.toolArgs ? ['', '参数:', JSON.stringify(detail.toolArgs, null, 2)] : []),
295
+ ...(body ? ['', '内容:', body] : []),
296
+ ...(detail.truncationNote ? ['', `⚠️ ${detail.truncationNote}`] : []),
297
+ ].join('\n');
298
+ })
299
+ .join('\n\n────────\n\n'));
280
300
  break;
281
301
  }
282
302
  const detail = turnDetail(session, num(positional[1]) ?? 1);
@@ -292,13 +312,15 @@ async function main() {
292
312
  return `${head} ${oneLine(event.text ?? event.toolResult ?? JSON.stringify(event.toolArgs), 160)}`;
293
313
  }),
294
314
  '',
295
- `(用 --event <k> 展开单个事件的完整内容)`,
315
+ `(--event <k> 展开单个事件的完整内容;--event ${detail.summary.events[0]}-` +
316
+ `${Math.min(detail.summary.events[0] + 2, detail.summary.events[1])} 或 --event a,b,c 一次读相邻几条)`,
296
317
  ].join('\n'));
297
318
  break;
298
319
  }
299
320
  case 'search': {
300
321
  const query = positional.join(' ');
301
- const hits = await searchSessions(query, {
322
+ const includeSelf = flags['include-self'] === true;
323
+ const found = await searchSessions(query, {
302
324
  workspace: scopeWorkspace(flags),
303
325
  since: str(flags.since),
304
326
  limit: num(flags.limit),
@@ -310,22 +332,42 @@ async function main() {
310
332
  maxPerSession: num(flags['max-hits']),
311
333
  useIndex,
312
334
  });
335
+ // The searcher's own live transcript is noise by default: the query is
336
+ // in it because this very command put it there.
337
+ const hits = includeSelf ? found : found.filter((hit) => !hit.self);
313
338
  const total = hits.reduce((sum, hit) => sum + hit.totalMatches, 0);
339
+ const foldedSessions = found.length - hits.length;
340
+ const foldedMatches = found.reduce((sum, hit) => sum + (hit.suppressed ?? 0) + (hits.includes(hit) ? 0 : hit.totalMatches), 0);
341
+ const foldNote = !includeSelf && foldedMatches
342
+ ? `(已折叠 ${foldedMatches} 处本次检索自身留下的回声` +
343
+ `${foldedSessions ? `,涉及 ${foldedSessions} 个会话` : ''};--include-self 展开)`
344
+ : undefined;
314
345
  print(json, hits, hits.length
315
346
  ? [
316
347
  `${total} 处命中,分布在 ${hits.length} 个会话`,
348
+ ...(foldNote ? [foldNote] : []),
317
349
  ...hits.flatMap((hit) => [
318
350
  '',
319
351
  `### ${hit.session.provider} ${hit.session.id.slice(0, 8)} ` +
320
352
  `${hit.session.createdAt ?? '?'} → ${hit.session.updatedAt ?? '?'} (${hit.totalMatches} 命中)`,
321
353
  ` ${oneLine(hit.session.title, 80)}`,
322
- ...hit.matches.map((match) => ` #${match.index} ${match.kind}${match.toolName ? `(${match.toolName})` : ''} ${match.excerpt}`),
354
+ ...hit.matches.map((match) => ` ${handle(match.turn, match.index)} ` +
355
+ `${match.kind}${match.toolName ? `(${match.toolName})` : ''} ${match.excerpt}`),
356
+ ...(hit.matches[0]
357
+ ? [
358
+ ` ↳ 1session turn ${hit.session.id.slice(0, 8)} ` +
359
+ `${hit.matches[0].turn || '<轮次>'} --event ${hit.matches[0].index}`,
360
+ ]
361
+ : []),
323
362
  ...(hit.totalMatches > hit.matches.length
324
363
  ? [` …另有 ${hit.totalMatches - hit.matches.length} 处(--max-hits 调大或 --json 查看全部)`]
325
364
  : []),
365
+ ...(!includeSelf && hit.suppressed
366
+ ? [` …另折叠 ${hit.suppressed} 处本次检索自身的回声(--include-self 展开)`]
367
+ : []),
326
368
  ]),
327
369
  ].join('\n')
328
- : `无命中:${query}`);
370
+ : [`无命中:${query}`, ...(foldNote ? [foldNote] : [])].join('\n'));
329
371
  break;
330
372
  }
331
373
  case 'index': {
@@ -423,6 +465,19 @@ async function main() {
423
465
  : `${row.id} 尚无关系边(没有任何会话通过 1session 查过它,它也没查过别人)`);
424
466
  break;
425
467
  }
468
+ case 'serve': {
469
+ const { serve, DEFAULT_PORT } = await import('../src/serve/http.js');
470
+ await serve({
471
+ port: num(flags.port) ?? DEFAULT_PORT,
472
+ ...(str(flags.host) ? { host: str(flags.host) } : {}),
473
+ ...(str(flags.token) ? { token: str(flags.token) } : {}),
474
+ ...(str(flags['base-url']) ? { baseUrl: str(flags['base-url']) } : {}),
475
+ ...(flags['no-report'] === true ? { report: false } : {}),
476
+ });
477
+ // The server owns the process from here; nothing after this resolves.
478
+ await new Promise(() => { });
479
+ break;
480
+ }
426
481
  case 'skill': {
427
482
  const { describeState, installSkill, skillStatus, uninstallSkill, bundledSkillDir, } = await import('../src/skill.js');
428
483
  const agents = str(flags.agent)
@@ -4,9 +4,9 @@ export { distillSession, editedFiles, shellCommand } from './distiller.js';
4
4
  export { buildOverview } from './overview.js';
5
5
  export { classifyUserTurn } from './classify.js';
6
6
  export { commandLedger, displayPath, errorLedger, fileLedger, jobCounts, jobLedger, } from './ledger.js';
7
- export { eventDetail, summarizeTurns, turnDetail, type EventDetail } from './turns.js';
7
+ export { eventDetail, eventDetails, parseEventSpec, summarizeTurns, turnDetail, turnNoAt, turnStarts, turnStartsFrom, type EventDetail, } from './turns.js';
8
8
  export { analyzableCommand, fileWrites, rawCommand, stripHeredocs, resolveWritePath, type FileWrite, } from './writes.js';
9
- export { mandatoryLiteral, planQuery, searchSessions, type QueryPlan, type SearchHit, type SearchMatch, type SearchOptions, } from './search.js';
9
+ export { echoFolder, isCallerSession, mandatoryLiteral, planQuery, searchSessions, type Candidate, type QueryPlan, type SearchHit, type SearchMatch, type SearchOptions, } from './search.js';
10
10
  export { aggregateWorkspaceSessions, type AggregateOptions } from './aggregator.js';
11
11
  export type { ProviderAdapter, SessionCandidate } from './parsers/provider.js';
12
12
  export { canonicalizePath, isInside, slugifyWorkspace } from './util/paths.js';
@@ -18,3 +18,5 @@ export { captureRuntimeEdge, deriveEdges, edgeEvidence, edgesOf, invocationsOf,
18
18
  export { deriveFacts } from './store/facts.js';
19
19
  export { EDGE_VERSION, EXTRACTOR_VERSION, PARSER_VERSION, SCHEMA_VERSION, } from './store/schema.js';
20
20
  export { SKILL_NAME, agentTargets, bundledSkillDir, describeState, installSkill, skillStatus, uninstallSkill, type AgentStatus, type AgentTarget, type EntryState, type InstallMode, type InstallOptions, type InstallResult, type SkillAgent, type UninstallResult, } from './skill.js';
21
+ export { buildManifest, nodeIdentity, sessionUri, SESSION_CAPABILITIES, type AccessDescriptor, type NetworkService, type NodeManifest, } from './serve/node.js';
22
+ export { createServer, serve, type ServeOptions } from './serve/http.js';
package/dist/src/index.js CHANGED
@@ -3,9 +3,9 @@ export { distillSession, editedFiles, shellCommand } from './distiller.js';
3
3
  export { buildOverview } from './overview.js';
4
4
  export { classifyUserTurn } from './classify.js';
5
5
  export { commandLedger, displayPath, errorLedger, fileLedger, jobCounts, jobLedger, } from './ledger.js';
6
- export { eventDetail, summarizeTurns, turnDetail } from './turns.js';
6
+ export { eventDetail, eventDetails, parseEventSpec, summarizeTurns, turnDetail, turnNoAt, turnStarts, turnStartsFrom, } from './turns.js';
7
7
  export { analyzableCommand, fileWrites, rawCommand, stripHeredocs, resolveWritePath, } from './writes.js';
8
- export { mandatoryLiteral, planQuery, searchSessions, } from './search.js';
8
+ export { echoFolder, isCallerSession, mandatoryLiteral, planQuery, searchSessions, } from './search.js';
9
9
  export { aggregateWorkspaceSessions } from './aggregator.js';
10
10
  export { canonicalizePath, isInside, slugifyWorkspace } from './util/paths.js';
11
11
  export { defaultDbPath, openStore, resetStoreCache } from './store/db.js';
@@ -16,3 +16,5 @@ export { captureRuntimeEdge, deriveEdges, edgeEvidence, edgesOf, invocationsOf,
16
16
  export { deriveFacts } from './store/facts.js';
17
17
  export { EDGE_VERSION, EXTRACTOR_VERSION, PARSER_VERSION, SCHEMA_VERSION, } from './store/schema.js';
18
18
  export { SKILL_NAME, agentTargets, bundledSkillDir, describeState, installSkill, skillStatus, uninstallSkill, } from './skill.js';
19
+ export { buildManifest, nodeIdentity, sessionUri, SESSION_CAPABILITIES, } from './serve/node.js';
20
+ export { createServer, serve } from './serve/http.js';
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { isInside } from './util/paths.js';
3
3
  import { oneLine } from './util/text.js';
4
4
  import { analyzableCommand, fileWrites, rawCommand, resolveWritePath } from './writes.js';
5
- import { summarizeTurns } from './turns.js';
5
+ import { turnNoAt, turnStarts } from './turns.js';
6
6
  const SSH_HOST = /\b[\w.-]+@((?:\d{1,3}(?:\.\d{1,3}){3})|(?:[\w-]+(?:\.[\w-]+)+))/;
7
7
  const BACKGROUND = /\b(?:nohup|setsid|start_new_session\s*=\s*True|subprocess\.Popen|Popen\(|queue-[\w-]+\.py)\b/;
8
8
  const LOG_PATH = /([\w./-]+\.log)\b/;
@@ -11,11 +11,8 @@ const LOG_FILE = /(?:\.log|\.download\.log)$|progress\.jsonl?$/i;
11
11
  const TEMP_DIR = /^\/(?:private\/)?(?:tmp|var\/folders)\//;
12
12
  /** Maps each event index to the turn it belongs to. */
13
13
  function turnOf(session) {
14
- const ranges = summarizeTurns(session).map((turn) => turn.events);
15
- return (index) => {
16
- const found = ranges.findIndex(([start, end]) => index >= start && index <= end);
17
- return found === -1 ? 0 : found + 1;
18
- };
14
+ const starts = turnStarts(session);
15
+ return (index) => (index < 0 ? 0 : turnNoAt(starts, index));
19
16
  }
20
17
  /**
21
18
  * Every shell command with whatever is known about how it went. Codex records
@@ -12,9 +12,20 @@ export interface SearchOptions extends ListOptions {
12
12
  context?: number;
13
13
  /** Matches recorded per session before scanning moves on. */
14
14
  maxPerSession?: number;
15
+ /**
16
+ * The session doing the searching, so its own live transcript can be marked.
17
+ * Defaults to `SESSION_READER_CALLER_SESSION`; pass `''` to disable.
18
+ */
19
+ selfSessionId?: string;
15
20
  }
16
21
  export interface SearchMatch {
17
22
  index: number;
23
+ /**
24
+ * The turn this event belongs to — the other half of the drill-down handle,
25
+ * so `T<turn> · E<index>` maps straight onto `turn <id> <turn> --event <index>`.
26
+ * 0 when the session records no turn that contains the event.
27
+ */
28
+ turn: number;
18
29
  kind: TurnKind;
19
30
  toolName?: string;
20
31
  timestamp?: string;
@@ -24,6 +35,14 @@ export interface SearchHit {
24
35
  session: SessionRef;
25
36
  matches: SearchMatch[];
26
37
  totalMatches: number;
38
+ /** Matches dropped because they were this very search echoing back. */
39
+ suppressed?: number;
40
+ /**
41
+ * The searcher's own session: either the injected caller id, or a session
42
+ * whose every match was the running invocation. Never removed from the
43
+ * result — callers hide it, so the count stays reportable.
44
+ */
45
+ self?: boolean;
27
46
  }
28
47
  /**
29
48
  * A substring that every string matching `source` must contain, or `undefined`
@@ -49,6 +68,35 @@ export interface QueryPlan {
49
68
  * either way.
50
69
  */
51
70
  export declare function planQuery(query: string, options?: SearchOptions): QueryPlan;
71
+ export interface Candidate {
72
+ index: number;
73
+ kind: TurnKind;
74
+ toolName?: string;
75
+ timestamp?: string;
76
+ body: string;
77
+ }
78
+ /**
79
+ * Folds the Read Plane's own footprint out of one session: an invocation
80
+ * running right now, and the result carrying what it printed.
81
+ *
82
+ * It deliberately does not require the invocation to carry *this* query. A
83
+ * `1session` call from a minute ago prints other sessions' content verbatim,
84
+ * so it matches queries it never mentioned — and whatever it echoed is still
85
+ * in the session it was quoting from, where the search finds it properly, with
86
+ * a handle that drills down to the real thing instead of to a screenful of
87
+ * this tool's output.
88
+ *
89
+ * Stateful because the second half is only knowable from the first, so it is
90
+ * built fresh per session and fed events in index order — which both search
91
+ * paths already produce.
92
+ */
93
+ export declare function echoFolder(now: number): (candidate: Candidate) => boolean;
94
+ /**
95
+ * Whether a session is the one running the search. Accepts the canonical
96
+ * `provider:native_id`, the bare native id, or a 6+ character prefix of it —
97
+ * the same spellings `resolveSession` takes.
98
+ */
99
+ export declare function isCallerSession(ref: SessionRef, callerId: string | undefined): boolean;
52
100
  /**
53
101
  * Full-text search across sessions.
54
102
  *