@1agents/session-reader 0.4.0 → 0.5.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.
@@ -1,4 +1,5 @@
1
1
  import { adapters, listResolvedSessions, parseSince } from './resolver.js';
2
+ import { turnNoAt, turnStarts } from './turns.js';
2
3
  import { canonicalizePath, isInside } from './util/paths.js';
3
4
  const DEFAULT_CONTEXT = 100;
4
5
  const DEFAULT_MAX_PER_SESSION = 5;
@@ -126,25 +127,115 @@ export function planQuery(query, options = {}) {
126
127
  const run = longestCaselessRun(raw);
127
128
  return run.length >= 2 ? { literal: run } : {};
128
129
  }
129
- function accumulate(hits, sessionId, candidate, pattern, context, maxPerSession) {
130
+ // ---------------------------------------------------------------------------
131
+ // The searcher's own footprint
132
+ // ---------------------------------------------------------------------------
133
+ /**
134
+ * How recent a `1session` invocation has to be to be this very search rather
135
+ * than a historical one. The agent's tool call is written to its transcript
136
+ * seconds before the command runs, so the window only has to survive the
137
+ * indexing sweep — but a past session that genuinely ran the same query must
138
+ * stay a real hit, which is what keeps this narrow.
139
+ */
140
+ const SELF_ECHO_WINDOW_MS = 5 * 60_000;
141
+ /** How far after the invocation its own output may land, as in `commandLedger`. */
142
+ const RESULT_SPAN = 3;
143
+ const INVOCATION = /(?:^|[\s"'`/])1session\s/;
144
+ /**
145
+ * A `1session` invocation written in the last few minutes.
146
+ *
147
+ * Only the live transcript can hold one: a session that ended yesterday cannot
148
+ * have an event timestamped now, so the window is what separates "this
149
+ * investigation, happening" from "someone once ran this", and no past session
150
+ * is ever touched by it.
151
+ */
152
+ function isRunningInvocation(candidate, now) {
153
+ if (candidate.kind !== 'tool_call' || !candidate.timestamp)
154
+ return false;
155
+ const at = Date.parse(candidate.timestamp);
156
+ if (!Number.isFinite(at) || at < now - SELF_ECHO_WINDOW_MS || at > now + 60_000)
157
+ return false;
158
+ return INVOCATION.test(candidate.body);
159
+ }
160
+ /**
161
+ * Folds the Read Plane's own footprint out of one session: an invocation
162
+ * running right now, and the result carrying what it printed.
163
+ *
164
+ * It deliberately does not require the invocation to carry *this* query. A
165
+ * `1session` call from a minute ago prints other sessions' content verbatim,
166
+ * so it matches queries it never mentioned — and whatever it echoed is still
167
+ * in the session it was quoting from, where the search finds it properly, with
168
+ * a handle that drills down to the real thing instead of to a screenful of
169
+ * this tool's output.
170
+ *
171
+ * Stateful because the second half is only knowable from the first, so it is
172
+ * built fresh per session and fed events in index order — which both search
173
+ * paths already produce.
174
+ */
175
+ export function echoFolder(now) {
176
+ let lastEcho = Number.NEGATIVE_INFINITY;
177
+ return (candidate) => {
178
+ if (isRunningInvocation(candidate, now)) {
179
+ lastEcho = candidate.index;
180
+ return true;
181
+ }
182
+ return candidate.kind === 'tool_result' && candidate.index - lastEcho <= RESULT_SPAN;
183
+ };
184
+ }
185
+ /**
186
+ * Whether a session is the one running the search. Accepts the canonical
187
+ * `provider:native_id`, the bare native id, or a 6+ character prefix of it —
188
+ * the same spellings `resolveSession` takes.
189
+ */
190
+ export function isCallerSession(ref, callerId) {
191
+ const caller = callerId?.trim().toLowerCase();
192
+ if (!caller)
193
+ return false;
194
+ const native = ref.id.toLowerCase();
195
+ const canonical = `${ref.provider}:${native}`;
196
+ const bare = caller.includes(':') ? caller.slice(caller.indexOf(':') + 1) : caller;
197
+ return caller === canonical || bare === native || (bare.length >= 6 && native.startsWith(bare));
198
+ }
199
+ function accumulate(hits, sessionId, candidate, pattern, context, maxPerSession, echo) {
130
200
  if (!candidate.body)
131
201
  return;
132
202
  pattern.lastIndex = 0;
133
203
  const found = pattern.exec(candidate.body);
134
204
  if (!found)
135
205
  return;
136
- const bucket = hits.get(sessionId) ?? { matches: [], totalMatches: 0 };
206
+ const bucket = hits.get(sessionId) ?? { matches: [], totalMatches: 0, suppressed: 0 };
207
+ hits.set(sessionId, bucket);
208
+ if (echo(candidate)) {
209
+ bucket.suppressed++;
210
+ return;
211
+ }
137
212
  bucket.totalMatches++;
138
213
  if (bucket.matches.length < Math.min(maxPerSession, HARD_CAP)) {
139
214
  bucket.matches.push({
140
215
  index: candidate.index,
216
+ turn: 0, // filled in once the session's turn boundaries are known
141
217
  kind: candidate.kind,
142
218
  ...(candidate.toolName ? { toolName: candidate.toolName } : {}),
143
219
  ...(candidate.timestamp ? { timestamp: candidate.timestamp } : {}),
144
220
  excerpt: excerpt(candidate.body, found.index, found[0].length, context),
145
221
  });
146
222
  }
147
- hits.set(sessionId, bucket);
223
+ }
224
+ /** Stamps the drill-down handle onto every match of one session. */
225
+ function assignTurns(matches, starts) {
226
+ for (const match of matches)
227
+ match.turn = turnNoAt(starts, match.index);
228
+ }
229
+ /** A bucket as it leaves the search: empty ones only survive to be counted. */
230
+ function toHit(session, bucket, caller) {
231
+ const self = isCallerSession(session, caller) || (bucket.totalMatches === 0 && bucket.suppressed > 0);
232
+ return {
233
+ session,
234
+ matches: bucket.matches,
235
+ totalMatches: bucket.totalMatches,
236
+ ...(bucket.suppressed ? { suppressed: bucket.suppressed } : {}),
237
+ ...(self ? { self: true } : {}),
238
+ };
148
239
  }
149
240
  /**
150
241
  * Full-text search across sessions.
@@ -159,16 +250,28 @@ export async function searchSessions(query, options = {}) {
159
250
  const pattern = buildPattern(query, options);
160
251
  const context = options.context ?? DEFAULT_CONTEXT;
161
252
  const maxPerSession = options.maxPerSession ?? DEFAULT_MAX_PER_SESSION;
253
+ const now = Date.now();
254
+ const plan = {
255
+ pattern,
256
+ context,
257
+ maxPerSession,
258
+ newEchoFolder: () => echoFolder(now),
259
+ caller: options.selfSessionId ?? process.env.SESSION_READER_CALLER_SESSION,
260
+ };
162
261
  const direct = options.useIndex === false || process.env.SESSION_READER_NO_INDEX === '1';
163
262
  const hits = direct
164
- ? await searchByParsing(query, options, pattern, context, maxPerSession)
165
- : await searchByIndex(query, options, pattern, context, maxPerSession);
166
- return hits
167
- .sort((a, b) => Date.parse(b.session.updatedAt ?? '') - Date.parse(a.session.updatedAt ?? ''))
168
- .slice(0, options.limit ?? hits.length);
263
+ ? await searchByParsing(options, plan)
264
+ : await searchByIndex(query, options, plan);
265
+ hits.sort((a, b) => Date.parse(b.session.updatedAt ?? '') - Date.parse(a.session.updatedAt ?? ''));
266
+ // `limit` caps the sessions a caller has to read, so it counts the ones that
267
+ // will actually be shown; a folded self-hit carries nothing but its tally and
268
+ // must not push a real session out of the answer.
269
+ const limit = options.limit ?? Number.POSITIVE_INFINITY;
270
+ let shown = 0;
271
+ return hits.filter((hit) => (hit.self ? true : ++shown <= limit));
169
272
  }
170
273
  /** The oracle: parses every candidate from disk, touching no stored state. */
171
- async function searchByParsing(_query, options, pattern, context, maxPerSession) {
274
+ async function searchByParsing(options, plan) {
172
275
  const kinds = options.kinds?.length ? new Set(options.kinds) : undefined;
173
276
  const hits = [];
174
277
  const handles = await listResolvedSessions({
@@ -177,10 +280,13 @@ async function searchByParsing(_query, options, pattern, context, maxPerSession)
177
280
  scan: Number.POSITIVE_INFINITY,
178
281
  });
179
282
  for (const handle of handles) {
180
- const session = await handle.adapter.parse(handle.candidate).catch(() => undefined);
283
+ const session = await handle.adapter
284
+ .parse(handle.candidate)
285
+ .catch(() => undefined);
181
286
  if (!session)
182
287
  continue;
183
288
  const bucket = new Map();
289
+ const echo = plan.newEchoFolder();
184
290
  for (const turn of session.turns) {
185
291
  if (kinds && !kinds.has(turn.kind))
186
292
  continue;
@@ -190,11 +296,13 @@ async function searchByParsing(_query, options, pattern, context, maxPerSession)
190
296
  ...(turn.toolName ? { toolName: turn.toolName } : {}),
191
297
  ...(turn.timestamp ? { timestamp: turn.timestamp } : {}),
192
298
  body: haystack(turn),
193
- }, pattern, context, maxPerSession);
299
+ }, plan.pattern, plan.context, plan.maxPerSession, echo);
194
300
  }
195
301
  const found = bucket.get(session.ref.id);
196
- if (found)
197
- hits.push({ session: session.ref, ...found });
302
+ if (!found)
303
+ continue;
304
+ assignTurns(found.matches, turnStarts(session));
305
+ hits.push(toHit(session.ref, found, plan.caller));
198
306
  }
199
307
  return hits;
200
308
  }
@@ -204,11 +312,11 @@ async function searchByParsing(_query, options, pattern, context, maxPerSession)
204
312
  * Sessions are never materialized — rebuilding 622 of them into objects cost
205
313
  * more than every other part of a search put together.
206
314
  */
207
- async function searchByIndex(query, options, pattern, context, maxPerSession) {
315
+ async function searchByIndex(query, options, plan) {
208
316
  const { openStore } = await import('./store/db.js');
209
317
  const { refreshSession } = await import('./store/indexer.js');
210
318
  const { searchRows } = await import('./store/rows.js');
211
- const { refOf } = await import('./store/read.js');
319
+ const { refOf, turnStartsOf } = await import('./store/read.js');
212
320
  const db = await openStore();
213
321
  const sinceMs = parseSince(options.since);
214
322
  const ids = [];
@@ -223,16 +331,21 @@ async function searchByIndex(query, options, pattern, context, maxPerSession) {
223
331
  ids.push(result.id);
224
332
  }
225
333
  }
226
- const plan = planQuery(query, options);
334
+ const prefilter = planQuery(query, options);
227
335
  const workspace = options.workspace ? canonicalizePath(options.workspace) : undefined;
228
336
  const buckets = new Map();
337
+ let folding = { sessionId: '', echo: plan.newEchoFolder() };
229
338
  for (const row of searchRows(db, {
230
339
  ids,
231
340
  ...(workspace ? { workspace } : {}),
232
341
  ...(options.kinds?.length ? { kinds: options.kinds } : {}),
233
- ...(plan.literal ? { literal: plan.literal } : {}),
234
- ...(plan.fold ? { fold: true } : {}),
342
+ ...(prefilter.literal ? { literal: prefilter.literal } : {}),
343
+ ...(prefilter.fold ? { fold: true } : {}),
235
344
  })) {
345
+ // `searchRows` orders by (session_id, idx), which is what the folder needs.
346
+ if (row.session_id !== folding.sessionId) {
347
+ folding = { sessionId: row.session_id, echo: plan.newEchoFolder() };
348
+ }
236
349
  const args = row.tool_args_json ?? '';
237
350
  accumulate(buckets, row.session_id, {
238
351
  index: row.idx,
@@ -241,7 +354,7 @@ async function searchByIndex(query, options, pattern, context, maxPerSession) {
241
354
  ...(row.ts ? { timestamp: row.ts } : {}),
242
355
  // Rebuilt exactly as `haystack` does, so an excerpt cannot shift.
243
356
  body: [row.text ?? undefined, row.tool_result ?? undefined, args].filter(Boolean).join('\n'),
244
- }, pattern, context, maxPerSession);
357
+ }, plan.pattern, plan.context, plan.maxPerSession, folding.echo);
245
358
  }
246
359
  const hits = [];
247
360
  for (const [id, bucket] of buckets) {
@@ -251,7 +364,10 @@ async function searchByIndex(query, options, pattern, context, maxPerSession) {
251
364
  const ref = refOf(row);
252
365
  if (workspace && !isInside(workspace, ref.workspace ?? ''))
253
366
  continue;
254
- hits.push({ session: ref, ...bucket });
367
+ // Only the sessions that actually matched pay for this — a handful of
368
+ // index lookups, not the 622-session rebuild the row path exists to avoid.
369
+ assignTurns(bucket.matches, turnStartsOf(db, id, row.event_count));
370
+ hits.push(toHit(ref, bucket, plan.caller));
255
371
  }
256
372
  return hits;
257
373
  }
@@ -4,10 +4,13 @@
4
4
  * 它把本地 Read Plane 原样暴露成网络能力:`1session overview <id>` 成为
5
5
  * `sessions.read`。**不重新实现索引与事实层**,只是换一个调用入口。
6
6
  *
7
- * 零运行时依赖:只用 node:http。
7
+ * HTTP 层不引第三方框架,只用 node:http;端口与协议类型来自 L0 包。
8
8
  */
9
9
  import http from 'node:http';
10
+ /** 约定端口,由 L0 协议包定义——Control Plane 的 pull 探测照着它找服务。 */
11
+ export declare const DEFAULT_PORT: 7777;
10
12
  export interface ServeOptions {
13
+ /** 默认 {@link DEFAULT_PORT}。换端口就探测不到了,得自己 register。 */
11
14
  port?: number;
12
15
  /**
13
16
  * Defaults to loopback. Session transcripts contain source code, shell
@@ -19,6 +22,11 @@ export interface ServeOptions {
19
22
  token?: string;
20
23
  /** Advertised base url, when behind a proxy or a different tailnet name. */
21
24
  baseUrl?: string;
25
+ /**
26
+ * 向本机 node agent 报备自己。默认开——agent 没起时是静默 no-op,没有代价。
27
+ * 报备后外部探一个 36908 就能看见本服务,不必碰运气猜端口。
28
+ */
29
+ report?: boolean;
22
30
  }
23
31
  export declare function createServer(options?: ServeOptions): http.Server;
24
32
  export declare function serve(options?: ServeOptions): Promise<http.Server>;
@@ -4,7 +4,7 @@
4
4
  * 它把本地 Read Plane 原样暴露成网络能力:`1session overview <id>` 成为
5
5
  * `sessions.read`。**不重新实现索引与事实层**,只是换一个调用入口。
6
6
  *
7
- * 零运行时依赖:只用 node:http。
7
+ * HTTP 层不引第三方框架,只用 node:http;端口与协议类型来自 L0 包。
8
8
  */
9
9
  import http from 'node:http';
10
10
  import { listRecentSessions, loadSession } from '../resolver.js';
@@ -13,6 +13,11 @@ import { summarizeTurns } from '../turns.js';
13
13
  import { searchSessions } from '../search.js';
14
14
  import { canonicalizePath } from '../util/paths.js';
15
15
  import { buildManifest, nodeIdentity, sessionUri } from './node.js';
16
+ import { DEFAULT_PORTS } from '@1agents/dreammate-network';
17
+ import { reportAndHoldRegistration } from '@1agents/dreammate-node/client';
18
+ import { SESSION_CAPABILITIES } from './node.js';
19
+ /** 约定端口,由 L0 协议包定义——Control Plane 的 pull 探测照着它找服务。 */
20
+ export const DEFAULT_PORT = DEFAULT_PORTS['session-registry'];
16
21
  const json = (res, status, body) => {
17
22
  const payload = JSON.stringify(body, null, 2);
18
23
  res.writeHead(status, {
@@ -53,14 +58,13 @@ async function noteRead(verb, target, caller) {
53
58
  }
54
59
  async function route(ctx) {
55
60
  const { pathname } = ctx.url;
56
- const identity = nodeIdentity();
61
+ const identity = await nodeIdentity();
57
62
  if (pathname === '/health') {
58
63
  return { status: 200, body: { status: 'ok', node_id: identity.node_id, service: 'session-registry' } };
59
64
  }
60
- // `/manifest` is the network-wide contract; `/v1/node` is the same document
61
- // under this service's own prefix.
62
- if (pathname === '/manifest' || pathname === '/v1/node') {
63
- return { status: 200, body: buildManifest(ctx.url.origin) };
65
+ // 只报本服务自己——节点全貌在本机 agent 的 :36908/manifest。
66
+ if (pathname === '/manifest') {
67
+ return { status: 200, body: await buildManifest(ctx.url.origin) };
64
68
  }
65
69
  if (pathname === '/v1/sessions') {
66
70
  const refs = await listRecentSessions({
@@ -168,12 +172,30 @@ export function createServer(options = {}) {
168
172
  export async function serve(options = {}) {
169
173
  const host = options.host ?? '127.0.0.1';
170
174
  const server = createServer(options);
171
- await new Promise((resolve) => server.listen(options.port ?? 7777, host, resolve));
175
+ await new Promise((resolve) => server.listen(options.port ?? DEFAULT_PORT, host, resolve));
172
176
  const { port } = server.address();
173
- const identity = nodeIdentity();
177
+ const identity = await nodeIdentity();
178
+ // 只听回环的服务,外部发现得了却连不上——如实说,别让调用方白跑一趟。
179
+ const reachability = host === '127.0.0.1' || host === 'localhost' || host === '::1' ? 'localhost' : 'network';
180
+ const reported = options.report === false
181
+ ? undefined
182
+ : await reportAndHoldRegistration({
183
+ id: 'session-registry',
184
+ name: 'session-reader',
185
+ kind: 'session_registry',
186
+ capabilities: [...SESSION_CAPABILITIES],
187
+ port,
188
+ reachability,
189
+ resources: [{ scheme: 'session' }],
190
+ });
174
191
  console.log(`1session serve — node ${identity.name} (${identity.node_id})`);
175
192
  console.log(` http://${host}:${port}/manifest`);
176
193
  console.log(` capabilities: sessions.list, sessions.read, sessions.turns, sessions.search, sessions.graph`);
194
+ if (reported) {
195
+ console.log(reported.ok
196
+ ? ` 已向本机 node agent 报备(reachability=${reachability})`
197
+ : ` 未报备:${reported.reason}——不影响本服务,只是外部得靠约定端口找它`);
198
+ }
177
199
  if (host !== '127.0.0.1' && host !== 'localhost' && !options.token) {
178
200
  console.warn(` ⚠️ 绑定在 ${host} 且未设置 --token:会话原文(代码、shell 历史、密钥)将对该网络开放。`);
179
201
  }
@@ -1,26 +1,25 @@
1
- import { PROTOCOL_VERSION, type AccessDescriptor, type NodeManifest, type Service as NetworkService, type SessionURI } from '@1agents/dreammate-network';
2
- export { PROTOCOL_VERSION };
3
- export type { AccessDescriptor, NetworkService, NodeManifest };
4
- /** Where the node identity is kept, next to the index db. */
5
- export declare function nodeIdentityPath(): string;
6
- interface StoredIdentity {
7
- node_id: string;
8
- name: string;
9
- type: string;
10
- }
11
1
  /**
12
- * A stable node id that survives restarts, generated on first use and kept in
13
- * `~/.1agents/session-reader/node.json`.
2
+ * session-reader 作为 DreamMate Network Service 时的 manifest 构建。
14
3
  *
15
- * `DREAMMATE_NODE_ID` / `DREAMMATE_NODE_NAME` override it without touching the
16
- * file, which is what a container or a second instance on one host wants.
4
+ * 节点身份不在这里实现——它是每台机器的公共事实,由 `@1agents/dreammate-node`
5
+ * 统一提供(优先 tailnet,拿不到回退本地)。本机所有服务读到同一份,不会各自
6
+ * 生成 id 把一台机器裂成几个 Node。
17
7
  */
18
- export declare function nodeIdentity(): StoredIdentity;
8
+ import { nodeIdentity, type NodeIdentity } from '@1agents/dreammate-node';
9
+ import { PROTOCOL_VERSION, type AccessDescriptor, type NodeManifest, type Reachability, type Service as NetworkService, type SessionURI } from '@1agents/dreammate-network';
10
+ export { nodeIdentity, PROTOCOL_VERSION };
11
+ export type { AccessDescriptor, NetworkService, NodeIdentity, NodeManifest, Reachability };
19
12
  /**
20
13
  * The capabilities this Service answers for. Names are the network-facing
21
14
  * spelling of the CLI verbs: `1session overview` is `sessions.read`.
22
15
  */
23
16
  export declare const SESSION_CAPABILITIES: readonly ["sessions.list", "sessions.read", "sessions.turns", "sessions.search", "sessions.graph"];
24
- export declare function buildManifest(baseUrl: string): NodeManifest;
25
- /** `session://<node>/<runtime>/<session_id>` — the network-wide address of one session. */
17
+ export declare function buildManifest(baseUrl: string): Promise<NodeManifest>;
18
+ /**
19
+ * `session://<node>/<runtime>/<session_id>` — 一个会话在网络中的地址。
20
+ *
21
+ * `nodeName` 应该来自 {@link nodeIdentity},也就是 tailnet 的 DNSName 前缀。
22
+ * **不要传 `os.hostname()`**:iOS 设备的 hostname 全是 `localhost`,几台手机
23
+ * 接进来会产出一模一样的 `session://localhost/yima/...`。
24
+ */
26
25
  export declare function sessionUri(nodeName: string, provider: string, id: string): SessionURI;
@@ -1,57 +1,13 @@
1
1
  /**
2
- * Node identity and Manifest — session-reader 作为 DreamMate Network 的第一个标准 Service。
2
+ * session-reader 作为 DreamMate Network Service 时的 manifest 构建。
3
3
  *
4
- * 协议类型直接来自 L0 包 `@1agents/dreammate-network`,不再本地抄一份:
5
- * 单向依赖 L2 → L0 是允许的,而共用同一份定义才谈得上「公共语言」。
4
+ * 节点身份不在这里实现——它是每台机器的公共事实,由 `@1agents/dreammate-node`
5
+ * 统一提供(优先 tailnet,拿不到回退本地)。本机所有服务读到同一份,不会各自
6
+ * 生成 id 把一台机器裂成几个 Node。
6
7
  */
7
- import fs from 'node:fs';
8
- import os from 'node:os';
9
- import path from 'node:path';
10
- import { randomUUID } from 'node:crypto';
11
- import { PROTOCOL_VERSION, } from '@1agents/dreammate-network';
12
- export { PROTOCOL_VERSION };
13
- /* ---------- 身份 ---------- */
14
- /** Where the node identity is kept, next to the index db. */
15
- export function nodeIdentityPath() {
16
- return path.join(os.homedir(), '.1agents', 'session-reader', 'node.json');
17
- }
18
- const PLATFORM_TYPE = {
19
- darwin: 'macos',
20
- linux: 'linux',
21
- win32: 'windows',
22
- };
23
- /**
24
- * A stable node id that survives restarts, generated on first use and kept in
25
- * `~/.1agents/session-reader/node.json`.
26
- *
27
- * `DREAMMATE_NODE_ID` / `DREAMMATE_NODE_NAME` override it without touching the
28
- * file, which is what a container or a second instance on one host wants.
29
- */
30
- export function nodeIdentity() {
31
- const type = PLATFORM_TYPE[process.platform] ?? process.platform;
32
- const envId = process.env.DREAMMATE_NODE_ID?.trim();
33
- const envName = process.env.DREAMMATE_NODE_NAME?.trim();
34
- if (envId && envName)
35
- return { node_id: envId, name: envName, type };
36
- const file = nodeIdentityPath();
37
- let stored = {};
38
- try {
39
- stored = JSON.parse(fs.readFileSync(file, 'utf8'));
40
- }
41
- catch {
42
- // First run, or an unreadable file we are about to overwrite.
43
- }
44
- if (!stored.node_id) {
45
- stored = { node_id: `node_${randomUUID().replace(/-/g, '').slice(0, 12)}`, name: os.hostname(), type };
46
- fs.mkdirSync(path.dirname(file), { recursive: true });
47
- fs.writeFileSync(file, `${JSON.stringify(stored, null, 2)}\n`);
48
- }
49
- return {
50
- node_id: envId ?? stored.node_id,
51
- name: envName ?? stored.name ?? os.hostname(),
52
- type,
53
- };
54
- }
8
+ import { nodeIdentity } from '@1agents/dreammate-node';
9
+ import { DEFAULT_PORTS, PROTOCOL_VERSION, } from '@1agents/dreammate-network';
10
+ export { nodeIdentity, PROTOCOL_VERSION };
55
11
  /* ---------- Manifest ---------- */
56
12
  /**
57
13
  * The capabilities this Service answers for. Names are the network-facing
@@ -64,13 +20,20 @@ export const SESSION_CAPABILITIES = [
64
20
  'sessions.search',
65
21
  'sessions.graph',
66
22
  ];
67
- export function buildManifest(baseUrl) {
68
- const identity = nodeIdentity();
23
+ export async function buildManifest(baseUrl) {
24
+ const identity = await nodeIdentity();
25
+ // 有 MagicDNS 就用它:`http://scott-mac:7777` 跨网络稳定,不怕 IP 变,
26
+ // 而请求里的 Host 只是"调用方碰巧用了哪个地址"。
27
+ const advertised = identity.dnsName
28
+ ? baseUrl.replace(/\/\/[^/]+/, `//${identity.dnsName}:${new URL(baseUrl).port || String(DEFAULT_PORTS['session-registry'])}`)
29
+ : baseUrl;
69
30
  return {
70
- ...identity,
71
- tailscale_name: process.env.DREAMMATE_TAILSCALE_NAME?.trim() ?? identity.name,
31
+ node_id: identity.node_id,
32
+ name: identity.name,
33
+ type: identity.type,
34
+ tailscale_name: identity.dnsName ?? process.env.DREAMMATE_TAILSCALE_NAME?.trim() ?? null,
72
35
  online: true,
73
- metadata: { protocol_version: PROTOCOL_VERSION },
36
+ metadata: { protocol_version: PROTOCOL_VERSION, identity_source: identity.source },
74
37
  services: [
75
38
  {
76
39
  id: 'session-registry',
@@ -78,12 +41,18 @@ export function buildManifest(baseUrl) {
78
41
  kind: 'session_registry',
79
42
  capabilities: [...SESSION_CAPABILITIES],
80
43
  resources: [{ scheme: 'session', description: 'session://<node>/<runtime>/<session_id>' }],
81
- access: [{ protocol: 'http', base_url: `${baseUrl}/v1` }],
44
+ access: [{ protocol: 'http', base_url: `${advertised}/v1` }],
82
45
  },
83
46
  ],
84
47
  };
85
48
  }
86
- /** `session://<node>/<runtime>/<session_id>` — the network-wide address of one session. */
49
+ /**
50
+ * `session://<node>/<runtime>/<session_id>` — 一个会话在网络中的地址。
51
+ *
52
+ * `nodeName` 应该来自 {@link nodeIdentity},也就是 tailnet 的 DNSName 前缀。
53
+ * **不要传 `os.hostname()`**:iOS 设备的 hostname 全是 `localhost`,几台手机
54
+ * 接进来会产出一模一样的 `session://localhost/yima/...`。
55
+ */
87
56
  export function sessionUri(nodeName, provider, id) {
88
57
  return `session://${nodeName}/${provider}/${id}`;
89
58
  }
@@ -39,5 +39,13 @@ export interface ListQuery {
39
39
  * workspace never silently loses its older sessions to a candidate cap.
40
40
  */
41
41
  export declare function listSessionRows(db: DatabaseSync, query: ListQuery): SessionRow[];
42
+ /**
43
+ * Turn starts for one indexed session, without rebuilding it.
44
+ *
45
+ * The rule has to stay the rule `turnStarts` applies to a parsed session — a
46
+ * user event carrying real text opens a turn — so the two are fed to the same
47
+ * `turnStartsFrom`; only the source of the indices differs.
48
+ */
49
+ export declare function turnStartsOf(db: DatabaseSync, sessionId: string, eventCount: number): number[];
42
50
  export declare function refOf(row: SessionRow): SessionRef;
43
51
  export declare function readSession(db: DatabaseSync, row: SessionRow): NormalizedSession;
@@ -1,3 +1,4 @@
1
+ import { turnStartsFrom } from '../turns.js';
1
2
  import { emptyProviderStats, } from '../types.js';
2
3
  export function sessionRow(db, id) {
3
4
  return db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
@@ -49,6 +50,21 @@ export function listSessionRows(db, query) {
49
50
  'ORDER BY (ended_at IS NULL), ended_at DESC LIMIT ?')
50
51
  .all(...params);
51
52
  }
53
+ /**
54
+ * Turn starts for one indexed session, without rebuilding it.
55
+ *
56
+ * The rule has to stay the rule `turnStarts` applies to a parsed session — a
57
+ * user event carrying real text opens a turn — so the two are fed to the same
58
+ * `turnStartsFrom`; only the source of the indices differs.
59
+ */
60
+ export function turnStartsOf(db, sessionId, eventCount) {
61
+ const rows = db
62
+ .prepare(`SELECT idx FROM events
63
+ WHERE session_id = ? AND kind = 'user' AND trim(coalesce(text, '')) != ''
64
+ ORDER BY idx`)
65
+ .all(sessionId);
66
+ return turnStartsFrom(rows.map((row) => row.idx), eventCount);
67
+ }
52
68
  export function refOf(row) {
53
69
  return {
54
70
  id: row.native_id,
@@ -1,4 +1,23 @@
1
1
  import type { NormalizedSession, TurnDetail, TurnEvent, TurnSummary } from './types.js';
2
+ /**
3
+ * Turn boundaries from the user messages that open them.
4
+ *
5
+ * Split out from `turnStarts` so the index path can feed it the same indices
6
+ * straight from SQL without materializing the session: a `T` printed by
7
+ * `search` then means exactly what a `T` printed by `turns` means.
8
+ */
9
+ export declare function turnStartsFrom(userEventIndices: number[], eventCount: number): number[];
10
+ /**
11
+ * Turn boundaries: codex records them natively, the others start a new turn on
12
+ * every user message.
13
+ */
14
+ export declare function turnStarts(session: NormalizedSession): number[];
15
+ /**
16
+ * The turn (1-based) an event index falls in, or 0 when it falls before the
17
+ * first one. Turns tile the event stream, so "the last start at or before the
18
+ * index" is the whole rule.
19
+ */
20
+ export declare function turnNoAt(starts: number[], index: number): number;
2
21
  export declare function summarizeTurns(session: NormalizedSession): TurnSummary[];
3
22
  export declare function turnDetail(session: NormalizedSession, turnNo: number): TurnDetail;
4
23
  export interface EventDetail extends TurnEvent {
@@ -7,6 +26,23 @@ export interface EventDetail extends TurnEvent {
7
26
  /** Set when the transcript is short and the full copy could not be found. */
8
27
  truncationNote?: string;
9
28
  }
29
+ /**
30
+ * How many events one `--event` spec may expand to. Reading a tool call with
31
+ * its result and the assistant's verdict takes three; a spec asking for
32
+ * hundreds wanted `turn <n>` instead, and silently truncating would be worse
33
+ * than saying so.
34
+ */
35
+ export declare const MAX_EVENT_SPAN = 50;
36
+ /**
37
+ * `214`, `214-218`, `214,216,218` and any mix, as ascending unique indices.
38
+ *
39
+ * Ranges are clipped to the session — asking for `210-999` on a 300-event
40
+ * session is a reasonable way to say "to the end" — while a bare index is left
41
+ * alone so an out-of-range one still errors naming the number that was typed.
42
+ */
43
+ export declare function parseEventSpec(spec: string, eventCount: number): number[];
44
+ /** `eventDetail` over a spec: `214`, `214-218`, `214,216,218`. */
45
+ export declare function eventDetails(session: NormalizedSession, spec: string): Promise<EventDetail[]>;
10
46
  /**
11
47
  * Tier three: one event with its untruncated payload. Antigravity shortens long
12
48
  * step output in the transcript and keeps the full copy under `steps/<n>/`.