omp-onlyne 0.1.0 → 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/README.md +13 -5
- package/package.json +3 -3
- package/src/acquire.ts +182 -0
- package/src/bridge-text.ts +36 -0
- package/src/config.ts +35 -3
- package/src/decider-text.ts +33 -15
- package/src/decider.ts +306 -55
- package/src/handshake.ts +291 -0
- package/src/host-text.ts +10 -4
- package/src/messages.ts +110 -10
- package/src/node.ts +15 -3
- package/src/runtime.ts +268 -8
- package/src/tool-text.ts +64 -30
- package/src/tool.ts +125 -19
- package/src/types.ts +14 -0
- package/src/ui/bootstrap.ts +6 -1
- package/src/ui/viewer-text.ts +4 -0
- package/src/ui/viewer.ts +21 -4
- package/src/urls-text.ts +18 -0
- package/src/urls.ts +170 -29
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,6 +16,7 @@ import {
|
|
|
15
16
|
type ResourceInventory,
|
|
16
17
|
type ResourceRef,
|
|
17
18
|
type Thread,
|
|
19
|
+
type ThreadDescription,
|
|
18
20
|
type ThreadRef,
|
|
19
21
|
type TrustPhase,
|
|
20
22
|
} from "@onlyne/lib";
|
|
@@ -29,6 +31,7 @@ import {
|
|
|
29
31
|
} from "@oh-my-pi/pi-coding-agent/internal-urls";
|
|
30
32
|
import { resourceQuery } from "./resources.ts";
|
|
31
33
|
import { getRuntime, type PluginRuntime } from "./runtime.ts";
|
|
34
|
+
import { isTrustThread, trustThread } from "./handshake.ts";
|
|
32
35
|
import type { ContextRecord } from "./types.ts";
|
|
33
36
|
import { urlsText } from "./urls-text.ts";
|
|
34
37
|
|
|
@@ -79,10 +82,16 @@ const handler: ProtocolHandler = {
|
|
|
79
82
|
for (const thread of await visibleThreads(runtime, caller)) {
|
|
80
83
|
urls.push(threadPath(thread.thread_ref));
|
|
81
84
|
}
|
|
85
|
+
for (const row of await joinedThreads(runtime, caller)) {
|
|
86
|
+
urls.push(threadPath(row.thread_ref));
|
|
87
|
+
}
|
|
82
88
|
return leafDocuments(runtime, caller, urls);
|
|
83
89
|
}
|
|
84
90
|
if (view.host === "threads" && view.parts.length === 0) {
|
|
85
|
-
const urls =
|
|
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
|
+
];
|
|
86
95
|
return leafDocuments(runtime, caller, urls);
|
|
87
96
|
}
|
|
88
97
|
if (view.host === "threads") {
|
|
@@ -92,7 +101,7 @@ const handler: ProtocolHandler = {
|
|
|
92
101
|
const entry = await entryAt(runtime, thread, target.seq);
|
|
93
102
|
return [{ url: entryPath(thread.thread_ref, target.seq), content: `${JSON.stringify(entry, null, 2)}\n` }];
|
|
94
103
|
}
|
|
95
|
-
const entries = await
|
|
104
|
+
const entries = await threadEntries(runtime, thread.thread_ref);
|
|
96
105
|
return entries.map(entry => ({
|
|
97
106
|
url: entryPath(thread.thread_ref, entry.seq),
|
|
98
107
|
content: JSON.stringify(entry, null, 2),
|
|
@@ -233,6 +242,7 @@ async function renderRoot(runtime: PluginRuntime, caller: Caller): Promise<strin
|
|
|
233
242
|
}
|
|
234
243
|
const records = await scopedRecords(runtime, caller);
|
|
235
244
|
const threads = await visibleThreads(runtime, caller);
|
|
245
|
+
const joined = await joinedThreads(runtime, caller);
|
|
236
246
|
lines.push("", caller.main
|
|
237
247
|
? t("rootContexts", { count: records.length })
|
|
238
248
|
: t("rootContextsHeld", { agent: caller.agent_id ?? "", count: records.length }));
|
|
@@ -251,6 +261,16 @@ async function renderRoot(runtime: PluginRuntime, caller: Caller): Promise<strin
|
|
|
251
261
|
}));
|
|
252
262
|
}
|
|
253
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
|
+
}
|
|
254
274
|
lines.push(
|
|
255
275
|
"",
|
|
256
276
|
caller.main ? t("rootViewsResources") : t("rootViews"),
|
|
@@ -264,13 +284,30 @@ async function renderThreads(runtime: PluginRuntime, caller: Caller, view: View)
|
|
|
264
284
|
requireSession(caller, "threads");
|
|
265
285
|
if (view.parts.length === 0) {
|
|
266
286
|
const request = pageRequest(view);
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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)));
|
|
270
294
|
const index = await historyIndex(runtime, caller);
|
|
271
295
|
const page = localPage(threads, request, cursorScope("threads", caller, ""), thread => threadKey(thread.thread_ref));
|
|
272
296
|
const lines = [t("threadsTitle", { node: runtime.config.node.id }), ""];
|
|
273
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
|
+
}
|
|
274
311
|
const rows = index.get(threadKey(thread.thread_ref)) ?? [];
|
|
275
312
|
const first = rows[0];
|
|
276
313
|
const latest = rows.at(-1);
|
|
@@ -327,34 +364,54 @@ function threadTarget(parts: string[]): ThreadTarget {
|
|
|
327
364
|
return { alias: parts.join("/") };
|
|
328
365
|
}
|
|
329
366
|
|
|
330
|
-
async function entryAt(runtime: PluginRuntime, thread: Thread, seq: string): Promise<Entry> {
|
|
331
|
-
const entry = (await
|
|
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);
|
|
332
369
|
if (entry === undefined) throw new Error(t("noMessageAt", { key: threadKey(thread.thread_ref), seq }));
|
|
333
370
|
return entry;
|
|
334
371
|
}
|
|
335
372
|
|
|
336
|
-
async function messageView(runtime: PluginRuntime, thread: Thread, seq: string): Promise<Rendered> {
|
|
373
|
+
async function messageView(runtime: PluginRuntime, thread: Thread | JoinedThread, seq: string): Promise<Rendered> {
|
|
337
374
|
return json(await entryAt(runtime, thread, seq));
|
|
338
375
|
}
|
|
339
376
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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;
|
|
343
398
|
const lines = [
|
|
344
399
|
t("threadTitle", { title }),
|
|
345
400
|
"",
|
|
346
401
|
t("threadMeta", {
|
|
347
|
-
key: threadKey(
|
|
348
|
-
state: thread.state,
|
|
349
|
-
next
|
|
350
|
-
count
|
|
402
|
+
key: threadKey(ref),
|
|
403
|
+
state: described?.thread.state ?? thread.state ?? t("threadStateUnknown"),
|
|
404
|
+
next,
|
|
405
|
+
count,
|
|
351
406
|
}),
|
|
352
|
-
"",
|
|
353
|
-
t("members"),
|
|
354
|
-
"",
|
|
355
407
|
];
|
|
356
|
-
|
|
357
|
-
|
|
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 {
|
|
358
415
|
for (const member of described.members) {
|
|
359
416
|
lines.push(t("memberRow", {
|
|
360
417
|
peer: member.peer,
|
|
@@ -362,13 +419,11 @@ async function renderThread(runtime: PluginRuntime, thread: Thread): Promise<str
|
|
|
362
419
|
access: member.access.length === 0 ? t("noAccess") : member.access.join(", "),
|
|
363
420
|
}));
|
|
364
421
|
}
|
|
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
422
|
}
|
|
369
423
|
lines.push("", t("messages"), "");
|
|
370
|
-
for (const
|
|
371
|
-
|
|
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"));
|
|
372
427
|
return `${lines.join("\n")}\n`;
|
|
373
428
|
}
|
|
374
429
|
|
|
@@ -732,7 +787,7 @@ async function inboxLeaves(runtime: PluginRuntime, caller: Caller): Promise<Arra
|
|
|
732
787
|
}
|
|
733
788
|
const leaves: Array<{ url: string; content: string }> = [];
|
|
734
789
|
for (const group of byThread.values()) {
|
|
735
|
-
const entries = await
|
|
790
|
+
const entries = await threadEntries(runtime, group.ref);
|
|
736
791
|
for (const entry of entries) {
|
|
737
792
|
if (!group.seqs.has(entry.seq)) continue;
|
|
738
793
|
leaves.push({
|
|
@@ -758,19 +813,38 @@ async function visibleThreads(runtime: PluginRuntime, caller: Caller): Promise<T
|
|
|
758
813
|
return threads.filter(thread => allowed.has(threadKey(thread.thread_ref)));
|
|
759
814
|
}
|
|
760
815
|
|
|
761
|
-
async function findThread(runtime: PluginRuntime, caller: Caller, alias: string): Promise<Thread> {
|
|
816
|
+
async function findThread(runtime: PluginRuntime, caller: Caller, alias: string): Promise<Thread | JoinedThread> {
|
|
762
817
|
const threads = await visibleThreads(runtime, caller);
|
|
763
|
-
const matches = threads.filter(thread =>
|
|
818
|
+
const matches: Array<Thread | JoinedThread> = threads.filter(thread =>
|
|
764
819
|
thread.thread_ref.id === alias ||
|
|
765
820
|
thread.title === alias ||
|
|
766
821
|
threadKey(thread.thread_ref) === alias,
|
|
767
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
|
+
}
|
|
768
828
|
if (matches.length === 1) return matches[0]!;
|
|
769
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;
|
|
770
835
|
if (!caller.main && caller.agent_id !== undefined) throw new Error(t("noThreadSlice", { alias }));
|
|
771
836
|
throw new Error(t("noThreadNamed", { alias }));
|
|
772
837
|
}
|
|
773
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
|
+
|
|
774
848
|
/** Contexts this caller may see: every node-local record for the main session, its own records otherwise. */
|
|
775
849
|
async function scopedRecords(runtime: PluginRuntime, caller: Caller): Promise<ContextRecord[]> {
|
|
776
850
|
const records = (await runtime.contexts.list()).filter(record => record.node_id === runtime.config.node.id);
|
|
@@ -811,12 +885,32 @@ function conversationPermitted(conversation: Conversation, allowed: Set<string>,
|
|
|
811
885
|
}
|
|
812
886
|
|
|
813
887
|
async function localThreads(runtime: PluginRuntime): Promise<Thread[]> {
|
|
814
|
-
|
|
888
|
+
// RFC 0018 §3.2: the handshake thread is this host's own record, not a conversation.
|
|
889
|
+
const threads = await collect(cursor =>
|
|
815
890
|
runtime.node.threads.list(runtime.node.auth, {
|
|
816
891
|
page_size: PAGE,
|
|
817
892
|
...(cursor === undefined ? {} : { cursor }),
|
|
818
893
|
}),
|
|
819
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)));
|
|
820
914
|
}
|
|
821
915
|
|
|
822
916
|
async function allMessages(runtime: PluginRuntime, ref: Thread["thread_ref"]): Promise<Entry[]> {
|
|
@@ -829,6 +923,22 @@ async function allMessages(runtime: PluginRuntime, ref: Thread["thread_ref"]): P
|
|
|
829
923
|
);
|
|
830
924
|
}
|
|
831
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
|
+
|
|
832
942
|
/** The node's own outbound records; a subagent sees only replies tied to a message it received. */
|
|
833
943
|
async function outboundRows(runtime: PluginRuntime, caller: Caller): Promise<Outbound[]> {
|
|
834
944
|
const rows = await runtime.storage.transaction(tx => tx.scan<Outbound>("outbound/"));
|
|
@@ -1316,6 +1426,37 @@ function entryPath(ref: ThreadRef, seq: string): string {
|
|
|
1316
1426
|
return `onlyne://threads/${encodeURIComponent(ref.id)}/messages/${encodeURIComponent(seq)}`;
|
|
1317
1427
|
}
|
|
1318
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
|
+
|
|
1319
1460
|
function renderHistoryRow(row: HistoryMessage, nodeId: string): string {
|
|
1320
1461
|
const ref = row.message_ref;
|
|
1321
1462
|
const thread = row.thread_ref ?? row.message.meta?.thread;
|