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/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 type { ContextRecord } from "./types.ts";
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
- spec: { backing: "virtual", selectors: "lines", immutable: true },
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, immutable: true };
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 = (await visibleThreads(runtime, caller)).map(thread => threadPath(thread.thread_ref));
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 allMessages(runtime, thread.thread_ref);
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
- const threads = (await visibleThreads(runtime, caller))
268
- .slice()
269
- .sort((left, right) => threadKey(left.thread_ref).localeCompare(threadKey(right.thread_ref)));
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 allMessages(runtime, thread.thread_ref)).find(item => item.seq === seq);
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
- async function renderThread(runtime: PluginRuntime, thread: Thread): Promise<string> {
341
- const rows = await threadMessages(runtime, thread.thread_ref);
342
- const title = rows.length === 0 ? thread.title : titleOf(rows[0]!);
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(thread.thread_ref),
348
- state: thread.state,
349
- next: thread.next_seq,
350
- count: rows.length,
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
- try {
357
- const described = await runtime.node.threads.describe(runtime.node.auth, thread.thread_ref);
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 row of rows) lines.push(renderHistoryRow(row, runtime.config.node.id));
371
- if (rows.length === 0) lines.push(t("noMessages"));
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 allMessages(runtime, group.ref);
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
- return collect(cursor =>
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 });