omp-onlyne 0.1.0 → 0.3.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/README.md +16 -8
- package/package.json +3 -3
- package/src/bridge-text.ts +40 -8
- package/src/config.ts +39 -5
- package/src/decider-text.ts +36 -21
- package/src/decider.ts +253 -548
- package/src/drafts.ts +68 -19
- package/src/guard.ts +107 -0
- package/src/handshake.ts +291 -0
- package/src/messages.ts +134 -16
- package/src/plan.ts +133 -0
- package/src/questions.ts +250 -0
- package/src/resolve.ts +387 -0
- package/src/resources.ts +1 -1
- package/src/runtime.ts +454 -18
- package/src/tool-text.ts +64 -30
- package/src/tool.ts +136 -21
- package/src/types.ts +65 -57
- package/src/ui/bootstrap-text.ts +24 -0
- package/src/ui/bootstrap.ts +82 -3
- package/src/ui/index.ts +6 -2
- package/src/ui/mail-message.ts +5 -1
- package/src/ui/viewer-text.ts +116 -4
- package/src/ui/viewer.ts +650 -61
- package/src/urls-text.ts +24 -0
- package/src/urls.ts +206 -33
- package/src/work.ts +10 -0
- package/src/host-text.ts +0 -86
- package/src/node.ts +0 -127
- package/src/store.ts +0 -189
package/src/urls-text.ts
CHANGED
|
@@ -28,6 +28,8 @@ export const en = {
|
|
|
28
28
|
rootThreads: "Threads: {count}",
|
|
29
29
|
rootThreadRow: "- **{title}** `{key}` · {state} · next seq {next}",
|
|
30
30
|
rootNoThreads: "No threads.",
|
|
31
|
+
rootJoinedThreads: "Joined threads (read-only): {count}",
|
|
32
|
+
rootThreadJoinedRow: "- **{title}** `{key}` · read-only · last seq {last}",
|
|
31
33
|
rootViews: "Views: onlyne://neighbors, onlyne://threads, onlyne://inbox, onlyne://contexts, onlyne://outbound, onlyne://conversations",
|
|
32
34
|
rootViewsResources: "Views: onlyne://neighbors, onlyne://threads, onlyne://inbox, onlyne://contexts, onlyne://outbound, onlyne://conversations, onlyne://resources",
|
|
33
35
|
rootConversation: "Conversation: onlyne://conversations/<encoded-sender>/<encoded-id> · message: onlyne://messages/<encoded-sender>/<encoded-id>",
|
|
@@ -35,6 +37,9 @@ export const en = {
|
|
|
35
37
|
|
|
36
38
|
threadsTitle: "# Threads · {node}",
|
|
37
39
|
threadRow: "- **{title}** `{key}` · {state} · next seq {next} · {count} messages",
|
|
40
|
+
threadRowJoined: "- **{title}** `{key}` · read-only · last seq {last} · {count} messages",
|
|
41
|
+
threadJoinedLinks: " thread {thread} · read-only, hosted by {owner}",
|
|
42
|
+
joinedThreadTitle: "Thread {id} (hosted by {owner})",
|
|
38
43
|
threadNoHistory: " no local history yet · thread {url}",
|
|
39
44
|
threadLatest: " latest {direction} · {at} · {snippet}",
|
|
40
45
|
threadLinks: " thread {thread} · latest {latest} · conversation {conversation}",
|
|
@@ -44,6 +49,10 @@ export const en = {
|
|
|
44
49
|
noMessageAt: "No message at {key}/{seq}. onlyne:// lists the views.",
|
|
45
50
|
threadTitle: "# {title}",
|
|
46
51
|
threadMeta: "Thread `{key}` · {state} · next seq {next} · {count} messages",
|
|
52
|
+
threadReadOnly: "Read-only: `{owner}` hosts this thread; its state and members belong to the owner.",
|
|
53
|
+
threadStateUnknown: "state unknown",
|
|
54
|
+
ownerUnreachable: "the owner {owner} did not answer",
|
|
55
|
+
ownerHeld: "owner record",
|
|
47
56
|
members: "## Members",
|
|
48
57
|
memberRow: "- `{peer}` · {state} · {access}",
|
|
49
58
|
noAccess: "no access",
|
|
@@ -123,6 +132,9 @@ export const en = {
|
|
|
123
132
|
suffixReply: " · reply to {ref}",
|
|
124
133
|
suffixEntry: " · entry {url}",
|
|
125
134
|
suffixExpired: " · expired",
|
|
135
|
+
workLine: " → work {unit}{parent}{attempt}",
|
|
136
|
+
workParent: " · parent {unit}",
|
|
137
|
+
workAttempt: " · attempt {attempt}",
|
|
126
138
|
|
|
127
139
|
resourcesTitle: "# Resources · {node}",
|
|
128
140
|
resourceOwnerError: "- unavailable: {code} · {message}",
|
|
@@ -167,6 +179,8 @@ export const zh: Record<keyof typeof en, string> = {
|
|
|
167
179
|
rootThreads: "线程:{count}",
|
|
168
180
|
rootThreadRow: "- **{title}** `{key}` · {state} · 下一序号 {next}",
|
|
169
181
|
rootNoThreads: "没有线程。",
|
|
182
|
+
rootJoinedThreads: "受邀线程(只读):{count}",
|
|
183
|
+
rootThreadJoinedRow: "- **{title}** `{key}` · 只读 · 最后序号 {last}",
|
|
170
184
|
rootViews: "视图:onlyne://neighbors、onlyne://threads、onlyne://inbox、onlyne://contexts、onlyne://outbound、onlyne://conversations",
|
|
171
185
|
rootViewsResources: "视图:onlyne://neighbors、onlyne://threads、onlyne://inbox、onlyne://contexts、onlyne://outbound、onlyne://conversations、onlyne://resources",
|
|
172
186
|
rootConversation: "对话:onlyne://conversations/<encoded-sender>/<encoded-id> · 消息:onlyne://messages/<encoded-sender>/<encoded-id>",
|
|
@@ -174,6 +188,9 @@ export const zh: Record<keyof typeof en, string> = {
|
|
|
174
188
|
|
|
175
189
|
threadsTitle: "# 线程 · {node}",
|
|
176
190
|
threadRow: "- **{title}** `{key}` · {state} · 下一序号 {next} · {count} 条消息",
|
|
191
|
+
threadRowJoined: "- **{title}** `{key}` · 只读 · 最后序号 {last} · {count} 条消息",
|
|
192
|
+
threadJoinedLinks: " 线程 {thread} · 只读,由 {owner} 托管",
|
|
193
|
+
joinedThreadTitle: "线程 {id}(由 {owner} 托管)",
|
|
177
194
|
threadNoHistory: " 尚无本地历史 · 线程 {url}",
|
|
178
195
|
threadLatest: " 最新 {direction} · {at} · {snippet}",
|
|
179
196
|
threadLinks: " 线程 {thread} · 最新 {latest} · 对话 {conversation}",
|
|
@@ -183,6 +200,10 @@ export const zh: Record<keyof typeof en, string> = {
|
|
|
183
200
|
noMessageAt: "{key}/{seq} 处没有消息。onlyne:// 列出了所有视图。",
|
|
184
201
|
threadTitle: "# {title}",
|
|
185
202
|
threadMeta: "线程 `{key}` · {state} · 下一序号 {next} · {count} 条消息",
|
|
203
|
+
threadReadOnly: "只读:该线程由 `{owner}` 托管,状态与成员属于所有者。",
|
|
204
|
+
threadStateUnknown: "状态未知",
|
|
205
|
+
ownerUnreachable: "所有者 {owner} 没有应答",
|
|
206
|
+
ownerHeld: "所有者记录",
|
|
186
207
|
members: "## 成员",
|
|
187
208
|
memberRow: "- `{peer}` · {state} · {access}",
|
|
188
209
|
noAccess: "无访问权限",
|
|
@@ -262,6 +283,9 @@ export const zh: Record<keyof typeof en, string> = {
|
|
|
262
283
|
suffixReply: " · 回复 {ref}",
|
|
263
284
|
suffixEntry: " · 条目 {url}",
|
|
264
285
|
suffixExpired: " · 已过期",
|
|
286
|
+
workLine: " → 工作单元 {unit}{parent}{attempt}",
|
|
287
|
+
workParent: " · 父单元 {unit}",
|
|
288
|
+
workAttempt: " · 第 {attempt} 次",
|
|
265
289
|
|
|
266
290
|
resourcesTitle: "# 资源 · {node}",
|
|
267
291
|
resourceOwnerError: "- 不可用:{code} · {message}",
|
package/src/urls.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type Entry,
|
|
6
6
|
type HistoryMessage,
|
|
7
7
|
type HistoryQuery,
|
|
8
|
+
type JoinedThread,
|
|
8
9
|
type LocalAttention,
|
|
9
10
|
type MessageRef,
|
|
10
11
|
type OperationRecord,
|
|
@@ -15,8 +16,10 @@ import {
|
|
|
15
16
|
type ResourceInventory,
|
|
16
17
|
type ResourceRef,
|
|
17
18
|
type Thread,
|
|
19
|
+
type ThreadDescription,
|
|
18
20
|
type ThreadRef,
|
|
19
21
|
type TrustPhase,
|
|
22
|
+
type WorkRef,
|
|
20
23
|
} from "@onlyne/lib";
|
|
21
24
|
import { createTranslator } from "@onlyne/node-host/i18n";
|
|
22
25
|
import {
|
|
@@ -29,7 +32,8 @@ import {
|
|
|
29
32
|
} from "@oh-my-pi/pi-coding-agent/internal-urls";
|
|
30
33
|
import { resourceQuery } from "./resources.ts";
|
|
31
34
|
import { getRuntime, type PluginRuntime } from "./runtime.ts";
|
|
32
|
-
import
|
|
35
|
+
import { isTrustThread, trustThread } from "./handshake.ts";
|
|
36
|
+
import type { ContextRecord } from "@onlyne/node-host/types";
|
|
33
37
|
import { urlsText } from "./urls-text.ts";
|
|
34
38
|
|
|
35
39
|
type Caller = { main: boolean; agent_id?: string };
|
|
@@ -50,13 +54,15 @@ const TITLE_CHARS = 80;
|
|
|
50
54
|
|
|
51
55
|
const handler: ProtocolHandler = {
|
|
52
56
|
scheme: "onlyne",
|
|
53
|
-
|
|
57
|
+
// Every view reads live node state: threads, inbox, neighbors and conversations change
|
|
58
|
+
// between two reads of the same URL, so OMP must never serve a cached copy.
|
|
59
|
+
spec: { backing: "virtual", selectors: "lines", immutable: false },
|
|
54
60
|
|
|
55
61
|
async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
|
|
56
62
|
const runtime = getRuntime();
|
|
57
63
|
if (runtime === undefined) throw new Error(t("notConfigured"));
|
|
58
64
|
const rendered = await render(runtime, callerOf(runtime, context), viewOf(url));
|
|
59
|
-
return { url: url.href, content: rendered.content, contentType: rendered.contentType
|
|
65
|
+
return { url: url.href, content: rendered.content, contentType: rendered.contentType };
|
|
60
66
|
},
|
|
61
67
|
|
|
62
68
|
/**
|
|
@@ -79,10 +85,16 @@ const handler: ProtocolHandler = {
|
|
|
79
85
|
for (const thread of await visibleThreads(runtime, caller)) {
|
|
80
86
|
urls.push(threadPath(thread.thread_ref));
|
|
81
87
|
}
|
|
88
|
+
for (const row of await joinedThreads(runtime, caller)) {
|
|
89
|
+
urls.push(threadPath(row.thread_ref));
|
|
90
|
+
}
|
|
82
91
|
return leafDocuments(runtime, caller, urls);
|
|
83
92
|
}
|
|
84
93
|
if (view.host === "threads" && view.parts.length === 0) {
|
|
85
|
-
const urls =
|
|
94
|
+
const urls = [
|
|
95
|
+
...(await visibleThreads(runtime, caller)).map(thread => threadPath(thread.thread_ref)),
|
|
96
|
+
...(await joinedThreads(runtime, caller)).map(row => threadPath(row.thread_ref)),
|
|
97
|
+
];
|
|
86
98
|
return leafDocuments(runtime, caller, urls);
|
|
87
99
|
}
|
|
88
100
|
if (view.host === "threads") {
|
|
@@ -92,7 +104,7 @@ const handler: ProtocolHandler = {
|
|
|
92
104
|
const entry = await entryAt(runtime, thread, target.seq);
|
|
93
105
|
return [{ url: entryPath(thread.thread_ref, target.seq), content: `${JSON.stringify(entry, null, 2)}\n` }];
|
|
94
106
|
}
|
|
95
|
-
const entries = await
|
|
107
|
+
const entries = await threadEntries(runtime, thread.thread_ref);
|
|
96
108
|
return entries.map(entry => ({
|
|
97
109
|
url: entryPath(thread.thread_ref, entry.seq),
|
|
98
110
|
content: JSON.stringify(entry, null, 2),
|
|
@@ -233,6 +245,7 @@ async function renderRoot(runtime: PluginRuntime, caller: Caller): Promise<strin
|
|
|
233
245
|
}
|
|
234
246
|
const records = await scopedRecords(runtime, caller);
|
|
235
247
|
const threads = await visibleThreads(runtime, caller);
|
|
248
|
+
const joined = await joinedThreads(runtime, caller);
|
|
236
249
|
lines.push("", caller.main
|
|
237
250
|
? t("rootContexts", { count: records.length })
|
|
238
251
|
: t("rootContextsHeld", { agent: caller.agent_id ?? "", count: records.length }));
|
|
@@ -251,6 +264,16 @@ async function renderRoot(runtime: PluginRuntime, caller: Caller): Promise<strin
|
|
|
251
264
|
}));
|
|
252
265
|
}
|
|
253
266
|
if (threads.length === 0) lines.push(t("rootNoThreads"));
|
|
267
|
+
if (joined.length > 0) {
|
|
268
|
+
lines.push("", t("rootJoinedThreads", { count: joined.length }));
|
|
269
|
+
for (const row of joined) {
|
|
270
|
+
lines.push(t("rootThreadJoinedRow", {
|
|
271
|
+
title: row.title ?? t("joinedThreadTitle", { id: row.thread_ref.id, owner: row.thread_ref.owner }),
|
|
272
|
+
key: threadKey(row.thread_ref),
|
|
273
|
+
last: row.last_seq,
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
254
277
|
lines.push(
|
|
255
278
|
"",
|
|
256
279
|
caller.main ? t("rootViewsResources") : t("rootViews"),
|
|
@@ -264,13 +287,30 @@ async function renderThreads(runtime: PluginRuntime, caller: Caller, view: View)
|
|
|
264
287
|
requireSession(caller, "threads");
|
|
265
288
|
if (view.parts.length === 0) {
|
|
266
289
|
const request = pageRequest(view);
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
290
|
+
// One list of everything this node can read: a joined row is marked read-only and
|
|
291
|
+
// carries only what the local records prove, because its owner holds the rest. The
|
|
292
|
+
// merged order is the cursor's order, so it is sorted after the two sources join.
|
|
293
|
+
const threads: Array<Thread | JoinedThread> = [
|
|
294
|
+
...(await visibleThreads(runtime, caller)),
|
|
295
|
+
...(await joinedThreads(runtime, caller)),
|
|
296
|
+
].sort((left, right) => threadKey(left.thread_ref).localeCompare(threadKey(right.thread_ref)));
|
|
270
297
|
const index = await historyIndex(runtime, caller);
|
|
271
298
|
const page = localPage(threads, request, cursorScope("threads", caller, ""), thread => threadKey(thread.thread_ref));
|
|
272
299
|
const lines = [t("threadsTitle", { node: runtime.config.node.id }), ""];
|
|
273
300
|
for (const thread of page.items) {
|
|
301
|
+
if ("read_only" in thread) {
|
|
302
|
+
lines.push(t("threadRowJoined", {
|
|
303
|
+
title: thread.title ?? t("joinedThreadTitle", { id: thread.thread_ref.id, owner: thread.thread_ref.owner }),
|
|
304
|
+
key: threadKey(thread.thread_ref),
|
|
305
|
+
last: thread.last_seq,
|
|
306
|
+
count: thread.message_count,
|
|
307
|
+
}));
|
|
308
|
+
lines.push(t("threadJoinedLinks", {
|
|
309
|
+
thread: threadPath(thread.thread_ref),
|
|
310
|
+
owner: thread.thread_ref.owner,
|
|
311
|
+
}));
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
274
314
|
const rows = index.get(threadKey(thread.thread_ref)) ?? [];
|
|
275
315
|
const first = rows[0];
|
|
276
316
|
const latest = rows.at(-1);
|
|
@@ -327,34 +367,54 @@ function threadTarget(parts: string[]): ThreadTarget {
|
|
|
327
367
|
return { alias: parts.join("/") };
|
|
328
368
|
}
|
|
329
369
|
|
|
330
|
-
async function entryAt(runtime: PluginRuntime, thread: Thread, seq: string): Promise<Entry> {
|
|
331
|
-
const entry = (await
|
|
370
|
+
async function entryAt(runtime: PluginRuntime, thread: Thread | JoinedThread, seq: string): Promise<Entry> {
|
|
371
|
+
const entry = (await threadEntries(runtime, thread.thread_ref)).find(item => item.seq === seq);
|
|
332
372
|
if (entry === undefined) throw new Error(t("noMessageAt", { key: threadKey(thread.thread_ref), seq }));
|
|
333
373
|
return entry;
|
|
334
374
|
}
|
|
335
375
|
|
|
336
|
-
async function messageView(runtime: PluginRuntime, thread: Thread, seq: string): Promise<Rendered> {
|
|
376
|
+
async function messageView(runtime: PluginRuntime, thread: Thread | JoinedThread, seq: string): Promise<Rendered> {
|
|
337
377
|
return json(await entryAt(runtime, thread, seq));
|
|
338
378
|
}
|
|
339
379
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
380
|
+
/**
|
|
381
|
+
* One thread document. A hosted thread is rendered from this node's history projection; a
|
|
382
|
+
* joined one is read from its owner, and the page says so: the owner holds the state, the
|
|
383
|
+
* member table and the entries, and nothing here claims to change them.
|
|
384
|
+
*/
|
|
385
|
+
async function renderThread(runtime: PluginRuntime, thread: Thread | JoinedThread): Promise<string> {
|
|
386
|
+
const joined = "read_only" in thread;
|
|
387
|
+
const ref = thread.thread_ref;
|
|
388
|
+
const rows = joined ? [] : await threadMessages(runtime, ref);
|
|
389
|
+
const entries = joined ? await threadEntries(runtime, ref) : [];
|
|
390
|
+
let described: ThreadDescription | undefined;
|
|
391
|
+
try {
|
|
392
|
+
described = await runtime.node.client.describeThread(ref);
|
|
393
|
+
} catch {
|
|
394
|
+
// The owner's answer is what a joined page is mostly about, so its absence is stated
|
|
395
|
+
// below rather than papered over with local guesses.
|
|
396
|
+
described = undefined;
|
|
397
|
+
}
|
|
398
|
+
const title = thread.title ?? (rows.length === 0 ? ref.id : titleOf(rows[0]!));
|
|
399
|
+
const next = described?.thread.next_seq ?? ("next_seq" in thread ? thread.next_seq : thread.last_seq);
|
|
400
|
+
const count = joined ? entries.length : rows.length;
|
|
343
401
|
const lines = [
|
|
344
402
|
t("threadTitle", { title }),
|
|
345
403
|
"",
|
|
346
404
|
t("threadMeta", {
|
|
347
|
-
key: threadKey(
|
|
348
|
-
state: thread.state,
|
|
349
|
-
next
|
|
350
|
-
count
|
|
405
|
+
key: threadKey(ref),
|
|
406
|
+
state: described?.thread.state ?? thread.state ?? t("threadStateUnknown"),
|
|
407
|
+
next,
|
|
408
|
+
count,
|
|
351
409
|
}),
|
|
352
|
-
"",
|
|
353
|
-
t("members"),
|
|
354
|
-
"",
|
|
355
410
|
];
|
|
356
|
-
|
|
357
|
-
|
|
411
|
+
if (joined) lines.push("", t("threadReadOnly", { owner: ref.owner }));
|
|
412
|
+
lines.push("", t("members"), "");
|
|
413
|
+
if (described === undefined) {
|
|
414
|
+
lines.push(t("unavailable", { message: t("ownerUnreachable", { owner: ref.owner }) }));
|
|
415
|
+
} else if (described.members.length === 0) {
|
|
416
|
+
lines.push(t("noMembers"));
|
|
417
|
+
} else {
|
|
358
418
|
for (const member of described.members) {
|
|
359
419
|
lines.push(t("memberRow", {
|
|
360
420
|
peer: member.peer,
|
|
@@ -362,13 +422,11 @@ async function renderThread(runtime: PluginRuntime, thread: Thread): Promise<str
|
|
|
362
422
|
access: member.access.length === 0 ? t("noAccess") : member.access.join(", "),
|
|
363
423
|
}));
|
|
364
424
|
}
|
|
365
|
-
if (described.members.length === 0) lines.push(t("noMembers"));
|
|
366
|
-
} catch (error) {
|
|
367
|
-
lines.push(t("unavailable", { message: error instanceof Error ? error.message : String(error) }));
|
|
368
425
|
}
|
|
369
426
|
lines.push("", t("messages"), "");
|
|
370
|
-
for (const
|
|
371
|
-
|
|
427
|
+
if (joined) for (const entry of entries) lines.push(renderEntryRow(entry, runtime.config.node.id));
|
|
428
|
+
else for (const row of rows) lines.push(renderHistoryRow(row, runtime.config.node.id));
|
|
429
|
+
if (count === 0) lines.push(t("noMessages"));
|
|
372
430
|
return `${lines.join("\n")}\n`;
|
|
373
431
|
}
|
|
374
432
|
|
|
@@ -420,11 +478,23 @@ export async function neighborRows(runtime: PluginRuntime): Promise<NeighborRow[
|
|
|
420
478
|
base_url: record.base_url,
|
|
421
479
|
...(record.membership?.domain_id === undefined ? {} : { region: record.membership.domain_id }),
|
|
422
480
|
...(record.last_seen === undefined ? {} : { last_seen: record.last_seen }),
|
|
423
|
-
verified,
|
|
481
|
+
verified: verified || entry?.state === "trusted",
|
|
424
482
|
phase: entry === undefined ? "no_exchange" : trustPhase(entry),
|
|
425
483
|
fingerprint: entry?.fingerprint ?? null,
|
|
426
484
|
});
|
|
427
485
|
}
|
|
486
|
+
// A trust row outlives a discovery record: a peer paired earlier is still a neighbor
|
|
487
|
+
// before mDNS or a seed refresh brings it back, and the listing says so.
|
|
488
|
+
for (const entry of trust.values()) {
|
|
489
|
+
if (entry.node_id === runtime.config.node.id || byNode.has(entry.node_id)) continue;
|
|
490
|
+
byNode.set(entry.node_id, {
|
|
491
|
+
node_id: entry.node_id,
|
|
492
|
+
base_url: entry.base_url,
|
|
493
|
+
verified: entry.state === "trusted",
|
|
494
|
+
phase: trustPhase(entry),
|
|
495
|
+
fingerprint: entry.fingerprint,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
428
498
|
return [...byNode.values()].sort((left, right) => left.node_id.localeCompare(right.node_id));
|
|
429
499
|
}
|
|
430
500
|
|
|
@@ -532,6 +602,7 @@ function outboundRow(outbound: Outbound): { position: string; lines: string[] }
|
|
|
532
602
|
const at = outbound.created_at ?? null;
|
|
533
603
|
const status = [outbound.state, outbound.error?.code ?? ""].filter(part => part.length > 0).join(" · ");
|
|
534
604
|
const reply = related === undefined ? "" : t("suffixReply", { ref: `${related.sender}/${related.id}` });
|
|
605
|
+
const work = workText(message.meta?.work);
|
|
535
606
|
const position = `${at ?? ""}\u0000${refKey(ref)}`;
|
|
536
607
|
const lines = [
|
|
537
608
|
t("outboundRow", {
|
|
@@ -541,6 +612,7 @@ function outboundRow(outbound: Outbound): { position: string; lines: string[] }
|
|
|
541
612
|
status,
|
|
542
613
|
reply,
|
|
543
614
|
}),
|
|
615
|
+
...(work === undefined ? [] : [work]),
|
|
544
616
|
` ${firstLine(message.msg, SNIPPET_CHARS)}`,
|
|
545
617
|
t("outboundLinks", { message: messagePath(ref), conversation: conversationPath(ref) }),
|
|
546
618
|
];
|
|
@@ -732,7 +804,7 @@ async function inboxLeaves(runtime: PluginRuntime, caller: Caller): Promise<Arra
|
|
|
732
804
|
}
|
|
733
805
|
const leaves: Array<{ url: string; content: string }> = [];
|
|
734
806
|
for (const group of byThread.values()) {
|
|
735
|
-
const entries = await
|
|
807
|
+
const entries = await threadEntries(runtime, group.ref);
|
|
736
808
|
for (const entry of entries) {
|
|
737
809
|
if (!group.seqs.has(entry.seq)) continue;
|
|
738
810
|
leaves.push({
|
|
@@ -758,19 +830,38 @@ async function visibleThreads(runtime: PluginRuntime, caller: Caller): Promise<T
|
|
|
758
830
|
return threads.filter(thread => allowed.has(threadKey(thread.thread_ref)));
|
|
759
831
|
}
|
|
760
832
|
|
|
761
|
-
async function findThread(runtime: PluginRuntime, caller: Caller, alias: string): Promise<Thread> {
|
|
833
|
+
async function findThread(runtime: PluginRuntime, caller: Caller, alias: string): Promise<Thread | JoinedThread> {
|
|
762
834
|
const threads = await visibleThreads(runtime, caller);
|
|
763
|
-
const matches = threads.filter(thread =>
|
|
835
|
+
const matches: Array<Thread | JoinedThread> = threads.filter(thread =>
|
|
764
836
|
thread.thread_ref.id === alias ||
|
|
765
837
|
thread.title === alias ||
|
|
766
838
|
threadKey(thread.thread_ref) === alias,
|
|
767
839
|
);
|
|
840
|
+
// A thread this node was invited into is named the same way, but its record lives on the
|
|
841
|
+
// owner: the id and the `owner/id` key are what the local records can answer for.
|
|
842
|
+
for (const row of await joinedThreads(runtime, caller)) {
|
|
843
|
+
if (row.thread_ref.id === alias || row.title === alias || threadKey(row.thread_ref) === alias) matches.push(row);
|
|
844
|
+
}
|
|
768
845
|
if (matches.length === 1) return matches[0]!;
|
|
769
846
|
if (matches.length > 1) throw new Error(t("manyThreadsNamed", { alias }));
|
|
847
|
+
// RFC 0018 §3.2 keeps the handshake thread out of the conversation listings. An inbox row
|
|
848
|
+
// for a handshake entry still links straight to it, and a link that opens nothing is worse
|
|
849
|
+
// than a listing nobody needs: a reference that names it exactly resolves here.
|
|
850
|
+
const direct = caller.main ? await trustThreadNamed(runtime, alias) : undefined;
|
|
851
|
+
if (direct !== undefined) return direct;
|
|
770
852
|
if (!caller.main && caller.agent_id !== undefined) throw new Error(t("noThreadSlice", { alias }));
|
|
771
853
|
throw new Error(t("noThreadNamed", { alias }));
|
|
772
854
|
}
|
|
773
855
|
|
|
856
|
+
/** The handshake thread, when the alias names it exactly; the listings never offer it. */
|
|
857
|
+
async function trustThreadNamed(runtime: PluginRuntime, alias: string): Promise<Thread | undefined> {
|
|
858
|
+
const ref = trustThread(runtime.config.node.id);
|
|
859
|
+
const names = new Set([ref.id, threadKey(ref), `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`]);
|
|
860
|
+
if (!names.has(alias)) return undefined;
|
|
861
|
+
const described = await runtime.node.threads.describe(runtime.node.auth, ref).catch(() => undefined);
|
|
862
|
+
return described?.thread;
|
|
863
|
+
}
|
|
864
|
+
|
|
774
865
|
/** Contexts this caller may see: every node-local record for the main session, its own records otherwise. */
|
|
775
866
|
async function scopedRecords(runtime: PluginRuntime, caller: Caller): Promise<ContextRecord[]> {
|
|
776
867
|
const records = (await runtime.contexts.list()).filter(record => record.node_id === runtime.config.node.id);
|
|
@@ -811,12 +902,32 @@ function conversationPermitted(conversation: Conversation, allowed: Set<string>,
|
|
|
811
902
|
}
|
|
812
903
|
|
|
813
904
|
async function localThreads(runtime: PluginRuntime): Promise<Thread[]> {
|
|
814
|
-
|
|
905
|
+
// RFC 0018 §3.2: the handshake thread is this host's own record, not a conversation.
|
|
906
|
+
const threads = await collect(cursor =>
|
|
815
907
|
runtime.node.threads.list(runtime.node.auth, {
|
|
816
908
|
page_size: PAGE,
|
|
817
909
|
...(cursor === undefined ? {} : { cursor }),
|
|
818
910
|
}),
|
|
819
911
|
);
|
|
912
|
+
return threads.filter(thread => !isTrustThread(runtime.config.node.id, thread.thread_ref));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Threads this node was invited into. They live on their owner, so the rows carry only what
|
|
917
|
+
* the local records prove; the owner is asked for the title and the member table where the
|
|
918
|
+
* detail page needs them. A subagent sees the joined threads its own records name.
|
|
919
|
+
*/
|
|
920
|
+
async function joinedThreads(runtime: PluginRuntime, caller: Caller): Promise<JoinedThread[]> {
|
|
921
|
+
const rows = await collect(cursor =>
|
|
922
|
+
runtime.node.threads.joined(runtime.node.auth, {
|
|
923
|
+
page_size: PAGE,
|
|
924
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
925
|
+
}),
|
|
926
|
+
);
|
|
927
|
+
const visible = rows.filter(row => !isTrustThread(runtime.config.node.id, row.thread_ref));
|
|
928
|
+
if (caller.main) return visible;
|
|
929
|
+
const allowed = allowedThreads(await scopedRecords(runtime, caller));
|
|
930
|
+
return visible.filter(row => allowed.has(threadKey(row.thread_ref)));
|
|
820
931
|
}
|
|
821
932
|
|
|
822
933
|
async function allMessages(runtime: PluginRuntime, ref: Thread["thread_ref"]): Promise<Entry[]> {
|
|
@@ -829,6 +940,22 @@ async function allMessages(runtime: PluginRuntime, ref: Thread["thread_ref"]): P
|
|
|
829
940
|
);
|
|
830
941
|
}
|
|
831
942
|
|
|
943
|
+
/**
|
|
944
|
+
* A thread's entries: this node's own storage for a thread it hosts, the owner's message
|
|
945
|
+
* route otherwise. A member holds no copy of a joined thread's entries, so the owner is the
|
|
946
|
+
* only source that can answer for them.
|
|
947
|
+
*/
|
|
948
|
+
async function threadEntries(runtime: PluginRuntime, ref: ThreadRef): Promise<Entry[]> {
|
|
949
|
+
if (ref.owner === runtime.config.node.id) return allMessages(runtime, ref);
|
|
950
|
+
return collect(cursor =>
|
|
951
|
+
runtime.node.client.threadEntries(ref, {
|
|
952
|
+
attention: "thread",
|
|
953
|
+
page_size: PAGE,
|
|
954
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
955
|
+
}),
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
832
959
|
/** The node's own outbound records; a subagent sees only replies tied to a message it received. */
|
|
833
960
|
async function outboundRows(runtime: PluginRuntime, caller: Caller): Promise<Outbound[]> {
|
|
834
961
|
const rows = await runtime.storage.transaction(tx => tx.scan<Outbound>("outbound/"));
|
|
@@ -1316,6 +1443,39 @@ function entryPath(ref: ThreadRef, seq: string): string {
|
|
|
1316
1443
|
return `onlyne://threads/${encodeURIComponent(ref.id)}/messages/${encodeURIComponent(seq)}`;
|
|
1317
1444
|
}
|
|
1318
1445
|
|
|
1446
|
+
/** The entry route of a thread named by its full `owner/id` key, which a joined thread needs. */
|
|
1447
|
+
function ownedEntryPath(ref: ThreadRef, seq: string): string {
|
|
1448
|
+
return `onlyne://threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/messages/${encodeURIComponent(seq)}`;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* One entry as its owner holds it. A joined thread's page is the owner's record, so it reports
|
|
1453
|
+
* the entry itself and never claims this node's own delivery or consumption state for it.
|
|
1454
|
+
*/
|
|
1455
|
+
function renderEntryRow(entry: Entry, nodeId: string): string {
|
|
1456
|
+
const ref: MessageRef = { sender: entry.message.sender, id: entry.message.id };
|
|
1457
|
+
const related = entry.message.meta?.in_reply_to;
|
|
1458
|
+
const lines = [
|
|
1459
|
+
t("historyRow", {
|
|
1460
|
+
prefix: t("historySeqPrefix", { seq: entry.seq }),
|
|
1461
|
+
direction: entry.message.sender === nodeId ? "out" : "in",
|
|
1462
|
+
sender: ref.sender,
|
|
1463
|
+
at: entry.at,
|
|
1464
|
+
status: t("ownerHeld"),
|
|
1465
|
+
}),
|
|
1466
|
+
];
|
|
1467
|
+
if (related !== undefined) lines.push(t("replyTo", { ref: refKey(related), url: messagePath(related) }));
|
|
1468
|
+
const work = workText(entry.message.meta?.work);
|
|
1469
|
+
if (work !== undefined) lines.push(work);
|
|
1470
|
+
lines.push(t("historyLinks", {
|
|
1471
|
+
message: messagePath(ref),
|
|
1472
|
+
conversation: conversationPath(ref),
|
|
1473
|
+
entry: ownedEntryPath(entry.thread_ref, entry.seq),
|
|
1474
|
+
}));
|
|
1475
|
+
for (const line of entry.message.msg.split(/\r?\n/)) lines.push(` > ${line}`);
|
|
1476
|
+
return lines.join("\n");
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1319
1479
|
function renderHistoryRow(row: HistoryMessage, nodeId: string): string {
|
|
1320
1480
|
const ref = row.message_ref;
|
|
1321
1481
|
const thread = row.thread_ref ?? row.message.meta?.thread;
|
|
@@ -1338,6 +1498,8 @@ function renderHistoryRow(row: HistoryMessage, nodeId: string): string {
|
|
|
1338
1498
|
if (related !== undefined) {
|
|
1339
1499
|
lines.push(t("replyTo", { ref: `${related.sender}/${related.id}`, url: messagePath(related) }));
|
|
1340
1500
|
}
|
|
1501
|
+
const work = workText(row.message.meta?.work);
|
|
1502
|
+
if (work !== undefined) lines.push(work);
|
|
1341
1503
|
const entry = thread === undefined || row.seq === undefined || thread.owner !== nodeId
|
|
1342
1504
|
? ""
|
|
1343
1505
|
: t("suffixEntry", { url: entryPath(thread, row.seq) });
|
|
@@ -1345,3 +1507,14 @@ function renderHistoryRow(row: HistoryMessage, nodeId: string): string {
|
|
|
1345
1507
|
for (const line of row.message.msg.split(/\r?\n/)) lines.push(` > ${line}`);
|
|
1346
1508
|
return lines.join("\n");
|
|
1347
1509
|
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* The work unit a message belongs to (RFC 0003 §1), as the reader needs it: the unit a
|
|
1513
|
+
* receiver dedupes on, the enclosing unit, and which attempt this is.
|
|
1514
|
+
*/
|
|
1515
|
+
function workText(work: WorkRef | undefined): string | undefined {
|
|
1516
|
+
if (work === undefined) return undefined;
|
|
1517
|
+
const parent = work.parent === undefined || work.parent === null ? "" : t("workParent", { unit: work.parent });
|
|
1518
|
+
const attempt = work.attempt === undefined ? "" : t("workAttempt", { attempt: String(work.attempt) });
|
|
1519
|
+
return t("workLine", { unit: work.unit, parent, attempt });
|
|
1520
|
+
}
|
package/src/work.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { WorkRef } from "@onlyne/lib";
|
|
2
|
+
|
|
3
|
+
/** The work unit as one readable value: the dedupe key, its parent and the attempt number. */
|
|
4
|
+
export function workValue(work: WorkRef): string {
|
|
5
|
+
return [
|
|
6
|
+
work.unit,
|
|
7
|
+
work.parent === undefined || work.parent === null ? "" : `parent ${work.parent}`,
|
|
8
|
+
work.attempt === undefined ? "" : `attempt ${work.attempt}`,
|
|
9
|
+
].filter(part => part.length > 0).join(" · ");
|
|
10
|
+
}
|
package/src/host-text.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { createTranslator } from "@onlyne/node-host/i18n";
|
|
2
|
-
const en = {
|
|
3
|
-
regionEmpty: "region must not be empty.",
|
|
4
|
-
nodeIdEmpty: "node.id must not be empty.",
|
|
5
|
-
addressEmpty: "A node address must not be empty.",
|
|
6
|
-
addressUrl: "A node address must be an absolute URL, got {value}.",
|
|
7
|
-
addressScheme: "A node address must use http or https, got {value}.",
|
|
8
|
-
addressShape: "A node address is an origin (http://host:7801) or a service URL (http://host:7801{service}), got {value}.",
|
|
9
|
-
configMissing: "No Onlyne configuration at {path}. Run /onlyne init to create it.",
|
|
10
|
-
configParse: "{path}: {error}",
|
|
11
|
-
languageInvalid: "language must be en or zh-CN.",
|
|
12
|
-
keyScopeInvalid: "node.key_scope must be project, global or none, got {value}.",
|
|
13
|
-
publicUrlEmpty: "server.public_url must not be empty.",
|
|
14
|
-
bindEmpty: "server.bind must not be empty.",
|
|
15
|
-
bindShape: "server.bind must be a hostname or IP address, got {value}.",
|
|
16
|
-
pageSizeRange: "threads.page_size must be an integer from 1 to 100, got {value}.",
|
|
17
|
-
retiredEndpoint: "node.endpoint is not supported; the endpoint comes from server.bind and the assigned port.",
|
|
18
|
-
retiredPort: "server.port is not supported; the operating system assigns the listening port.",
|
|
19
|
-
retiredPeers: "peers is not supported; peers are discovered over the network and kept in the project SQLite.",
|
|
20
|
-
retiredOmp: "omp is not supported; delivery and spawning are decided by the OMP judge.",
|
|
21
|
-
retiredAttention: "threads.default_attention is not supported; reads default to the attention list.",
|
|
22
|
-
tableExpected: "{path}: {key} must be a table.",
|
|
23
|
-
stringExpected: "{field} must be a string.",
|
|
24
|
-
integerExpected: "{field} must be an integer.",
|
|
25
|
-
stringArrayExpected: "{field} must be an array of non-empty strings.",
|
|
26
|
-
trustArray: "{path}: trust must be an array of tables.",
|
|
27
|
-
trustEntry: "{path}: a trust entry needs node_id and a 16-character fingerprint.",
|
|
28
|
-
addressUnavailable: "No concrete network address is available for this bind host.",
|
|
29
|
-
keyProvider: "External credential references need a host key provider.",
|
|
30
|
-
loopbackBind: "The unauthenticated development binding requires a loopback bind host. Create a node key to serve a remote address.",
|
|
31
|
-
managementIdentity: "HTTP callers cannot claim this node's management identity.",
|
|
32
|
-
mentionNotFound: "No unique discovered node is named {name}.",
|
|
33
|
-
guideContacts: "Contacts",
|
|
34
|
-
guidePending: "Pending",
|
|
35
|
-
guideAccept: "Accept",
|
|
36
|
-
guideDiscover: "Discover",
|
|
37
|
-
guideRequestExchange: "Request key exchange",
|
|
38
|
-
resourcePageSize: "page_size must be from 1 to 100.",
|
|
39
|
-
resourceOwner: "Resource owner must be a non-empty node id.",
|
|
40
|
-
resourceRef: "Resource reads need meta.resource {owner,id}.",
|
|
41
|
-
resourceSince: "since must be a decimal event id.",
|
|
42
|
-
resourceOp: "Resource reads use list, describe, log, event with event_id, operations, or status with operation_id.",
|
|
43
|
-
};
|
|
44
|
-
const zh: Record<keyof typeof en, string> = {
|
|
45
|
-
regionEmpty: "region 不能为空。",
|
|
46
|
-
nodeIdEmpty: "node.id 不能为空。",
|
|
47
|
-
addressEmpty: "节点地址不能为空。",
|
|
48
|
-
addressUrl: "节点地址必须是绝对 URL,收到的是 {value}。",
|
|
49
|
-
addressScheme: "节点地址必须使用 http 或 https,收到的是 {value}。",
|
|
50
|
-
addressShape: "节点地址应写成源站(http://host:7801)或服务 URL(http://host:7801{service}),收到的是 {value}。",
|
|
51
|
-
configMissing: "在 {path} 没有 Onlyne 配置。运行 /onlyne init 创建。",
|
|
52
|
-
configParse: "{path}: {error}",
|
|
53
|
-
languageInvalid: "language 必须是 en 或 zh-CN。",
|
|
54
|
-
keyScopeInvalid: "node.key_scope 必须是 project、global 或 none,收到的是 {value}。",
|
|
55
|
-
publicUrlEmpty: "server.public_url 不能为空。",
|
|
56
|
-
bindEmpty: "server.bind 不能为空。",
|
|
57
|
-
bindShape: "server.bind 必须是主机名或 IP 地址,收到的是 {value}。",
|
|
58
|
-
pageSizeRange: "threads.page_size 必须是 1 到 100 之间的整数,收到的是 {value}。",
|
|
59
|
-
retiredEndpoint: "不支持 node.endpoint;端点由 server.bind 和系统分配的端口决定。",
|
|
60
|
-
retiredPort: "不支持 server.port;监听端口由操作系统分配。",
|
|
61
|
-
retiredPeers: "不支持 peers;对端通过网络发现,并保存在项目 SQLite 中。",
|
|
62
|
-
retiredOmp: "不支持 omp;投送与派生由 OMP judge 决定。",
|
|
63
|
-
retiredAttention: "不支持 threads.default_attention;读取默认使用关注列表。",
|
|
64
|
-
tableExpected: "{path}: {key} 必须是表。",
|
|
65
|
-
stringExpected: "{field} 必须是字符串。",
|
|
66
|
-
integerExpected: "{field} 必须是整数。",
|
|
67
|
-
stringArrayExpected: "{field} 必须是非空字符串数组。",
|
|
68
|
-
trustArray: "{path}: trust 必须是表数组。",
|
|
69
|
-
trustEntry: "{path}: 每条 trust 记录需要 node_id 和 16 个字符的指纹。",
|
|
70
|
-
addressUnavailable: "这个绑定主机没有可用的具体网络地址。",
|
|
71
|
-
keyProvider: "外部凭据引用需要主机密钥提供者。",
|
|
72
|
-
loopbackBind: "未认证的开发绑定需要回环绑定主机。要对外提供服务,请创建节点密钥。",
|
|
73
|
-
managementIdentity: "HTTP 调用方不能冒用本节点的管理身份。",
|
|
74
|
-
mentionNotFound: "没有唯一发现到的节点名为 {name}。",
|
|
75
|
-
guideContacts: "联系人",
|
|
76
|
-
guidePending: "待确认",
|
|
77
|
-
guideAccept: "同意",
|
|
78
|
-
guideDiscover: "发现",
|
|
79
|
-
guideRequestExchange: "请求交换密钥",
|
|
80
|
-
resourcePageSize: "page_size 必须是 1 到 100。",
|
|
81
|
-
resourceOwner: "资源 owner 必须是非空节点 id。",
|
|
82
|
-
resourceRef: "读取资源需要 meta.resource {owner,id}。",
|
|
83
|
-
resourceSince: "since 必须是十进制事件 id。",
|
|
84
|
-
resourceOp: "读取资源可用 list、describe、log、带 event_id 的 event、operations,或带 operation_id 的 status。",
|
|
85
|
-
};
|
|
86
|
-
export const hostText = createTranslator({ en, "zh-CN": zh });
|