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/AGENTS.sample.md +27 -0
- package/LICENSE +21 -0
- package/README.md +140 -2
- package/index.ts +55 -0
- package/package.json +62 -5
- package/src/acquire.ts +182 -0
- package/src/binding.ts +132 -0
- package/src/bridge-text.ts +145 -0
- package/src/config.ts +332 -0
- package/src/decider-text.ts +438 -0
- package/src/decider.ts +2233 -0
- package/src/drafts.ts +57 -0
- package/src/handshake.ts +291 -0
- package/src/host-text.ts +92 -0
- package/src/messages.ts +427 -0
- package/src/node.ts +139 -0
- package/src/resources.ts +36 -0
- package/src/runtime.ts +676 -0
- package/src/store.ts +189 -0
- package/src/tern.ts +66 -0
- package/src/todo-native.ts +230 -0
- package/src/todo.ts +896 -0
- package/src/tool-text.ts +167 -0
- package/src/tool.ts +342 -0
- package/src/types.ts +150 -0
- package/src/ui/bootstrap-text.ts +125 -0
- package/src/ui/bootstrap.ts +363 -0
- package/src/ui/command-text.ts +38 -0
- package/src/ui/format.ts +56 -0
- package/src/ui/index.ts +156 -0
- package/src/ui/mail-message.ts +263 -0
- package/src/ui/status.ts +213 -0
- package/src/ui/viewer-text.ts +309 -0
- package/src/ui/viewer.ts +1403 -0
- package/src/urls-text.ts +304 -0
- package/src/urls.ts +1488 -0
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
|
+
}
|