@1agents/session-reader 0.1.1 → 0.2.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
@@ -36,7 +36,7 @@ npm run build && node dist/bin/1session.js <command>
36
36
 
37
37
  | 命令 | 说明 |
38
38
  | --- | --- |
39
- | `1session list [--limit n] [--workspace path] [--provider name] [--since 24h] [--json]` | 按最近更新列出各智能体的会话 |
39
+ | `1session list [--limit n] [--scope <path>\|cwd\|global] [--provider name] [--since 24h] [--json]` | 按最近更新列出各智能体的会话(默认当前 pwd 子树,见 `--scope`) |
40
40
  | `1session overview <session-id> [--json]` | **第 1 层**:统计卡片(轮次/文件/命令/提交/产物/上传/后台任务/token)+ 目标、口径修正、状态锚点、全量落盘 |
41
41
  | `1session turns <session-id> [--json]` | **第 2 层**:逐轮概要——时间、耗时、事件区间、文件/命令/失败数、用户说了什么、agent 回了什么 |
42
42
  | `1session turn <session-id> <n> [--event k] [--json]` | **第 3 层**:展开某一轮的全部事件;`--event` 定位单次工具调用,输出完整参数与**未截断**结果 |
@@ -46,12 +46,57 @@ npm run build && node dist/bin/1session.js <command>
46
46
  | `1session commands <id> [--failed] [--host h] [--turn n] [--json]` | 命令账本:exit_code / 耗时 / cwd |
47
47
  | `1session files <id> [--group project\|runtime\|log\|all] [--json]` | 文件账本,按项目 / 运行态 / 日志分组 |
48
48
  | `1session errors <id> [--json]` | 失败命令,带 stderr 与"同前缀命令后续是否成功" |
49
- | `1session search <query> [--workspace p] [--since 24h] [--limit n] [--kind k1,k2] [--regex] [--case] [--context n] [--max-hits n] [--json]` | 跨会话全文检索:命中轮次 + 上下文片段 |
50
- | `1session index [<id>] [--all] [--force] [--since 30d]` | 建立 / 刷新索引;`--all` 全库回填 |
49
+ | `1session search <query> [--scope <path>\|cwd\|global] [--since 24h] [--limit n] [--kind k1,k2] [--regex] [--case] [--context n] [--max-hits n] [--json]` | 跨会话全文检索:命中轮次 + 上下文片段(默认当前 pwd 子树,见 `--scope`) |
50
+ | `1session index [<id>] [--all] [--scope <path>\|cwd\|global] [--force] [--since 30d]` | 建立 / 刷新索引;`--all` 全库回填 |
51
51
  | `1session graph <id> [--json]`(别名 `related`) | 会话之间的引用关系 + 每条边的证据 |
52
52
 
53
53
  全局开关 `--no-index` 绕过索引直读源文件。
54
54
 
55
+ ### `--scope`:会话按路径子树取
56
+
57
+ `list` / `search` / `index --all` **默认只看当前 pwd 这棵子树下的会话**;跨项目要显式说出来。`--scope` 取三种值:
58
+
59
+ | 值 | 含义 |
60
+ | --- | --- |
61
+ | 省略 / `cwd` | 当前 pwd **及其所有子目录**下的会话 |
62
+ | 相对路径(`..`、`../web`、`~/proj`)或绝对路径(`/Users/me/proj`) | 该目录**及其所有子目录**下的会话 |
63
+ | `global`(或 `--global`,或 `--scope /`) | 全部会话 |
64
+
65
+ 按子树取而不是按目录相等取:`--scope ~/Documents` 会列出 `~/Documents` 下每一个项目的会话,而不只是恰好在 `~/Documents` 里启动的那几个。目录写错会直接报错,不会静悄悄地返回"0 个会话"。
66
+
67
+ ```bash
68
+ 1session list # 当前项目(含子模块)
69
+ 1session list --scope .. # 连同同级的兄弟项目
70
+ 1session list --scope ~/Documents # 这棵树下的全部项目
71
+ 1session list --global # 全部
72
+ 1session search "超时" --scope ../.. # 在祖父目录这棵树里检索
73
+ 1session index --all --global # 全库回填索引
74
+ ```
75
+
76
+ (`--workspace <path>` 是路径形式的旧拼法,仍然可用,优先于 `--scope`。)
77
+
78
+ **路径从哪来**(三家各写各的,读之前先归一):
79
+
80
+ | Provider | 项目路径 | 时间 |
81
+ | --- | --- | --- |
82
+ | claude | 目录名 = cwd 的 slug(`~/.claude/projects/-Users-me-proj/`),每行 JSONL 另带 `cwd` | `updatedAt` = 文件 mtime;`createdAt` = 首行 `timestamp` |
83
+ | codex | 目录只按日期分(`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`),路径只在 `session_meta` / `turn_context` 的 `payload.cwd` 里 | `updatedAt` = mtime;`createdAt` = `session_meta.timestamp`,兜底解析文件名里的时间戳 |
84
+ | 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` |
85
+
86
+ 排序和 `--since` 都只用 mtime,`listCandidates()` 一次 `stat` 就够,不必打开文件 —— 打开文件(拿标题、cwd、创建时间)才是贵的那一步,所以它走索引缓存。
87
+
88
+ Antigravity 的 626 个会话里,本机有 14 个至今没有路径 —— 不是没读到,是 IDE 自己把它们标成了 `outside-of-project`(开着聊天窗口、没开文件夹时起的会话)。这类会话只在 `--global` 下出现。
89
+
90
+ ### 列表为什么是全的
91
+
92
+ `list` / `workspace` / `search` 都走索引:先对每个会话文件做一次指纹检查(size + mtime + 头部哈希),**只有字节动过的才重新解析**,然后用一条 SQL 出结果。所以:
93
+
94
+ - 没有扫描预算,`--scope` 再大也不会悄悄丢掉更早的会话;
95
+ - 第一次全量解析本机 625 个会话约 **10s**,之后每次 `list` 约 **0.5s**;
96
+ - `--no-index` 仍然可以绕开索引直读源文件,两条路的结果应当逐条一致(可以 `diff` 验证)。
97
+
98
+ 升级到这一版后,索引会因为 `PARSER_VERSION` 提升而自动重建一次(antigravity 的 workspace 口径变了),无需手动清库;想主动做可以跑 `1session index --all --global`。
99
+
55
100
  `<session-id>` 支持完整 id、id 前缀(≥6 位)或原始文件路径。
56
101
 
57
102
  ```bash
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { aggregateWorkspaceSessions } from '../src/aggregator.js';
4
5
  import { distillSession } from '../src/distiller.js';
@@ -11,7 +12,7 @@ import { canonicalizePath } from '../src/util/paths.js';
11
12
  import { oneLine } from '../src/util/text.js';
12
13
  const USAGE = `1session — cross-agent session Read Plane
13
14
 
14
- 1session list [--limit <n>] [--workspace <path>] [--provider <name>] [--since 24h] [--json]
15
+ 1session list [--limit <n>] [--scope <path>|cwd|global] [--provider <name>] [--since 24h] [--json]
15
16
  1session overview <session-id> [--json] 第 1 层:会话概要
16
17
  1session turns <session-id> [--json] 第 2 层:逐轮概要
17
18
  1session turn <session-id> <n> [--event <k>] [--json] 第 3 层:单轮 / 单次工具调用明细
@@ -21,19 +22,22 @@ const USAGE = `1session — cross-agent session Read Plane
21
22
  1session errors <session-id> [--json]
22
23
  1session digest <session-id> [--focus marketing|review|full] [--json]
23
24
  1session workspace [path] [--since 24h] [--limit <n>] [--digest] [--focus <f>] [--json]
24
- 1session index [<session-id>] [--all] [--force] [--since 30d] 建立/刷新索引
25
+ 1session index [<session-id>] [--all] [--scope <path>|cwd|global] [--force] [--since 30d] 建立/刷新索引
25
26
  1session graph <session-id> [--json] 会话之间的引用关系
26
- 1session search <query> [--workspace path] [--since 24h] [--limit n] [--provider name]
27
+ 1session search <query> [--scope <path>|cwd|global] [--since 24h] [--limit n] [--provider name]
27
28
  [--kind user,assistant,thinking,tool_call,tool_result]
28
29
  [--regex] [--case] [--context n] [--max-hits n] [--json]
29
30
 
30
31
  全局:--no-index 绕过索引直读源文件。索引位于 ~/.1agents/session-reader/index.db
32
+ list / search / index --all 默认只看当前 pwd 目录(含子目录)下的会话。
33
+ --scope 取当前目录的相对路径或绝对路径,按子树匹配:--scope .. 含同级项目,
34
+ --scope ~ 含 home 下全部;--global(= --scope global,= --scope /)跨全部项目。
31
35
 
32
36
  Providers: antigravity (~/.gemini/antigravity/brain), claude (~/.claude/projects), codex (~/.codex/sessions).
33
37
  `;
34
38
  /** Flags that never take a value, so they cannot swallow a positional. */
35
39
  const BOOLEAN_FLAGS = new Set([
36
- 'json', 'failed', 'digest', 'regex', 'case', 'all', 'force', 'no-index',
40
+ 'json', 'failed', 'digest', 'regex', 'case', 'all', 'force', 'no-index', 'global',
37
41
  ]);
38
42
  function parseArgs(argv) {
39
43
  const [command = 'help', ...rest] = argv;
@@ -85,6 +89,23 @@ const focusOf = (value) => {
85
89
  const focus = str(value);
86
90
  return focus === 'marketing' || focus === 'full' ? focus : 'review';
87
91
  };
92
+ /**
93
+ * Which folder a listing covers. `cwd` (the default) is the current working
94
+ * directory, `global` is everything, and anything else is read as a path —
95
+ * relative to the cwd or absolute — whose whole subtree is included, so
96
+ * `--scope ..` covers the sibling projects too and `--scope /` is `global`.
97
+ * `--workspace <path>` is the older spelling of the path form and wins.
98
+ */
99
+ function scopeWorkspace(flags) {
100
+ const scope = str(flags.workspace) ?? str(flags.scope) ?? (flags.global === true ? 'global' : 'cwd');
101
+ if (scope === 'global')
102
+ return undefined;
103
+ const target = canonicalizePath(scope === 'cwd' ? process.cwd() : scope);
104
+ // A mistyped path would otherwise read as an honest "no sessions found".
105
+ if (!existsSync(target))
106
+ throw new Error(`--scope 目录不存在:${scope}`);
107
+ return target;
108
+ }
88
109
  function print(json, data, text) {
89
110
  console.log(json ? JSON.stringify(data, null, 2) : text);
90
111
  }
@@ -114,9 +135,10 @@ async function main() {
114
135
  case 'list': {
115
136
  const refs = await listRecentSessions({
116
137
  limit: num(flags.limit) ?? 20,
117
- workspace: str(flags.workspace),
138
+ workspace: scopeWorkspace(flags),
118
139
  since: str(flags.since),
119
140
  provider: str(flags.provider),
141
+ useIndex,
120
142
  });
121
143
  print(json, refs, refs
122
144
  .map((ref) => `${ref.provider.padEnd(11)} ${ref.id.slice(0, 8)} ${ref.updatedAt ?? '?'} ` +
@@ -210,6 +232,7 @@ async function main() {
210
232
  workspace: target,
211
233
  since: str(flags.since),
212
234
  limit: num(flags.limit) ?? 50,
235
+ useIndex,
213
236
  });
214
237
  print(json, { workspace: target, sessions: refs }, [
215
238
  `workspace ${target}`,
@@ -273,7 +296,7 @@ async function main() {
273
296
  case 'search': {
274
297
  const query = positional.join(' ');
275
298
  const hits = await searchSessions(query, {
276
- workspace: str(flags.workspace),
299
+ workspace: scopeWorkspace(flags),
277
300
  since: str(flags.since),
278
301
  limit: num(flags.limit),
279
302
  provider: str(flags.provider),
@@ -328,6 +351,7 @@ async function main() {
328
351
  const handles = await listResolvedSessions({
329
352
  limit: num(flags.limit) ?? Number.POSITIVE_INFINITY,
330
353
  scan: num(flags.scan) ?? Number.POSITIVE_INFINITY,
354
+ workspace: scopeWorkspace(flags),
331
355
  ...(str(flags.since) ? { since: str(flags.since) } : {}),
332
356
  ...(str(flags.provider) ? { provider: str(flags.provider) } : {}),
333
357
  });
@@ -1,4 +1,13 @@
1
1
  import type { ProviderAdapter } from './provider.js';
2
+ /**
3
+ * The folder a trajectory was opened in, out of the IDE's own metadata blob.
4
+ *
5
+ * Field 1.1 is that folder as a `file://` URI (1.2 holds the enclosing
6
+ * workspace root when the two differ, 1.4 the git branch). Sessions started
7
+ * with no folder open — the IDE labels them `outside-of-project` — carry no
8
+ * field 1 at all, and an unknown workspace is the honest answer for them.
9
+ */
10
+ export declare function workspaceFromTrajectoryBlob(data: Uint8Array): string | undefined;
2
11
  export declare const antigravityAdapter: ProviderAdapter;
3
12
  /** Antigravity shortens long step output in the transcript; this is the full copy. */
4
13
  export declare function readFullStepOutput(transcriptPath: string, stepIndex: number): Promise<string | undefined>;
@@ -3,9 +3,12 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { readJsonl } from '../util/jsonl.js';
5
5
  import { canonicalizePath, findRepoRoot } from '../util/paths.js';
6
+ import { importSqlite } from '../util/sqlite.js';
6
7
  import { clip, oneLine, stripPromptEnvelope } from '../util/text.js';
7
8
  import { emptyProviderStats, } from '../types.js';
8
9
  const BRAIN_DIR = path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
10
+ /** The IDE's own project↔session mapping: one SQLite file per conversation. */
11
+ const CONVERSATIONS_DIR = path.join(os.homedir(), '.gemini', 'antigravity', 'conversations');
9
12
  const TRANSCRIPTS = ['transcript.jsonl', 'transcript_full.jsonl'];
10
13
  /** Args whose value is an absolute path we can use to locate the repository. */
11
14
  const PATH_ARGS = ['AbsolutePath', 'DirectoryPath', 'SearchPath', 'TargetFile', 'FilePath'];
@@ -76,6 +79,85 @@ function unwrapArgs(args) {
76
79
  }
77
80
  return out;
78
81
  }
82
+ /** One protobuf varint: its value and the offset just past it. */
83
+ function varint(buf, at) {
84
+ let value = 0;
85
+ let shift = 0;
86
+ let i = at;
87
+ while (i < buf.length) {
88
+ const byte = buf[i++];
89
+ value += (byte & 0x7f) * 2 ** shift;
90
+ shift += 7;
91
+ if (!(byte & 0x80))
92
+ break;
93
+ }
94
+ return [value, i];
95
+ }
96
+ /** The bytes of the first length-delimited field `no`, or nothing. */
97
+ function protoField(buf, no) {
98
+ let i = 0;
99
+ while (i < buf.length) {
100
+ const [key, afterKey] = varint(buf, i);
101
+ const wire = key & 7;
102
+ if (wire === 2) {
103
+ const [length, afterLength] = varint(buf, afterKey);
104
+ if (key >>> 3 === no)
105
+ return buf.subarray(afterLength, afterLength + length);
106
+ i = afterLength + length;
107
+ continue;
108
+ }
109
+ // Skip the fixed and varint shapes; anything else is not a message we know.
110
+ if (wire === 0)
111
+ i = varint(buf, afterKey)[1];
112
+ else if (wire === 5)
113
+ i = afterKey + 4;
114
+ else if (wire === 1)
115
+ i = afterKey + 8;
116
+ else
117
+ return undefined;
118
+ }
119
+ return undefined;
120
+ }
121
+ /**
122
+ * The folder a trajectory was opened in, out of the IDE's own metadata blob.
123
+ *
124
+ * Field 1.1 is that folder as a `file://` URI (1.2 holds the enclosing
125
+ * workspace root when the two differ, 1.4 the git branch). Sessions started
126
+ * with no folder open — the IDE labels them `outside-of-project` — carry no
127
+ * field 1 at all, and an unknown workspace is the honest answer for them.
128
+ */
129
+ export function workspaceFromTrajectoryBlob(data) {
130
+ const opened = protoField(Buffer.from(data), 1);
131
+ const uri = opened && protoField(opened, 1);
132
+ return uri?.length ? canonicalizePath(uri.toString('utf8')) : undefined;
133
+ }
134
+ /**
135
+ * The workspace as the IDE itself records it.
136
+ *
137
+ * Antigravity keeps a SQLite file per conversation whose `trajectory_metadata_blob`
138
+ * holds the blob above. That is the same relation the IDE draws its project
139
+ * list from, so it beats guessing from tool arguments — which returns the git
140
+ * root, or a path from some other tree the session happened to touch first.
141
+ */
142
+ async function workspaceFromConversation(id) {
143
+ const file = path.join(CONVERSATIONS_DIR, `${id}.db`);
144
+ if (!(await fs.access(file).then(() => true, () => false)))
145
+ return undefined;
146
+ const { DatabaseSync } = await importSqlite();
147
+ let db;
148
+ try {
149
+ db = new DatabaseSync(file, { readOnly: true });
150
+ const row = db.prepare('SELECT data FROM trajectory_metadata_blob LIMIT 1').get();
151
+ return row?.data ? workspaceFromTrajectoryBlob(row.data) : undefined;
152
+ }
153
+ catch {
154
+ // A half-written or future-shaped database is not worth failing a listing over.
155
+ return undefined;
156
+ }
157
+ finally {
158
+ db?.close();
159
+ }
160
+ }
79
161
  function workspaceFromCall(name, args) {
80
162
  if (name === 'run_command' && typeof args.Cwd === 'string' && args.Cwd) {
81
163
  return canonicalizePath(args.Cwd);
@@ -165,7 +247,7 @@ export const antigravityAdapter = {
165
247
  },
166
248
  async scanRef(candidate) {
167
249
  let title;
168
- let workspace;
250
+ let workspace = await workspaceFromConversation(candidate.id);
169
251
  let createdAt;
170
252
  for await (const raw of readJsonl(candidate.path, { maxLines: 400 })) {
171
253
  const step = raw;
@@ -215,7 +297,7 @@ export const antigravityAdapter = {
215
297
  async parse(candidate) {
216
298
  const turns = [];
217
299
  let title;
218
- let workspace;
300
+ let workspace = await workspaceFromConversation(candidate.id);
219
301
  let createdAt;
220
302
  const stats = emptyProviderStats();
221
303
  const push = (turn) => {
@@ -53,11 +53,14 @@ export const claudeAdapter = {
53
53
  return found.sort((a, b) => b.mtimeMs - a.mtimeMs);
54
54
  },
55
55
  /**
56
- * Only the head of the file is read, so the title is the opening request —
57
- * a session's own `custom-title`/`ai-title` is picked up by {@link parse}.
56
+ * Only the head of the file is read. Claude writes its own titles a few
57
+ * lines after the opening request, so the scan does not stop at the first
58
+ * prompt — it keeps reading the head for a `custom-title`/`ai-title`.
58
59
  */
59
60
  async scanRef(candidate) {
60
- let title;
61
+ let customTitle;
62
+ let aiTitle;
63
+ let firstPrompt;
61
64
  let workspace;
62
65
  let createdAt;
63
66
  for await (const raw of readJsonl(candidate.path, { maxLines: 60 })) {
@@ -66,18 +69,21 @@ export const claudeAdapter = {
66
69
  workspace ??= canonicalizePath(entry.cwd);
67
70
  createdAt ??= entry.timestamp;
68
71
  if (entry.type === 'custom-title' && entry.customTitle)
69
- title = entry.customTitle;
70
- if (!title && entry.type === 'user' && !entry.isMeta && typeof entry.message?.content === 'string') {
71
- title = oneLine(stripPromptEnvelope(entry.message.content), 120);
72
+ customTitle = entry.customTitle;
73
+ if (entry.type === 'ai-title' && entry.aiTitle)
74
+ aiTitle = entry.aiTitle;
75
+ if (!firstPrompt && entry.type === 'user' && !entry.isMeta && typeof entry.message?.content === 'string') {
76
+ firstPrompt = oneLine(stripPromptEnvelope(entry.message.content), 120);
72
77
  }
73
- if (title && workspace)
78
+ // A user-set title outranks everything, so nothing later can beat it.
79
+ if (customTitle && workspace)
74
80
  break;
75
81
  }
76
82
  return {
77
83
  id: candidate.id,
78
84
  provider: 'claude',
79
85
  path: candidate.path,
80
- title,
86
+ title: customTitle ?? aiTitle ?? firstPrompt,
81
87
  workspace,
82
88
  createdAt,
83
89
  updatedAt: new Date(candidate.mtimeMs).toISOString(),
@@ -91,7 +97,8 @@ export const claudeAdapter = {
91
97
  let cacheRead = 0;
92
98
  // One assistant message can appear on several entries; count its usage once.
93
99
  const countedUsage = new Set();
94
- let title;
100
+ let customTitle;
101
+ let aiTitle;
95
102
  let firstPrompt;
96
103
  let workspace;
97
104
  let createdAt;
@@ -108,9 +115,9 @@ export const claudeAdapter = {
108
115
  updatedAt = entry.timestamp;
109
116
  }
110
117
  if (entry.type === 'custom-title' && entry.customTitle)
111
- title = entry.customTitle;
112
- if (entry.type === 'ai-title' && typeof entry.title === 'string')
113
- title ??= entry.title;
118
+ customTitle = entry.customTitle;
119
+ if (entry.type === 'ai-title' && entry.aiTitle)
120
+ aiTitle = entry.aiTitle;
114
121
  if (entry.gitBranch && !stats.branches.includes(entry.gitBranch))
115
122
  stats.branches.push(entry.gitBranch);
116
123
  const model = entry.message?.model;
@@ -189,7 +196,7 @@ export const claudeAdapter = {
189
196
  id: candidate.id,
190
197
  provider: 'claude',
191
198
  path: candidate.path,
192
- title: title ?? firstPrompt,
199
+ title: customTitle ?? aiTitle ?? firstPrompt,
193
200
  workspace,
194
201
  createdAt,
195
202
  updatedAt: updatedAt ?? new Date(candidate.mtimeMs).toISOString(),
@@ -3,6 +3,13 @@ import type { AgentProvider, NormalizedSession, SessionRef } from './types.js';
3
3
  export declare const adapters: ProviderAdapter[];
4
4
  export interface ListOptions {
5
5
  limit?: number;
6
+ /**
7
+ * Serve the listing from the index (the default). The sweep still checks
8
+ * every session file's fingerprint, but only the ones whose bytes moved are
9
+ * parsed again — so a listing is complete regardless of `scan`, and cheap
10
+ * after the first run. Set false to read the source files directly.
11
+ */
12
+ useIndex?: boolean;
6
13
  /**
7
14
  * How many session files may be opened. Defaults to a small budget so an
8
15
  * unfiltered `list` stays cheap; pass `Infinity` when completeness matters
@@ -44,7 +44,10 @@ function couldBelong(candidate, provider, workspace) {
44
44
  /** Discovery that keeps the adapter handle, so callers can parse without a second scan. */
45
45
  export async function listResolvedSessions(options = {}) {
46
46
  const limit = options.limit ?? 20;
47
- const workspace = options.workspace ? canonicalizePath(options.workspace) : undefined;
47
+ // `/` contains every session by definition, so filtering on it would only
48
+ // cost a scan and drop the sessions whose cwd we could not recover.
49
+ const scoped = options.workspace ? canonicalizePath(options.workspace) : undefined;
50
+ const workspace = scoped && scoped !== '/' ? scoped : undefined;
48
51
  const sinceMs = parseSince(options.since);
49
52
  const found = [];
50
53
  for (const adapter of adapters) {
@@ -74,7 +77,43 @@ export async function listResolvedSessions(options = {}) {
74
77
  .slice(0, limit);
75
78
  }
76
79
  export async function listRecentSessions(options = {}) {
77
- return (await listResolvedSessions(options)).map((session) => session.ref);
80
+ if (options.useIndex === false || process.env.SESSION_READER_NO_INDEX === '1') {
81
+ return (await listResolvedSessions(options)).map((session) => session.ref);
82
+ }
83
+ return listIndexedSessions(options);
84
+ }
85
+ /**
86
+ * The listing as the index sees it: a fingerprint sweep over every candidate
87
+ * (cheap once indexed), then one SQL query. Unlike the scanning path this one
88
+ * has no candidate budget, so a workspace never quietly loses its older
89
+ * sessions once a provider grows past `WORKSPACE_SCAN` files.
90
+ */
91
+ async function listIndexedSessions(options) {
92
+ const { openStore } = await import('./store/db.js');
93
+ const { refreshSession } = await import('./store/indexer.js');
94
+ const { listSessionRows, refOf } = await import('./store/read.js');
95
+ const db = await openStore();
96
+ const sinceMs = parseSince(options.since);
97
+ const ids = [];
98
+ for (const adapter of adapters) {
99
+ if (options.provider && adapter.provider !== options.provider)
100
+ continue;
101
+ for (const candidate of await adapter.listCandidates()) {
102
+ if (sinceMs && candidate.mtimeMs < sinceMs)
103
+ break; // candidates are newest first
104
+ const result = await refreshSession(db, { adapter, candidate }, { edges: false }).catch(() => undefined);
105
+ if (result)
106
+ ids.push(result.id);
107
+ }
108
+ }
109
+ const scoped = options.workspace ? canonicalizePath(options.workspace) : undefined;
110
+ return listSessionRows(db, {
111
+ ids,
112
+ ...(scoped && scoped !== '/' ? { workspace: scoped } : {}),
113
+ ...(options.provider ? { provider: options.provider } : {}),
114
+ ...(sinceMs ? { sinceMs } : {}),
115
+ limit: options.limit ?? 20,
116
+ }).map(refOf);
78
117
  }
79
118
  export async function findSessionsByWorkspace(workspacePath, options = {}) {
80
119
  return listRecentSessions({ ...options, limit: options.limit ?? 50, workspace: workspacePath });
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { importSqlite } from '../util/sqlite.js';
4
5
  import { DDL, SCHEMA_VERSION } from './schema.js';
5
6
  /** Where the index lives. `SESSION_READER_DB` overrides it (tests, CI). */
6
7
  export function defaultDbPath() {
@@ -9,26 +10,6 @@ export function defaultDbPath() {
9
10
  return override;
10
11
  return path.join(os.homedir(), '.1agents', 'session-reader', 'index.db');
11
12
  }
12
- /**
13
- * `node:sqlite` is still flagged experimental and prints a warning on load.
14
- * Filtering just that one message keeps every other warning intact — far less
15
- * rude than `removeAllListeners('warning')`.
16
- */
17
- async function importSqlite() {
18
- const original = process.emitWarning;
19
- process.emitWarning = ((warning, ...rest) => {
20
- const text = typeof warning === 'string' ? warning : (warning?.message ?? '');
21
- if (text.includes('SQLite is an experimental feature'))
22
- return;
23
- return original.call(process, warning, ...rest);
24
- });
25
- try {
26
- return await import('node:sqlite');
27
- }
28
- finally {
29
- process.emitWarning = original;
30
- }
31
- }
32
13
  let cached;
33
14
  let cachedPath;
34
15
  /**
@@ -25,5 +25,19 @@ export interface SessionRow {
25
25
  export declare function sessionRow(db: DatabaseSync, id: string): SessionRow | undefined;
26
26
  /** Resolves a full id, an id prefix, or a native id against the index. */
27
27
  export declare function findSessionRow(db: DatabaseSync, needle: string): SessionRow | undefined;
28
+ export interface ListQuery {
29
+ /** Ids seen on disk during this sweep; rows outside it are stale and skipped. */
30
+ ids: string[];
31
+ /** Canonical root; matches the directory itself and everything under it. */
32
+ workspace?: string;
33
+ provider?: string;
34
+ sinceMs?: number;
35
+ limit?: number;
36
+ }
37
+ /**
38
+ * The listing served from the index: newest first, no scan budget, so a
39
+ * workspace never silently loses its older sessions to a candidate cap.
40
+ */
41
+ export declare function listSessionRows(db: DatabaseSync, query: ListQuery): SessionRow[];
28
42
  export declare function refOf(row: SessionRow): SessionRef;
29
43
  export declare function readSession(db: DatabaseSync, row: SessionRow): NormalizedSession;
@@ -15,6 +15,40 @@ export function findSessionRow(db, needle) {
15
15
  .prepare('SELECT * FROM sessions WHERE native_id LIKE ? ORDER BY ended_at DESC LIMIT 1')
16
16
  .get(`${needle}%`);
17
17
  }
18
+ /**
19
+ * The listing served from the index: newest first, no scan budget, so a
20
+ * workspace never silently loses its older sessions to a candidate cap.
21
+ */
22
+ export function listSessionRows(db, query) {
23
+ if (!query.ids.length)
24
+ return [];
25
+ db.exec('DROP TABLE IF EXISTS temp.list_scope');
26
+ db.exec('CREATE TEMP TABLE list_scope (id TEXT PRIMARY KEY)');
27
+ const insert = db.prepare('INSERT OR IGNORE INTO temp.list_scope (id) VALUES (?)');
28
+ for (const id of query.ids)
29
+ insert.run(id);
30
+ const where = ['id IN (SELECT id FROM temp.list_scope)'];
31
+ const params = [];
32
+ if (query.workspace) {
33
+ // Prefix comparison rather than LIKE: real paths contain `_`, a LIKE wildcard.
34
+ where.push('(workspace = ? OR substr(workspace, 1, ?) = ?)');
35
+ params.push(query.workspace, query.workspace.length + 1, `${query.workspace}/`);
36
+ }
37
+ if (query.provider) {
38
+ where.push('provider = ?');
39
+ params.push(query.provider);
40
+ }
41
+ if (query.sinceMs) {
42
+ where.push('source_mtime_ms >= ?');
43
+ params.push(query.sinceMs);
44
+ }
45
+ params.push(query.limit ?? 20);
46
+ return db
47
+ .prepare(`SELECT * FROM sessions WHERE ${where.join(' AND ')} ` +
48
+ // Undated rows sort last instead of first, which is where DESC puts NULL.
49
+ 'ORDER BY (ended_at IS NULL), ended_at DESC LIMIT ?')
50
+ .all(...params);
51
+ }
18
52
  export function refOf(row) {
19
53
  return {
20
54
  id: row.native_id,
@@ -9,7 +9,7 @@
9
9
  /** DDL layout. A bump drops and rebuilds the whole database. */
10
10
  export declare const SCHEMA_VERSION = 1;
11
11
  /** L1 semantics — anything in `parsers/` that changes normalized events. */
12
- export declare const PARSER_VERSION = 1;
12
+ export declare const PARSER_VERSION = 2;
13
13
  /** L2 rules — `writes.ts` / `ledger.ts`. Re-derives facts from stored events. */
14
14
  export declare const EXTRACTOR_VERSION = 1;
15
15
  /** L3 rules — `store/edges.ts`. Re-derives edges from stored events. */
@@ -9,7 +9,7 @@
9
9
  /** DDL layout. A bump drops and rebuilds the whole database. */
10
10
  export const SCHEMA_VERSION = 1;
11
11
  /** L1 semantics — anything in `parsers/` that changes normalized events. */
12
- export const PARSER_VERSION = 1;
12
+ export const PARSER_VERSION = 2;
13
13
  /** L2 rules — `writes.ts` / `ledger.ts`. Re-derives facts from stored events. */
14
14
  export const EXTRACTOR_VERSION = 1;
15
15
  /** L3 rules — `store/edges.ts`. Re-derives edges from stored events. */
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `node:sqlite` is still flagged experimental and prints a warning on load.
3
+ * Filtering just that one message keeps every other warning intact — far less
4
+ * rude than `removeAllListeners('warning')`.
5
+ */
6
+ export declare function importSqlite(): Promise<typeof import('node:sqlite')>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `node:sqlite` is still flagged experimental and prints a warning on load.
3
+ * Filtering just that one message keeps every other warning intact — far less
4
+ * rude than `removeAllListeners('warning')`.
5
+ */
6
+ export async function importSqlite() {
7
+ const original = process.emitWarning;
8
+ process.emitWarning = ((warning, ...rest) => {
9
+ const text = typeof warning === 'string' ? warning : (warning?.message ?? '');
10
+ if (text.includes('SQLite is an experimental feature'))
11
+ return;
12
+ return original.call(process, warning, ...rest);
13
+ });
14
+ try {
15
+ return await import('node:sqlite');
16
+ }
17
+ finally {
18
+ process.emitWarning = original;
19
+ }
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agents/session-reader",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Read Plane: cross-agent session discovery, turn inspection, workspace aggregation and distillation from raw local session files.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -30,7 +30,7 @@
30
30
  }
31
31
  },
32
32
  "bin": {
33
- "1session": "./dist/bin/1session.js"
33
+ "1session": "dist/bin/1session.js"
34
34
  },
35
35
  "files": [
36
36
  "dist",