@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.
@@ -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
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `1session serve` — session-reader 作为 DreamMate Network 的第一个标准 Service。
3
+ *
4
+ * 它把本地 Read Plane 原样暴露成网络能力:`1session overview <id>` 成为
5
+ * `sessions.read`。**不重新实现索引与事实层**,只是换一个调用入口。
6
+ *
7
+ * HTTP 层不引第三方框架,只用 node:http;端口与协议类型来自 L0 包。
8
+ */
9
+ import http from 'node:http';
10
+ /** 约定端口,由 L0 协议包定义——Control Plane 的 pull 探测照着它找服务。 */
11
+ export declare const DEFAULT_PORT: 7777;
12
+ export interface ServeOptions {
13
+ /** 默认 {@link DEFAULT_PORT}。换端口就探测不到了,得自己 register。 */
14
+ port?: number;
15
+ /**
16
+ * Defaults to loopback. Session transcripts contain source code, shell
17
+ * history and whatever secrets happened to scroll past, so going beyond this
18
+ * machine has to be a deliberate act — pass the tailnet address explicitly.
19
+ */
20
+ host?: string;
21
+ /** When set, every request must carry it as `Authorization: Bearer <token>`. */
22
+ token?: string;
23
+ /** Advertised base url, when behind a proxy or a different tailnet name. */
24
+ baseUrl?: string;
25
+ /**
26
+ * 向本机 node agent 报备自己。默认开——agent 没起时是静默 no-op,没有代价。
27
+ * 报备后外部探一个 36908 就能看见本服务,不必碰运气猜端口。
28
+ */
29
+ report?: boolean;
30
+ }
31
+ export declare function createServer(options?: ServeOptions): http.Server;
32
+ export declare function serve(options?: ServeOptions): Promise<http.Server>;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * `1session serve` — session-reader 作为 DreamMate Network 的第一个标准 Service。
3
+ *
4
+ * 它把本地 Read Plane 原样暴露成网络能力:`1session overview <id>` 成为
5
+ * `sessions.read`。**不重新实现索引与事实层**,只是换一个调用入口。
6
+ *
7
+ * HTTP 层不引第三方框架,只用 node:http;端口与协议类型来自 L0 包。
8
+ */
9
+ import http from 'node:http';
10
+ import { listRecentSessions, loadSession } from '../resolver.js';
11
+ import { buildOverview } from '../overview.js';
12
+ import { summarizeTurns } from '../turns.js';
13
+ import { searchSessions } from '../search.js';
14
+ import { canonicalizePath } from '../util/paths.js';
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'];
21
+ const json = (res, status, body) => {
22
+ const payload = JSON.stringify(body, null, 2);
23
+ res.writeHead(status, {
24
+ 'content-type': 'application/json; charset=utf-8',
25
+ 'content-length': Buffer.byteLength(payload),
26
+ });
27
+ res.end(payload);
28
+ };
29
+ const q = (ctx, name) => ctx.url.searchParams.get(name) ?? undefined;
30
+ const qn = (ctx, name) => {
31
+ const raw = q(ctx, name);
32
+ if (raw === undefined)
33
+ return undefined;
34
+ const parsed = Number(raw);
35
+ return Number.isFinite(parsed) ? parsed : undefined;
36
+ };
37
+ /**
38
+ * `?scope=` is a path whose subtree is included; omitting it means the whole
39
+ * machine. The CLI defaults to the cwd instead, which is meaningless for a
40
+ * long-running server.
41
+ */
42
+ const scopeOf = (ctx) => {
43
+ const scope = q(ctx, 'scope');
44
+ return scope && scope !== 'global' ? canonicalizePath(scope) : undefined;
45
+ };
46
+ /** Records `caller --references--> target` while the read is happening. */
47
+ async function noteRead(verb, target, caller) {
48
+ if (!caller)
49
+ return;
50
+ try {
51
+ const { openStore } = await import('../store/db.js');
52
+ const { captureRuntimeEdge } = await import('../store/edges.js');
53
+ captureRuntimeEdge(await openStore(), verb, target, caller);
54
+ }
55
+ catch {
56
+ // An edge is a nice-to-have; never fail the read over it.
57
+ }
58
+ }
59
+ async function route(ctx) {
60
+ const { pathname } = ctx.url;
61
+ const identity = await nodeIdentity();
62
+ if (pathname === '/health') {
63
+ return { status: 200, body: { status: 'ok', node_id: identity.node_id, service: 'session-registry' } };
64
+ }
65
+ // `/manifest` is the network-wide contract; `/v1/node` is the same document
66
+ // under this service's own prefix.
67
+ if (pathname === '/manifest' || pathname === '/v1/node') {
68
+ return { status: 200, body: await buildManifest(ctx.url.origin) };
69
+ }
70
+ if (pathname === '/v1/sessions') {
71
+ const refs = await listRecentSessions({
72
+ limit: qn(ctx, 'limit') ?? 20,
73
+ workspace: scopeOf(ctx),
74
+ since: q(ctx, 'since'),
75
+ provider: q(ctx, 'provider'),
76
+ useIndex: true,
77
+ });
78
+ return {
79
+ status: 200,
80
+ body: {
81
+ node: identity.name,
82
+ sessions: refs.map((ref) => ({ ...ref, uri: sessionUri(identity.name, ref.provider, ref.id) })),
83
+ },
84
+ };
85
+ }
86
+ const detail = /^\/v1\/sessions\/([^/]+)(\/turns)?$/.exec(pathname);
87
+ if (detail) {
88
+ const id = decodeURIComponent(detail[1]);
89
+ const wantTurns = detail[2] !== undefined;
90
+ const session = await loadSession(id, { useIndex: true });
91
+ await noteRead(wantTurns ? 'turns' : 'overview', id, ctx.caller);
92
+ const uri = sessionUri(identity.name, session.ref.provider, session.ref.id);
93
+ return wantTurns
94
+ ? { status: 200, body: { uri, turns: summarizeTurns(session) } }
95
+ : { status: 200, body: { uri, ...buildOverview(session) } };
96
+ }
97
+ if (pathname === '/v1/search') {
98
+ const query = q(ctx, 'q') ?? q(ctx, 'query');
99
+ if (!query)
100
+ return { status: 400, body: { error: 'missing ?q=' } };
101
+ const hits = await searchSessions(query, {
102
+ workspace: scopeOf(ctx),
103
+ since: q(ctx, 'since'),
104
+ limit: qn(ctx, 'limit'),
105
+ provider: q(ctx, 'provider'),
106
+ kinds: q(ctx, 'kind')?.split(',').map((k) => k.trim()).filter(Boolean),
107
+ regex: q(ctx, 'regex') === 'true',
108
+ caseSensitive: q(ctx, 'case') === 'true',
109
+ context: qn(ctx, 'context'),
110
+ maxPerSession: qn(ctx, 'max-hits'),
111
+ useIndex: true,
112
+ });
113
+ return {
114
+ status: 200,
115
+ body: {
116
+ query,
117
+ total: hits.reduce((sum, hit) => sum + hit.totalMatches, 0),
118
+ hits: hits.map((hit) => ({
119
+ ...hit,
120
+ uri: sessionUri(identity.name, hit.session.provider, hit.session.id),
121
+ })),
122
+ },
123
+ };
124
+ }
125
+ const graph = /^\/v1\/graph\/([^/]+)$/.exec(pathname);
126
+ if (graph) {
127
+ const id = decodeURIComponent(graph[1]);
128
+ const { openStore } = await import('../store/db.js');
129
+ const { edgeEvidence, edgesOf } = await import('../store/edges.js');
130
+ const { findSessionRow } = await import('../store/read.js');
131
+ await loadSession(id, { useIndex: true }); // make sure it is indexed first
132
+ await noteRead('graph', id, ctx.caller);
133
+ const db = await openStore();
134
+ const row = findSessionRow(db, id);
135
+ if (!row)
136
+ return { status: 404, body: { error: `session not found: ${id}` } };
137
+ const edges = edgesOf(db, row.id).map((edge) => ({
138
+ ...edge,
139
+ evidence: edgeEvidence(db, edge.from, edge.to, edge.relation),
140
+ }));
141
+ return { status: 200, body: { session: row.id, edges } };
142
+ }
143
+ return { status: 404, body: { error: `no route: ${pathname}` } };
144
+ }
145
+ export function createServer(options = {}) {
146
+ return http.createServer((req, res) => {
147
+ void (async () => {
148
+ try {
149
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
150
+ return json(res, 405, { error: 'read-only service: GET only' });
151
+ }
152
+ const url = new URL(req.url ?? '/', options.baseUrl ?? `http://${req.headers.host ?? 'localhost'}`);
153
+ if (options.token) {
154
+ const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? '')?.[1];
155
+ if (bearer !== options.token)
156
+ return json(res, 401, { error: 'unauthorized' });
157
+ }
158
+ const caller = req.headers['x-caller-session'];
159
+ const { status, body } = await route({
160
+ url,
161
+ caller: typeof caller === 'string' ? caller : undefined,
162
+ });
163
+ json(res, status, body);
164
+ }
165
+ catch (error) {
166
+ const message = error instanceof Error ? error.message : String(error);
167
+ // A bad session id reads as "not found", not as a server fault.
168
+ json(res, /not found|missing|no session/i.test(message) ? 404 : 500, { error: message });
169
+ }
170
+ })();
171
+ });
172
+ }
173
+ export async function serve(options = {}) {
174
+ const host = options.host ?? '127.0.0.1';
175
+ const server = createServer(options);
176
+ await new Promise((resolve) => server.listen(options.port ?? DEFAULT_PORT, host, resolve));
177
+ const { port } = server.address();
178
+ const identity = await nodeIdentity();
179
+ // 只听回环的服务,外部发现得了却连不上——如实说,别让调用方白跑一趟。
180
+ const reachability = host === '127.0.0.1' || host === 'localhost' || host === '::1' ? 'localhost' : 'network';
181
+ const reported = options.report === false
182
+ ? undefined
183
+ : await reportAndHoldRegistration({
184
+ id: 'session-registry',
185
+ name: 'session-reader',
186
+ kind: 'session_registry',
187
+ capabilities: [...SESSION_CAPABILITIES],
188
+ port,
189
+ reachability,
190
+ resources: [{ scheme: 'session' }],
191
+ });
192
+ console.log(`1session serve — node ${identity.name} (${identity.node_id})`);
193
+ console.log(` http://${host}:${port}/manifest`);
194
+ console.log(` capabilities: sessions.list, sessions.read, sessions.turns, sessions.search, sessions.graph`);
195
+ if (reported) {
196
+ console.log(reported.ok
197
+ ? ` 已向本机 node agent 报备(reachability=${reachability})`
198
+ : ` 未报备:${reported.reason}——不影响本服务,只是外部得靠约定端口找它`);
199
+ }
200
+ if (host !== '127.0.0.1' && host !== 'localhost' && !options.token) {
201
+ console.warn(` ⚠️ 绑定在 ${host} 且未设置 --token:会话原文(代码、shell 历史、密钥)将对该网络开放。`);
202
+ }
203
+ return server;
204
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * session-reader 作为 DreamMate Network Service 时的 manifest 构建。
3
+ *
4
+ * 节点身份不在这里实现——它是每台机器的公共事实,由 `@1agents/dreammate-node`
5
+ * 统一提供(优先 tailnet,拿不到回退本地)。本机所有服务读到同一份,不会各自
6
+ * 生成 id 把一台机器裂成几个 Node。
7
+ */
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 };
12
+ /**
13
+ * The capabilities this Service answers for. Names are the network-facing
14
+ * spelling of the CLI verbs: `1session overview` is `sessions.read`.
15
+ */
16
+ export declare const SESSION_CAPABILITIES: readonly ["sessions.list", "sessions.read", "sessions.turns", "sessions.search", "sessions.graph"];
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
+ */
25
+ export declare function sessionUri(nodeName: string, provider: string, id: string): SessionURI;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * session-reader 作为 DreamMate Network Service 时的 manifest 构建。
3
+ *
4
+ * 节点身份不在这里实现——它是每台机器的公共事实,由 `@1agents/dreammate-node`
5
+ * 统一提供(优先 tailnet,拿不到回退本地)。本机所有服务读到同一份,不会各自
6
+ * 生成 id 把一台机器裂成几个 Node。
7
+ */
8
+ import { nodeIdentity } from '@1agents/dreammate-node';
9
+ import { DEFAULT_PORTS, PROTOCOL_VERSION, } from '@1agents/dreammate-network';
10
+ export { nodeIdentity, PROTOCOL_VERSION };
11
+ /* ---------- Manifest ---------- */
12
+ /**
13
+ * The capabilities this Service answers for. Names are the network-facing
14
+ * spelling of the CLI verbs: `1session overview` is `sessions.read`.
15
+ */
16
+ export const SESSION_CAPABILITIES = [
17
+ 'sessions.list',
18
+ 'sessions.read',
19
+ 'sessions.turns',
20
+ 'sessions.search',
21
+ 'sessions.graph',
22
+ ];
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;
30
+ return {
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,
35
+ online: true,
36
+ metadata: { protocol_version: PROTOCOL_VERSION, identity_source: identity.source },
37
+ services: [
38
+ {
39
+ id: 'session-registry',
40
+ name: 'session-reader',
41
+ kind: 'session_registry',
42
+ capabilities: [...SESSION_CAPABILITIES],
43
+ resources: [{ scheme: 'session', description: 'session://<node>/<runtime>/<session_id>' }],
44
+ access: [{ protocol: 'http', base_url: `${advertised}/v1` }],
45
+ },
46
+ ],
47
+ };
48
+ }
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
+ */
56
+ export function sessionUri(nodeName, provider, id) {
57
+ return `session://${nodeName}/${provider}/${id}`;
58
+ }
@@ -27,7 +27,13 @@ export declare function deriveEdges(db: DatabaseSync, id: string, session: Norma
27
27
  * Injected by a hook or the ACP context as `SESSION_READER_CALLER_SESSION`;
28
28
  * absent everywhere else, in which case nothing is written.
29
29
  */
30
- export declare function captureRuntimeEdge(db: DatabaseSync, verb: string, target: string): void;
30
+ export declare function captureRuntimeEdge(db: DatabaseSync, verb: string, target: string,
31
+ /**
32
+ * Who is doing the reading. Defaults to the CLI's injected caller; `1session
33
+ * serve` passes the `X-Caller-Session` header instead, so one process can
34
+ * serve several callers without going through the environment.
35
+ */
36
+ callerId?: string | undefined): void;
31
37
  export interface EdgeView {
32
38
  from: string;
33
39
  to: string;
@@ -102,8 +102,14 @@ export function deriveEdges(db, id, session) {
102
102
  * Injected by a hook or the ACP context as `SESSION_READER_CALLER_SESSION`;
103
103
  * absent everywhere else, in which case nothing is written.
104
104
  */
105
- export function captureRuntimeEdge(db, verb, target) {
106
- const caller = process.env.SESSION_READER_CALLER_SESSION?.trim();
105
+ export function captureRuntimeEdge(db, verb, target,
106
+ /**
107
+ * Who is doing the reading. Defaults to the CLI's injected caller; `1session
108
+ * serve` passes the `X-Caller-Session` header instead, so one process can
109
+ * serve several callers without going through the environment.
110
+ */
111
+ callerId = process.env.SESSION_READER_CALLER_SESSION) {
112
+ const caller = callerId?.trim();
107
113
  const relation = VERB_RELATION[verb];
108
114
  if (!caller || !relation)
109
115
  return;
@@ -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,