omp-onlyne 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/urls.ts ADDED
@@ -0,0 +1,1488 @@
1
+ import {
2
+ OnlyneError,
3
+ trustPhase,
4
+ type Conversation,
5
+ type Entry,
6
+ type HistoryMessage,
7
+ type HistoryQuery,
8
+ type JoinedThread,
9
+ type LocalAttention,
10
+ type MessageRef,
11
+ type OperationRecord,
12
+ type Outbound,
13
+ type Page,
14
+ type Resource,
15
+ type ResourceEvent,
16
+ type ResourceInventory,
17
+ type ResourceRef,
18
+ type Thread,
19
+ type ThreadDescription,
20
+ type ThreadRef,
21
+ type TrustPhase,
22
+ } from "@onlyne/lib";
23
+ import { createTranslator } from "@onlyne/node-host/i18n";
24
+ import {
25
+ InternalUrlRouter,
26
+ parseInternalUrl,
27
+ type InternalResource,
28
+ type InternalUrl,
29
+ type ProtocolHandler,
30
+ type ResolveContext,
31
+ } from "@oh-my-pi/pi-coding-agent/internal-urls";
32
+ import { resourceQuery } from "./resources.ts";
33
+ import { getRuntime, type PluginRuntime } from "./runtime.ts";
34
+ import { isTrustThread, trustThread } from "./handshake.ts";
35
+ import type { ContextRecord } from "./types.ts";
36
+ import { urlsText } from "./urls-text.ts";
37
+
38
+ type Caller = { main: boolean; agent_id?: string };
39
+ type View = { host: string; parts: string[]; params: URLSearchParams };
40
+ type Rendered = { content: string; contentType: InternalResource["contentType"] };
41
+ type UrlLike = Pick<URL, "hostname" | "pathname" | "search"> & { rawHost?: string; rawPathname?: string };
42
+
43
+ const t = createTranslator(urlsText);
44
+
45
+ /** Scan size for collecting a library page sequence; the visible page size is separate. */
46
+ const PAGE = 100;
47
+ /** A page loop that exceeds this is a bug in the source, not a reason to truncate silently. */
48
+ const MAX_PAGES = 10_000;
49
+ const DEFAULT_PAGE = 50;
50
+ const MAX_PAGE = 100;
51
+ const SNIPPET_CHARS = 96;
52
+ const TITLE_CHARS = 80;
53
+
54
+ const handler: ProtocolHandler = {
55
+ scheme: "onlyne",
56
+ spec: { backing: "virtual", selectors: "lines", immutable: true },
57
+
58
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
59
+ const runtime = getRuntime();
60
+ if (runtime === undefined) throw new Error(t("notConfigured"));
61
+ const rendered = await render(runtime, callerOf(runtime, context), viewOf(url));
62
+ return { url: url.href, content: rendered.content, contentType: rendered.contentType, immutable: true };
63
+ },
64
+
65
+ /**
66
+ * Leaf documents for `read`, `grep` and `find`. The root expands into views and
67
+ * thread documents; a thread expands into its message documents; the inbox
68
+ * expands into the messages its visible attention items name; a conversation
69
+ * expands into its messages; a resource collection expands into its readable
70
+ * detail documents, and a log or operation collection into its entry records.
71
+ */
72
+ async enumerate(url: InternalUrl, context?: ResolveContext): Promise<Array<{ url: string; content: string }>> {
73
+ const runtime = getRuntime();
74
+ if (runtime === undefined) throw new Error(t("notConfigured"));
75
+ const caller = callerOf(runtime, context);
76
+ const view = viewOf(url);
77
+ if (view.host === "") {
78
+ const urls = caller.agent_id === undefined
79
+ ? []
80
+ : ["onlyne://inbox", "onlyne://contexts", "onlyne://outbound", "onlyne://conversations"];
81
+ if (caller.main) urls.push("onlyne://resources");
82
+ for (const thread of await visibleThreads(runtime, caller)) {
83
+ urls.push(threadPath(thread.thread_ref));
84
+ }
85
+ for (const row of await joinedThreads(runtime, caller)) {
86
+ urls.push(threadPath(row.thread_ref));
87
+ }
88
+ return leafDocuments(runtime, caller, urls);
89
+ }
90
+ if (view.host === "threads" && view.parts.length === 0) {
91
+ const urls = [
92
+ ...(await visibleThreads(runtime, caller)).map(thread => threadPath(thread.thread_ref)),
93
+ ...(await joinedThreads(runtime, caller)).map(row => threadPath(row.thread_ref)),
94
+ ];
95
+ return leafDocuments(runtime, caller, urls);
96
+ }
97
+ if (view.host === "threads") {
98
+ const target = threadTarget(view.parts);
99
+ const thread = await findThread(runtime, caller, target.alias);
100
+ if (target.seq !== undefined) {
101
+ const entry = await entryAt(runtime, thread, target.seq);
102
+ return [{ url: entryPath(thread.thread_ref, target.seq), content: `${JSON.stringify(entry, null, 2)}\n` }];
103
+ }
104
+ const entries = await threadEntries(runtime, thread.thread_ref);
105
+ return entries.map(entry => ({
106
+ url: entryPath(thread.thread_ref, entry.seq),
107
+ content: JSON.stringify(entry, null, 2),
108
+ }));
109
+ }
110
+ if (view.host === "inbox") {
111
+ return inboxLeaves(runtime, caller);
112
+ }
113
+ if ((view.host === "neighbors" || view.host === "peers") && view.parts.length === 0) {
114
+ const urls = (await neighborRows(runtime)).map(row => `onlyne://neighbors/${encodeURIComponent(row.node_id)}`);
115
+ return leafDocuments(runtime, caller, urls);
116
+ }
117
+ if (view.host === "conversations" && view.parts.length === 0) {
118
+ const conversations = await visibleConversations(runtime, caller, conversationFilter(view).query);
119
+ return leafDocuments(runtime, caller, conversations.map(conversation => conversationPath(conversation.id)));
120
+ }
121
+ if (view.host === "conversations") {
122
+ const ref = messageRefFromParts(view.parts, t("conversationsShape"));
123
+ const conversation = await authorizeConversation(runtime, caller, ref);
124
+ return conversation.messages.map(message => ({
125
+ url: messagePath(message.message_ref),
126
+ content: `${JSON.stringify(message, null, 2)}\n`,
127
+ }));
128
+ }
129
+ if (view.host === "messages") {
130
+ const ref = messageRefFromParts(view.parts, t("messagesShape"));
131
+ return leafDocuments(runtime, caller, [messagePath(ref)]);
132
+ }
133
+ if (view.host === "resources") return resourceLeaves(runtime, caller, view);
134
+ return leafDocuments(runtime, caller, [`onlyne://${view.host}${view.parts.length === 0 ? "" : `/${view.parts.map(encodeURIComponent).join("/")}`}`]);
135
+ },
136
+ };
137
+
138
+ /** Register once per process; another copy of the plugin reuses the existing handler. */
139
+ export function registerOnlyneUrls(): void {
140
+ const router = InternalUrlRouter.instance();
141
+ if (!router.canHandle("onlyne://")) router.register(handler);
142
+ }
143
+
144
+ function callerOf(runtime: PluginRuntime, context: ResolveContext | undefined): Caller {
145
+ const agentId = context?.session?.getAgentId?.() ?? undefined;
146
+ return { agent_id: agentId, main: agentId !== undefined && agentId === runtime.mainAgentId };
147
+ }
148
+
149
+ function viewOf(url: UrlLike): View {
150
+ const host = (url.rawHost || url.hostname || "").toLowerCase();
151
+ const raw = url.rawPathname ?? url.pathname;
152
+ const parts = raw
153
+ .split("/")
154
+ .filter(part => part.length > 0)
155
+ .map(part => {
156
+ try {
157
+ return decodeURIComponent(part);
158
+ } catch {
159
+ throw new Error(t("malformedEncoding", { path: raw }));
160
+ }
161
+ });
162
+ return { host: host === "root" ? "" : host, parts, params: new URLSearchParams(url.search ?? "") };
163
+ }
164
+
165
+ function viewOfHref(href: string): View {
166
+ return viewOf(parseInternalUrl(href));
167
+ }
168
+
169
+ function markdown(content: string): Rendered {
170
+ return { content, contentType: "text/markdown" };
171
+ }
172
+
173
+ function json(value: unknown): Rendered {
174
+ return { content: `${JSON.stringify(value, null, 2)}\n`, contentType: "application/json" };
175
+ }
176
+
177
+ function routeShape(shape: string): Error {
178
+ return new Error(t("routeShape", { shape }));
179
+ }
180
+
181
+ async function render(runtime: PluginRuntime, caller: Caller, view: View): Promise<Rendered> {
182
+ if (view.host === "") return markdown(await renderRoot(runtime, caller));
183
+ if (view.host === "threads") return renderThreads(runtime, caller, view);
184
+ if (view.host === "neighbors" || view.host === "peers") {
185
+ requireSession(caller, "neighbors");
186
+ if (view.parts.length > 1) throw routeShape(t("neighborsShape"));
187
+ if (view.parts.length === 1) return json(await neighborView(runtime, view.parts[0]!));
188
+ return markdown(await renderNeighbors(runtime, caller, view));
189
+ }
190
+ if (view.host === "contexts") {
191
+ requireSession(caller, "contexts");
192
+ if (view.parts.length > 0) throw routeShape(t("contextsShape"));
193
+ return markdown(await renderContexts(runtime, caller, view));
194
+ }
195
+ if (view.host === "inbox") {
196
+ requireSession(caller, "inbox");
197
+ if (view.parts.length > 0) throw routeShape(t("inboxShape"));
198
+ return renderInbox(runtime, caller, view);
199
+ }
200
+ if (view.host === "outbound") return renderOutbound(runtime, caller, view);
201
+ if (view.host === "conversations") return renderConversations(runtime, caller, view);
202
+ if (view.host === "messages") {
203
+ requireSession(caller, "messages");
204
+ const ref = messageRefFromParts(view.parts, t("messagesShape"));
205
+ await authorizeConversation(runtime, caller, ref);
206
+ return json(await runtime.node.history.message(runtime.node.auth, ref));
207
+ }
208
+ if (view.host === "resources") return renderResources(runtime, caller, view);
209
+ throw new Error(t("unknownRoute", { host: view.host }));
210
+ }
211
+
212
+ function requireSession(caller: Caller, view: string): void {
213
+ if (caller.agent_id !== undefined) return;
214
+ throw new Error(t("privateSession", { view }));
215
+ }
216
+
217
+ /** Resource reads are the main session's: a subagent's slice never covers a peer's inventory. */
218
+ function requireMain(caller: Caller, view: string): void {
219
+ if (caller.main) return;
220
+ throw new Error(t("mainSessionOnly", { view }));
221
+ }
222
+
223
+ async function renderRoot(runtime: PluginRuntime, caller: Caller): Promise<string> {
224
+ const lines = [
225
+ t("rootTitle", { node: runtime.config.node.id }),
226
+ "",
227
+ t("rootRegion", { region: runtime.config.region }),
228
+ t("rootEndpoint", { endpoint: runtime.endpoint }),
229
+ ];
230
+ try {
231
+ const domains = await runtime.node.domains.list(runtime.node.auth, { page_size: PAGE });
232
+ const active = domains.items.filter(membership => membership.state === "active");
233
+ lines.push(t("rootDomain", {
234
+ domains: active.length === 0 ? t("rootDomainNone") : active.map(membership => membership.domain_id).join(", "),
235
+ }));
236
+ } catch (error) {
237
+ lines.push(t("rootDomainUnavailable", { message: error instanceof Error ? error.message : String(error) }));
238
+ }
239
+ if (caller.agent_id === undefined) {
240
+ lines.push("", t("rootPrivateViews"), t("rootResourceViews"));
241
+ return `${lines.join("\n")}\n`;
242
+ }
243
+ const records = await scopedRecords(runtime, caller);
244
+ const threads = await visibleThreads(runtime, caller);
245
+ const joined = await joinedThreads(runtime, caller);
246
+ lines.push("", caller.main
247
+ ? t("rootContexts", { count: records.length })
248
+ : t("rootContextsHeld", { agent: caller.agent_id ?? "", count: records.length }));
249
+ for (const record of records) {
250
+ const thread = record.thread_ref === undefined ? "" : t("suffixThread", { key: threadKey(record.thread_ref) });
251
+ lines.push(t("rootContextRow", { alias: record.alias, state: record.state, summary: record.summary, thread }));
252
+ }
253
+ if (records.length === 0) lines.push(t("rootNoContexts"));
254
+ lines.push("", t("rootThreads", { count: threads.length }));
255
+ for (const thread of threads) {
256
+ lines.push(t("rootThreadRow", {
257
+ title: thread.title,
258
+ key: threadKey(thread.thread_ref),
259
+ state: thread.state,
260
+ next: thread.next_seq,
261
+ }));
262
+ }
263
+ if (threads.length === 0) lines.push(t("rootNoThreads"));
264
+ if (joined.length > 0) {
265
+ lines.push("", t("rootJoinedThreads", { count: joined.length }));
266
+ for (const row of joined) {
267
+ lines.push(t("rootThreadJoinedRow", {
268
+ title: row.title ?? t("joinedThreadTitle", { id: row.thread_ref.id, owner: row.thread_ref.owner }),
269
+ key: threadKey(row.thread_ref),
270
+ last: row.last_seq,
271
+ }));
272
+ }
273
+ }
274
+ lines.push(
275
+ "",
276
+ caller.main ? t("rootViewsResources") : t("rootViews"),
277
+ t("rootConversation"),
278
+ t("rootCollection"),
279
+ );
280
+ return `${lines.join("\n")}\n`;
281
+ }
282
+
283
+ async function renderThreads(runtime: PluginRuntime, caller: Caller, view: View): Promise<Rendered> {
284
+ requireSession(caller, "threads");
285
+ if (view.parts.length === 0) {
286
+ const request = pageRequest(view);
287
+ // One list of everything this node can read: a joined row is marked read-only and
288
+ // carries only what the local records prove, because its owner holds the rest. The
289
+ // merged order is the cursor's order, so it is sorted after the two sources join.
290
+ const threads: Array<Thread | JoinedThread> = [
291
+ ...(await visibleThreads(runtime, caller)),
292
+ ...(await joinedThreads(runtime, caller)),
293
+ ].sort((left, right) => threadKey(left.thread_ref).localeCompare(threadKey(right.thread_ref)));
294
+ const index = await historyIndex(runtime, caller);
295
+ const page = localPage(threads, request, cursorScope("threads", caller, ""), thread => threadKey(thread.thread_ref));
296
+ const lines = [t("threadsTitle", { node: runtime.config.node.id }), ""];
297
+ for (const thread of page.items) {
298
+ if ("read_only" in thread) {
299
+ lines.push(t("threadRowJoined", {
300
+ title: thread.title ?? t("joinedThreadTitle", { id: thread.thread_ref.id, owner: thread.thread_ref.owner }),
301
+ key: threadKey(thread.thread_ref),
302
+ last: thread.last_seq,
303
+ count: thread.message_count,
304
+ }));
305
+ lines.push(t("threadJoinedLinks", {
306
+ thread: threadPath(thread.thread_ref),
307
+ owner: thread.thread_ref.owner,
308
+ }));
309
+ continue;
310
+ }
311
+ const rows = index.get(threadKey(thread.thread_ref)) ?? [];
312
+ const first = rows[0];
313
+ const latest = rows.at(-1);
314
+ const title = first === undefined ? thread.title : titleOf(first);
315
+ lines.push(t("threadRow", {
316
+ title,
317
+ key: threadKey(thread.thread_ref),
318
+ state: thread.state,
319
+ next: thread.next_seq,
320
+ count: rows.length,
321
+ }));
322
+ if (latest === undefined) {
323
+ lines.push(t("threadNoHistory", { url: threadPath(thread.thread_ref) }));
324
+ } else {
325
+ lines.push(t("threadLatest", {
326
+ direction: latest.direction,
327
+ at: latest.at ?? t("noTime"),
328
+ snippet: firstLine(latest.message.msg, SNIPPET_CHARS),
329
+ }));
330
+ lines.push(t("threadLinks", {
331
+ thread: threadPath(thread.thread_ref),
332
+ latest: messagePath(latest.message_ref),
333
+ conversation: conversationPath(latest.message_ref),
334
+ }));
335
+ }
336
+ }
337
+ if (page.items.length === 0) lines.push(t("noThreads"));
338
+ lines.push("", nextLink("threads", request, page.next));
339
+ return markdown(`${lines.join("\n")}\n`);
340
+ }
341
+ const target = threadTarget(view.parts);
342
+ const thread = await findThread(runtime, caller, target.alias);
343
+ if (target.seq !== undefined) return messageView(runtime, thread, target.seq);
344
+ return markdown(await renderThread(runtime, thread));
345
+ }
346
+
347
+ interface ThreadTarget {
348
+ alias: string;
349
+ seq?: string;
350
+ }
351
+
352
+ /**
353
+ * A thread alias is one segment or two (`<owner>/<id>`), and the messages tail is
354
+ * optional. The tail is found by name so an unencoded `<owner>/<id>/messages/<seq>`
355
+ * and a percent-encoded `<alias>/messages/<seq>` both resolve.
356
+ */
357
+ function threadTarget(parts: string[]): ThreadTarget {
358
+ const tail = parts.indexOf("messages");
359
+ if (tail > 0) {
360
+ if (parts.length !== tail + 2) throw routeShape(t("threadSeqShape"));
361
+ return { alias: parts.slice(0, tail).join("/"), seq: parts[tail + 1]! };
362
+ }
363
+ if (parts.length > 2) throw routeShape(t("threadAliasShape"));
364
+ return { alias: parts.join("/") };
365
+ }
366
+
367
+ async function entryAt(runtime: PluginRuntime, thread: Thread | JoinedThread, seq: string): Promise<Entry> {
368
+ const entry = (await threadEntries(runtime, thread.thread_ref)).find(item => item.seq === seq);
369
+ if (entry === undefined) throw new Error(t("noMessageAt", { key: threadKey(thread.thread_ref), seq }));
370
+ return entry;
371
+ }
372
+
373
+ async function messageView(runtime: PluginRuntime, thread: Thread | JoinedThread, seq: string): Promise<Rendered> {
374
+ return json(await entryAt(runtime, thread, seq));
375
+ }
376
+
377
+ /**
378
+ * One thread document. A hosted thread is rendered from this node's history projection; a
379
+ * joined one is read from its owner, and the page says so: the owner holds the state, the
380
+ * member table and the entries, and nothing here claims to change them.
381
+ */
382
+ async function renderThread(runtime: PluginRuntime, thread: Thread | JoinedThread): Promise<string> {
383
+ const joined = "read_only" in thread;
384
+ const ref = thread.thread_ref;
385
+ const rows = joined ? [] : await threadMessages(runtime, ref);
386
+ const entries = joined ? await threadEntries(runtime, ref) : [];
387
+ let described: ThreadDescription | undefined;
388
+ try {
389
+ described = await runtime.node.client.describeThread(ref);
390
+ } catch {
391
+ // The owner's answer is what a joined page is mostly about, so its absence is stated
392
+ // below rather than papered over with local guesses.
393
+ described = undefined;
394
+ }
395
+ const title = thread.title ?? (rows.length === 0 ? ref.id : titleOf(rows[0]!));
396
+ const next = described?.thread.next_seq ?? ("next_seq" in thread ? thread.next_seq : thread.last_seq);
397
+ const count = joined ? entries.length : rows.length;
398
+ const lines = [
399
+ t("threadTitle", { title }),
400
+ "",
401
+ t("threadMeta", {
402
+ key: threadKey(ref),
403
+ state: described?.thread.state ?? thread.state ?? t("threadStateUnknown"),
404
+ next,
405
+ count,
406
+ }),
407
+ ];
408
+ if (joined) lines.push("", t("threadReadOnly", { owner: ref.owner }));
409
+ lines.push("", t("members"), "");
410
+ if (described === undefined) {
411
+ lines.push(t("unavailable", { message: t("ownerUnreachable", { owner: ref.owner }) }));
412
+ } else if (described.members.length === 0) {
413
+ lines.push(t("noMembers"));
414
+ } else {
415
+ for (const member of described.members) {
416
+ lines.push(t("memberRow", {
417
+ peer: member.peer,
418
+ state: member.state,
419
+ access: member.access.length === 0 ? t("noAccess") : member.access.join(", "),
420
+ }));
421
+ }
422
+ }
423
+ lines.push("", t("messages"), "");
424
+ if (joined) for (const entry of entries) lines.push(renderEntryRow(entry, runtime.config.node.id));
425
+ else for (const row of rows) lines.push(renderHistoryRow(row, runtime.config.node.id));
426
+ if (count === 0) lines.push(t("noMessages"));
427
+ return `${lines.join("\n")}\n`;
428
+ }
429
+
430
+ async function renderContexts(runtime: PluginRuntime, caller: Caller, view: View): Promise<string> {
431
+ const request = pageRequest(view);
432
+ const records = (await scopedRecords(runtime, caller))
433
+ .slice()
434
+ .sort((left, right) => left.alias.localeCompare(right.alias));
435
+ const page = localPage(records, request, cursorScope("contexts", caller, ""), record => record.alias);
436
+ const lines = [t("contextsTitle", { node: runtime.config.node.id }), ""];
437
+ for (const record of page.items) {
438
+ const thread = record.thread_ref === undefined ? "" : t("suffixThread", { key: threadKey(record.thread_ref) });
439
+ const agent = record.agent_id === undefined ? "" : t("suffixAgent", { agent: record.agent_id });
440
+ const source = record.source === undefined
441
+ ? ""
442
+ : t("suffixSource", { ref: `${record.source.sender}/${record.source.id}` });
443
+ lines.push(t("contextRow", { alias: record.alias, state: record.state, summary: record.summary, thread, agent, source }));
444
+ }
445
+ if (page.items.length === 0) lines.push(t("noContexts"));
446
+ lines.push("", nextLink("contexts", request, page.next));
447
+ return `${lines.join("\n")}\n`;
448
+ }
449
+
450
+ export interface NeighborRow {
451
+ node_id: string;
452
+ base_url: string;
453
+ region?: string;
454
+ last_seen?: string;
455
+ verified: boolean;
456
+ /** The exchange stage from `trustPhase`, or `no_exchange` when no key exchange has happened. */
457
+ phase: TrustPhase | "no_exchange";
458
+ fingerprint: string | null;
459
+ }
460
+
461
+ /** Every node this process has observed, with its trust state: the viewer and onlyne:// read this. */
462
+ export async function neighborRows(runtime: PluginRuntime): Promise<NeighborRow[]> {
463
+ const records = await runtime.node.discovery.listRecords();
464
+ const trust = new Map((await runtime.node.trust.list(runtime.node.auth)).map(entry => [entry.node_id, entry]));
465
+ const at = Date.now();
466
+ const byNode = new Map<string, NeighborRow>();
467
+ for (const record of records) {
468
+ if (record.node_id === runtime.config.node.id) continue;
469
+ const verified = record.verified_at !== undefined && Date.parse(record.expires_at) > at;
470
+ const current = byNode.get(record.node_id);
471
+ if (current !== undefined && (current.verified || !verified)) continue;
472
+ const entry = trust.get(record.node_id);
473
+ byNode.set(record.node_id, {
474
+ node_id: record.node_id,
475
+ base_url: record.base_url,
476
+ ...(record.membership?.domain_id === undefined ? {} : { region: record.membership.domain_id }),
477
+ ...(record.last_seen === undefined ? {} : { last_seen: record.last_seen }),
478
+ verified,
479
+ phase: entry === undefined ? "no_exchange" : trustPhase(entry),
480
+ fingerprint: entry?.fingerprint ?? null,
481
+ });
482
+ }
483
+ return [...byNode.values()].sort((left, right) => left.node_id.localeCompare(right.node_id));
484
+ }
485
+
486
+ async function renderNeighbors(runtime: PluginRuntime, caller: Caller, view: View): Promise<string> {
487
+ const request = pageRequest(view);
488
+ const rows = await neighborRows(runtime);
489
+ const page = localPage(rows, request, cursorScope("neighbors", caller, ""), row => row.node_id);
490
+ const lines = [t("neighborsTitle", { node: runtime.config.node.id }), ""];
491
+ for (const row of page.items) {
492
+ const region = row.region === undefined ? t("noRegion") : row.region;
493
+ const seen = row.last_seen === undefined ? "" : t("suffixLastSeen", { at: row.last_seen });
494
+ const fp = row.fingerprint === null ? "" : t("suffixFingerprint", { fp: row.fingerprint });
495
+ lines.push(t("neighborRow", {
496
+ node: row.node_id,
497
+ region,
498
+ verified: row.verified ? t("verified") : t("unverified"),
499
+ phase: row.phase,
500
+ seen,
501
+ fp,
502
+ }));
503
+ }
504
+ if (page.items.length === 0) lines.push(t("noNeighbors"));
505
+ lines.push("", nextLink("neighbors", request, page.next));
506
+ return `${lines.join("\n")}\n`;
507
+ }
508
+
509
+ async function neighborView(runtime: PluginRuntime, node: string): Promise<NeighborRow> {
510
+ const rows = await neighborRows(runtime);
511
+ const row = rows.find(entry => entry.node_id === node);
512
+ if (row === undefined) throw new Error(t("noNeighborNamed", { node }));
513
+ return row;
514
+ }
515
+
516
+ async function inboxItems(runtime: PluginRuntime): Promise<LocalAttention[]> {
517
+ return collect(cursor => runtime.node.messages.inbox(runtime.node.auth, { page_size: PAGE, ...(cursor === undefined ? {} : { cursor }) }));
518
+ }
519
+
520
+ async function renderInbox(runtime: PluginRuntime, caller: Caller, view: View): Promise<Rendered> {
521
+ const request = pageRequest(view);
522
+ const rows = (await visibleInbox(runtime, caller))
523
+ .map(item => inboxRow(runtime, item))
524
+ .sort((left, right) => left.position.localeCompare(right.position));
525
+ const page = localPage(rows, request, cursorScope("inbox", caller, ""), row => row.position);
526
+ const lines = [t("inboxTitle", { node: runtime.config.node.id }), ""];
527
+ for (const row of page.items) lines.push(...row.lines);
528
+ if (page.items.length === 0) lines.push(t("noAttention"));
529
+ lines.push("", nextLink("inbox", request, page.next));
530
+ return markdown(`${lines.join("\n")}\n`);
531
+ }
532
+
533
+ function inboxRow(runtime: PluginRuntime, item: LocalAttention): { position: string; lines: string[] } {
534
+ const attention = item.attention;
535
+ const entry = attention.entry;
536
+ const direction = entry === undefined ? "in" : entry.message.sender === runtime.config.node.id ? "out" : "in";
537
+ const at = entry?.at;
538
+ const status = [attention.acked ? t("acked") : t("pending"), attention.disposition === "expired" ? t("expired") : ""]
539
+ .filter(part => part.length > 0)
540
+ .join(" · ");
541
+ const ref = attention.message_ref;
542
+ const position = `${at ?? ""}\u0000${threadKey(attention.thread_ref)}\u0000${attention.seq}`;
543
+ const lines = [
544
+ t("attentionRow", {
545
+ key: threadKey(attention.thread_ref),
546
+ seq: attention.seq,
547
+ direction,
548
+ author: attention.authenticated_author,
549
+ at: at ?? t("noTime"),
550
+ status,
551
+ }),
552
+ ];
553
+ if (entry !== undefined) lines.push(` ${firstLine(entry.message.msg, SNIPPET_CHARS)}`);
554
+ lines.push(t("messageLinks", {
555
+ message: messagePath(ref),
556
+ conversation: conversationPath(ref),
557
+ entry: entryPath(attention.thread_ref, attention.seq),
558
+ }));
559
+ return { position, lines };
560
+ }
561
+
562
+ async function renderOutbound(runtime: PluginRuntime, caller: Caller, view: View): Promise<Rendered> {
563
+ requireSession(caller, "outbound");
564
+ const rows = await outboundRows(runtime, caller);
565
+ if (view.parts.length === 0) {
566
+ const request = pageRequest(view);
567
+ const list = rows
568
+ .map(outbound => outboundRow(outbound))
569
+ .sort((left, right) => left.position.localeCompare(right.position));
570
+ const page = localPage(list, request, cursorScope("outbound", caller, ""), row => row.position);
571
+ const lines = [t("outboundTitle", { node: runtime.config.node.id }), ""];
572
+ for (const row of page.items) lines.push(...row.lines);
573
+ if (page.items.length === 0) lines.push(t("noOutbound"));
574
+ lines.push("", nextLink("outbound", request, page.next));
575
+ return markdown(`${lines.join("\n")}\n`);
576
+ }
577
+ if (view.parts.length !== 1) throw routeShape(t("outboundShape"));
578
+ const found = rows.find(row => row.request.message.id === view.parts[0]);
579
+ if (found === undefined) throw new Error(t("noOutboundNamed", { id: view.parts[0]! }));
580
+ return json(await readableOutbound(runtime, found));
581
+ }
582
+
583
+ function outboundRow(outbound: Outbound): { position: string; lines: string[] } {
584
+ const message = outbound.request.message;
585
+ const ref: MessageRef = { sender: message.sender, id: message.id };
586
+ const related = message.meta?.in_reply_to;
587
+ const at = outbound.created_at ?? null;
588
+ const status = [outbound.state, outbound.error?.code ?? ""].filter(part => part.length > 0).join(" · ");
589
+ const reply = related === undefined ? "" : t("suffixReply", { ref: `${related.sender}/${related.id}` });
590
+ const position = `${at ?? ""}\u0000${refKey(ref)}`;
591
+ const lines = [
592
+ t("outboundRow", {
593
+ id: ref.id,
594
+ endpoint: outbound.request.target_endpoint,
595
+ at: at ?? t("noTime"),
596
+ status,
597
+ reply,
598
+ }),
599
+ ` ${firstLine(message.msg, SNIPPET_CHARS)}`,
600
+ t("outboundLinks", { message: messagePath(ref), conversation: conversationPath(ref) }),
601
+ ];
602
+ return { position, lines };
603
+ }
604
+
605
+ /**
606
+ * The outbound record as its sender reads it back: the body itself (not a digest), the local
607
+ * history row for it, and the reply the local conversation holds. Peer-owned threads are never
608
+ * read here; `entry` is the local messages route.
609
+ */
610
+ async function readableOutbound(runtime: PluginRuntime, outbound: Outbound): Promise<Record<string, unknown>> {
611
+ const message = outbound.request.message;
612
+ const ref: MessageRef = { sender: message.sender, id: message.id };
613
+ const conversation = await runtime.node.history.conversation(runtime.node.auth, ref);
614
+ const self = conversation.messages.find(item => item.message_ref.sender === ref.sender && item.message_ref.id === ref.id);
615
+ const related = message.meta?.in_reply_to;
616
+ const reply = conversation.messages.find(item => {
617
+ const target = item.message.meta?.in_reply_to;
618
+ return target !== undefined && target.sender === message.sender && target.id === message.id;
619
+ });
620
+ const landed = self?.thread_ref === undefined
621
+ ? undefined
622
+ : { thread: threadKey(self.thread_ref), seq: self.seq, guarantee: self.guarantee };
623
+ return {
624
+ message_id: message.id,
625
+ to: outbound.request.target_endpoint,
626
+ body: message.msg,
627
+ state: outbound.state,
628
+ status: self?.delivery ?? outbound.state,
629
+ error: outbound.error === undefined ? undefined : outbound.error.code,
630
+ in_reply_to: related === undefined ? undefined : { sender: related.sender, id: related.id },
631
+ at: self?.at ?? outbound.created_at ?? null,
632
+ entry: messagePath(ref),
633
+ conversation: conversationPath(ref),
634
+ landed,
635
+ reply: reply === undefined ? undefined : {
636
+ by: reply.message.sender,
637
+ message_id: reply.message.id,
638
+ seq: reply.seq,
639
+ direction: reply.direction,
640
+ at: reply.at,
641
+ body: reply.message.msg,
642
+ message: messagePath(reply.message_ref),
643
+ conversation: conversationPath(reply.message_ref),
644
+ },
645
+ retry_until: outbound.request.retry_until,
646
+ };
647
+ }
648
+
649
+ async function renderConversations(runtime: PluginRuntime, caller: Caller, view: View): Promise<Rendered> {
650
+ requireSession(caller, "conversations");
651
+ if (view.parts.length === 0) {
652
+ const request = pageRequest(view);
653
+ const filter = conversationFilter(view);
654
+ const conversations = await visibleConversations(runtime, caller, filter.query);
655
+ const sorted = conversations
656
+ .slice()
657
+ .sort((left, right) => `${left.last_at ?? ""}\u0000${refKey(left.id)}`.localeCompare(`${right.last_at ?? ""}\u0000${refKey(right.id)}`));
658
+ const page = localPage(
659
+ sorted,
660
+ request,
661
+ cursorScope("conversations", caller, filter.key),
662
+ conversation => `${conversation.last_at ?? ""}\u0000${refKey(conversation.id)}`,
663
+ );
664
+ const lines = [t("conversationsTitle", { node: runtime.config.node.id }), ""];
665
+ for (const conversation of page.items) {
666
+ const latest = conversation.messages.at(-1);
667
+ const title = conversation.title.length > 0
668
+ ? conversation.title
669
+ : latest === undefined ? refKey(conversation.id) : titleOf(latest);
670
+ lines.push(t("conversationRow", {
671
+ title,
672
+ key: refKey(conversation.id),
673
+ count: conversation.messages.length,
674
+ at: conversation.last_at ?? t("conversationUnknown"),
675
+ }));
676
+ if (latest === undefined) {
677
+ lines.push(t("conversationOnly", { url: conversationPath(conversation.id) }));
678
+ } else {
679
+ lines.push(t("conversationLatest", {
680
+ direction: latest.direction,
681
+ at: latest.at ?? t("noTime"),
682
+ snippet: firstLine(latest.message.msg, SNIPPET_CHARS),
683
+ }));
684
+ lines.push(t("conversationLinks", {
685
+ conversation: conversationPath(conversation.id),
686
+ message: messagePath(latest.message_ref),
687
+ }));
688
+ }
689
+ if (conversation.thread_refs.length > 0) {
690
+ lines.push(t("conversationThreads", { threads: conversation.thread_refs.map(threadPath).join(", ") }));
691
+ }
692
+ }
693
+ if (page.items.length === 0) lines.push(t("noConversations"));
694
+ lines.push("", nextLink("conversations", request, page.next, filter.params));
695
+ return markdown(`${lines.join("\n")}\n`);
696
+ }
697
+ if (view.parts.length !== 2) throw routeShape(t("conversationsShape"));
698
+ const ref: MessageRef = { sender: view.parts[0]!, id: view.parts[1]! };
699
+ const conversation = await authorizeConversation(runtime, caller, ref);
700
+ const rows = conversation.messages;
701
+ const title = conversation.title.length > 0 ? conversation.title : rows.length === 0 ? refKey(ref) : titleOf(rows[0]!);
702
+ const lines = [
703
+ t("threadTitle", { title }),
704
+ "",
705
+ t("conversationMeta", {
706
+ key: refKey(ref),
707
+ count: rows.length,
708
+ at: conversation.last_at ?? t("conversationUnknown"),
709
+ }),
710
+ ];
711
+ if (conversation.thread_refs.length > 0) {
712
+ lines.push(t("threadsList", { threads: conversation.thread_refs.map(threadPath).join(", ") }));
713
+ }
714
+ lines.push("", t("messages"), "");
715
+ for (const row of rows) lines.push(renderHistoryRow(row, runtime.config.node.id));
716
+ if (rows.length === 0) lines.push(t("noMessages"));
717
+ return markdown(`${lines.join("\n")}\n`);
718
+ }
719
+
720
+ interface ConversationFilter {
721
+ query: HistoryQuery;
722
+ key: string;
723
+ params: Record<string, string>;
724
+ }
725
+
726
+ function conversationFilter(view: View): ConversationFilter {
727
+ const threadRaw = view.params.get("thread");
728
+ const messageRaw = view.params.get("message");
729
+ const params: Record<string, string> = {};
730
+ let thread: ThreadRef | undefined;
731
+ let message: MessageRef | undefined;
732
+ if (threadRaw !== null) {
733
+ const [owner, id] = parsePair(threadRaw, "thread");
734
+ thread = { owner, id };
735
+ params.thread = threadRaw;
736
+ }
737
+ if (messageRaw !== null) {
738
+ const [sender, id] = parsePair(messageRaw, "message");
739
+ message = { sender, id };
740
+ params.message = messageRaw;
741
+ }
742
+ return {
743
+ query: { ...(thread === undefined ? {} : { thread }), ...(message === undefined ? {} : { message }) },
744
+ key: JSON.stringify([threadRaw, messageRaw]),
745
+ params,
746
+ };
747
+ }
748
+
749
+ function parsePair(raw: string, label: string): [string, string] {
750
+ const parts = raw.split("/");
751
+ if (parts.length !== 2 || parts[0] === undefined || parts[0].length === 0 || parts[1] === undefined || parts[1].length === 0) {
752
+ throw routeShape(t("pairShape", { label }));
753
+ }
754
+ return [parts[0]!, parts[1]!];
755
+ }
756
+
757
+ function messageRefFromParts(parts: string[], shape: string): MessageRef {
758
+ if (parts.length !== 2) throw routeShape(shape);
759
+ return { sender: parts[0]!, id: parts[1]! };
760
+ }
761
+
762
+ async function leafDocuments(
763
+ runtime: PluginRuntime,
764
+ caller: Caller,
765
+ hrefs: string[],
766
+ ): Promise<Array<{ url: string; content: string }>> {
767
+ const leaves: Array<{ url: string; content: string }> = [];
768
+ for (const href of hrefs) {
769
+ const rendered = await render(runtime, caller, viewOfHref(href));
770
+ leaves.push({ url: href, content: rendered.content });
771
+ }
772
+ return leaves;
773
+ }
774
+
775
+ async function inboxLeaves(runtime: PluginRuntime, caller: Caller): Promise<Array<{ url: string; content: string }>> {
776
+ const items = await visibleInbox(runtime, caller);
777
+ const byThread = new Map<string, { ref: ThreadRef; seqs: Set<string> }>();
778
+ for (const item of items) {
779
+ const attention = item.attention;
780
+ const key = threadKey(attention.thread_ref);
781
+ const seen = byThread.get(key);
782
+ if (seen !== undefined) {
783
+ seen.seqs.add(attention.seq);
784
+ continue;
785
+ }
786
+ byThread.set(key, { ref: attention.thread_ref, seqs: new Set([attention.seq]) });
787
+ }
788
+ const leaves: Array<{ url: string; content: string }> = [];
789
+ for (const group of byThread.values()) {
790
+ const entries = await threadEntries(runtime, group.ref);
791
+ for (const entry of entries) {
792
+ if (!group.seqs.has(entry.seq)) continue;
793
+ leaves.push({
794
+ url: entryPath(group.ref, entry.seq),
795
+ content: `${JSON.stringify(entry, null, 2)}\n`,
796
+ });
797
+ }
798
+ }
799
+ return leaves;
800
+ }
801
+
802
+ async function visibleInbox(runtime: PluginRuntime, caller: Caller): Promise<LocalAttention[]> {
803
+ const items = await inboxItems(runtime);
804
+ if (caller.main) return items;
805
+ const allowed = allowedThreads(await scopedRecords(runtime, caller));
806
+ return items.filter(item => allowed.has(threadKey(item.attention.thread_ref)));
807
+ }
808
+
809
+ async function visibleThreads(runtime: PluginRuntime, caller: Caller): Promise<Thread[]> {
810
+ const threads = await localThreads(runtime);
811
+ if (caller.main) return threads;
812
+ const allowed = allowedThreads(await scopedRecords(runtime, caller));
813
+ return threads.filter(thread => allowed.has(threadKey(thread.thread_ref)));
814
+ }
815
+
816
+ async function findThread(runtime: PluginRuntime, caller: Caller, alias: string): Promise<Thread | JoinedThread> {
817
+ const threads = await visibleThreads(runtime, caller);
818
+ const matches: Array<Thread | JoinedThread> = threads.filter(thread =>
819
+ thread.thread_ref.id === alias ||
820
+ thread.title === alias ||
821
+ threadKey(thread.thread_ref) === alias,
822
+ );
823
+ // A thread this node was invited into is named the same way, but its record lives on the
824
+ // owner: the id and the `owner/id` key are what the local records can answer for.
825
+ for (const row of await joinedThreads(runtime, caller)) {
826
+ if (row.thread_ref.id === alias || row.title === alias || threadKey(row.thread_ref) === alias) matches.push(row);
827
+ }
828
+ if (matches.length === 1) return matches[0]!;
829
+ if (matches.length > 1) throw new Error(t("manyThreadsNamed", { alias }));
830
+ // RFC 0018 §3.2 keeps the handshake thread out of the conversation listings. An inbox row
831
+ // for a handshake entry still links straight to it, and a link that opens nothing is worse
832
+ // than a listing nobody needs: a reference that names it exactly resolves here.
833
+ const direct = caller.main ? await trustThreadNamed(runtime, alias) : undefined;
834
+ if (direct !== undefined) return direct;
835
+ if (!caller.main && caller.agent_id !== undefined) throw new Error(t("noThreadSlice", { alias }));
836
+ throw new Error(t("noThreadNamed", { alias }));
837
+ }
838
+
839
+ /** The handshake thread, when the alias names it exactly; the listings never offer it. */
840
+ async function trustThreadNamed(runtime: PluginRuntime, alias: string): Promise<Thread | undefined> {
841
+ const ref = trustThread(runtime.config.node.id);
842
+ const names = new Set([ref.id, threadKey(ref), `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`]);
843
+ if (!names.has(alias)) return undefined;
844
+ const described = await runtime.node.threads.describe(runtime.node.auth, ref).catch(() => undefined);
845
+ return described?.thread;
846
+ }
847
+
848
+ /** Contexts this caller may see: every node-local record for the main session, its own records otherwise. */
849
+ async function scopedRecords(runtime: PluginRuntime, caller: Caller): Promise<ContextRecord[]> {
850
+ const records = (await runtime.contexts.list()).filter(record => record.node_id === runtime.config.node.id);
851
+ if (caller.main) return records;
852
+ if (caller.agent_id === undefined) return [];
853
+ return records.filter(record => record.agent_id === caller.agent_id);
854
+ }
855
+
856
+ function allowedThreads(records: ContextRecord[]): Set<string> {
857
+ const allowed = new Set<string>();
858
+ for (const record of records) {
859
+ if (record.thread_ref === undefined) continue;
860
+ allowed.add(threadKey(record.thread_ref));
861
+ }
862
+ return allowed;
863
+ }
864
+
865
+ /** The inbound message a subagent was dispatched for; its replies are readable even without a thread link. */
866
+ function sourceRefs(records: ContextRecord[]): Set<string> {
867
+ const sources = new Set<string>();
868
+ for (const record of records) {
869
+ if (record.source === undefined) continue;
870
+ sources.add(`${record.source.sender}/${record.source.id}`);
871
+ }
872
+ return sources;
873
+ }
874
+
875
+ function messagePermitted(message: HistoryMessage, allowed: Set<string>, sources: Set<string>): boolean {
876
+ if (sources.has(refKey(message.message_ref))) return true;
877
+ const thread = message.thread_ref ?? message.message.meta?.thread;
878
+ return thread !== undefined && allowed.has(threadKey(thread));
879
+ }
880
+
881
+ function conversationPermitted(conversation: Conversation, allowed: Set<string>, sources: Set<string>): boolean {
882
+ if (sources.has(refKey(conversation.id))) return true;
883
+ if (conversation.thread_refs.some(thread => allowed.has(threadKey(thread)))) return true;
884
+ return conversation.messages.some(message => messagePermitted(message, allowed, sources));
885
+ }
886
+
887
+ async function localThreads(runtime: PluginRuntime): Promise<Thread[]> {
888
+ // RFC 0018 §3.2: the handshake thread is this host's own record, not a conversation.
889
+ const threads = await collect(cursor =>
890
+ runtime.node.threads.list(runtime.node.auth, {
891
+ page_size: PAGE,
892
+ ...(cursor === undefined ? {} : { cursor }),
893
+ }),
894
+ );
895
+ return threads.filter(thread => !isTrustThread(runtime.config.node.id, thread.thread_ref));
896
+ }
897
+
898
+ /**
899
+ * Threads this node was invited into. They live on their owner, so the rows carry only what
900
+ * the local records prove; the owner is asked for the title and the member table where the
901
+ * detail page needs them. A subagent sees the joined threads its own records name.
902
+ */
903
+ async function joinedThreads(runtime: PluginRuntime, caller: Caller): Promise<JoinedThread[]> {
904
+ const rows = await collect(cursor =>
905
+ runtime.node.threads.joined(runtime.node.auth, {
906
+ page_size: PAGE,
907
+ ...(cursor === undefined ? {} : { cursor }),
908
+ }),
909
+ );
910
+ const visible = rows.filter(row => !isTrustThread(runtime.config.node.id, row.thread_ref));
911
+ if (caller.main) return visible;
912
+ const allowed = allowedThreads(await scopedRecords(runtime, caller));
913
+ return visible.filter(row => allowed.has(threadKey(row.thread_ref)));
914
+ }
915
+
916
+ async function allMessages(runtime: PluginRuntime, ref: Thread["thread_ref"]): Promise<Entry[]> {
917
+ return collect(cursor =>
918
+ runtime.node.messages.list(runtime.node.auth, ref, {
919
+ attention: "thread",
920
+ page_size: PAGE,
921
+ ...(cursor === undefined ? {} : { cursor }),
922
+ }),
923
+ );
924
+ }
925
+
926
+ /**
927
+ * A thread's entries: this node's own storage for a thread it hosts, the owner's message
928
+ * route otherwise. A member holds no copy of a joined thread's entries, so the owner is the
929
+ * only source that can answer for them.
930
+ */
931
+ async function threadEntries(runtime: PluginRuntime, ref: ThreadRef): Promise<Entry[]> {
932
+ if (ref.owner === runtime.config.node.id) return allMessages(runtime, ref);
933
+ return collect(cursor =>
934
+ runtime.node.client.threadEntries(ref, {
935
+ attention: "thread",
936
+ page_size: PAGE,
937
+ ...(cursor === undefined ? {} : { cursor }),
938
+ }),
939
+ );
940
+ }
941
+
942
+ /** The node's own outbound records; a subagent sees only replies tied to a message it received. */
943
+ async function outboundRows(runtime: PluginRuntime, caller: Caller): Promise<Outbound[]> {
944
+ const rows = await runtime.storage.transaction(tx => tx.scan<Outbound>("outbound/"));
945
+ const values = rows.map(row => row.value);
946
+ if (caller.main) return values;
947
+ const sources = sourceRefs(await scopedRecords(runtime, caller));
948
+ if (sources.size === 0) return [];
949
+ return values.filter(outbound => {
950
+ const related = outbound.request.message.meta?.in_reply_to;
951
+ return related !== undefined && sources.has(`${related.sender}/${related.id}`);
952
+ });
953
+ }
954
+
955
+ /** Every conversation the caller may read: the node's own history for main, its linked slice otherwise. */
956
+ async function visibleConversations(
957
+ runtime: PluginRuntime,
958
+ caller: Caller,
959
+ query: HistoryQuery = {},
960
+ ): Promise<Conversation[]> {
961
+ const conversations = await collect(cursor =>
962
+ runtime.node.history.list(runtime.node.auth, {
963
+ ...query,
964
+ page_size: PAGE,
965
+ ...(cursor === undefined ? {} : { cursor }),
966
+ }),
967
+ );
968
+ if (caller.main) return conversations;
969
+ const records = await scopedRecords(runtime, caller);
970
+ const allowed = allowedThreads(records);
971
+ const sources = sourceRefs(records);
972
+ return conversations.filter(conversation => conversationPermitted(conversation, allowed, sources));
973
+ }
974
+
975
+ /**
976
+ * A conversation reached through the thread filter can carry the other node's thread ref on
977
+ * its outbound rows, so the whole chain is shown; dedup is by message reference.
978
+ */
979
+ async function threadMessages(runtime: PluginRuntime, ref: ThreadRef): Promise<HistoryMessage[]> {
980
+ const conversations = await collect(cursor =>
981
+ runtime.node.history.list(runtime.node.auth, {
982
+ thread: ref,
983
+ page_size: PAGE,
984
+ ...(cursor === undefined ? {} : { cursor }),
985
+ }),
986
+ );
987
+ const rows = new Map<string, HistoryMessage>();
988
+ for (const conversation of conversations) {
989
+ for (const message of conversation.messages) {
990
+ rows.set(refKey(message.message_ref), message);
991
+ }
992
+ }
993
+ return sortRows([...rows.values()]);
994
+ }
995
+
996
+ /** Thread rows for the list view, keyed by thread and deduplicated by message reference. */
997
+ async function historyIndex(runtime: PluginRuntime, caller: Caller): Promise<Map<string, HistoryMessage[]>> {
998
+ const conversations = await visibleConversations(runtime, caller);
999
+ const byThread = new Map<string, Map<string, HistoryMessage>>();
1000
+ for (const conversation of conversations) {
1001
+ for (const message of conversation.messages) {
1002
+ const thread = message.thread_ref ?? message.message.meta?.thread;
1003
+ if (thread === undefined) continue;
1004
+ const key = threadKey(thread);
1005
+ const rows = byThread.get(key) ?? new Map<string, HistoryMessage>();
1006
+ rows.set(refKey(message.message_ref), message);
1007
+ byThread.set(key, rows);
1008
+ }
1009
+ }
1010
+ const index = new Map<string, HistoryMessage[]>();
1011
+ for (const [key, rows] of byThread) index.set(key, sortRows([...rows.values()]));
1012
+ return index;
1013
+ }
1014
+
1015
+ async function authorizeConversation(runtime: PluginRuntime, caller: Caller, ref: MessageRef): Promise<Conversation> {
1016
+ const conversation = await runtime.node.history.conversation(runtime.node.auth, ref);
1017
+ if (caller.main) return conversation;
1018
+ const records = await scopedRecords(runtime, caller);
1019
+ if (!conversationPermitted(conversation, allowedThreads(records), sourceRefs(records))) {
1020
+ throw new Error(t("noConversationSlice", { key: refKey(ref) }));
1021
+ }
1022
+ return conversation;
1023
+ }
1024
+
1025
+ /**
1026
+ * Resource views. Reads go through the library client so every peer page is a signed
1027
+ * request, and the caller boundary is checked before any query runs: resources are
1028
+ * the main session's channel, never a subagent's slice.
1029
+ */
1030
+ async function renderResources(runtime: PluginRuntime, caller: Caller, view: View): Promise<Rendered> {
1031
+ requireMain(caller, "resources");
1032
+ const parts = view.parts;
1033
+ if (parts.length === 0) {
1034
+ const request = pageRequest(view);
1035
+ if (request.cursor !== undefined) throw routeShape(t("resourceCursorOneOwner"));
1036
+ const query = await resourceQuery(runtime.node, { op: "list", page_size: request.size });
1037
+ const inventory = (await runtime.node.client.readResources(query)) as ResourceInventory;
1038
+ return markdown(renderInventory(runtime, inventory, request));
1039
+ }
1040
+ const owner = parts[0]!;
1041
+ if (parts.length === 1) {
1042
+ const request = pageRequest(view);
1043
+ const query = await resourceQuery(runtime.node, {
1044
+ op: "list",
1045
+ owner,
1046
+ page_size: request.size,
1047
+ ...(request.cursor === undefined ? {} : { cursor: request.cursor }),
1048
+ });
1049
+ const inventory = (await runtime.node.client.readResources(query)) as ResourceInventory;
1050
+ return markdown(renderInventory(runtime, inventory, request));
1051
+ }
1052
+ const ref: ResourceRef = { owner, id: parts[1]! };
1053
+ if (parts.length === 2) {
1054
+ const query = await resourceQuery(runtime.node, { op: "describe", resource: ref });
1055
+ return json(await runtime.node.client.readResources(query));
1056
+ }
1057
+ const tail = parts[2]!;
1058
+ if (tail === "log") {
1059
+ if (parts.length === 3) {
1060
+ const request = pageRequest(view);
1061
+ const since = view.params.get("since") ?? undefined;
1062
+ const query = await resourceQuery(runtime.node, {
1063
+ op: "log",
1064
+ resource: ref,
1065
+ page_size: request.size,
1066
+ ...(request.cursor === undefined ? {} : { cursor: request.cursor }),
1067
+ ...(since === undefined ? {} : { since }),
1068
+ });
1069
+ const page = (await runtime.node.client.readResources(query)) as Page<ResourceEvent>;
1070
+ return markdown(renderLog(ref, page, request, since));
1071
+ }
1072
+ if (parts.length === 4) {
1073
+ const query = await resourceQuery(runtime.node, { op: "event", resource: ref, event_id: parts[3]! });
1074
+ return json(await runtime.node.client.readResources(query));
1075
+ }
1076
+ throw routeShape(t("resourceLogShape"));
1077
+ }
1078
+ if (tail === "operations") {
1079
+ if (parts.length === 3) {
1080
+ const request = pageRequest(view);
1081
+ const query = await resourceQuery(runtime.node, {
1082
+ op: "operations",
1083
+ resource: ref,
1084
+ page_size: request.size,
1085
+ ...(request.cursor === undefined ? {} : { cursor: request.cursor }),
1086
+ });
1087
+ const page = (await runtime.node.client.readResources(query)) as Page<OperationRecord>;
1088
+ return markdown(renderOperations(ref, page, request));
1089
+ }
1090
+ if (parts.length === 4) {
1091
+ // Status is read directly: a former handler keeps its own operation record after
1092
+ // a handover, and an extra describe first would refuse the very access this route exists for.
1093
+ const query = await resourceQuery(runtime.node, { op: "status", resource: ref, operation_id: parts[3]! });
1094
+ return json(await runtime.node.client.readResources(query));
1095
+ }
1096
+ throw routeShape(t("resourceOperationsShape"));
1097
+ }
1098
+ throw routeShape(t("resourceShape"));
1099
+ }
1100
+
1101
+ function renderInventory(runtime: PluginRuntime, inventory: ResourceInventory, request: PageRequest): string {
1102
+ const lines = [t("resourcesTitle", { node: runtime.config.node.id }), ""];
1103
+ for (const entry of inventory.owners) {
1104
+ lines.push(`## ${entry.owner}`);
1105
+ if ("error" in entry) {
1106
+ lines.push(t("resourceOwnerError", { code: entry.error.code, message: entry.error.message }));
1107
+ continue;
1108
+ }
1109
+ if (entry.page.items.length === 0) lines.push(t("noResources"));
1110
+ for (const resource of entry.page.items) {
1111
+ const ref = resource.resource_ref;
1112
+ lines.push(t("resourceRow", {
1113
+ id: ref.id,
1114
+ kind: resource.kind,
1115
+ operations: resource.operations.length === 0 ? t("noOperations") : resource.operations.join(", "),
1116
+ handler: resource.binding.handler,
1117
+ cursor: resource.binding.cursor,
1118
+ revision: resource.revision,
1119
+ event_seq: resource.event_seq,
1120
+ }));
1121
+ lines.push(t("resourceLinks", {
1122
+ detail: resourcePath(ref),
1123
+ log: resourceLogPath(ref),
1124
+ operations: resourceOperationsPath(ref),
1125
+ }));
1126
+ }
1127
+ // A list cursor names one owner, so its continuation leaves the aggregate route.
1128
+ if (entry.page.next_cursor !== null) {
1129
+ lines.push(nextLink(`resources/${encodeURIComponent(entry.owner)}`, request, entry.page.next_cursor));
1130
+ }
1131
+ }
1132
+ return `${lines.join("\n")}\n`;
1133
+ }
1134
+
1135
+ function renderLog(ref: ResourceRef, page: Page<ResourceEvent>, request: PageRequest, since: string | undefined): string {
1136
+ const lines = [t("resourceLogTitle", { resource: `${ref.owner}/${ref.id}` }), ""];
1137
+ for (const event of page.items) {
1138
+ lines.push(t("eventRow", {
1139
+ id: event.event_id,
1140
+ source: event.source_id,
1141
+ at: event.at,
1142
+ expires: event.expires_at,
1143
+ expired: event.expired ? t("suffixExpired") : "",
1144
+ }));
1145
+ lines.push(t("eventLink", { url: resourceEventPath(ref, event.event_id) }));
1146
+ }
1147
+ if (page.items.length === 0) lines.push(t("noEvents"));
1148
+ lines.push("", nextLink(
1149
+ `resources/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/log`,
1150
+ request,
1151
+ page.next_cursor,
1152
+ since === undefined ? {} : { since },
1153
+ ));
1154
+ return `${lines.join("\n")}\n`;
1155
+ }
1156
+
1157
+ function renderOperations(ref: ResourceRef, page: Page<OperationRecord>, request: PageRequest): string {
1158
+ const lines = [t("resourceOperationsTitle", { resource: `${ref.owner}/${ref.id}` }), ""];
1159
+ for (const record of page.items) {
1160
+ const id = record.request.operation_id;
1161
+ lines.push(t("operationRow", {
1162
+ id,
1163
+ operation: record.request.operation,
1164
+ state: record.result.state,
1165
+ actor: record.actor,
1166
+ at: record.accepted_at,
1167
+ }));
1168
+ lines.push(t("statusLink", { url: resourceStatusPath(ref, id) }));
1169
+ }
1170
+ if (page.items.length === 0) lines.push(t("noOperationRecords"));
1171
+ lines.push("", nextLink(
1172
+ `resources/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/operations`,
1173
+ request,
1174
+ page.next_cursor,
1175
+ ));
1176
+ return `${lines.join("\n")}\n`;
1177
+ }
1178
+
1179
+ /**
1180
+ * Resource enumeration. A collection expands into the detail documents the caller may
1181
+ * read; a log or operation collection expands into its entry records with the same
1182
+ * filters the route accepts. The main-session check runs before any query.
1183
+ */
1184
+ async function resourceLeaves(
1185
+ runtime: PluginRuntime,
1186
+ caller: Caller,
1187
+ view: View,
1188
+ ): Promise<Array<{ url: string; content: string }>> {
1189
+ requireMain(caller, "resources");
1190
+ const parts = view.parts;
1191
+ if (parts.length === 0) {
1192
+ if (view.params.get("cursor") !== null) throw routeShape(t("resourceCursorOneOwner"));
1193
+ return detailLeaves(runtime, await listResources(runtime));
1194
+ }
1195
+ const owner = parts[0]!;
1196
+ if (parts.length === 1) {
1197
+ return detailLeaves(runtime, await listResources(runtime, owner));
1198
+ }
1199
+ const ref: ResourceRef = { owner, id: parts[1]! };
1200
+ if (parts.length === 2) return leafDocuments(runtime, caller, [resourcePath(ref)]);
1201
+ const tail = parts[2]!;
1202
+ if (tail !== "log" && tail !== "operations") throw routeShape(t("resourceShape"));
1203
+ if (parts.length === 3) {
1204
+ const request = pageRequest(view);
1205
+ if (tail === "log") {
1206
+ const since = view.params.get("since") ?? undefined;
1207
+ const query = await resourceQuery(runtime.node, {
1208
+ op: "log",
1209
+ resource: ref,
1210
+ page_size: request.size,
1211
+ ...(request.cursor === undefined ? {} : { cursor: request.cursor }),
1212
+ ...(since === undefined ? {} : { since }),
1213
+ });
1214
+ const page = (await runtime.node.client.readResources(query)) as Page<ResourceEvent>;
1215
+ return page.items.map(event => ({
1216
+ url: resourceEventPath(ref, event.event_id),
1217
+ content: `${JSON.stringify(event, null, 2)}\n`,
1218
+ }));
1219
+ }
1220
+ const query = await resourceQuery(runtime.node, {
1221
+ op: "operations",
1222
+ resource: ref,
1223
+ page_size: request.size,
1224
+ ...(request.cursor === undefined ? {} : { cursor: request.cursor }),
1225
+ });
1226
+ const page = (await runtime.node.client.readResources(query)) as Page<OperationRecord>;
1227
+ return page.items.map(record => ({
1228
+ url: resourceStatusPath(ref, record.request.operation_id),
1229
+ content: `${JSON.stringify(record, null, 2)}\n`,
1230
+ }));
1231
+ }
1232
+ if (parts.length === 4) {
1233
+ return leafDocuments(runtime, caller, [
1234
+ tail === "log" ? resourceEventPath(ref, parts[3]!) : resourceStatusPath(ref, parts[3]!),
1235
+ ]);
1236
+ }
1237
+ throw routeShape(tail === "log" ? t("resourceLogShape") : t("resourceOperationsShape"));
1238
+ }
1239
+
1240
+ /** Every resource in the caller's inventory, across owner pages; enumeration is not a view page. */
1241
+ async function listResources(runtime: PluginRuntime, owner?: string): Promise<Resource[]> {
1242
+ const first = (await runtime.node.client.readResources(await resourceQuery(runtime.node, {
1243
+ op: "list",
1244
+ page_size: PAGE,
1245
+ ...(owner === undefined ? {} : { owner }),
1246
+ }))) as ResourceInventory;
1247
+ const resources: Resource[] = [];
1248
+ for (const entry of first.owners) {
1249
+ if ("error" in entry) continue;
1250
+ resources.push(...entry.page.items);
1251
+ let cursor = entry.page.next_cursor;
1252
+ for (let page = 0; cursor !== null; page++) {
1253
+ if (page >= MAX_PAGES) throw new Error(t("paginationCap"));
1254
+ const next = (await runtime.node.client.readResources(await resourceQuery(runtime.node, {
1255
+ op: "list",
1256
+ owner: entry.owner,
1257
+ page_size: PAGE,
1258
+ cursor,
1259
+ }))) as ResourceInventory;
1260
+ const row = next.owners[0];
1261
+ if (row === undefined || "error" in row) break;
1262
+ resources.push(...row.page.items);
1263
+ cursor = row.page.next_cursor;
1264
+ }
1265
+ }
1266
+ return resources;
1267
+ }
1268
+
1269
+ /** One detail document per readable resource; a list row the caller cannot describe is not a leaf. */
1270
+ async function detailLeaves(
1271
+ runtime: PluginRuntime,
1272
+ resources: Resource[],
1273
+ ): Promise<Array<{ url: string; content: string }>> {
1274
+ const leaves: Array<{ url: string; content: string }> = [];
1275
+ for (const resource of resources) {
1276
+ const ref = resource.resource_ref;
1277
+ try {
1278
+ const query = await resourceQuery(runtime.node, { op: "describe", resource: ref });
1279
+ const described = await runtime.node.client.readResources(query);
1280
+ leaves.push({ url: resourcePath(ref), content: `${JSON.stringify(described, null, 2)}\n` });
1281
+ } catch (error) {
1282
+ // The listing already filtered readability; a resource that changed between the
1283
+ // list and the describe simply has no detail leaf to enumerate.
1284
+ if (!(error instanceof OnlyneError)) throw error;
1285
+ }
1286
+ }
1287
+ return leaves;
1288
+ }
1289
+
1290
+ function resourcePath(ref: ResourceRef): string {
1291
+ return `onlyne://resources/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`;
1292
+ }
1293
+
1294
+ function resourceLogPath(ref: ResourceRef): string {
1295
+ return `${resourcePath(ref)}/log`;
1296
+ }
1297
+
1298
+ function resourceEventPath(ref: ResourceRef, eventId: string): string {
1299
+ return `${resourceLogPath(ref)}/${encodeURIComponent(eventId)}`;
1300
+ }
1301
+
1302
+ function resourceOperationsPath(ref: ResourceRef): string {
1303
+ return `${resourcePath(ref)}/operations`;
1304
+ }
1305
+
1306
+ function resourceStatusPath(ref: ResourceRef, operationId: string): string {
1307
+ return `${resourceOperationsPath(ref)}/${encodeURIComponent(operationId)}`;
1308
+ }
1309
+
1310
+ async function collect<T>(fetch: (cursor?: string) => Promise<Page<T>>): Promise<T[]> {
1311
+ const items: T[] = [];
1312
+ let cursor: string | undefined;
1313
+ for (let page = 0; page < MAX_PAGES; page++) {
1314
+ const result = await fetch(cursor);
1315
+ items.push(...result.items);
1316
+ const next = result.next_cursor ?? undefined;
1317
+ if (next === undefined) return items;
1318
+ cursor = next;
1319
+ }
1320
+ throw new Error(t("paginationCap"));
1321
+ }
1322
+
1323
+ interface PageRequest {
1324
+ size: number;
1325
+ cursor?: string;
1326
+ }
1327
+
1328
+ function pageRequest(view: View): PageRequest {
1329
+ const explicit = view.params.get("page_size");
1330
+ const alias = view.params.get("limit");
1331
+ if (explicit !== null && alias !== null && explicit !== alias) {
1332
+ throw routeShape(t("pageSizeDisagree"));
1333
+ }
1334
+ const raw = explicit ?? alias;
1335
+ const size = raw === null ? DEFAULT_PAGE : Number(raw);
1336
+ if (!Number.isInteger(size) || size < 1 || size > MAX_PAGE) {
1337
+ throw routeShape(t("pageSizeRange", { max: MAX_PAGE }));
1338
+ }
1339
+ const cursor = view.params.get("cursor");
1340
+ return { size, ...(cursor === null || cursor.length === 0 ? {} : { cursor }) };
1341
+ }
1342
+
1343
+ function cursorScope(route: string, caller: Caller, query: string): string {
1344
+ return JSON.stringify([route, caller.main ? "main" : `agent:${caller.agent_id ?? "anonymous"}`, query]);
1345
+ }
1346
+
1347
+ function decodeCursor(scope: string, cursor: string): string {
1348
+ let parsed: unknown;
1349
+ try {
1350
+ parsed = JSON.parse(decodeURIComponent(cursor));
1351
+ } catch {
1352
+ throw routeShape(t("invalidCursor"));
1353
+ }
1354
+ const record = parsed as { scope?: unknown; after?: unknown } | null;
1355
+ if (record === null || typeof record !== "object" || record.scope !== scope || typeof record.after !== "string") {
1356
+ throw routeShape(t("foreignCursor"));
1357
+ }
1358
+ return record.after;
1359
+ }
1360
+
1361
+ function localPage<T>(items: T[], request: PageRequest, scope: string, position: (item: T) => string): { items: T[]; next: string | null } {
1362
+ const after = request.cursor === undefined ? undefined : decodeCursor(scope, request.cursor);
1363
+ const remaining = after === undefined ? items : items.filter(item => position(item) > after);
1364
+ const selected = remaining.slice(0, request.size);
1365
+ const last = selected.at(-1);
1366
+ const next = remaining.length > selected.length && last !== undefined
1367
+ ? encodeURIComponent(JSON.stringify({ scope, after: position(last) }))
1368
+ : null;
1369
+ return { items: selected, next };
1370
+ }
1371
+
1372
+ function nextLink(route: string, request: PageRequest, next: string | null, filters: Record<string, string> = {}): string {
1373
+ if (next === null) return t("endOfList");
1374
+ const params = new URLSearchParams();
1375
+ for (const [key, value] of Object.entries(filters)) params.set(key, value);
1376
+ params.set("page_size", String(request.size));
1377
+ params.set("cursor", next);
1378
+ return t("next", { url: `onlyne://${route}?${params.toString()}` });
1379
+ }
1380
+
1381
+ function refKey(ref: MessageRef): string {
1382
+ return `${ref.sender}/${ref.id}`;
1383
+ }
1384
+
1385
+ function threadKey(ref: ThreadRef): string {
1386
+ return `${ref.owner}/${ref.id}`;
1387
+ }
1388
+
1389
+ function sortRows(rows: HistoryMessage[]): HistoryMessage[] {
1390
+ return rows.sort((left, right) => {
1391
+ const at = (left.at ?? "").localeCompare(right.at ?? "");
1392
+ if (at !== 0) return at;
1393
+ if (left.seq !== undefined && right.seq !== undefined && left.seq !== right.seq) {
1394
+ return left.seq.length - right.seq.length || left.seq.localeCompare(right.seq);
1395
+ }
1396
+ return refKey(left.message_ref).localeCompare(refKey(right.message_ref));
1397
+ });
1398
+ }
1399
+
1400
+ function firstLine(text: string, limit: number): string {
1401
+ const line = text.split(/\r?\n/).map(part => part.trim()).find(part => part.length > 0) ?? "";
1402
+ return line.length > limit ? `${line.slice(0, limit - 1)}…` : line;
1403
+ }
1404
+
1405
+ function titleOf(row: HistoryMessage): string {
1406
+ const line = firstLine(row.message.msg, TITLE_CHARS);
1407
+ return line.length === 0 ? refKey(row.message_ref) : line;
1408
+ }
1409
+
1410
+ function threadPath(ref: ThreadRef): string {
1411
+ return `onlyne://threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`;
1412
+ }
1413
+
1414
+ /** The local messages route; a sender reads its own outbound and inbound here alike. */
1415
+ export function messagePath(ref: MessageRef): string {
1416
+ return `onlyne://messages/${encodeURIComponent(ref.sender)}/${encodeURIComponent(ref.id)}`;
1417
+ }
1418
+
1419
+ /** The whole local conversation around a message, from the root message reference. */
1420
+ export function conversationPath(ref: MessageRef): string {
1421
+ return `onlyne://conversations/${encodeURIComponent(ref.sender)}/${encodeURIComponent(ref.id)}`;
1422
+ }
1423
+
1424
+ /** The raw local thread entry route, kept alongside the history projection. */
1425
+ function entryPath(ref: ThreadRef, seq: string): string {
1426
+ return `onlyne://threads/${encodeURIComponent(ref.id)}/messages/${encodeURIComponent(seq)}`;
1427
+ }
1428
+
1429
+ /** The entry route of a thread named by its full `owner/id` key, which a joined thread needs. */
1430
+ function ownedEntryPath(ref: ThreadRef, seq: string): string {
1431
+ return `onlyne://threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/messages/${encodeURIComponent(seq)}`;
1432
+ }
1433
+
1434
+ /**
1435
+ * One entry as its owner holds it. A joined thread's page is the owner's record, so it reports
1436
+ * the entry itself and never claims this node's own delivery or consumption state for it.
1437
+ */
1438
+ function renderEntryRow(entry: Entry, nodeId: string): string {
1439
+ const ref: MessageRef = { sender: entry.message.sender, id: entry.message.id };
1440
+ const related = entry.message.meta?.in_reply_to;
1441
+ const lines = [
1442
+ t("historyRow", {
1443
+ prefix: t("historySeqPrefix", { seq: entry.seq }),
1444
+ direction: entry.message.sender === nodeId ? "out" : "in",
1445
+ sender: ref.sender,
1446
+ at: entry.at,
1447
+ status: t("ownerHeld"),
1448
+ }),
1449
+ ];
1450
+ if (related !== undefined) lines.push(t("replyTo", { ref: refKey(related), url: messagePath(related) }));
1451
+ lines.push(t("historyLinks", {
1452
+ message: messagePath(ref),
1453
+ conversation: conversationPath(ref),
1454
+ entry: ownedEntryPath(entry.thread_ref, entry.seq),
1455
+ }));
1456
+ for (const line of entry.message.msg.split(/\r?\n/)) lines.push(` > ${line}`);
1457
+ return lines.join("\n");
1458
+ }
1459
+
1460
+ function renderHistoryRow(row: HistoryMessage, nodeId: string): string {
1461
+ const ref = row.message_ref;
1462
+ const thread = row.thread_ref ?? row.message.meta?.thread;
1463
+ const related = row.message.meta?.in_reply_to;
1464
+ const status = [
1465
+ row.delivery,
1466
+ row.consumption === undefined ? "" : t("consumptionSuffix", { value: row.consumption }),
1467
+ row.guarantee === undefined ? "" : t("guaranteeSuffix", { value: row.guarantee }),
1468
+ row.error === undefined ? "" : t("errorSuffix", { code: row.error.code }),
1469
+ ].filter(part => part.length > 0).join(" · ");
1470
+ const lines = [
1471
+ t("historyRow", {
1472
+ prefix: row.seq === undefined ? "" : t("historySeqPrefix", { seq: row.seq }),
1473
+ direction: row.direction,
1474
+ sender: ref.sender,
1475
+ at: row.at ?? t("noTime"),
1476
+ status,
1477
+ }),
1478
+ ];
1479
+ if (related !== undefined) {
1480
+ lines.push(t("replyTo", { ref: `${related.sender}/${related.id}`, url: messagePath(related) }));
1481
+ }
1482
+ const entry = thread === undefined || row.seq === undefined || thread.owner !== nodeId
1483
+ ? ""
1484
+ : t("suffixEntry", { url: entryPath(thread, row.seq) });
1485
+ lines.push(t("historyLinks", { message: messagePath(ref), conversation: conversationPath(ref), entry }));
1486
+ for (const line of row.message.msg.split(/\r?\n/)) lines.push(` > ${line}`);
1487
+ return lines.join("\n");
1488
+ }