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 CHANGED
@@ -24,6 +24,7 @@ key_scope = "project"
24
24
 
25
25
  [server]
26
26
  bind = "127.0.0.1"
27
+ # port is written by the runtime; set it only to pin one by hand.
27
28
 
28
29
  [threads]
29
30
  page_size = 50
@@ -35,12 +36,15 @@ references. `/onlyne init` suggests region from the current Tern session name
35
36
  and leaves the node id empty for the user. A short region is saved as
36
37
  `urn:onlyne:region:<encoded name>`.
37
38
 
38
- The listening port is allocated by the operating system at startup; the runtime
39
- derives the endpoint and publishes it through discovery. Discovery uses a seed
40
- address supplied during bootstrap and an HTTP candidate exchange stored in
41
- `.onlyne/omp.db`. DNS-SD is an optional same-link accelerator: install
39
+ The first start lets the operating system choose the listening port, and the
40
+ runtime writes that port back as `server.port`; every later start reuses it, so
41
+ the address a peer recorded keeps answering. A port another process already
42
+ holds moves the node to a system-chosen one, which is written back the same way.
43
+ The runtime derives the endpoint and publishes it through discovery. Discovery
44
+ uses a seed address supplied during bootstrap and an HTTP candidate exchange
45
+ stored in `.onlyne/omp.db`. DNS-SD is an optional same-link accelerator: install
42
46
  `bonjour-service` to enable it. The configuration does not contain endpoint,
43
- port, peers, task-spawn policy or supplement windows.
47
+ peers, task-spawn policy or supplement windows.
44
48
 
45
49
  `node.key_scope` names where the identity key lives. `project` keeps it in
46
50
  `<project>/.onlyne/keys`, which a git work tree must ignore; the node refuses to
@@ -66,6 +70,10 @@ separate security proposal; leave both at their defaults for the first E2E.
66
70
  `onlyne://outbound`, `onlyne://neighbors` — read-only views. List views accept
67
71
  `page_size` (or `limit`) and `cursor`; a conversation holds both directions of one
68
72
  exchange.
73
+ - `onlyne://threads` also lists the threads this node was invited into and does not own.
74
+ Those rows carry the owner, the last sequence the local records know, and the member
75
+ list when the owner answers; they are read-only, so the composer refuses them and
76
+ entries are read from the owner.
69
77
  - `onlyne://resources` reads the local node and peers whose key exchange is complete.
70
78
  Each owner returns its authorized resources or an explicit failure. Select
71
79
  `onlyne://resources/<encoded-owner>` for one owner's inventory and pagination.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-onlyne",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Onlyne v3 for OMP: one msg tool, thread-aware attention and SystemOne delivery decisions",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -39,8 +39,8 @@
39
39
  "test": "bun test"
40
40
  },
41
41
  "dependencies": {
42
- "@onlyne/lib": "^3.0.0",
43
- "@onlyne/node-host": "^3.0.0"
42
+ "@onlyne/lib": "^3.1.0",
43
+ "@onlyne/node-host": "^3.1.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@oh-my-pi/pi-agent-core": "*",
package/src/acquire.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { BlockList, isIP } from "node:net";
3
+
4
+ /** RFC 0018 §2: how far a peer may be acquired without a human confirming first. */
5
+ export type AcquirePolicy = "all" | "segment" | "none";
6
+
7
+ /** RFC 0018 §5: host-side acquisition settings. The library never reads them. */
8
+ export interface AcquisitionPolicy {
9
+ policy: AcquirePolicy;
10
+ /** CIDR blocks and host names; only read when `policy` is `segment`. */
11
+ allow: string[];
12
+ }
13
+
14
+ /**
15
+ * The default delegation: loopback only. Discovery excludes loopback by default, so the two
16
+ * defaults together mean a remote peer is neither discovered nor approved (RFC 0018 §5.1).
17
+ */
18
+ export const DEFAULT_ACQUISITION: AcquisitionPolicy = {
19
+ policy: "segment",
20
+ allow: ["127.0.0.0/8", "::1/128"],
21
+ };
22
+
23
+ /** Turns a host name into the addresses it names; an empty list means it names none. */
24
+ export type Resolver = (host: string) => Promise<string[]>;
25
+
26
+ const dnsResolver: Resolver = async host => (await lookup(host, { all: true })).map(row => row.address);
27
+
28
+ /** The configured policy, or the documented default when the config carries none. */
29
+ export function acquisitionOf(config: { acquisition?: AcquisitionPolicy }): AcquisitionPolicy {
30
+ return config.acquisition ?? DEFAULT_ACQUISITION;
31
+ }
32
+
33
+ export type AcquireReason =
34
+ | "policy_all"
35
+ | "policy_segment"
36
+ | "policy_denied_none"
37
+ | "policy_unobserved"
38
+ | "policy_denied_observed"
39
+ | "policy_denied_declared"
40
+ | "policy_denied_recorded";
41
+
42
+ /** RFC 0018 §2.1–2.2: what the host observed, what the peer declared, what discovery recorded. */
43
+ export interface AcquireInput {
44
+ /** The address this statement's connection arrived from; absent when it was not observed. */
45
+ observed?: string;
46
+ /** The `base_url` the peer wrote into its own statement. */
47
+ declared?: string;
48
+ /** The discovery addresses recorded for this node id when the statement arrived. */
49
+ recorded?: readonly string[];
50
+ }
51
+
52
+ export interface AcquireVerdict {
53
+ allowed: boolean;
54
+ reason: AcquireReason;
55
+ /** The allow entry the observed address matched, when one did. */
56
+ matched?: string;
57
+ }
58
+
59
+ interface Block {
60
+ network: string;
61
+ prefix: number;
62
+ type: "ipv4" | "ipv6";
63
+ }
64
+
65
+ const HOST_NAME = /^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/;
66
+
67
+ /** One `[acquisition] allow` entry: `network/prefix`, or a host name to accept as written. */
68
+ export function parseAllowBlock(value: string): Block | { name: string } | undefined {
69
+ const trimmed = value.trim();
70
+ if (trimmed.length === 0) return undefined;
71
+ const slash = trimmed.lastIndexOf("/");
72
+ if (slash <= 0) {
73
+ // A name is matched by its own spelling, so listing it involves no resolution. A bare
74
+ // address is not a block: it needs a prefix to say what it covers.
75
+ if (isIP(trimmed) !== 0 || !HOST_NAME.test(trimmed)) return undefined;
76
+ return { name: trimmed.toLowerCase() };
77
+ }
78
+ const prefix = Number(trimmed.slice(slash + 1));
79
+ if (!Number.isInteger(prefix) || prefix < 0) return undefined;
80
+ const network = literalAddress(trimmed.slice(0, slash));
81
+ if (network === undefined) return undefined;
82
+ if (prefix > (network.type === "ipv4" ? 32 : 128)) return undefined;
83
+ return { network: network.address, prefix, type: network.type };
84
+ }
85
+
86
+ /** The host a value names: a URL's hostname, or the value itself when it is already a host. */
87
+ function hostOf(value: string): string | undefined {
88
+ const trimmed = value.trim();
89
+ if (trimmed.length === 0) return undefined;
90
+ if (!trimmed.includes("://")) return trimmed.replace(/^\[(.*)\]$/, "$1");
91
+ try {
92
+ const host = new URL(trimmed).hostname;
93
+ return host.replace(/^\[(.*)\]$/, "$1");
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ /** A host that is already a literal address, with IPv4-mapped IPv6 read as IPv4. */
100
+ function literalAddress(host: string): { address: string; type: "ipv4" | "ipv6" } | undefined {
101
+ const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
102
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(bare);
103
+ if (mapped !== null) return { address: mapped[1]!, type: "ipv4" };
104
+ const family = isIP(bare);
105
+ if (family === 4) return { address: bare, type: "ipv4" };
106
+ if (family === 6) return { address: bare, type: "ipv6" };
107
+ return undefined;
108
+ }
109
+
110
+ function blockHolds(address: { address: string; type: "ipv4" | "ipv6" }, block: Block): boolean {
111
+ if (block.type !== address.type) return false;
112
+ const blocks = new BlockList();
113
+ blocks.addSubnet(block.network, block.prefix, block.type);
114
+ return blocks.check(address.address, block.type);
115
+ }
116
+
117
+ /** The allow entry an address falls in, or undefined. */
118
+ function entryFor(address: { address: string; type: "ipv4" | "ipv6" }, allow: readonly string[]): string | undefined {
119
+ for (const entry of allow) {
120
+ const block = parseAllowBlock(entry);
121
+ if (block === undefined || "name" in block) continue;
122
+ if (blockHolds(address, block)) return entry.trim();
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ /**
128
+ * The allow entry a value matches, or undefined. A literal address is judged as itself. A host
129
+ * name matches an allow entry of the same name; otherwise it is resolved and every address it
130
+ * names must fall inside the allow set (RFC 0018 §2.1) — a name that resolves to nothing, or to
131
+ * one address outside the set, matches nothing.
132
+ */
133
+ export async function allowMatch(value: string, allow: readonly string[], resolve: Resolver = dnsResolver): Promise<string | undefined> {
134
+ const host = hostOf(value);
135
+ if (host === undefined) return undefined;
136
+ const literal = literalAddress(host);
137
+ if (literal !== undefined) return entryFor(literal, allow);
138
+ const named = allow.find(entry => {
139
+ const block = parseAllowBlock(entry);
140
+ return block !== undefined && "name" in block && block.name === host.toLowerCase();
141
+ });
142
+ if (named !== undefined) return named.trim();
143
+ const resolved = await resolve(host).catch(() => [] as string[]);
144
+ let matched: string | undefined;
145
+ for (const address of resolved) {
146
+ const target = literalAddress(address);
147
+ if (target === undefined) return undefined;
148
+ const entry = entryFor(target, allow);
149
+ if (entry === undefined) return undefined;
150
+ matched ??= entry;
151
+ }
152
+ return matched;
153
+ }
154
+
155
+ /**
156
+ * RFC 0018 §2: decide whether this peer may be approved without a human confirming first.
157
+ *
158
+ * The declared address is checked because the trust row stores it and `peerBase` falls back to
159
+ * it when discovery has no record. The recorded addresses are checked because
160
+ * `discovery.resolve()` re-probes every one of them before it connects, so a record outside the
161
+ * allow set is an outbound request the policy never authorized.
162
+ */
163
+ export async function evaluateAcquisition(
164
+ policy: AcquisitionPolicy,
165
+ input: AcquireInput,
166
+ resolve: Resolver = dnsResolver,
167
+ ): Promise<AcquireVerdict> {
168
+ if (policy.policy === "none") return { allowed: false, reason: "policy_denied_none" };
169
+ if (policy.policy === "all") return { allowed: true, reason: "policy_all" };
170
+ if (input.observed === undefined) return { allowed: false, reason: "policy_unobserved" };
171
+ const observed = await allowMatch(input.observed, policy.allow, resolve);
172
+ if (observed === undefined) return { allowed: false, reason: "policy_denied_observed" };
173
+ if (input.declared !== undefined && await allowMatch(input.declared, policy.allow, resolve) === undefined) {
174
+ return { allowed: false, reason: "policy_denied_declared", matched: observed };
175
+ }
176
+ for (const address of input.recorded ?? []) {
177
+ if (await allowMatch(address, policy.allow, resolve) === undefined) {
178
+ return { allowed: false, reason: "policy_denied_recorded", matched: observed };
179
+ }
180
+ }
181
+ return { allowed: true, reason: "policy_segment", matched: observed };
182
+ }
@@ -51,6 +51,24 @@ const en = {
51
51
  todoNoLinks: "Onlyne todo metadata on entry {entry} has no links array; the association cannot be replayed.",
52
52
  todoUnknownRecordedAction: "Onlyne todo decision on entry {entry} has an unknown action.",
53
53
  todoMissingString: "Onlyne todo metadata on entry {entry} has no string {field}.",
54
+
55
+ trustHeader: "Onlyne trust · {phase} · {node}",
56
+ trustFingerprint: "fingerprint: {fingerprint}",
57
+ trustDeclared: "declared address: {declared}",
58
+ trustObserved: "observed address: {observed}",
59
+ trustUnobserved: "not observed",
60
+ trustPolicy: "acquisition policy: {policy}",
61
+ trustJudgement: "judgement: {reason} · {conclusion}",
62
+ trustAllowed: "allowed",
63
+ trustRefused: "refused",
64
+ trustInvited: "This peer's approval waits for yours. Decide it explicitly:",
65
+ trustApproved: "The acquisition policy approved this peer before any human confirmed, and the approval is committed. No action is requested.",
66
+ trustRequested: "Local approval is committed; the exchange completes when the peer confirms. This is a status update; no action is requested.",
67
+ trustExchanged: "Both sides approved. This is a status update; no action is requested.",
68
+ trustRevoked: "Local approval was dropped. This is a status update; no action is requested.",
69
+ trustAcceptAction: 'accept: msg({ delivery_kind: "accept the trust handshake", msg: "accept {node}", meta: { op: "trust_accept", node_id: "{node}", fingerprint: "{fingerprint}" } })',
70
+ trustRevokeAction: 'revoke: msg({ delivery_kind: "revoke the trust handshake", msg: "revoke {node}", meta: { op: "trust_revoke", node_id: "{node}" } })',
71
+ trustPanelOnly: "The acquisition policy is none, so this approval is the human's: only the panel can accept it. You may read and revoke.",
54
72
  };
55
73
 
56
74
  const zh: Record<keyof typeof en, string> = {
@@ -104,6 +122,24 @@ const zh: Record<keyof typeof en, string> = {
104
122
  todoNoLinks: "条目 {entry} 上的 onlyne todo 元数据没有 links 数组;无法重放该关联。",
105
123
  todoUnknownRecordedAction: "条目 {entry} 上的 onlyne todo 决策含有未知动作。",
106
124
  todoMissingString: "条目 {entry} 上的 onlyne todo 元数据缺少字符串字段 {field}。",
125
+
126
+ trustHeader: "Onlyne 信任 · {phase} · {node}",
127
+ trustFingerprint: "指纹:{fingerprint}",
128
+ trustDeclared: "声明地址:{declared}",
129
+ trustObserved: "观测地址:{observed}",
130
+ trustUnobserved: "未观测到",
131
+ trustPolicy: "获取策略:{policy}",
132
+ trustJudgement: "判定:{reason} · {conclusion}",
133
+ trustAllowed: "允许",
134
+ trustRefused: "拒绝",
135
+ trustInvited: "这个对端的批准在等你。请显式决定:",
136
+ trustApproved: "获取策略在任何人工确认之前批准了这个对端,批准已经提交。不索取任何动作。",
137
+ trustRequested: "本地批准已提交;对端确认后交换完成。这是状态更新,不索取任何动作。",
138
+ trustExchanged: "双方都已批准。这是状态更新,不索取任何动作。",
139
+ trustRevoked: "本地批准已撤销。这是状态更新,不索取任何动作。",
140
+ trustAcceptAction: '批准:msg({ delivery_kind: "批准信任握手", msg: "批准 {node}", meta: { op: "trust_accept", node_id: "{node}", fingerprint: "{fingerprint}" } })',
141
+ trustRevokeAction: '撤销:msg({ delivery_kind: "撤销信任握手", msg: "撤销 {node}", meta: { op: "trust_revoke", node_id: "{node}" } })',
142
+ trustPanelOnly: "获取策略是 none,这次批准属于人类:只有面板能接受它。你可以读取与撤销。",
107
143
  };
108
144
 
109
145
  export const bridgeText = createTranslator({ en, "zh-CN": zh });
package/src/config.ts CHANGED
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import type { OnlyneConfig } from "./types.ts";
4
4
  import type { Locale } from "@onlyne/node-host/i18n";
5
5
  import { hostText as t } from "./host-text.ts";
6
+ import { DEFAULT_ACQUISITION, parseAllowBlock, type AcquirePolicy, type AcquisitionPolicy } from "./acquire.ts";
6
7
 
7
8
  const CONFIG_RELATIVE_PATH = join(".onlyne", "config.toml");
8
9
  const REGION_PREFIX = "urn:onlyne:region:";
@@ -136,8 +137,12 @@ export function parseConfig(text: string, path: string): OnlyneConfig {
136
137
  const keyRef = optionalString(node, "key_ref", "node.key_ref");
137
138
  const keyScope = optionalString(node, "key_scope", "node.key_scope");
138
139
  const publicUrl = optionalString(server, "public_url", "server.public_url");
140
+ const port = optionalInteger(server, "port", "server.port");
139
141
  const seeds = discovery === undefined ? undefined : stringArray(discovery, "seeds", "discovery.seeds");
140
142
  const trust = trustSeeds(raw, path);
143
+ const acquisition = raw.acquisition === undefined ? undefined : table(raw, "acquisition", path);
144
+ const acquirePolicy = acquisition === undefined ? undefined : optionalString(acquisition, "policy", "acquisition.policy");
145
+ const acquireAllow = acquisition === undefined ? undefined : stringArray(acquisition, "allow", "acquisition.allow");
141
146
  const bind = optionalString(server, "bind", "server.bind");
142
147
  const pageSize = optionalInteger(threads, "page_size", "threads.page_size");
143
148
  return validateConfig({
@@ -145,8 +150,9 @@ export function parseConfig(text: string, path: string): OnlyneConfig {
145
150
  ...(raw.language === undefined ? {} : { language: raw.language as Locale }),
146
151
  region: region ?? "",
147
152
  node: { id: id ?? "", ...(keyRef === undefined ? {} : { key_ref: keyRef }), ...(keyScope === undefined ? {} : { key_scope: keyScope as "project" | "global" | "none" }) },
148
- server: { bind: bind ?? DEFAULT_BIND, ...(publicUrl === undefined ? {} : { public_url: publicUrl }) },
153
+ server: { bind: bind ?? DEFAULT_BIND, ...(port === undefined ? {} : { port }), ...(publicUrl === undefined ? {} : { public_url: publicUrl }) },
149
154
  threads: { page_size: pageSize ?? DEFAULT_PAGE_SIZE },
155
+ acquisition: { policy: acquirePolicyOf(acquirePolicy), allow: acquireAllow ?? [...DEFAULT_ACQUISITION.allow] },
150
156
  ...(seeds === undefined && trust === undefined ? {} : { discovery: { ...(seeds === undefined ? {} : { seeds }), ...(trust === undefined ? {} : { trust }) } }),
151
157
  ...(trust === undefined ? {} : { trust }),
152
158
  });
@@ -167,11 +173,15 @@ export function serializeConfig(config: OnlyneConfig): string {
167
173
  "",
168
174
  "[server]",
169
175
  `bind = ${tomlString(config.server.bind)}`,
176
+ ...(config.server.port === undefined ? [] : [`port = ${String(config.server.port)}`]),
170
177
  ...(config.server.public_url === undefined ? [] : [`public_url = ${tomlString(config.server.public_url)}`]),
171
178
  "",
172
179
  "[threads]",
173
180
  `page_size = ${String(config.threads.page_size)}`,
174
181
  );
182
+ const acquisition = config.acquisition ?? DEFAULT_ACQUISITION;
183
+ lines.push("", "[acquisition]", `policy = ${tomlString(acquisition.policy)}`);
184
+ if (acquisition.allow.length > 0) lines.push(`allow = [${acquisition.allow.map(entry => tomlString(entry)).join(", ")}]`);
175
185
  if (config.discovery?.seeds !== undefined || config.trust !== undefined) {
176
186
  lines.push("", "[discovery]");
177
187
  const seeds = config.discovery?.seeds ?? [];
@@ -192,11 +202,15 @@ function validateConfig(config: OnlyneConfig): OnlyneConfig {
192
202
  const keyRef = config.node.key_ref?.trim();
193
203
  const bind = config.server.bind.trim();
194
204
  const publicUrl = config.server.public_url?.trim();
205
+ const port = config.server.port;
195
206
  const keyScope = config.node.key_scope;
196
207
  if (keyScope !== undefined && keyScope !== "project" && keyScope !== "global" && keyScope !== "none") {
197
208
  throw new ConfigError("invalid", t("keyScopeInvalid", { value: JSON.stringify(keyScope) }));
198
209
  }
199
210
  if (publicUrl !== undefined && publicUrl.length === 0) throw new ConfigError("invalid", t("publicUrlEmpty"));
211
+ if (port !== undefined && (!Number.isInteger(port) || port < 0 || port > 65535)) {
212
+ throw new ConfigError("invalid", t("portRange", { value: String(port) }));
213
+ }
200
214
  // A seed is contacted at every start, so it is stored in the one spelling the node uses.
201
215
  const seeds = [...new Set((config.discovery?.seeds ?? []).map(seed => seed.trim()).filter(seed => seed.length > 0).map(normalizeServiceUrl))];
202
216
  if (bind.length === 0) throw new ConfigError("invalid", t("bindEmpty"));
@@ -207,13 +221,25 @@ function validateConfig(config: OnlyneConfig): OnlyneConfig {
207
221
  if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
208
222
  throw new ConfigError("invalid", t("pageSizeRange", { value: String(pageSize) }));
209
223
  }
224
+ const policy = config.acquisition?.policy ?? DEFAULT_ACQUISITION.policy;
225
+ if (policy !== "all" && policy !== "segment" && policy !== "none") {
226
+ throw new ConfigError("invalid", t("acquirePolicy", { value: JSON.stringify(policy) }));
227
+ }
228
+ const acquisition: AcquisitionPolicy = {
229
+ policy,
230
+ allow: [...new Set((config.acquisition?.allow ?? DEFAULT_ACQUISITION.allow).map(entry => entry.trim()).filter(entry => entry.length > 0))],
231
+ };
232
+ for (const entry of acquisition.allow) {
233
+ if (parseAllowBlock(entry) === undefined) throw new ConfigError("invalid", t("acquireAllow", { value: JSON.stringify(entry) }));
234
+ }
210
235
  return {
211
236
  path: config.path,
212
237
  ...(config.language === undefined ? {} : { language: config.language }),
213
238
  region,
214
239
  node: { id, ...(keyRef === undefined || keyRef.length === 0 ? {} : { key_ref: keyRef }), ...(keyScope === undefined ? {} : { key_scope: keyScope }) },
215
- server: { bind, ...(publicUrl === undefined ? {} : { public_url: publicUrl }) },
240
+ server: { bind, ...(port === undefined || port === 0 ? {} : { port }), ...(publicUrl === undefined ? {} : { public_url: publicUrl }) },
216
241
  threads: { page_size: pageSize },
242
+ acquisition,
217
243
  ...(seeds.length === 0 ? {} : { discovery: { seeds } }),
218
244
  ...(config.trust === undefined ? {} : { trust: config.trust }),
219
245
  };
@@ -226,7 +252,6 @@ function rejectRetired(
226
252
  threads: Record<string, unknown>,
227
253
  ): void {
228
254
  rejectKey(node, "endpoint", t("retiredEndpoint"));
229
- rejectKey(server, "port", t("retiredPort"));
230
255
  rejectKey(raw, "peers", t("retiredPeers"));
231
256
  rejectKey(raw, "omp", t("retiredOmp"));
232
257
  rejectKey(threads, "default_attention", t("retiredAttention"));
@@ -236,6 +261,13 @@ function rejectKey(source: Record<string, unknown>, key: string, message: string
236
261
  if (source[key] !== undefined) throw new ConfigError("invalid", message);
237
262
  }
238
263
 
264
+ /** `acquisition.policy`: the documented default when absent; any other word is a configuration error. */
265
+ function acquirePolicyOf(value: string | undefined): AcquirePolicy {
266
+ if (value === undefined) return DEFAULT_ACQUISITION.policy;
267
+ if (value === "all" || value === "segment" || value === "none") return value;
268
+ throw new ConfigError("invalid", t("acquirePolicy", { value: JSON.stringify(value) }));
269
+ }
270
+
239
271
  function table(root: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
240
272
  const value = root[key];
241
273
  if (value === undefined) return {};
@@ -7,11 +7,11 @@ import { createTranslator } from "@onlyne/node-host/i18n";
7
7
  const en = {
8
8
  "judge.o1.instructions":
9
9
  "Choose the one action the model's message asks for. communicate: deliver a message, reply or progress note to another agent. inspect: look up local or network state. manage_thread: create, close, describe or change members of a local thread. manage_domain: manage this node's domain membership. operate_resource: operate a resource the message names explicitly.",
10
- "judge.o1.communicate": "The message is meant for another agent or node, including asking a peer something.",
10
+ "judge.o1.communicate": "The message is meant for another agent or node: delivering, replying, asking, reporting, or starting a new task or topic with one. Work another agent should carry out is this intent even when the action sounds physical (deploy, archive, clean up).",
11
11
  "judge.o1.inspect":
12
- "The message asks to see node-level state: who is online, the inbox, a thread's messages, one message, or delivery evidence. The thread registry and a thread's members are not this.",
12
+ "The message asks to see node-level state: who is online, the neighbors or contacts list, the inbox, a thread's messages, one message, or delivery evidence. Listing or reading any of these is inspect. The thread registry and a thread's members are not this.",
13
13
  "judge.o1.manage_thread":
14
- "The message works on threads themselves: list the threads, describe one thread's members, create, close, add or remove a member. Reading a thread's messages is inspect.",
14
+ "The message works on threads themselves: list the threads, describe one thread's members, create, close, add or remove a member. Reading a thread's messages is inspect. Starting a new task or conversation with a peer is communicate, not this.",
15
15
  "judge.o1.manage_domain":
16
16
  "The message changes or reads this node's domain membership: join, leave, list or describe the membership, or discover a domain's members.",
17
17
  "judge.o1.operate_resource":
@@ -20,12 +20,14 @@ const en = {
20
20
  "judge.relation.instructions":
21
21
  "The message references an earlier message. Is it a final reply or a progress supplement?",
22
22
  "judge.relation.reply": "A final answer that may release the original sender's wait.",
23
- "judge.relation.followup": "A supplement or progress note that does not release a wait.",
23
+ "judge.relation.followup": "A supplement or progress note that does not release a wait. A courtesy acknowledgement of a message that asked for nothing (收到、感谢同步) is this, or a plain new message — not a reply that releases an expectation nobody holds.",
24
24
 
25
25
  "judge.target.instructions":
26
- "Choose the node that should receive this message. Each candidate is named by its node id; the alias is its short name.",
26
+ "Choose the node that should receive this message. Each candidate is named by its node id; the alias is its short name. none: no candidate is named or implied by this call — refusing beats a random guess.",
27
27
  "judge.target.peer":
28
28
  "Discovered peer {node} at {base_url} (instance {instance}, last seen {last_seen}).",
29
+ "judge.target.none":
30
+ "No candidate is named or implied by this call; sending would be a guess.",
29
31
 
30
32
  "judge.response.instructions":
31
33
  "Two facts decide: does the receiver have to act, and can the sender continue without the answer?",
@@ -50,7 +52,7 @@ const en = {
50
52
  "The message does not continue any published context and starts fresh work instead.",
51
53
 
52
54
  "judge.inbound.instructions":
53
- "Choose where this inbound message is handled. main_agent: the main session takes it. A context alias: the named execution context continues the work it was created for. new_subagent: fresh independent work in a new subagent.",
55
+ "Choose where this inbound message is handled. main_agent: the main session takes it — a message that needs no answer and is not a self-contained work item always lands here, however clearly it says no follow-up is wanted. A context alias: the named execution context continues the work it was created for. new_subagent: fresh independent work in a new subagent.",
54
56
  "judge.inbound.main_agent": "The message needs an answer, or no registered context's work matches it.",
55
57
  "judge.inbound.context": "{summary} (state: {state}, agent: {agent})",
56
58
  "judge.inbound.context_same_thread":
@@ -59,11 +61,11 @@ const en = {
59
61
  "The message is a self-contained work item with its own background, and needs no answer. A status note, greeting, relay or summary is not this.",
60
62
 
61
63
  "judge.needs_answer.instructions":
62
- "Does the sender expect this node to answer? A reference to an earlier message does not by itself create that expectation.",
64
+ "Does the sender expect this node to answer? A reference to an earlier message does not by itself create that expectation, and a message that explicitly says no reply is needed (不用回、不用发给我、报告直接入库即可) is always no, however much work it requests.",
63
65
  "judge.needs_answer.yes":
64
66
  "The sender asks a question, greets and waits for an answer, requests work whose result must be reported back, or otherwise needs a reply from this node. plan.response await or async is consistent with this.",
65
67
  "judge.needs_answer.no":
66
- "The sender expects nothing back: it completes a reply this node asked for, reports status, records a fact, or says no answer is needed. plan.response none is consistent with this.",
68
+ "The sender expects nothing back: it completes a reply this node asked for, reports status, records a fact, or explicitly says no reply is needed. plan.response none is consistent with this.",
67
69
 
68
70
  "judge.insertion.instructions":
69
71
  "The message goes to the main session. Choose when it should enter the agent's work.",
@@ -118,10 +120,17 @@ const en = {
118
120
  "judge.domain_op.describe": "Describe this node's membership.",
119
121
  "judge.domain_op.discover": "Discover a domain's declared members.",
120
122
 
123
+ "judge.original.instructions":
124
+ "This call states neither an in_reply_to reference nor a relation. Decide whether it answers one of the listed inbound messages — records this context can see that nobody has answered yet — or starts a new message. Decide from what the call is for; recency or being the only candidate never makes a call an answer.",
125
+ "judge.original.new": "Not an answer to any of them; a new message on its own topic.",
126
+ "judge.original.candidate": "Answer the inbound message from {from} ({kind}) at thread {thread} seq {seq}: {body}",
127
+
121
128
  "error.outbound_intent": "The decision did not choose an outbound intent.",
122
129
  "error.relation_needs_reference": "A relation needs an in_reply_to reference.",
123
130
  "error.reply_authority":
124
131
  "The reply must go to its original reply authority {authority}, not {target}.",
132
+ "error.reply_reference_conflict":
133
+ "The referenced message id belongs to {actual}, not to the sender you named ({named}). Answer that message, or name its sender.",
125
134
  "error.delivery_target": "The decision did not choose a delivery target.",
126
135
  "error.execution_context_missing":
127
136
  '{to} publishes no execution context this message continues. Send it with execution "new" to start one.',
@@ -222,11 +231,11 @@ const en = {
222
231
  const zh: Record<keyof typeof en, string> = {
223
232
  "judge.o1.instructions":
224
233
  "选择模型这条消息要求执行的那一个动作。communicate:向另一个 agent 投递消息、回复或进度说明。inspect:查询本地或网络状态。manage_thread:创建、关闭、描述本地 thread 或变更其成员。manage_domain:管理本节点的域成员身份。operate_resource:操作消息明确点名的资源。",
225
- "judge.o1.communicate": "这条消息是发给另一个 agent 或节点的,包括向对端提问。",
234
+ "judge.o1.communicate": "这条消息是发给另一个 agent 或节点的:投递、回复、提问、汇报,或与对端开启一项新任务、新话题。交给另一个 agent 去做的操作也属此类,即使动作听起来像物理操作(部署、归档、清理)。",
226
235
  "judge.o1.inspect":
227
- "这条消息要求查看节点级状态:谁在线、inbox、某个 thread 的消息、单条消息或投递凭据。thread 注册表和某个 thread 的成员不属于此。",
236
+ "这条消息要求查看节点级状态:谁在线、邻居或联系人列表、inbox、某个 thread 的消息、单条消息或投递凭据。列出或读取其中任何一项都属 inspect。thread 注册表和某个 thread 的成员不属于此。",
228
237
  "judge.o1.manage_thread":
229
- "这条消息作用于 thread 本身:列出 thread、描述某个 thread 的成员、创建、关闭、添加或移除成员。读取 thread 内的消息属于 inspect。",
238
+ "这条消息作用于 thread 本身:列出 thread、描述某个 thread 的成员、创建、关闭、添加或移除成员。读取 thread 内的消息属于 inspect。与对端开启一项新任务或新对话属于 communicate,不属于此。",
230
239
  "judge.o1.manage_domain":
231
240
  "这条消息改变或读取本节点的域成员身份:加入、离开、列出或描述成员身份,或发现某个域的成员。",
232
241
  "judge.o1.operate_resource":
@@ -234,12 +243,19 @@ const zh: Record<keyof typeof en, string> = {
234
243
 
235
244
  "judge.relation.instructions": "这条消息引用了更早的一条消息。它是最终回复还是进度补充?",
236
245
  "judge.relation.reply": "一个最终答复,可以解除原发送方的等待。",
237
- "judge.relation.followup": "一个补充或进度说明,不解除任何等待。",
246
+ "judge.relation.followup": "一个补充或进度说明,不解除任何等待。对并未索取回答的消息致意确认(收到、感谢同步)属此,或干脆作为新消息——而不是解除无人持有的等待的 reply。",
247
+
248
+ "judge.original.instructions":
249
+ "这次调用既没有 in_reply_to 引用,也没有 relation。请判断它是否在回答下面列出的某条入站消息(本上下文可见、且尚无人答复的记录),还是一条独立的新消息。按调用的目的判断;最新或唯一候选本身不能说明它就是回答。",
250
+ "judge.original.new": "都不是;这是一条独立话题的新消息。",
251
+ "judge.original.candidate": "回答来自 {from} 的入站消息({kind}),位于 thread {thread} seq {seq}:{body}",
238
252
 
239
253
  "judge.target.instructions":
240
- "选择应当接收这条消息的节点。每个候选以其 node id 命名,alias 是它的短名。",
254
+ "选择应当接收这条消息的节点。每个候选以其 node id 命名,alias 是它的短名。none:这次调用没有点名或暗示任何候选——宁可拒绝也不随机猜测。",
241
255
  "judge.target.peer":
242
256
  "已发现的 peer {node},位于 {base_url}(实例 {instance},最后出现于 {last_seen})。",
257
+ "judge.target.none":
258
+ "这次调用没有点名或暗示任何候选;强行发送只是猜测。",
243
259
 
244
260
  "judge.response.instructions":
245
261
  "两个事实决定:接收方是否必须动手,以及发送方能否在没有答复的情况下继续。",
@@ -261,7 +277,7 @@ const zh: Record<keyof typeof en, string> = {
261
277
  "judge.context.none": "这条消息不延续任何已发布的上下文,而是开启新工作。",
262
278
 
263
279
  "judge.inbound.instructions":
264
- "选择这条入站消息在哪里处理。main_agent:由主会话处理。上下文别名:由该执行上下文延续它当初被创建来处理的工作。new_subagent:在新的 subagent 中处理独立的新工作。",
280
+ "选择这条入站消息在哪里处理。main_agent:由主会话处理——不需要答复、也不是自成一体工作项的消息一律落在这里,无论它把「不用后续处理」说得多明确。上下文别名:由该执行上下文延续它当初被创建来处理的工作。new_subagent:在新的 subagent 中处理独立的新工作。",
265
281
  "judge.inbound.main_agent": "这条消息需要答复,或没有任何已注册上下文的工作与之匹配。",
266
282
  "judge.inbound.context": "{summary}(state: {state}, agent: {agent})",
267
283
  "judge.inbound.context_same_thread":
@@ -270,7 +286,7 @@ const zh: Record<keyof typeof en, string> = {
270
286
  "这条消息是一个自带背景、自成一体的工作项,且无需答复。状态说明、问候、转达或汇总不属于此。",
271
287
 
272
288
  "judge.needs_answer.instructions":
273
- "发送方是否期待本节点答复?仅引用更早的消息本身并不构成这种期待。",
289
+ "发送方是否期待本节点答复?仅引用更早的消息本身并不构成这种期待;明说不必回复(不用回、不用发给我、报告直接入库即可)的消息,无论请求多少工作,一律为 no。",
274
290
  "judge.needs_answer.yes":
275
291
  "发送方提出问题、问候并等待答复、请求必须回报结果的工作,或以其他方式需要本节点回复。plan.response 为 await 或 async 与此一致。",
276
292
  "judge.needs_answer.no":
@@ -329,6 +345,8 @@ const zh: Record<keyof typeof en, string> = {
329
345
  "error.outbound_intent": "决策没有选择出站意图。",
330
346
  "error.relation_needs_reference": "relation 需要 in_reply_to 引用。",
331
347
  "error.reply_authority": "回复必须发往其原始回复权威 {authority},而不是 {target}。",
348
+ "error.reply_reference_conflict":
349
+ "引用的消息 id 属于 {actual},而不是你指名的发送方({named})。请回答那条消息,或改用它的发送方。",
332
350
  "error.delivery_target": "决策没有选择投递目标。",
333
351
  "error.execution_context_missing":
334
352
  '{to} 未发布这条消息所延续的执行上下文。要以 execution "new" 发送以新建一个。',