@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 +48 -3
- package/dist/bin/1session.js +30 -6
- package/dist/src/parsers/antigravity.d.ts +9 -0
- package/dist/src/parsers/antigravity.js +84 -2
- package/dist/src/parsers/claude.js +20 -13
- package/dist/src/resolver.d.ts +7 -0
- package/dist/src/resolver.js +41 -2
- package/dist/src/store/db.js +1 -20
- package/dist/src/store/read.d.ts +14 -0
- package/dist/src/store/read.js +34 -0
- package/dist/src/store/schema.d.ts +1 -1
- package/dist/src/store/schema.js +1 -1
- package/dist/src/util/sqlite.d.ts +6 -0
- package/dist/src/util/sqlite.js +20 -0
- package/package.json +2 -2
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] [--
|
|
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> [--
|
|
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
|
package/dist/bin/1session.js
CHANGED
|
@@ -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>] [--
|
|
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> [--
|
|
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:
|
|
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:
|
|
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
|
|
57
|
-
*
|
|
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
|
|
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
|
-
|
|
70
|
-
if (
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
112
|
-
if (entry.type === 'ai-title' &&
|
|
113
|
-
|
|
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:
|
|
199
|
+
title: customTitle ?? aiTitle ?? firstPrompt,
|
|
193
200
|
workspace,
|
|
194
201
|
createdAt,
|
|
195
202
|
updatedAt: updatedAt ?? new Date(candidate.mtimeMs).toISOString(),
|
package/dist/src/resolver.d.ts
CHANGED
|
@@ -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
|
package/dist/src/resolver.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 });
|
package/dist/src/store/db.js
CHANGED
|
@@ -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
|
/**
|
package/dist/src/store/read.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/store/read.js
CHANGED
|
@@ -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 =
|
|
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. */
|
package/dist/src/store/schema.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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": "
|
|
33
|
+
"1session": "dist/bin/1session.js"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"dist",
|