omp-onlyne 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ import { createTranslator } from "@onlyne/node-host/i18n";
2
+
3
+ const en = {
4
+ label: "Onlyne Message",
5
+ "param.delivery_kind": "The delivery intent in natural language",
6
+ "param.msg": "The message body or the local query",
7
+ "param.meta": "Explicit references and operation fields",
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
+
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
+
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
+
14
+ A reply arrives by itself. The plugin injects it as an onlyne.inbound message and starts a turn; you do not need to check. Do not poll after a send: no inbox scans, thread reads, evidence queries, log greps or SQLite lookups. Read state only when the user asks about history, or when a specific fact is missing from the result in hand.
15
+
16
+ State reads: read the root \`onlyne://\` for the views this node publishes — neighbors, threads, conversations, inbox, contexts, outbound — instead of guessing a route. A conversation is one exchange with both directions: \`onlyne://conversations\` lists them with the latest message and time, \`onlyne://conversations/<sender>/<id>\` shows every message in it, and one message is at \`onlyne://messages/<sender>/<id>\`. Raw thread entries stay at \`onlyne://threads/<thread-id>/messages/<seq>\`.
17
+
18
+ Resource reads belong to the main OMP session; a subagent session is refused and reads its own message slice instead. meta.scope "resources" selects the resource channel and meta.op names the query:
19
+
20
+ - Inventory: { delivery_kind: "list the resources", msg: "which resources does this node serve", meta: { scope: "resources", op: "list" } }. Without meta.owner the read covers the local node and every owner whose key exchange completed, and each owner answers with its own page or its own error, so one unreachable owner does not hide the others. meta.owner narrows the read to one owner: meta { scope: "resources", op: "list", owner: "<node>" }.
21
+ - One resource: meta { scope: "resources", op: "describe", resource: { owner: "<node>", id: "<resource>" } }.
22
+ - Event log: the same resource with op "log", meta.since as a decimal event id to resume after the last event you read, and meta.page_size (1 to 100) with meta.cursor for the page.
23
+ - One event: the same resource with op "event" and meta.event_id.
24
+ - Operation history: the same resource with op "operations", paged with meta.page_size and meta.cursor.
25
+ - One operation: the same resource with op "status" and meta.operation_id.
26
+
27
+ The same reads are internal paths: \`onlyne://resources\` lists every owner, \`onlyne://resources/<owner>\` one owner, \`onlyne://resources/<owner>/<id>\` one resource, \`onlyne://resources/<owner>/<id>/log\` the event log with \`onlyne://resources/<owner>/<id>/log/<event-id>\` for one event, \`onlyne://resources/<owner>/<id>/operations\` the operation history with \`onlyne://resources/<owner>/<id>/operations/<operation-id>\` for one operation. Page them with ?page_size= and ?cursor=, and resume a log with ?since=.
28
+
29
+ A delivery is stored in the thread it names, and the thread's owner stores it: the initiator owns the thread for the whole conversation, so a member writes into it and the owner admits. A reply names no thread; it lands in the thread that holds the original message. The receipt reports the landing as thread <id> seq <n> and names the message it answers as in_reply_to.
30
+
31
+ meta.to takes one node or a list of nodes. A list is delivered one target at a time and returns one result per target, so a failure on one target does not hide the others; report each result separately.
32
+
33
+ Use cases, each a complete call:
34
+
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
+ - Send a fresh message to one peer: { delivery_kind: "greet lazuli", msg: "Hello from the workbench.", meta: { to: "lazuli" } }
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
+ - 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
+ - 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
+ - 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: { 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.`,
50
+ "summary.no_result": "Onlyne returned no structured result.",
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}",
55
+ "summary.answering": ", answering {sender}/{id}",
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.",
65
+ "summary.not_confirmed": "Not confirmed: {failure}. {next}",
66
+ "summary.retry": "The record stays open and the runtime retries it; sending again would commit a second intent.",
67
+ "summary.recover": "The record stays open for recovery; do not re-send the same intent.",
68
+ "summary.plain": "Onlyne answered; the structured result follows.",
69
+ "failure.invalid_args": "Invalid msg arguments: {message}",
70
+ "failure.not_configured": "Onlyne is not configured for this project. Create .onlyne/config.toml or run /onlyne init.",
71
+ "failure.no_target": "meta.to names no target. Name one peer, or send one call per peer.",
72
+ "draft.ready": "Draft {draft_id} is ready; the main session sends it with meta.draft_id.",
73
+ "multi.delivered": "→ {to}: {summary}",
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.",
83
+ } as const;
84
+
85
+ const zh: Record<keyof typeof en, string> = {
86
+ label: "Onlyne 消息",
87
+ "param.delivery_kind": "用自然语言描述的投递意图",
88
+ "param.msg": "消息正文,或本地查询",
89
+ "param.meta": "显式引用与操作字段",
90
+ description: `Onlyne 消息、thread 与资源的唯一入口。没有 CLI,也没有别处要查:把意图交给这个工具,不要先去读文档或源码。
91
+
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。
93
+
94
+ 发送是一次调用。结果就是投递事实:admission "accepted" 表示接收方已经记录这条消息;wait "timed_out" 只表示等待期内没有回信,消息本身已经投递;reply_body "withheld" 表示这个上下文已经展示过那段正文。报告结果,到此为止。
95
+
96
+ 回信会自己到达。插件把它作为 onlyne.inbound 消息注入并开启一轮对话;你不需要去查。发送之后不要轮询:不要扫信箱、读 thread、查证据、grep 日志或翻 SQLite。只在用户问起历史、或手上的结果缺某个具体事实时才读状态。
97
+
98
+ 状态读取:读根路径 \`onlyne://\`,看这个节点发布了哪些视图——neighbors、threads、conversations、inbox、contexts、outbound——不要猜路径。一次 conversation 是一段双向往来:\`onlyne://conversations\` 列出它们以及最新一条消息和时间,\`onlyne://conversations/<sender>/<id>\` 展示其中每一条消息,单条消息在 \`onlyne://messages/<sender>/<id>\`。thread 的原始条目留在 \`onlyne://threads/<thread-id>/messages/<seq>\`。
99
+
100
+ 资源读取只属于主 OMP 会话;子代理会话会被拒绝,改为读自己的消息切片。meta.scope 为 "resources" 选中资源通道,meta.op 指明查询:
101
+
102
+ - 清单:{ delivery_kind: "列出资源", msg: "这个节点提供哪些资源", meta: { scope: "resources", op: "list" } }。不带 meta.owner 时,读取覆盖本节点和所有已完成密钥交换的 owner,每个 owner 各自返回一页或各自返回一条错误,所以一个 owner 连不上不会遮住其他 owner。meta.owner 把读取收窄到一个 owner:meta { scope: "resources", op: "list", owner: "<node>" }。
103
+ - 单个资源:meta { scope: "resources", op: "describe", resource: { owner: "<node>", id: "<resource>" } }。
104
+ - 事件日志:同一个 resource 配 op "log",meta.since 是十进制事件 id,用来从你上次读到的事件之后继续,meta.page_size(1 到 100)配 meta.cursor 取页。
105
+ - 单个事件:同一个 resource 配 op "event" 和 meta.event_id。
106
+ - 操作历史:同一个 resource 配 op "operations",用 meta.page_size 和 meta.cursor 分页。
107
+ - 单个操作:同一个 resource 配 op "status" 和 meta.operation_id。
108
+
109
+ 同样的读取也是内部路径:\`onlyne://resources\` 列出所有 owner,\`onlyne://resources/<owner>\` 一个 owner,\`onlyne://resources/<owner>/<id>\` 一个资源,\`onlyne://resources/<owner>/<id>/log\` 是事件日志,单个事件用 \`onlyne://resources/<owner>/<id>/log/<event-id>\`,\`onlyne://resources/<owner>/<id>/operations\` 是操作历史,单个操作用 \`onlyne://resources/<owner>/<id>/operations/<operation-id>\`。用 ?page_size= 和 ?cursor= 分页,用 ?since= 续读日志。
110
+
111
+ 一次投递存进它指名的 thread,并由 thread 的 owner 保存:发起者在整段对话里都是 owner,所以成员往 thread 里写、由 owner 准入。回信不指名 thread;它落进持有原消息的那个 thread。回执以 thread <id> seq <n> 报告落点,并以 in_reply_to 指出它回复的那条消息。
112
+
113
+ meta.to 接受一个节点或一组节点。一组会逐个目标投递,每个目标各得一条结果,所以某个目标失败不会掩盖其他目标;逐个报告结果。
114
+
115
+ 用例,每个都是完整调用:
116
+
117
+ - 回复你收到的消息。引用写成 {thread, seq},从投递卡片上照抄——绝不凭记忆写 id;接收方会把回信放到原消息所在的 thread:
118
+ { delivery_kind: "回答 dpdp 的问题", msg: "可以,第二组没问题。", meta: { in_reply_to: { thread: "<thread id>", seq: "<seq>" }, relation: "reply" } }
119
+ - 给一个 peer 发新消息:{ delivery_kind: "向 lazuli 问好", msg: "来自工作台的问候。", meta: { to: "lazuli" } }
120
+ - 把同一段话发给两个 peer:{ delivery_kind: "通知双方", msg: "报告已上传。", meta: { to: ["caerulea", "lazuli"] } } —— 每个目标各得一条结果,某个目标失败不会掩盖另一个。
121
+ - 读与某个 peer 的整段往来:先读 \`onlyne://conversations\` 拿到一条,再读 \`onlyne://conversations/<sender>/<id>\`。读单条消息用 \`onlyne://messages/<sender>/<id>\`。
122
+ - 查你发出的消息后来怎样了:读 \`onlyne://outbound/<message-id>\`;它显示接收方报告的落点、是否被接纳,以及回信(如果到了)。
123
+ - 操作资源前先看它提供什么:读 \`onlyne://resources\` 或 \`onlyne://resources/<owner>/<id>\`,然后发操作 { delivery_kind: "列出该资源的操作", msg: "看看它能做什么", meta: { resource: { owner: "<node>", id: "<resource>" }, operation: "<name>" } }。meta.data 带参数;meta.expected_revision 要求版本与你读到的一致。
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),把结果交回主会话,由主会话发送。`,
132
+ "summary.no_result": "Onlyne 没有返回结构化结果。",
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}",
137
+ "summary.answering": ",回复 {sender}/{id}",
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}。等待被取消;回信会作为入站消息自己到达。",
147
+ "summary.not_confirmed": "未确认:{failure}。{next}",
148
+ "summary.retry": "记录保持打开,运行时自己重试;再发一次会提交第二个意图。",
149
+ "summary.recover": "记录保持打开,等待恢复;不要再发同一个意图。",
150
+ "summary.plain": "Onlyne 已应答;结构化结果如下。",
151
+ "failure.invalid_args": "msg 参数无效:{message}",
152
+ "failure.not_configured": "这个项目没有配置 Onlyne。创建 .onlyne/config.toml,或运行 /onlyne init。",
153
+ "failure.no_target": "meta.to 没有指明目标。写一个 peer,或每个 peer 各发一次。",
154
+ "draft.ready": "草稿 {draft_id} 已就绪;主会话用 meta.draft_id 发出它。",
155
+ "multi.delivered": "→ {to}: {summary}",
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": "只有主会话可以批准或撤销对端;子代理只能起草。",
165
+ };
166
+
167
+ export const toolText = createTranslator({ en, "zh-CN": zh });
package/src/tool.ts ADDED
@@ -0,0 +1,342 @@
1
+ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
+ import { type ExtensionAPI, type ExtensionContext, type SessionEntry, z } from "@oh-my-pi/pi-coding-agent";
3
+ import { OnlyneError, trustPhase, type Json, type MsgInput, type SendOutcome } from "@onlyne/lib";
4
+ import { runDecision } from "./decider.ts";
5
+ import { createDraft, sendDraft } from "./drafts.ts";
6
+ import { observeToolResult } from "./messages.ts";
7
+ import { getRuntime, type PluginRuntime } from "./runtime.ts";
8
+ import { toolText } from "./tool-text.ts";
9
+
10
+ /** Custom entry naming a reply an `await` returned, left pending for the host bridge to confirm. */
11
+ export const AWAIT_RESULT = "onlyne.await-result";
12
+ /** The custom entry type naming an await result the model has actually read. */
13
+ export const AWAIT_CONSUMED = "onlyne.await-consumed";
14
+
15
+ export function registerMsgTool(pi: ExtensionAPI): void {
16
+ const parameters = z.object({
17
+ delivery_kind: z.string().min(1).describe(toolText("param.delivery_kind")),
18
+ msg: z.string().describe(toolText("param.msg")),
19
+ meta: z.record(z.string(), z.unknown()).optional().describe(toolText("param.meta")),
20
+ });
21
+ pi.registerTool({
22
+ name: "msg",
23
+ label: toolText("label"),
24
+ description: toolText("description"),
25
+ parameters,
26
+ approval: "read",
27
+ strict: true,
28
+ execute: async (
29
+ toolCallId,
30
+ raw,
31
+ signal,
32
+ _onUpdate,
33
+ ctx,
34
+ ): Promise<AgentToolResult<{ result: unknown }>> => {
35
+ const parsed = parameters.safeParse(raw);
36
+ if (!parsed.success) return failure(toolText("failure.invalid_args", { message: parsed.error.message }));
37
+ const runtime = getRuntime();
38
+ if (runtime === undefined) return failure(toolText("failure.not_configured"));
39
+ const input: MsgInput = {
40
+ delivery_kind: parsed.data.delivery_kind,
41
+ msg: parsed.data.msg,
42
+ ...(parsed.data.meta === undefined ? {} : { meta: parsed.data.meta as Record<string, Json> }),
43
+ };
44
+ const scope = {
45
+ ctx,
46
+ input,
47
+ ...(signal === undefined ? {} : { signal }),
48
+ ...(ctx.agent.kind === "sub" ? { readOnly: true } : {}),
49
+ };
50
+ const started = Date.now();
51
+ try {
52
+ const op = parsed.data.meta?.op;
53
+ if (op === "draft") {
54
+ const result = await createDraft(runtime, ctx, input, signal);
55
+ pi.logger.info("onlyne.draft.created", { ms: Date.now() - started });
56
+ // createDraft is typed unknown but always returns the draft record it just stored.
57
+ const draft = result as { draft_id: string };
58
+ return { content: [{ type: "text", text: toolText("draft.ready", { draft_id: draft.draft_id }) }], details: { result } };
59
+ }
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
+ if (typeof parsed.data.meta?.draft_id === "string") {
84
+ const result = await sendDraft(runtime, ctx, parsed.data.meta.draft_id, signal);
85
+ recordAwaitResult(pi, toolCallId, result, ctx);
86
+ return { content: [{ type: "text", text: `${summary(result, spelling(parsed.data.meta?.to))}\n\n${JSON.stringify(visibleResult(result), null, 2)}` }], details: { result: visibleResult(result) } };
87
+ }
88
+ const targets = parsed.data.meta?.to;
89
+ if (Array.isArray(targets)) {
90
+ if (targets.length === 0) return failure(toolText("failure.no_target"));
91
+ const rows: Array<{ to: unknown; outcome?: unknown; error?: string }> = [];
92
+ for (const target of targets) {
93
+ const one: MsgInput = { ...input, meta: { ...input.meta, to: target as Json } };
94
+ const oneScope = { ...scope, input: one };
95
+ try {
96
+ rows.push({ to: target, outcome: await runDecision(oneScope, () => runtime.node.client.msg(one, {
97
+ ...(signal === undefined ? {} : { signal }), context: ctx.agent.id,
98
+ })) });
99
+ } catch (error) {
100
+ rows.push({ to: target, error: error instanceof Error ? error.message : String(error) });
101
+ }
102
+ }
103
+ const text = rows.map(row => row.error === undefined
104
+ ? toolText("multi.delivered", { to: String(row.to), summary: summary(row.outcome, spelling(row.to)) })
105
+ : toolText("multi.failed", { to: String(row.to), error: row.error })).join("\n");
106
+ pi.logger.info("onlyne.msg.multi", { targets: rows.length, failed: rows.filter(row => row.error !== undefined).length });
107
+ return { content: [{ type: "text", text: `${text}\n\n${JSON.stringify(rows, null, 2)}` }], details: { result: rows } };
108
+ }
109
+ const result = await runDecision(scope, () =>
110
+ runtime.node.client.msg(input, {
111
+ ...(signal === undefined ? {} : { signal }),
112
+ context: ctx.agent.id,
113
+ }),
114
+ );
115
+ recordAwaitResult(pi, toolCallId, result, ctx);
116
+ const visible = visibleResult(result);
117
+ pi.logger.info("onlyne.msg.result", { kind: input.delivery_kind, ms: Date.now() - started, ...outcomeFacts(result) });
118
+ const text = op === "member_add" || op === "member_remove"
119
+ ? membershipText(op, visible, parsed.data.meta)
120
+ : summary(result, spelling(parsed.data.meta?.to));
121
+ return {
122
+ content: [{ type: "text", text: `${text}\n\n${JSON.stringify(visible, null, 2)}` }],
123
+ details: { result: visible },
124
+ };
125
+ } catch (error) {
126
+ pi.logger.warn("onlyne.msg.failed", {
127
+ kind: input.delivery_kind, ms: Date.now() - started,
128
+ error: error instanceof Error ? error.message : String(error),
129
+ });
130
+ return failure(error instanceof Error ? error.message : String(error));
131
+ }
132
+ },
133
+ });
134
+ }
135
+
136
+ /**
137
+ * The outcome as one sentence. A structured result invites a key-by-key search —
138
+ * `wait: timed_out` beside `admission: accepted` reads as a contradiction until the
139
+ * reader knows the contract — so the delivery fact is stated first and the JSON below
140
+ * it is for the rare case that needs a machine-readable detail.
141
+ *
142
+ * `addressed` is the target as the caller wrote it. A receipt names the thread's owner, which
143
+ * is this node whenever it started the conversation, so leading with it made a message to a
144
+ * peer read as a message to itself; the addressee comes first and the storage second.
145
+ */
146
+ export function summary(result: unknown, addressed?: string): string {
147
+ if (result === null || typeof result !== "object") return toolText("summary.no_result");
148
+ const outcome = result as Partial<SendOutcome>;
149
+ const receipt = outcome.receipt;
150
+ // The message's own sender is this node: `send` refuses a message another node authors.
151
+ const self = receipt?.message_ref.sender;
152
+ const to = outcome.resolved ?? outcome.requested ?? addressed;
153
+ // The answer is part of the delivery fact: a reply the receipt cannot attribute is one the
154
+ // sender cannot prove landed on the message it meant (cyanea report 2, problem 2).
155
+ const answers = receipt?.in_reply_to;
156
+ const answering = answers === undefined ? "" : toolText("summary.answering", { sender: answers.sender, id: answers.id });
157
+ // The receipt names the thread owner, which is this node when it started the conversation;
158
+ // a peer's thread is stored at the peer, so the two cases are stated apart.
159
+ const storedHere = receipt !== undefined && receipt.thread_ref.owner === self;
160
+ const storage = receipt === undefined ? "" : toolText(storedHere ? "summary.stored_here" : "summary.stored_there", {
161
+ owner: receipt.thread_ref.owner, thread: receipt.thread_ref.id, seq: receipt.seq, answering,
162
+ });
163
+ const facts = [
164
+ // The self case is the one the reader has to be told about: this node's own id reads as a
165
+ // peer name, and the thread is hosted here either way.
166
+ to === undefined ? "" : toolText(to === self ? "summary.addressee_self" : "summary.addressee", { to }),
167
+ storage,
168
+ // The resolved addressee is what keeps an alias send from reading as a self-send.
169
+ outcome.resolved === undefined || outcome.resolved === outcome.requested ? "" : toolText("summary.resolved", { node: outcome.resolved }),
170
+ // The receipt names a thread the receiver owns, which is not a local object; the outbound
171
+ // record is. Point the reader at the handle that opens (dpdp report, problem 1).
172
+ receipt === undefined ? "" : toolText("summary.outbound", { id: receipt.message_ref.id }),
173
+ ].filter(part => part.length > 0).join(toolText("summary.sep"));
174
+ // A thread this node hosts and a peer it addressed: the entry sits here because this node
175
+ // hosts the thread, so the sentence names the addressee that reads it. Without that, "hosted
176
+ // on your node" beside "Sent to <peer>" reads as a message this node wrote to itself.
177
+ const mechanism = storedHere && to !== undefined && to !== self
178
+ ? toolText("summary.grant_notice", { to })
179
+ : "";
180
+ // With no addressee the sentence opens on the storage fact, so it is capitalized like a lead.
181
+ const body = to === undefined && facts.length > 0 ? facts.charAt(0).toUpperCase() + facts.slice(1) : facts;
182
+ if (outcome.admission !== undefined) {
183
+ if (outcome.admission !== "accepted") {
184
+ const error = outcome.error;
185
+ const failure = error === undefined ? outcome.admission : `${error.code}: ${error.message}`;
186
+ // `retryable` belongs to the runtime, not to the caller: the record stays open and the
187
+ // runtime drives it again, while a second send commits a second intent.
188
+ const next = error?.retryable === true
189
+ ? toolText("summary.retry")
190
+ : toolText("summary.recover");
191
+ return toolText("summary.not_confirmed", { failure, next });
192
+ }
193
+ // No receipt means no addressee and no storage to state; the JSON below carries the rest.
194
+ if (body.length === 0) return toolText("summary.plain");
195
+ const vars = { facts: body, mechanism };
196
+ if (outcome.wait === "answered" && outcome.reply !== undefined) {
197
+ const answer = { ...vars, author: outcome.reply.authenticated_author, seq: outcome.reply.seq };
198
+ return outcome.reply_body === "withheld"
199
+ ? toolText("summary.delivered_withheld", answer)
200
+ : toolText("summary.delivered_answered", answer);
201
+ }
202
+ if (outcome.wait === "timed_out") {
203
+ return toolText("summary.delivered_timed_out", vars);
204
+ }
205
+ if (outcome.wait === "cancelled") {
206
+ return toolText("summary.delivered_cancelled", vars);
207
+ }
208
+ return toolText("summary.delivered", vars);
209
+ }
210
+ return toolText("summary.plain");
211
+ }
212
+
213
+ /** The caller's spelling of one target, when it is a plain name rather than a structured ref. */
214
+ function spelling(value: unknown): string | undefined {
215
+ return typeof value === "string" && value.length > 0 ? value : undefined;
216
+ }
217
+
218
+ /** The facts one `msg` call produced, without the reply body. */
219
+ function outcomeFacts(result: unknown): Record<string, unknown> {
220
+ if (result === null || typeof result !== "object") return {};
221
+ const outcome = result as Partial<SendOutcome>;
222
+ return {
223
+ ...(outcome.admission === undefined ? {} : { admission: outcome.admission }),
224
+ ...(outcome.wait === undefined ? {} : { wait: outcome.wait }),
225
+ ...(outcome.reply_body === undefined ? {} : { withheld: outcome.reply_body === "withheld" }),
226
+ ...(outcome.receipt?.seq === undefined ? {} : { seq: outcome.receipt.seq }),
227
+ ...(outcome.receipt?.in_reply_to === undefined ? {} : { reply_to: `${outcome.receipt.in_reply_to.sender}/${outcome.receipt.in_reply_to.id}` }),
228
+ ...(outcome.reply === undefined ? {} : { entry: `${outcome.reply.thread_ref.owner}/${outcome.reply.thread_ref.id}/${outcome.reply.seq}` }),
229
+ ...(outcome.error === undefined ? {} : { code: outcome.error.code }),
230
+ };
231
+ }
232
+
233
+ /**
234
+ * An `await` that returned a reply is recorded as a pending custom entry. The
235
+ * host bridge confirms consumption when the model actually reads the result;
236
+ * submitting the tool result proves nothing (RFC 0008 §5).
237
+ */
238
+ function recordAwaitResult(pi: ExtensionAPI, toolCallId: string, result: unknown, ctx: ExtensionContext): void {
239
+ if (result === null || typeof result !== "object") return;
240
+ if ((result as Partial<SendOutcome>).reply_body === "withheld") return;
241
+ const reply = (result as Partial<SendOutcome>).reply;
242
+ if (reply === null || reply === undefined || typeof reply !== "object") return;
243
+ if (typeof reply.seq !== "string" || typeof reply.thread_ref?.owner !== "string" || typeof reply.thread_ref.id !== "string") return;
244
+ if (typeof reply.message?.sender !== "string" || typeof reply.message.id !== "string") return;
245
+ observeToolResult(toolCallId, reply, ctx.agent.id);
246
+ pi.appendEntry(AWAIT_RESULT, {
247
+ tool_call_id: toolCallId,
248
+ context: ctx.agent.id,
249
+ ref: { sender: reply.message.sender, id: reply.message.id },
250
+ thread_ref: reply.thread_ref,
251
+ seq: reply.seq,
252
+ state: "pending",
253
+ at: new Date().toISOString(),
254
+ });
255
+ }
256
+
257
+ function visibleResult(result: unknown): unknown {
258
+ if (result === null || typeof result !== "object") return result;
259
+ const outcome = result as Partial<SendOutcome>;
260
+ if (outcome.reply_body !== "withheld" || outcome.reply === undefined) return result;
261
+ const { thread_ref, seq, authenticated_author, reply_authority, at, message } = outcome.reply;
262
+ return {
263
+ ...outcome,
264
+ reply: {
265
+ thread_ref, seq, authenticated_author, reply_authority, at,
266
+ message: { sender: message.sender, id: message.id },
267
+ },
268
+ };
269
+ }
270
+
271
+ export function pendingAwaitResults(branch: readonly SessionEntry[]): Array<{ toolCallId: string; context: string; entry: NonNullable<SendOutcome["reply"]> }> {
272
+ const consumed = new Set(branch.flatMap(entry => entry.type === "custom" && entry.customType === AWAIT_CONSUMED
273
+ && typeof (entry.data as { tool_call_id?: unknown })?.tool_call_id === "string"
274
+ ? [(entry.data as { tool_call_id: string }).tool_call_id] : []));
275
+ const results = new Map(branch.flatMap(entry => entry.type === "message" && entry.message.role === "toolResult"
276
+ ? [[entry.message.toolCallId, entry.message] as const] : []));
277
+ return branch.flatMap(entry => {
278
+ if (entry.type !== "custom" || entry.customType !== AWAIT_RESULT) return [];
279
+ const data = entry.data as { tool_call_id?: unknown; context?: unknown; ref?: { sender?: unknown; id?: unknown } } | undefined;
280
+ if (typeof data?.tool_call_id !== "string" || typeof data.context !== "string" || consumed.has(data.tool_call_id)) return [];
281
+ const outcome = (results.get(data.tool_call_id)?.details as { result?: Partial<SendOutcome> } | undefined)?.result;
282
+ const reply = outcome?.reply;
283
+ if (outcome?.reply_body === "withheld" || typeof reply?.message?.msg !== "string"
284
+ || reply.message.sender !== data.ref?.sender || reply.message.id !== data.ref?.id) return [];
285
+ return [{ toolCallId: data.tool_call_id, context: data.context, entry: reply }];
286
+ });
287
+ }
288
+
289
+ function failure(text: string): AgentToolResult<{ result: unknown }> {
290
+ return {
291
+ content: [{ type: "text", text }],
292
+ details: { result: undefined },
293
+ isError: true,
294
+ };
295
+ }
296
+
297
+ /**
298
+ * RFC 0018 §3.1: a trust op names its peer mechanically. The fields are read before any
299
+ * judgement or storage write, so a malformed call is a caller error, never a policy answer.
300
+ */
301
+ function trustField(meta: Record<string, unknown> | undefined, field: string): string {
302
+ const value = meta?.[field];
303
+ if (typeof value !== "string" || value.length === 0) throw new OnlyneError("invalid_request", toolText("failure.trust_fields", { field }));
304
+ return value;
305
+ }
306
+
307
+ /**
308
+ * RFC 0018 §3.1: the peer a trust op names, resolved through the same peer set a send resolves
309
+ * `meta.to` from. An ambiguous alias names its matches; an unknown one is `not_found` and says
310
+ * which action asked, because the same alias may be valid for another one.
311
+ */
312
+ async function trustNode(runtime: PluginRuntime, op: string, meta: Record<string, unknown> | undefined): Promise<string> {
313
+ const node = trustField(meta, "node_id");
314
+ try {
315
+ return await runtime.decider.peerId(node);
316
+ } catch (error) {
317
+ if (error instanceof OnlyneError && error.error.code === "not_found") {
318
+ throw new OnlyneError("not_found", toolText("failure.trust_node_unknown", { action: op, node }), error.error.details);
319
+ }
320
+ throw error;
321
+ }
322
+ }
323
+
324
+ /** The sentence one membership op earns: the member, the thread, and where a later array send lands. */
325
+ function membershipText(op: "member_add" | "member_remove", result: unknown, meta: Record<string, unknown> | undefined): string {
326
+ const named = result !== null && typeof result === "object" && "peer" in result && typeof result.peer === "string"
327
+ ? result.peer
328
+ : threadLabel(meta?.to);
329
+ return toolText(op === "member_add" ? "thread.member_added" : "thread.member_removed", {
330
+ member: named,
331
+ thread: threadLabel(meta?.thread),
332
+ });
333
+ }
334
+
335
+ /** A reference as the caller wrote it: one name, or `owner/id` for the record form. */
336
+ function threadLabel(value: unknown): string {
337
+ if (typeof value === "string") return value;
338
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "";
339
+ const owner = "owner" in value ? value.owner : undefined;
340
+ const id = "id" in value ? value.id : undefined;
341
+ return typeof owner === "string" && typeof id === "string" ? `${owner}/${id}` : "";
342
+ }