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/tool-text.ts CHANGED
@@ -7,7 +7,7 @@ const en = {
7
7
  "param.meta": "Explicit references and operation fields",
8
8
  description: `The only surface for Onlyne messages, threads and resources. There is no CLI and nothing else to look up: call this tool with your intent instead of reading docs or source first.
9
9
 
10
- Shape: { delivery_kind, msg, meta }. delivery_kind names the intent in plain language ("reply to dpdp's greeting", "show this thread", "read that message"). meta carries references you already know: to, thread, context, in_reply_to {thread,seq} or {sender,id}, relation (reply|followup), mentions, message {sender,id} or {thread,seq}, resource {owner,id}, operation, expected_revision, op, title, scope, seq, attention, cursor, page_size, data, domain_id, owner, since, event_id, operation_id.
10
+ Shape: { delivery_kind, msg, meta }. delivery_kind names the intent in plain language ("reply to dpdp's greeting", "show this thread", "read that message"). meta carries references you already know: to, thread, context, in_reply_to {thread,seq} (preferred) or {sender,id}, relation (reply|followup), mentions, message {sender,id} or {thread,seq}, resource {owner,id}, operation, expected_revision, op, title, scope, seq, attention, cursor, page_size, data, domain_id, owner, since, event_id, operation_id.
11
11
 
12
12
  A send is one call. The result is the delivery fact: admission "accepted" means the receiver recorded the message; wait "timed_out" only means no reply arrived before the wait bound, the message is still delivered; reply_body "withheld" means this context already showed that body. Report the outcome and stop there.
13
13
 
@@ -32,27 +32,36 @@ meta.to takes one node or a list of nodes. A list is delivered one target at a t
32
32
 
33
33
  Use cases, each a complete call:
34
34
 
35
- - Reply to a message you received. Do not name a thread; the receiver lands the reply where the original lives:
36
- { delivery_kind: "answer dpdp's question", msg: "Yes, the second set is fine.", meta: { in_reply_to: { sender: "urn:onlyne:agent:dpdp", id: "<message id>" }, relation: "reply" } }
35
+ - Reply to a message you received. Reference it as {thread, seq}, copied from the delivery card — never an id from memory; the receiver lands the reply where the original lives:
36
+ { delivery_kind: "answer dpdp's question", msg: "Yes, the second set is fine.", meta: { in_reply_to: { thread: "<thread id>", seq: "<seq>" }, relation: "reply" } }
37
37
  - Send a fresh message to one peer: { delivery_kind: "greet lazuli", msg: "Hello from the workbench.", meta: { to: "lazuli" } }
38
38
  - Send the same note to two peers: { delivery_kind: "notify both", msg: "Report is up.", meta: { to: ["caerulea", "lazuli"] } } — you get one result per target, and a failure on one does not hide the other.
39
39
  - Read the whole exchange with one peer: read \`onlyne://conversations/<sender>/<id>\` from a list you got at \`onlyne://conversations\`. Read one message with \`onlyne://messages/<sender>/<id>\`.
40
40
  - Check what happened to a message you sent: read \`onlyne://outbound/<message-id>\`; it shows the landing the receiver reported, whether it is accepted, and the reply if one arrived.
41
41
  - Look at what a resource offers before operating it: read \`onlyne://resources\` or \`onlyne://resources/<owner>/<id>\`, then send the operation as { delivery_kind: "list the operations of this resource", msg: "show me what it can do", meta: { resource: { owner: "<node>", id: "<resource>" }, operation: "<name>" } }. meta.data carries the arguments; meta.expected_revision requires the revision you read.
42
- - In a subagent, answer without sending: { delivery_kind: "draft the reply", msg: "Here is the answer.", meta: { op: "draft", to: "dpdp", relation: "reply", in_reply_to: { sender: "urn:onlyne:agent:dpdp", id: "<message id>" } } } returns { draft_id }. The main session sends exactly that draft: { delivery_kind: "send the drafted reply", msg: "send draft", meta: { draft_id: "<draft id>" } }.
43
- - When a send fails with no trusted key, do not retry and do not try to pair: the error names both nodes, the responsible holder and the entry in the host surface. Report it and stop.
44
-
45
- The judge role chooses the action and plan from your intent, so never invent message ids, idempotency keys or revisions. A reply or followup states its relation; omitting it is rejected rather than assumed. A reply targets the original reply authority, so a conflicting to is rejected. A subagent session reads its own slice (onlyne://threads, onlyne://inbox, onlyne://contexts) and returns the result to the main session, which does the sending.`,
42
+ - In a subagent, answer without sending: { delivery_kind: "draft the reply", msg: "Here is the answer.", meta: { op: "draft", to: "dpdp", relation: "reply", in_reply_to: { thread: "<thread id>", seq: "<seq>" } } } returns { draft_id }. The main session sends exactly that draft: { delivery_kind: "send the drafted reply", msg: "send draft", meta: { draft_id: "<draft id>" } }.
43
+ - Approve a peer whose invitation waits for you: { delivery_kind: "accept the trust handshake", msg: "accept the peer", meta: { op: "trust_accept", node_id: "<node>", fingerprint: "<fingerprint>" } }. The acquisition policy judges it first, and a refusal names the rule it matched and leaves the peer as it was.
44
+ - Drop an approval: { delivery_kind: "revoke the trust handshake", msg: "revoke the peer", meta: { op: "trust_revoke", node_id: "<node>" } }. Revocation is never judged by the policy and names no fingerprint.
45
+ - Start an exchange with a discovered peer you are not paired with yet: { delivery_kind: "start the handshake with lazuli", msg: "start the exchange", meta: { op: "trust_begin", node_id: "lazuli" } }. The acquisition policy judges it before anything is sent: a refusal names the rule it matched and leaves the peer as it was, and an allow commits the local approval, sends the statement and reports the phase that followed.
46
+ - Put a peer into a conversation, so the thread carries several members: { delivery_kind: "add dpdp to the plan thread", msg: "add dpdp", meta: { op: "member_add", thread: "<thread id or title>", to: "dpdp" } }; one call per member when meta.to is a list. Take one out with op "member_remove". Adding a member puts them in that thread, so a later meta.to array WITH meta.thread addresses one thread, while without meta.thread each target gets its own.
47
+ - When a send fails with no trusted key, do not retry: the error names both nodes, and a pending invitation is confirmed by the acquisition policy or by that node's holder, not by another send. Report it and stop.
48
+
49
+ The judge role chooses the action and plan from your intent, so never invent message ids, idempotency keys or revisions. Reference a message as {thread, seq}; when a call states neither a reference nor a relation and this context has unanswered inbound messages, the judge asks which one it answers. A reply targets the original reply authority, so a conflicting to is rejected. A subagent session reads its own slice (onlyne://threads, onlyne://inbox, onlyne://contexts) and returns the result to the main session, which does the sending.`,
46
50
  "summary.no_result": "Onlyne returned no structured result.",
47
- "summary.where": " to {owner}",
48
- "summary.thread_ref": " (thread {thread} seq {seq}{answering})",
51
+ "summary.addressee": "Sent to {to}",
52
+ "summary.addressee_self": "Sent to yourself",
53
+ "summary.stored_here": "thread {thread} is hosted on your node, seq {seq}{answering}",
54
+ "summary.stored_there": "thread {thread} is hosted on {owner}, seq {seq}{answering}",
49
55
  "summary.answering": ", answering {sender}/{id}",
50
- "summary.outbound": " · read back: onlyne://outbound/{id}",
51
- "summary.delivered": "Delivered{where}{facts}{trail} and recorded durably.",
52
- "summary.delivered_answered": "Delivered{where}{facts}{trail}. {author} answered at seq {seq}.",
53
- "summary.delivered_withheld": "Delivered{where}{facts}{trail}. {author} answered at seq {seq}, but that body was already shown in this context, so only its arrival is reported.",
54
- "summary.delivered_timed_out": "Delivered{where}{facts}{trail} and recorded durably. No reply arrived within the wait; it will arrive on its own as an inbound message.",
55
- "summary.delivered_cancelled": "Delivered{where}{facts}{trail} and recorded durably. The wait was cancelled; a reply will arrive on its own as an inbound message.",
56
+ "summary.sep": " · ",
57
+ "summary.resolved": "resolved: {node}",
58
+ "summary.outbound": "read back: onlyne://outbound/{id}",
59
+ "summary.grant_notice": "; the entry lives here because you host the thread, and {to} reads it as a member once the grant notice reaches it",
60
+ "summary.delivered": "{facts} and recorded durably{mechanism}.",
61
+ "summary.delivered_answered": "{facts} and recorded durably{mechanism}. {author} answered at seq {seq}.",
62
+ "summary.delivered_withheld": "{facts} and recorded durably{mechanism}. {author} answered at seq {seq}, but that body was already shown in this context, so only its arrival is reported.",
63
+ "summary.delivered_timed_out": "{facts} and recorded durably{mechanism}. No reply arrived within the wait; it will arrive on its own as an inbound message.",
64
+ "summary.delivered_cancelled": "{facts} and recorded durably{mechanism}. The wait was cancelled; a reply will arrive on its own as an inbound message.",
56
65
  "summary.not_confirmed": "Not confirmed: {failure}. {next}",
57
66
  "summary.retry": "The record stays open and the runtime retries it; sending again would commit a second intent.",
58
67
  "summary.recover": "The record stays open for recovery; do not re-send the same intent.",
@@ -63,6 +72,14 @@ The judge role chooses the action and plan from your intent, so never invent mes
63
72
  "draft.ready": "Draft {draft_id} is ready; the main session sends it with meta.draft_id.",
64
73
  "multi.delivered": "→ {to}: {summary}",
65
74
  "multi.failed": "→ {to}: not sent — {error}",
75
+ "trust.accepted": "Approved {node} ({fingerprint}); the trust phase is now {phase}.",
76
+ "trust.revoked": "Revoked {node}; local approval is dropped.",
77
+ "trust.begun": "Started the exchange with {node} ({fingerprint}); the trust phase is now {phase}. The local approval is committed and the statement is sent, so this peer can exchange messages once it approves in turn.",
78
+ "thread.member_added": "Added {member} to thread {thread}. That member now reads and posts in it, and a later meta.to array with meta.thread {thread} puts every target in this one thread; the same array without meta.thread gives each target its own thread.",
79
+ "thread.member_removed": "Removed {member} from thread {thread}. The thread stays open and its earlier entries are still its history; a later bare send to {member} opens its own two-party thread.",
80
+ "failure.trust_node_unknown": "{action} names no known node {node}. Name the node id, or a short alias that exactly one known peer carries.",
81
+ "failure.trust_fields": "A trust action needs a non-empty string {field}.",
82
+ "failure.trust_main_only": "Only the main session approves or revokes a peer; a subagent drafts instead.",
66
83
  } as const;
67
84
 
68
85
  const zh: Record<keyof typeof en, string> = {
@@ -72,7 +89,7 @@ const zh: Record<keyof typeof en, string> = {
72
89
  "param.meta": "显式引用与操作字段",
73
90
  description: `Onlyne 消息、thread 与资源的唯一入口。没有 CLI,也没有别处要查:把意图交给这个工具,不要先去读文档或源码。
74
91
 
75
- 形状:{ delivery_kind, msg, meta }。delivery_kind 用日常语言说出意图(“回复 dpdp 的问候”“看看这个 thread”“读那条消息”)。meta 放你已经知道的引用:to、thread、context、in_reply_to {thread,seq} 或 {sender,id}、relation(reply|followup)、mentions、message {sender,id} 或 {thread,seq}、resource {owner,id}、operation、expected_revision、op、title、scope、seq、attention、cursor、page_size、data、domain_id、owner、since、event_id、operation_id。
92
+ 形状:{ delivery_kind, msg, meta }。delivery_kind 用日常语言说出意图(“回复 dpdp 的问候”“看看这个 thread”“读那条消息”)。meta 放你已经知道的引用:to、thread、context、in_reply_to {thread,seq}(首选)或 {sender,id}、relation(reply|followup)、mentions、message {sender,id} 或 {thread,seq}、resource {owner,id}、operation、expected_revision、op、title、scope、seq、attention、cursor、page_size、data、domain_id、owner、since、event_id、operation_id。
76
93
 
77
94
  发送是一次调用。结果就是投递事实:admission "accepted" 表示接收方已经记录这条消息;wait "timed_out" 只表示等待期内没有回信,消息本身已经投递;reply_body "withheld" 表示这个上下文已经展示过那段正文。报告结果,到此为止。
78
95
 
@@ -97,27 +114,36 @@ meta.to 接受一个节点或一组节点。一组会逐个目标投递,每个
97
114
 
98
115
  用例,每个都是完整调用:
99
116
 
100
- - 回复你收到的消息。不要指名 thread;接收方会把回信放到原消息所在的 thread:
101
- { delivery_kind: "回答 dpdp 的问题", msg: "可以,第二组没问题。", meta: { in_reply_to: { sender: "urn:onlyne:agent:dpdp", id: "<message id>" }, relation: "reply" } }
117
+ - 回复你收到的消息。引用写成 {thread, seq},从投递卡片上照抄——绝不凭记忆写 id;接收方会把回信放到原消息所在的 thread:
118
+ { delivery_kind: "回答 dpdp 的问题", msg: "可以,第二组没问题。", meta: { in_reply_to: { thread: "<thread id>", seq: "<seq>" }, relation: "reply" } }
102
119
  - 给一个 peer 发新消息:{ delivery_kind: "向 lazuli 问好", msg: "来自工作台的问候。", meta: { to: "lazuli" } }
103
120
  - 把同一段话发给两个 peer:{ delivery_kind: "通知双方", msg: "报告已上传。", meta: { to: ["caerulea", "lazuli"] } } —— 每个目标各得一条结果,某个目标失败不会掩盖另一个。
104
121
  - 读与某个 peer 的整段往来:先读 \`onlyne://conversations\` 拿到一条,再读 \`onlyne://conversations/<sender>/<id>\`。读单条消息用 \`onlyne://messages/<sender>/<id>\`。
105
122
  - 查你发出的消息后来怎样了:读 \`onlyne://outbound/<message-id>\`;它显示接收方报告的落点、是否被接纳,以及回信(如果到了)。
106
123
  - 操作资源前先看它提供什么:读 \`onlyne://resources\` 或 \`onlyne://resources/<owner>/<id>\`,然后发操作 { delivery_kind: "列出该资源的操作", msg: "看看它能做什么", meta: { resource: { owner: "<node>", id: "<resource>" }, operation: "<name>" } }。meta.data 带参数;meta.expected_revision 要求版本与你读到的一致。
107
- - 在子代理里,只写不发:{ delivery_kind: "起草回复", msg: "这是答案。", meta: { op: "draft", to: "dpdp", relation: "reply", in_reply_to: { sender: "urn:onlyne:agent:dpdp", id: "<message id>" } } } 返回 { draft_id }。主会话原样发出这份草稿:{ delivery_kind: "发出起草的回复", msg: "发送草稿", meta: { draft_id: "<draft id>" } }。
108
- - 发送失败且没有可信密钥时,不要重试也不要尝试配对:错误里写明了两个节点、负责的持有者和宿主界面上的入口。报告它,然后停下。
109
-
110
- 动作和执行计划由 judge 角色根据你的意图选择,所以永远不要自己编 message id、幂等键或版本号。回信或 followup 必须说明关系;省略会被拒绝,而不是被假定。回信只针对原来的回信权限方,所以冲突的 to 会被拒绝。子代理会话读自己的切片(onlyne://threads、onlyne://inbox、onlyne://contexts),把结果交回主会话,由主会话发送。`,
124
+ - 在子代理里,只写不发:{ delivery_kind: "起草回复", msg: "这是答案。", meta: { op: "draft", to: "dpdp", relation: "reply", in_reply_to: { thread: "<thread id>", seq: "<seq>" } } } 返回 { draft_id }。主会话原样发出这份草稿:{ delivery_kind: "发出起草的回复", msg: "发送草稿", meta: { draft_id: "<draft id>" } }。
125
+ - 批准一个在等你决定的对端:{ delivery_kind: "批准信任握手", msg: "批准这个对端", meta: { op: "trust_accept", node_id: "<node>", fingerprint: "<fingerprint>" } }。先由获取策略判定;被拒时错误里写明命中的规则,对端保持原样。
126
+ - 撤回一次批准:{ delivery_kind: "撤销信任握手", msg: "撤销这个对端", meta: { op: "trust_revoke", node_id: "<node>" } }。撤销永远不受策略限制,也不需要指纹。
127
+ - 与一个已发现但尚未配对的对端发起交换:{ delivery_kind: "与 lazuli 开始握手", msg: "开始交换", meta: { op: "trust_begin", node_id: "lazuli" } }。获取策略会在发出任何请求之前判定:被拒时错误里写明命中的规则,对端保持原样;放行时提交本地批准、发出陈述,并报告其后的信任阶段。
128
+ - 把一个对端加进一段对话,让这个 thread 带多个成员:{ delivery_kind: "把 dpdp 加进 plan thread", msg: "加上 dpdp", meta: { op: "member_add", thread: "<thread id 或标题>", to: "dpdp" } };meta.to 是列表时每个成员各一次调用。用 op "member_remove" 移出。加入成员就是把他放进那个 thread,所以之后带 meta.thread 的 meta.to 数组只指向一个 thread,不带 meta.thread 时每个目标各得一个。
129
+ - 发送失败且没有可信密钥时,不要重试:错误里写明了两个节点;待确认的邀请由获取策略或那一侧的持有者确认,再发一次不会。报告它,然后停下。
130
+
131
+ 动作和执行计划由 judge 角色根据你的意图选择,所以永远不要自己编 message id、幂等键或版本号。引用消息一律写成 {thread, seq};当一次调用既没有引用也没有 relation、而本上下文还有未答复的入站消息时,由 judge 判断它在回答哪一条。回信只针对原来的回信权限方,所以冲突的 to 会被拒绝。子代理会话读自己的切片(onlyne://threads、onlyne://inbox、onlyne://contexts),把结果交回主会话,由主会话发送。`,
111
132
  "summary.no_result": "Onlyne 没有返回结构化结果。",
112
- "summary.where": ",投递给 {owner}",
113
- "summary.thread_ref": "(thread {thread} seq {seq}{answering})",
133
+ "summary.addressee": "已发给 {to}",
134
+ "summary.addressee_self": "已发给自己",
135
+ "summary.stored_here": "thread {thread} 建在你这侧,seq {seq}{answering}",
136
+ "summary.stored_there": "thread {thread} 建在 {owner} 那侧,seq {seq}{answering}",
114
137
  "summary.answering": ",回复 {sender}/{id}",
115
- "summary.outbound": ";读回: onlyne://outbound/{id}",
116
- "summary.delivered": "已送达{where}{facts},并已持久记录{trail}。",
117
- "summary.delivered_answered": "已送达{where}{facts},并已持久记录{trail}。{author} 在 seq {seq} 回复了。",
118
- "summary.delivered_withheld": "已送达{where}{facts},并已持久记录{trail}。{author} 在 seq {seq} 回复了,但那段正文已经在这个上下文里展示过,所以只报告它到达。",
119
- "summary.delivered_timed_out": "已送达{where}{facts},并已持久记录{trail}。等待期内没有回信;回信会作为入站消息自己到达。",
120
- "summary.delivered_cancelled": "已送达{where}{facts},并已持久记录{trail}。等待被取消;回信会作为入站消息自己到达。",
138
+ "summary.sep": ";",
139
+ "summary.resolved": "resolved: {node}",
140
+ "summary.outbound": "读回: onlyne://outbound/{id}",
141
+ "summary.grant_notice": ";条目留在你这里是因为 thread 由你持有,{to} 收到 grant 通知后作为成员读它",
142
+ "summary.delivered": "{facts},并已持久记录{mechanism}。",
143
+ "summary.delivered_answered": "{facts},并已持久记录{mechanism}。{author} 在 seq {seq} 回复了。",
144
+ "summary.delivered_withheld": "{facts},并已持久记录{mechanism}。{author} 在 seq {seq} 回复了,但那段正文已经在这个上下文里展示过,所以只报告它到达。",
145
+ "summary.delivered_timed_out": "{facts},并已持久记录{mechanism}。等待期内没有回信;回信会作为入站消息自己到达。",
146
+ "summary.delivered_cancelled": "{facts},并已持久记录{mechanism}。等待被取消;回信会作为入站消息自己到达。",
121
147
  "summary.not_confirmed": "未确认:{failure}。{next}",
122
148
  "summary.retry": "记录保持打开,运行时自己重试;再发一次会提交第二个意图。",
123
149
  "summary.recover": "记录保持打开,等待恢复;不要再发同一个意图。",
@@ -128,6 +154,14 @@ meta.to 接受一个节点或一组节点。一组会逐个目标投递,每个
128
154
  "draft.ready": "草稿 {draft_id} 已就绪;主会话用 meta.draft_id 发出它。",
129
155
  "multi.delivered": "→ {to}: {summary}",
130
156
  "multi.failed": "→ {to}: 未发送 —— {error}",
157
+ "trust.accepted": "已批准 {node}({fingerprint}),信任阶段现在是 {phase}。",
158
+ "trust.revoked": "已撤销 {node};本地批准已撤回。",
159
+ "trust.begun": "已与 {node}({fingerprint})发起交换,信任阶段现在是 {phase}。本地批准已提交、陈述已发出,等对端反向批准后即可互通消息。",
160
+ "thread.member_added": "已把 {member} 加入 thread {thread}。该成员现在可以读写这个 thread;之后带 meta.thread {thread} 的 meta.to 数组会把每个目标都放进这一个 thread,而不带 meta.thread 的同一数组会让每个目标各得一个 thread。",
161
+ "thread.member_removed": "已把 {member} 移出 thread {thread}。thread 本身保留、此前的条目仍是它的历史;之后单独发给 {member} 的消息会另开一个两人 thread。",
162
+ "failure.trust_node_unknown": "{action} 没有指名任何已知节点 {node}。写节点 id,或写只有一个已知对端持有的短别名。",
163
+ "failure.trust_fields": "信任动作需要非空字符串字段 {field}。",
164
+ "failure.trust_main_only": "只有主会话可以批准或撤销对端;子代理只能起草。",
131
165
  };
132
166
 
133
167
  export const toolText = createTranslator({ en, "zh-CN": zh });
package/src/tool.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
2
  import { type ExtensionAPI, type ExtensionContext, type SessionEntry, z } from "@oh-my-pi/pi-coding-agent";
3
- import type { Json, MsgInput, SendOutcome } from "@onlyne/lib";
3
+ import { OnlyneError, trustPhase, type Json, type MsgInput, type SendOutcome } from "@onlyne/lib";
4
4
  import { runDecision } from "./decider.ts";
5
5
  import { createDraft, sendDraft } from "./drafts.ts";
6
6
  import { observeToolResult } from "./messages.ts";
7
- import { getRuntime } from "./runtime.ts";
7
+ import { getRuntime, type PluginRuntime } from "./runtime.ts";
8
8
  import { toolText } from "./tool-text.ts";
9
9
 
10
10
  /** Custom entry naming a reply an `await` returned, left pending for the host bridge to confirm. */
@@ -57,10 +57,42 @@ export function registerMsgTool(pi: ExtensionAPI): void {
57
57
  const draft = result as { draft_id: string };
58
58
  return { content: [{ type: "text", text: toolText("draft.ready", { draft_id: draft.draft_id }) }], details: { result } };
59
59
  }
60
- if (typeof parsed.data.meta?.draft_id === "string") {
61
- const result = await sendDraft(runtime, ctx, parsed.data.meta.draft_id, signal);
60
+ // RFC 0018 §3.1: a trust action is mechanical — no judging, and no action kind
61
+ // invented for it, because the library executes exactly five and a trust action is
62
+ // not one of them (client.ts admits send, read, thread, resource and domain).
63
+ if (op === "trust_accept" || op === "trust_revoke" || op === "trust_begin") {
64
+ if (ctx.agent.kind === "sub") throw new OnlyneError("forbidden", toolText("failure.trust_main_only"));
65
+ const node = await trustNode(runtime, op, parsed.data.meta);
66
+ if (op === "trust_revoke") {
67
+ await runtime.acquireRevoke(node);
68
+ return { content: [{ type: "text", text: toolText("trust.revoked", { node }) }], details: { result: { op, node_id: node } } };
69
+ }
70
+ if (op === "trust_begin") {
71
+ const entry = await runtime.acquireBegin(node);
72
+ return {
73
+ content: [{ type: "text", text: toolText("trust.begun", { node, fingerprint: entry.fingerprint, phase: trustPhase(entry) }) }],
74
+ details: { result: entry },
75
+ };
76
+ }
77
+ const entry = await runtime.acquireAccept(node, trustField(parsed.data.meta, "fingerprint"));
78
+ return {
79
+ content: [{ type: "text", text: toolText("trust.accepted", { node, fingerprint: entry.fingerprint, phase: trustPhase(entry) }) }],
80
+ details: { result: entry },
81
+ };
82
+ }
83
+ const draftId = typeof parsed.data.meta?.draft_id === "string" ? parsed.data.meta.draft_id : undefined;
84
+ // `send_draft` names the act, not the row: the draft is found by the message it
85
+ // answers, or by the newest one this session created. `meta.draft_id` alone keeps
86
+ // working because the handoff from a drafting child reports exactly that id.
87
+ if (op === "send_draft" || draftId !== undefined) {
88
+ const result = await sendDraft(runtime, ctx, {
89
+ ...(draftId === undefined ? {} : { id: draftId }),
90
+ ...(parsed.data.meta?.in_reply_to === undefined
91
+ ? {}
92
+ : { in_reply_to: parsed.data.meta.in_reply_to as Json }),
93
+ }, signal);
62
94
  recordAwaitResult(pi, toolCallId, result, ctx);
63
- return { content: [{ type: "text", text: `${summary(result)}\n\n${JSON.stringify(visibleResult(result), null, 2)}` }], details: { result: visibleResult(result) } };
95
+ return { content: [{ type: "text", text: `${summary(result, spelling(parsed.data.meta?.to))}\n\n${JSON.stringify(visibleResult(result), null, 2)}` }], details: { result: visibleResult(result) } };
64
96
  }
65
97
  const targets = parsed.data.meta?.to;
66
98
  if (Array.isArray(targets)) {
@@ -78,7 +110,7 @@ export function registerMsgTool(pi: ExtensionAPI): void {
78
110
  }
79
111
  }
80
112
  const text = rows.map(row => row.error === undefined
81
- ? toolText("multi.delivered", { to: String(row.to), summary: summary(row.outcome) })
113
+ ? toolText("multi.delivered", { to: String(row.to), summary: summary(row.outcome, spelling(row.to)) })
82
114
  : toolText("multi.failed", { to: String(row.to), error: row.error })).join("\n");
83
115
  pi.logger.info("onlyne.msg.multi", { targets: rows.length, failed: rows.filter(row => row.error !== undefined).length });
84
116
  return { content: [{ type: "text", text: `${text}\n\n${JSON.stringify(rows, null, 2)}` }], details: { result: rows } };
@@ -92,8 +124,11 @@ export function registerMsgTool(pi: ExtensionAPI): void {
92
124
  recordAwaitResult(pi, toolCallId, result, ctx);
93
125
  const visible = visibleResult(result);
94
126
  pi.logger.info("onlyne.msg.result", { kind: input.delivery_kind, ms: Date.now() - started, ...outcomeFacts(result) });
127
+ const text = op === "member_add" || op === "member_remove"
128
+ ? membershipText(op, visible, parsed.data.meta)
129
+ : summary(result, spelling(parsed.data.meta?.to));
95
130
  return {
96
- content: [{ type: "text", text: `${summary(result)}\n\n${JSON.stringify(visible, null, 2)}` }],
131
+ content: [{ type: "text", text: `${text}\n\n${JSON.stringify(visible, null, 2)}` }],
97
132
  details: { result: visible },
98
133
  };
99
134
  } catch (error) {
@@ -112,22 +147,47 @@ export function registerMsgTool(pi: ExtensionAPI): void {
112
147
  * `wait: timed_out` beside `admission: accepted` reads as a contradiction until the
113
148
  * reader knows the contract — so the delivery fact is stated first and the JSON below
114
149
  * it is for the rare case that needs a machine-readable detail.
150
+ *
151
+ * `addressed` is the target as the caller wrote it. A receipt names the thread's owner, which
152
+ * is this node whenever it started the conversation, so leading with it made a message to a
153
+ * peer read as a message to itself; the addressee comes first and the storage second.
115
154
  */
116
- export function summary(result: unknown): string {
155
+ export function summary(result: unknown, addressed?: string): string {
117
156
  if (result === null || typeof result !== "object") return toolText("summary.no_result");
118
157
  const outcome = result as Partial<SendOutcome>;
119
- const owner = outcome.receipt?.thread_ref?.owner ?? outcome.reply?.thread_ref.owner;
120
- const where = owner === undefined ? "" : toolText("summary.where", { owner });
158
+ const receipt = outcome.receipt;
159
+ // The message's own sender is this node: `send` refuses a message another node authors.
160
+ const self = receipt?.message_ref.sender;
161
+ const to = outcome.resolved ?? outcome.requested ?? addressed;
121
162
  // The answer is part of the delivery fact: a reply the receipt cannot attribute is one the
122
163
  // sender cannot prove landed on the message it meant (cyanea report 2, problem 2).
123
- const answers = outcome.receipt?.in_reply_to;
164
+ const answers = receipt?.in_reply_to;
124
165
  const answering = answers === undefined ? "" : toolText("summary.answering", { sender: answers.sender, id: answers.id });
125
- const facts = outcome.receipt === undefined
126
- ? ""
127
- : toolText("summary.thread_ref", { thread: outcome.receipt.thread_ref.id, seq: outcome.receipt.seq, answering });
128
- // The receipt names a thread the receiver owns, which is not a local object; the outbound
129
- // record is. Point the reader at the handle that opens (dpdp report, problem 1).
130
- const trail = outcome.receipt === undefined ? "" : toolText("summary.outbound", { id: outcome.receipt.message_ref.id });
166
+ // The receipt names the thread owner, which is this node when it started the conversation;
167
+ // a peer's thread is stored at the peer, so the two cases are stated apart.
168
+ const storedHere = receipt !== undefined && receipt.thread_ref.owner === self;
169
+ const storage = receipt === undefined ? "" : toolText(storedHere ? "summary.stored_here" : "summary.stored_there", {
170
+ owner: receipt.thread_ref.owner, thread: receipt.thread_ref.id, seq: receipt.seq, answering,
171
+ });
172
+ const facts = [
173
+ // The self case is the one the reader has to be told about: this node's own id reads as a
174
+ // peer name, and the thread is hosted here either way.
175
+ to === undefined ? "" : toolText(to === self ? "summary.addressee_self" : "summary.addressee", { to }),
176
+ storage,
177
+ // The resolved addressee is what keeps an alias send from reading as a self-send.
178
+ outcome.resolved === undefined || outcome.resolved === outcome.requested ? "" : toolText("summary.resolved", { node: outcome.resolved }),
179
+ // The receipt names a thread the receiver owns, which is not a local object; the outbound
180
+ // record is. Point the reader at the handle that opens (dpdp report, problem 1).
181
+ receipt === undefined ? "" : toolText("summary.outbound", { id: receipt.message_ref.id }),
182
+ ].filter(part => part.length > 0).join(toolText("summary.sep"));
183
+ // A thread this node hosts and a peer it addressed: the entry sits here because this node
184
+ // hosts the thread, so the sentence names the addressee that reads it. Without that, "hosted
185
+ // on your node" beside "Sent to <peer>" reads as a message this node wrote to itself.
186
+ const mechanism = storedHere && to !== undefined && to !== self
187
+ ? toolText("summary.grant_notice", { to })
188
+ : "";
189
+ // With no addressee the sentence opens on the storage fact, so it is capitalized like a lead.
190
+ const body = to === undefined && facts.length > 0 ? facts.charAt(0).toUpperCase() + facts.slice(1) : facts;
131
191
  if (outcome.admission !== undefined) {
132
192
  if (outcome.admission !== "accepted") {
133
193
  const error = outcome.error;
@@ -139,23 +199,31 @@ export function summary(result: unknown): string {
139
199
  : toolText("summary.recover");
140
200
  return toolText("summary.not_confirmed", { failure, next });
141
201
  }
202
+ // No receipt means no addressee and no storage to state; the JSON below carries the rest.
203
+ if (body.length === 0) return toolText("summary.plain");
204
+ const vars = { facts: body, mechanism };
142
205
  if (outcome.wait === "answered" && outcome.reply !== undefined) {
143
- const answer = { author: outcome.reply.authenticated_author, seq: outcome.reply.seq, where, facts, trail };
206
+ const answer = { ...vars, author: outcome.reply.authenticated_author, seq: outcome.reply.seq };
144
207
  return outcome.reply_body === "withheld"
145
208
  ? toolText("summary.delivered_withheld", answer)
146
209
  : toolText("summary.delivered_answered", answer);
147
210
  }
148
211
  if (outcome.wait === "timed_out") {
149
- return toolText("summary.delivered_timed_out", { where, facts, trail });
212
+ return toolText("summary.delivered_timed_out", vars);
150
213
  }
151
214
  if (outcome.wait === "cancelled") {
152
- return toolText("summary.delivered_cancelled", { where, facts, trail });
215
+ return toolText("summary.delivered_cancelled", vars);
153
216
  }
154
- return toolText("summary.delivered", { where, facts, trail });
217
+ return toolText("summary.delivered", vars);
155
218
  }
156
219
  return toolText("summary.plain");
157
220
  }
158
221
 
222
+ /** The caller's spelling of one target, when it is a plain name rather than a structured ref. */
223
+ function spelling(value: unknown): string | undefined {
224
+ return typeof value === "string" && value.length > 0 ? value : undefined;
225
+ }
226
+
159
227
  /** The facts one `msg` call produced, without the reply body. */
160
228
  function outcomeFacts(result: unknown): Record<string, unknown> {
161
229
  if (result === null || typeof result !== "object") return {};
@@ -234,3 +302,50 @@ function failure(text: string): AgentToolResult<{ result: unknown }> {
234
302
  isError: true,
235
303
  };
236
304
  }
305
+
306
+ /**
307
+ * RFC 0018 §3.1: a trust op names its peer mechanically. The fields are read before any
308
+ * judgement or storage write, so a malformed call is a caller error, never a policy answer.
309
+ */
310
+ function trustField(meta: Record<string, unknown> | undefined, field: string): string {
311
+ const value = meta?.[field];
312
+ if (typeof value !== "string" || value.length === 0) throw new OnlyneError("invalid_request", toolText("failure.trust_fields", { field }));
313
+ return value;
314
+ }
315
+
316
+ /**
317
+ * RFC 0018 §3.1: the peer a trust op names, resolved through the same peer set a send resolves
318
+ * `meta.to` from. An ambiguous alias names its matches; an unknown one is `not_found` and says
319
+ * which action asked, because the same alias may be valid for another one.
320
+ */
321
+ async function trustNode(runtime: PluginRuntime, op: string, meta: Record<string, unknown> | undefined): Promise<string> {
322
+ const node = trustField(meta, "node_id");
323
+ try {
324
+ return await runtime.decider.peerId(node);
325
+ } catch (error) {
326
+ if (error instanceof OnlyneError && error.error.code === "not_found") {
327
+ throw new OnlyneError("not_found", toolText("failure.trust_node_unknown", { action: op, node }), error.error.details);
328
+ }
329
+ throw error;
330
+ }
331
+ }
332
+
333
+ /** The sentence one membership op earns: the member, the thread, and where a later array send lands. */
334
+ function membershipText(op: "member_add" | "member_remove", result: unknown, meta: Record<string, unknown> | undefined): string {
335
+ const named = result !== null && typeof result === "object" && "peer" in result && typeof result.peer === "string"
336
+ ? result.peer
337
+ : threadLabel(meta?.to);
338
+ return toolText(op === "member_add" ? "thread.member_added" : "thread.member_removed", {
339
+ member: named,
340
+ thread: threadLabel(meta?.thread),
341
+ });
342
+ }
343
+
344
+ /** A reference as the caller wrote it: one name, or `owner/id` for the record form. */
345
+ function threadLabel(value: unknown): string {
346
+ if (typeof value === "string") return value;
347
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "";
348
+ const owner = "owner" in value ? value.owner : undefined;
349
+ const id = "id" in value ? value.id : undefined;
350
+ return typeof owner === "string" && typeof id === "string" ? `${owner}/${id}` : "";
351
+ }
package/src/types.ts CHANGED
@@ -1,54 +1,6 @@
1
- import type { MessageRef, NodeId, ThreadRef, TrustPhase, TrustSeed } from "@onlyne/lib";
2
- import type { Locale } from "@onlyne/node-host/i18n";
3
-
4
- /**
5
- * Project-scoped configuration in `<project>/.onlyne/config.toml`.
6
- *
7
- * Delivery decisions are made by OMP's `judge` model role, so no model
8
- * endpoint or model name belongs here.
9
- */
10
- export interface OnlyneConfig {
11
- path: string;
12
- language?: Locale;
13
- /** Protocol membership, independent from the node identity. */
14
- region: string;
15
- node: {
16
- id: NodeId;
17
- key_ref?: string;
18
- /** Where the node identity lives: the project's `.onlyne/keys`, the user's key store, or no key at all. */
19
- key_scope?: "project" | "global" | "none";
20
- };
21
- server: {
22
- bind: string;
23
- /** The externally reachable base URL when a reverse proxy fronts the node. */
24
- public_url?: string;
25
- };
26
- threads: {
27
- page_size: number;
28
- };
29
- /** Bootstrap contact seeds the node reaches at every start. */
30
- discovery?: {
31
- seeds?: string[];
32
- };
33
- /** Pinned peer fingerprints; an arriving key that matches is trusted without a prompt. */
34
- trust?: TrustSeed[];
35
- }
36
-
37
- /** One execution context the plugin registered for a delivered message. */
38
- export interface ContextRecord {
39
- alias: string;
40
- node_id: NodeId;
41
- main_agent_id: string;
42
- source?: { sender: NodeId; id: string };
43
- agent_id?: string;
44
- session_file?: string;
45
- parent_id?: string;
46
- thread_ref?: ThreadRef;
47
- summary: string;
48
- state: "unbound" | "idle" | "running" | "parked" | "lost";
49
- created_at: string;
50
- bound_at?: string;
51
- }
1
+ import type { MessageRef, NodeId, ThreadRef, TrustPhase, WorkRef } from "@onlyne/lib";
2
+ import type { AcquirePolicy } from "@onlyne/node-host/acquire";
3
+ import type { ContextRecord } from "@onlyne/node-host/types";
52
4
 
53
5
  /** One row of the viewer's queue page. */
54
6
  export interface QueueItem {
@@ -68,6 +20,8 @@ export interface ViewerMessage {
68
20
  at: string;
69
21
  text: string;
70
22
  relation?: string;
23
+ /** The work unit the sender named (RFC 0003 §1), when the message carries one. */
24
+ work?: WorkRef;
71
25
  }
72
26
 
73
27
  /** One thread row in the viewer's chat page. */
@@ -79,6 +33,11 @@ export interface ViewerThread {
79
33
  unread: number;
80
34
  last_seq: string;
81
35
  messages: ViewerMessage[];
36
+ /**
37
+ * A thread another node hosts: this node was invited into it, so the viewer can show it and
38
+ * cannot offer the writes that belong to its owner.
39
+ */
40
+ read_only?: boolean;
82
41
  }
83
42
 
84
43
  /** One discovery record row the viewer lists as an automatic-discovery candidate. */
@@ -104,6 +63,56 @@ export interface ViewerContact {
104
63
  last_seen: string;
105
64
  }
106
65
 
66
+ /** RFC 0018 §5: the acquisition settings this runtime actually runs with, read back for the panel. */
67
+ export interface AcquisitionSnapshot {
68
+ policy: AcquirePolicy;
69
+ allow: string[];
70
+ /**
71
+ * The discovery layer's separate loopback switch (RFC 0018 §5.1). It decides what may enter
72
+ * the catalog, not what may be approved without a human, and the panel shows both values
73
+ * together so loosening one is never mistaken for loosening the other.
74
+ */
75
+ allow_loopback: boolean;
76
+ }
77
+
78
+ /**
79
+ * One acquisition audit row. The actors stay distinguishable (RFC 0018 §5): a human's panel
80
+ * action, an agent's decision, a configured seed's pre-authorization, and the policy's own
81
+ * automatic verdict. `allowed` is the verdict the gate reached; `override` marks a human
82
+ * approval that committed although the policy refused it, and a seed authorization that
83
+ * bypasses the policy by design.
84
+ */
85
+ export interface AcquisitionAuditEntry {
86
+ at: string;
87
+ actor: "human" | "agent" | "seed" | "policy";
88
+ action: string;
89
+ node_id?: NodeId;
90
+ fingerprint?: string;
91
+ /** The address the peer's statement arrived from, as this host observed it. */
92
+ observed?: string;
93
+ /** The address the peer wrote into its own statement. */
94
+ declared?: string;
95
+ policy?: AcquirePolicy;
96
+ allowed?: boolean;
97
+ reason?: string;
98
+ override?: boolean;
99
+ /** The configuration file a seed authorization came from. */
100
+ source?: string;
101
+ }
102
+
103
+ /**
104
+ * One trust row with both addresses the panel needs. `base_url` is what the peer declared;
105
+ * `observed` is where its last statement actually arrived from, and the two need not agree
106
+ * (RFC 0018 §2.1).
107
+ */
108
+ export interface ViewerTrustRow {
109
+ node_id: NodeId;
110
+ fingerprint: string;
111
+ phase: TrustPhase;
112
+ base_url: string;
113
+ observed?: string;
114
+ }
115
+
107
116
  /** Read-only snapshot the viewer renders. */
108
117
  export interface ViewerSnapshot {
109
118
  region?: string;
@@ -115,12 +124,11 @@ export interface ViewerSnapshot {
115
124
  contacts: ViewerContact[];
116
125
  discovery_error?: string;
117
126
  /** Every trust row, projected through `trustPhase`: the only stage this package renders. */
118
- trust?: Array<{
119
- node_id: NodeId;
120
- fingerprint: string;
121
- phase: TrustPhase;
122
- base_url: string;
123
- }>;
127
+ trust?: ViewerTrustRow[];
128
+ /** RFC 0018 §5: the acquisition panel's current settings. */
129
+ acquisition?: AcquisitionSnapshot;
130
+ /** The persisted acquisition audit, newest first; the panel renders the first 20. */
131
+ audit?: AcquisitionAuditEntry[];
124
132
  identity?: { public_key: string; fingerprint: string };
125
133
  }
126
134
 
@@ -15,6 +15,8 @@ const en = {
15
15
  "bootstrap.field.keyScope": "Node key (project/global/none)",
16
16
  "bootstrap.field.pageSize": "Page size",
17
17
  "bootstrap.field.contact": "Node addresses (optional)",
18
+ "bootstrap.field.policy": "Acquisition policy (all/segment/none)",
19
+ "bootstrap.field.allow": "Acquisition allow set",
18
20
  "bootstrap.hint.region": "A short name is stored as urn:onlyne:region:<name>",
19
21
  "bootstrap.hint.nodeId": "A short name is stored as urn:onlyne:agent:<name>",
20
22
  "bootstrap.hint.bind": "127.0.0.1",
@@ -23,6 +25,16 @@ const en = {
23
25
  "bootstrap.hint.pageSize": "1..100, default 50",
24
26
  "bootstrap.hint.contact":
25
27
  "Nodes discovery cannot reach are contacted by address; stored as discovery.seeds and contacted on every start. Separate addresses with commas",
28
+ "bootstrap.hint.policy":
29
+ "segment: only addresses inside the allow set are approved automatically; all: any peer; none: only a human approves",
30
+ "bootstrap.hint.allow":
31
+ "CIDR blocks or host names, comma separated. The default is loopback only; a private range lets every host in it pair without asking you",
32
+ "bootstrap.policyInvalid": "Acquisition policy must be all, segment or none.",
33
+ "bootstrap.allowInvalid": "Allow entries must be network/prefix blocks or host names, got {value}.",
34
+ "bootstrap.policy.all": "all",
35
+ "bootstrap.policy.segment": "segment",
36
+ "bootstrap.policy.none": "none",
37
+ "bootstrap.policy.title": "Set the acquisition policy to {value}",
26
38
  "bootstrap.empty": "[empty]",
27
39
  "bootstrap.regionMissing": "Region is required; a short name is stored as urn:onlyne:region:<name>",
28
40
  "bootstrap.regionPreview": "Will be stored as {uri}",
@@ -53,6 +65,7 @@ const en = {
53
65
  "mail.thread": "Thread",
54
66
  "mail.relation": "Relation",
55
67
  "mail.inReplyTo": "In-Reply-To",
68
+ "mail.work": "Work",
56
69
  "mail.delivery": "Delivery",
57
70
  "mail.status": "Status",
58
71
  "mail.consumed": " · consumed {state}",
@@ -73,6 +86,8 @@ const zh: Record<keyof typeof en, string> = {
73
86
  "bootstrap.field.keyScope": "Node key (project/global/none)",
74
87
  "bootstrap.field.pageSize": "Page size",
75
88
  "bootstrap.field.contact": "节点地址(可选)",
89
+ "bootstrap.field.policy": "获取策略(all/segment/none)",
90
+ "bootstrap.field.allow": "获取允许集合",
76
91
  "bootstrap.hint.region": "短名保存为 urn:onlyne:region:<短名>",
77
92
  "bootstrap.hint.nodeId": "短名保存为 urn:onlyne:agent:<短名>",
78
93
  "bootstrap.hint.bind": "127.0.0.1",
@@ -80,6 +95,14 @@ const zh: Record<keyof typeof en, string> = {
80
95
  "project:密钥存 .onlyne/keys(git 内需 ignore);global:存 ~/.config/onlyne/keys;none:回环开发绑定",
81
96
  "bootstrap.hint.pageSize": "1..100,默认 50",
82
97
  "bootstrap.hint.contact": "自动发现到不了的节点按地址联系;保存为 discovery.seeds,每次启动都会联系。多个地址用逗号分隔",
98
+ "bootstrap.hint.policy": "segment:只自动批准允许集合内的地址;all:任意对端;none:只有人类能批准",
99
+ "bootstrap.hint.allow": "CIDR 网段或主机名,逗号分隔。默认只允许回环;填私有网段等于允许其中每台主机不经你确认完成配对",
100
+ "bootstrap.policyInvalid": "获取策略只能是 all、segment 或 none。",
101
+ "bootstrap.allowInvalid": "允许条目必须是 network/prefix 网段或主机名,收到的是 {value}。",
102
+ "bootstrap.policy.all": "all",
103
+ "bootstrap.policy.segment": "segment",
104
+ "bootstrap.policy.none": "none",
105
+ "bootstrap.policy.title": "把获取策略设为 {value}",
83
106
  "bootstrap.empty": "[空]",
84
107
  "bootstrap.regionMissing": "Region 必填;短名将保存为 urn:onlyne:region:<短名>",
85
108
  "bootstrap.regionPreview": "将保存为 {uri}",
@@ -110,6 +133,7 @@ const zh: Record<keyof typeof en, string> = {
110
133
  "mail.thread": "线程",
111
134
  "mail.relation": "关系",
112
135
  "mail.inReplyTo": "回复",
136
+ "mail.work": "工作单元",
113
137
  "mail.delivery": "投送",
114
138
  "mail.status": "状态",
115
139
  "mail.consumed": " · 已消费 {state}",