omp-onlyne 0.2.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/README.md +3 -3
- package/package.json +3 -3
- package/src/bridge-text.ts +4 -8
- package/src/config.ts +6 -4
- package/src/decider-text.ts +3 -6
- package/src/decider.ts +124 -670
- package/src/drafts.ts +68 -19
- package/src/guard.ts +107 -0
- package/src/handshake.ts +2 -2
- package/src/messages.ts +24 -6
- package/src/plan.ts +133 -0
- package/src/questions.ts +250 -0
- package/src/resolve.ts +387 -0
- package/src/resources.ts +1 -1
- package/src/runtime.ts +201 -25
- package/src/tool.ts +11 -2
- package/src/types.ts +60 -66
- package/src/ui/bootstrap-text.ts +24 -0
- package/src/ui/bootstrap.ts +77 -3
- package/src/ui/index.ts +6 -2
- package/src/ui/mail-message.ts +5 -1
- package/src/ui/viewer-text.ts +112 -4
- package/src/ui/viewer.ts +629 -57
- package/src/urls-text.ts +6 -0
- package/src/urls.ts +36 -4
- package/src/work.ts +10 -0
- package/src/acquire.ts +0 -182
- package/src/host-text.ts +0 -92
- package/src/node.ts +0 -139
- package/src/store.ts +0 -189
package/src/decider.ts
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
type Entry,
|
|
11
11
|
type JoinedThread,
|
|
12
12
|
type Json,
|
|
13
|
-
type MessageRef,
|
|
14
13
|
type MsgInput,
|
|
15
14
|
type NodeId,
|
|
16
15
|
type Page,
|
|
@@ -21,26 +20,41 @@ import {
|
|
|
21
20
|
} from "@onlyne/lib";
|
|
22
21
|
import { getLocale } from "@onlyne/node-host/i18n";
|
|
23
22
|
import { isTrustThread } from "./handshake.ts";
|
|
24
|
-
import type { ContextRecord, OnlyneConfig } from "
|
|
25
|
-
import { canAnswer, type ContextRegistry } from "
|
|
23
|
+
import type { ContextRecord, OnlyneConfig } from "@onlyne/node-host/types";
|
|
24
|
+
import { canAnswer, type ContextRegistry } from "@onlyne/node-host/store";
|
|
26
25
|
import { text } from "./decider-text.ts";
|
|
26
|
+
import { ActionGuard, settleIntent, type OutboundIntent } from "./guard.ts";
|
|
27
|
+
import { continueThread, inheritedModes, resolvedModes, resourceAction, resourceFields, sendAction } from "./plan.ts";
|
|
28
|
+
import {
|
|
29
|
+
choice,
|
|
30
|
+
contextQuestions,
|
|
31
|
+
domainOpQuestions,
|
|
32
|
+
insertionQuestions,
|
|
33
|
+
intentQuestions,
|
|
34
|
+
needsAnswerQuestions,
|
|
35
|
+
originalQuestions,
|
|
36
|
+
planQuestions,
|
|
37
|
+
readScopeQuestions,
|
|
38
|
+
relationQuestions,
|
|
39
|
+
targetQuestions,
|
|
40
|
+
threadOpQuestions,
|
|
41
|
+
threadQuestions,
|
|
42
|
+
type Answers,
|
|
43
|
+
type Questions,
|
|
44
|
+
} from "./questions.ts";
|
|
45
|
+
import {
|
|
46
|
+
References,
|
|
47
|
+
knownPeers,
|
|
48
|
+
lastSegment,
|
|
49
|
+
threadKey,
|
|
50
|
+
type PeerRecord,
|
|
51
|
+
type ReplyReference,
|
|
52
|
+
} from "./resolve.ts";
|
|
27
53
|
|
|
28
54
|
type Row = Record<string, unknown>;
|
|
29
55
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
instructions: string;
|
|
33
|
-
criteria: Record<string, string>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export type Questions = Record<string, ChoiceQuestion>;
|
|
37
|
-
|
|
38
|
-
interface ChoiceAnswer {
|
|
39
|
-
type?: string;
|
|
40
|
-
choice?: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export type Answers = Record<string, ChoiceAnswer | undefined>;
|
|
56
|
+
export type { Answers, Questions } from "./questions.ts";
|
|
57
|
+
export type { PeerRecord } from "./resolve.ts";
|
|
44
58
|
|
|
45
59
|
/** A decision input that either carries its records or states why it has none. */
|
|
46
60
|
type Source<T> = { available: true; value: T } | { available: false; reason: string };
|
|
@@ -59,14 +73,6 @@ function valueOf<T>(source: unknown): T | undefined {
|
|
|
59
73
|
return row.value as T;
|
|
60
74
|
}
|
|
61
75
|
|
|
62
|
-
/** One discovered peer, as the library's discovery records it. */
|
|
63
|
-
export interface PeerRecord {
|
|
64
|
-
node_id: NodeId;
|
|
65
|
-
base_url: string;
|
|
66
|
-
instance_id: string;
|
|
67
|
-
last_seen: string;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
76
|
/** One execution context a receiving node publishes for a target-qualified delivery. */
|
|
71
77
|
export interface ReceiverContext {
|
|
72
78
|
alias: string;
|
|
@@ -108,72 +114,7 @@ export function currentDecision(): DecisionScope | undefined {
|
|
|
108
114
|
return decisionScope.getStore();
|
|
109
115
|
}
|
|
110
116
|
|
|
111
|
-
|
|
112
|
-
* The thread a send continues: the most recently opened open thread this node owns whose members
|
|
113
|
-
* already include the addressee. Derived from local records alone (RFC 0016 §5), so it costs no
|
|
114
|
-
* judgement. A node may hold several open threads with one peer; the newest is the conversation
|
|
115
|
-
* still in use, and a caller that wants a fresh topic names one or plans `execution: new`.
|
|
116
|
-
*
|
|
117
|
-
* Only a thread holding this node and the addressee continues: a thread with a third member is a
|
|
118
|
-
* group conversation, and folding a private message into it would put a body in front of every
|
|
119
|
-
* member. A group is addressed by naming it, which is why `meta.thread` wins over this lookup.
|
|
120
|
-
*/
|
|
121
|
-
export async function reuseThread(deps: DeciderDeps, to: NodeId): Promise<ThreadRef | undefined> {
|
|
122
|
-
const self = deps.config.node.id;
|
|
123
|
-
const threads = await deps.listThreads();
|
|
124
|
-
const candidates = threads
|
|
125
|
-
.filter(thread => thread.thread_ref.owner === self && thread.state === "open")
|
|
126
|
-
.sort((left, right) => right.created_at.localeCompare(left.created_at));
|
|
127
|
-
for (const thread of candidates) {
|
|
128
|
-
let members: NodeId[];
|
|
129
|
-
try {
|
|
130
|
-
members = await deps.threadMembers(thread.thread_ref);
|
|
131
|
-
} catch (error) {
|
|
132
|
-
// An unreadable member list is a reason to try the next thread, not to open a new one
|
|
133
|
-
// silently, so it is reported and skipped.
|
|
134
|
-
deps.log?.("onlyne.thread.members_unreadable", { thread: threadKey(thread.thread_ref), error: message(error) });
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
if (members.includes(to) && members.every(member => member === self || member === to)) return thread.thread_ref;
|
|
138
|
-
}
|
|
139
|
-
return undefined;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Every peer this node can name, from both sources that know one.
|
|
144
|
-
*
|
|
145
|
-
* A trust row is identity: the peer exists whether or not anyone answered a probe recently. A
|
|
146
|
-
* discovery record is an address, and a live one wins over the address a statement declared.
|
|
147
|
-
* Callers that ask "who is this" and callers that ask "who may receive this" must not diverge,
|
|
148
|
-
* or the same peer resolves for `meta.to` and vanishes from the target question — the shape of
|
|
149
|
-
* the reported `No discovered node is named …` failure.
|
|
150
|
-
*/
|
|
151
|
-
export async function knownPeers(deps: DeciderDeps): Promise<PeerRecord[]> {
|
|
152
|
-
const byId = new Map<NodeId, PeerRecord>();
|
|
153
|
-
let readable = false;
|
|
154
|
-
let failure = "";
|
|
155
|
-
try {
|
|
156
|
-
for (const row of await deps.listTrust()) {
|
|
157
|
-
readable = true;
|
|
158
|
-
if (row.phase === "revoked") continue;
|
|
159
|
-
byId.set(row.node_id, { node_id: row.node_id, base_url: row.base_url ?? "", instance_id: "trust", last_seen: "" });
|
|
160
|
-
}
|
|
161
|
-
} catch (error) {
|
|
162
|
-
failure = message(error);
|
|
163
|
-
deps.log?.("onlyne.judge.trust_unreadable", { error: failure });
|
|
164
|
-
}
|
|
165
|
-
try {
|
|
166
|
-
for (const peer of await deps.listPeers()) {
|
|
167
|
-
readable = true;
|
|
168
|
-
byId.set(peer.node_id, peer);
|
|
169
|
-
}
|
|
170
|
-
} catch (error) {
|
|
171
|
-
failure = message(error);
|
|
172
|
-
deps.log?.("onlyne.judge.peers_unreadable", { error: failure });
|
|
173
|
-
}
|
|
174
|
-
if (!readable) throw new OnlyneError("unavailable", text("reason.discovery_unreadable", { error: failure }));
|
|
175
|
-
return [...byId.values()].filter(peer => peer.node_id !== deps.config.node.id);
|
|
176
|
-
}
|
|
117
|
+
export { knownPeers, reuseThread } from "./resolve.ts";
|
|
177
118
|
|
|
178
119
|
export interface DeciderDeps {
|
|
179
120
|
config: OnlyneConfig;
|
|
@@ -261,13 +202,20 @@ export function fitState(state: Row): { fitted: Row; dropped: string[]; chars: n
|
|
|
261
202
|
|
|
262
203
|
/** Decisions go through OMP's `judge` role; the plugin never picks a target or a plan itself. */
|
|
263
204
|
export class JudgeDecider {
|
|
264
|
-
|
|
205
|
+
/** Deterministic reference resolution: one rule per reference, shared by every entry point. */
|
|
206
|
+
readonly refs: References;
|
|
207
|
+
private readonly guard: ActionGuard;
|
|
208
|
+
|
|
209
|
+
constructor(private readonly deps: DeciderDeps) {
|
|
210
|
+
this.refs = new References(this.deps);
|
|
211
|
+
this.guard = new ActionGuard({ contexts: deps.contexts, nodeId: deps.config.node.id });
|
|
212
|
+
}
|
|
265
213
|
|
|
266
214
|
async decide(scope: DecisionScope): Promise<Action> {
|
|
267
215
|
const explicit = await this.#explicit(scope);
|
|
268
216
|
if (explicit !== undefined) {
|
|
269
|
-
const filled = await this
|
|
270
|
-
await this.#
|
|
217
|
+
const filled = await continueThread(this.deps, explicit);
|
|
218
|
+
await this.#allow(scope, filled);
|
|
271
219
|
return filled;
|
|
272
220
|
}
|
|
273
221
|
|
|
@@ -276,7 +224,13 @@ export class JudgeDecider {
|
|
|
276
224
|
// asked for its own slice was told it had no rights at all (caerulea report 4, problem 1).
|
|
277
225
|
// Classification costs one local judge call; a write is refused either way.
|
|
278
226
|
const meta = scope.input.meta ?? {};
|
|
279
|
-
const
|
|
227
|
+
const settlement = settleIntent(await this.#intent(scope), meta);
|
|
228
|
+
if (settlement.override !== undefined) {
|
|
229
|
+
// The judge named an intent whose fields the call does not carry. Naming the fallback
|
|
230
|
+
// in the record is what makes a misread visible instead of a silent rewrite.
|
|
231
|
+
this.deps.log?.("onlyne.judge.overridden", { direction: "outbound", ...settlement.override });
|
|
232
|
+
}
|
|
233
|
+
const intent = settlement.intent;
|
|
280
234
|
const action = intent === "communicate"
|
|
281
235
|
? await this.#communicate(scope, meta)
|
|
282
236
|
: intent === "inspect"
|
|
@@ -285,32 +239,28 @@ export class JudgeDecider {
|
|
|
285
239
|
? await this.#threadAction(scope, meta)
|
|
286
240
|
: intent === "manage_domain"
|
|
287
241
|
? await this.#domainAction(scope, meta)
|
|
288
|
-
: await
|
|
289
|
-
const filled = await this
|
|
290
|
-
await this.#
|
|
242
|
+
: await resourceAction(meta);
|
|
243
|
+
const filled = await continueThread(this.deps, action);
|
|
244
|
+
await this.#allow(scope, filled);
|
|
291
245
|
this.deps.log?.("onlyne.judge.action", { direction: "outbound", ...actionFacts(filled) });
|
|
292
246
|
return filled;
|
|
293
247
|
}
|
|
294
248
|
|
|
249
|
+
/** The one guard every decided action passes before the client commits it. */
|
|
250
|
+
async #allow(scope: DecisionScope, action: Action): Promise<void> {
|
|
251
|
+
await this.guard.assert({ readOnly: scope.readOnly === true, agentId: scope.ctx.agent.id }, action);
|
|
252
|
+
}
|
|
253
|
+
|
|
295
254
|
/**
|
|
296
|
-
*
|
|
255
|
+
* Node O-1: one question, only the options this call can execute, no lists of peers,
|
|
256
|
+
* threads, or history.
|
|
297
257
|
*
|
|
298
|
-
*
|
|
299
|
-
* that says
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
* recorded. This is a lookup over local records, not a judgement (RFC 0016 §5), so it sits
|
|
303
|
-
* after both the explicit and the judged paths rather than inside either.
|
|
258
|
+
* `operate_resource` is offered only when the call names a complete resource action. The
|
|
259
|
+
* criterion that says a message mentioning a file is not this intent is still prose the
|
|
260
|
+
* model may misread; withholding the option makes the misread unanswerable rather than
|
|
261
|
+
* lethal (issue: outbound-intent-resource-false-positive).
|
|
304
262
|
*/
|
|
305
|
-
async #
|
|
306
|
-
if (action.kind !== "send" || action.thread !== undefined) return action;
|
|
307
|
-
if (action.in_reply_to !== undefined || action.execution === "new") return action;
|
|
308
|
-
const thread = await reuseThread(this.deps, action.to);
|
|
309
|
-
return thread === undefined ? action : { ...action, thread };
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/** Node O-1: one question, five options, no lists of peers, threads, or history. */
|
|
313
|
-
async #intent(scope: DecisionScope): Promise<"communicate" | "inspect" | "manage_thread" | "manage_domain" | "operate_resource"> {
|
|
263
|
+
async #intent(scope: DecisionScope): Promise<OutboundIntent> {
|
|
314
264
|
const started = Date.now();
|
|
315
265
|
const answers = await this.#judge(
|
|
316
266
|
scope.ctx,
|
|
@@ -319,19 +269,7 @@ export class JudgeDecider {
|
|
|
319
269
|
trigger: this.#trigger(scope.ctx),
|
|
320
270
|
host_env: { session_type: scope.ctx.agent.kind, read_only: scope.readOnly === true },
|
|
321
271
|
},
|
|
322
|
-
{
|
|
323
|
-
intent: {
|
|
324
|
-
type: "choice",
|
|
325
|
-
instructions: text("judge.o1.instructions"),
|
|
326
|
-
criteria: {
|
|
327
|
-
communicate: text("judge.o1.communicate"),
|
|
328
|
-
inspect: text("judge.o1.inspect"),
|
|
329
|
-
manage_thread: text("judge.o1.manage_thread"),
|
|
330
|
-
manage_domain: text("judge.o1.manage_domain"),
|
|
331
|
-
operate_resource: text("judge.o1.operate_resource"),
|
|
332
|
-
},
|
|
333
|
-
},
|
|
334
|
-
},
|
|
272
|
+
intentQuestions({ resource: resourceFields(scope.input.meta) !== undefined }),
|
|
335
273
|
scope.signal,
|
|
336
274
|
);
|
|
337
275
|
const intent = choice(answers, "intent");
|
|
@@ -344,7 +282,12 @@ export class JudgeDecider {
|
|
|
344
282
|
|
|
345
283
|
/** Nodes O-2A / O-3A / O-3A-Ctx for a send, reply or followup. */
|
|
346
284
|
async #communicate(scope: DecisionScope, meta: Record<string, Json>): Promise<Action> {
|
|
347
|
-
|
|
285
|
+
// Every reference this send names — recipient, thread, mentions, the message it answers —
|
|
286
|
+
// is resolved here, once, before the delivery plan is chosen. A plan built on a
|
|
287
|
+
// half-resolved reference is how the same spelling came to mean two things on two paths
|
|
288
|
+
// (audit §2.7).
|
|
289
|
+
const refs = await this.refs.forSend(meta);
|
|
290
|
+
let reply = refs.reply;
|
|
348
291
|
const explicitRelation = relation(meta);
|
|
349
292
|
if (reply === undefined && explicitRelation === undefined) {
|
|
350
293
|
// Node O-2A: no reference and no relation. The judge still answers one question —
|
|
@@ -363,23 +306,21 @@ export class JudgeDecider {
|
|
|
363
306
|
if (reply !== undefined) {
|
|
364
307
|
// O-2A pre-pruning: the reply authority is the only target; meta.to and the judge's
|
|
365
308
|
// answer must not move a reply to another node (caerulea report 3).
|
|
366
|
-
|
|
367
|
-
if (explicitTarget !== undefined && explicitTarget !== reply.authority) {
|
|
309
|
+
if (refs.target !== undefined && refs.target !== reply.authority) {
|
|
368
310
|
throw new OnlyneError(
|
|
369
311
|
"invalid_request",
|
|
370
|
-
text("error.reply_authority", { authority: reply.authority, target:
|
|
312
|
+
text("error.reply_authority", { authority: reply.authority, target: refs.target }),
|
|
371
313
|
);
|
|
372
314
|
}
|
|
373
315
|
to = reply.authority;
|
|
374
316
|
} else {
|
|
375
|
-
to =
|
|
317
|
+
to = refs.target ?? await this.#targetQuestion(scope, meta);
|
|
376
318
|
if (to.length === 0) {
|
|
377
319
|
throw new OnlyneError("ambiguous_target", text("error.delivery_target"));
|
|
378
320
|
}
|
|
379
321
|
}
|
|
380
322
|
|
|
381
323
|
const plan = await this.#deliveryPlan(scope, meta, lockedRelation, reply !== undefined);
|
|
382
|
-
const thread = await this.#sendThread(meta, to);
|
|
383
324
|
const context = plan.execution === "new" ? undefined : await this.#receiverContext(scope, to, meta);
|
|
384
325
|
if (plan.execution === "existing" && context === undefined) {
|
|
385
326
|
// RFC 0005 §1: `existing` disambiguates or refuses. Quietly opening a new context made
|
|
@@ -389,18 +330,16 @@ export class JudgeDecider {
|
|
|
389
330
|
text("error.execution_context_missing", { to }),
|
|
390
331
|
);
|
|
391
332
|
}
|
|
392
|
-
|
|
393
|
-
return {
|
|
394
|
-
kind: "send",
|
|
333
|
+
return sendAction({
|
|
395
334
|
to,
|
|
396
|
-
...(thread === undefined ? {} : { thread }),
|
|
335
|
+
...(refs.thread === undefined ? {} : { thread: refs.thread }),
|
|
397
336
|
...(context === undefined ? {} : { context }),
|
|
398
|
-
...(reply === undefined ? {} : {
|
|
337
|
+
...(reply === undefined ? {} : { reply }),
|
|
399
338
|
...(lockedRelation === undefined ? {} : { relation: lockedRelation }),
|
|
400
|
-
...(mentions === undefined ? {} : { mentions }),
|
|
339
|
+
...(refs.mentions === undefined ? {} : { mentions: refs.mentions }),
|
|
401
340
|
response: plan.response,
|
|
402
341
|
execution: plan.execution,
|
|
403
|
-
};
|
|
342
|
+
});
|
|
404
343
|
}
|
|
405
344
|
|
|
406
345
|
/**
|
|
@@ -409,23 +348,25 @@ export class JudgeDecider {
|
|
|
409
348
|
* the exact MessageRef and the owner-attested reply authority from the record it names, so
|
|
410
349
|
* no message id ever travels from the model to the wire.
|
|
411
350
|
*/
|
|
412
|
-
async #originalQuestion(scope: DecisionScope): Promise<
|
|
351
|
+
async #originalQuestion(scope: DecisionScope): Promise<ReplyReference | undefined> {
|
|
413
352
|
const candidates = await this.#replyCandidates(scope);
|
|
414
353
|
if (candidates.length === 0) return undefined;
|
|
415
354
|
const started = Date.now();
|
|
416
355
|
const keyOf = (index: number): string => `answer_${index + 1}`;
|
|
417
|
-
const criteria: Record<string, string> = { new_message: text("judge.original.new") };
|
|
418
356
|
const facts = candidates.map((entry, index) => {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
357
|
+
return {
|
|
358
|
+
key: keyOf(index),
|
|
359
|
+
from: entry.message.sender,
|
|
360
|
+
kind: entry.message.delivery_kind,
|
|
361
|
+
thread: threadKey(entry.thread_ref),
|
|
362
|
+
seq: entry.seq,
|
|
363
|
+
body: boundText(entry.message.msg),
|
|
364
|
+
};
|
|
424
365
|
});
|
|
425
366
|
const answers = await this.#judge(
|
|
426
367
|
scope.ctx,
|
|
427
368
|
{ focus: this.#focus(scope), trigger: this.#trigger(scope.ctx), candidates: facts },
|
|
428
|
-
|
|
369
|
+
originalQuestions(facts),
|
|
429
370
|
scope.signal,
|
|
430
371
|
);
|
|
431
372
|
this.#record("outbound.o2a_original", scope.input, answers, Date.now() - started);
|
|
@@ -505,16 +446,7 @@ export class JudgeDecider {
|
|
|
505
446
|
const answers = await this.#judge(
|
|
506
447
|
scope.ctx,
|
|
507
448
|
{ focus: this.#focus(scope), in_reply_to: meta.in_reply_to ?? null },
|
|
508
|
-
|
|
509
|
-
relation: {
|
|
510
|
-
type: "choice",
|
|
511
|
-
instructions: text("judge.relation.instructions"),
|
|
512
|
-
criteria: {
|
|
513
|
-
reply: text("judge.relation.reply"),
|
|
514
|
-
followup: text("judge.relation.followup"),
|
|
515
|
-
},
|
|
516
|
-
},
|
|
517
|
-
},
|
|
449
|
+
relationQuestions(),
|
|
518
450
|
scope.signal,
|
|
519
451
|
);
|
|
520
452
|
this.#record("outbound.o2a", scope.input, answers, Date.now() - started);
|
|
@@ -545,26 +477,7 @@ export class JudgeDecider {
|
|
|
545
477
|
})),
|
|
546
478
|
recent_inbound_unanswered: await this.#unanswered(scope.ctx),
|
|
547
479
|
},
|
|
548
|
-
|
|
549
|
-
target: {
|
|
550
|
-
type: "choice",
|
|
551
|
-
instructions: text("judge.target.instructions"),
|
|
552
|
-
criteria: {
|
|
553
|
-
// The abstain exit: an unnamed recipient must surface as ambiguous_target,
|
|
554
|
-
// never as a low-confidence random pick among peers.
|
|
555
|
-
none: text("judge.target.none"),
|
|
556
|
-
...Object.fromEntries(peers.map(peer => [
|
|
557
|
-
peer.node_id,
|
|
558
|
-
text("judge.target.peer", {
|
|
559
|
-
node: peer.node_id,
|
|
560
|
-
base_url: peer.base_url,
|
|
561
|
-
instance: peer.instance_id,
|
|
562
|
-
last_seen: peer.last_seen,
|
|
563
|
-
}),
|
|
564
|
-
])),
|
|
565
|
-
},
|
|
566
|
-
},
|
|
567
|
-
},
|
|
480
|
+
targetQuestions(peers),
|
|
568
481
|
scope.signal,
|
|
569
482
|
);
|
|
570
483
|
this.#record("outbound.o2a", scope.input, answers, Date.now() - started);
|
|
@@ -585,63 +498,25 @@ export class JudgeDecider {
|
|
|
585
498
|
relation: "reply" | "followup" | undefined,
|
|
586
499
|
isReply: boolean,
|
|
587
500
|
): Promise<{ response: "await" | "async" | "none"; execution: "existing" | "new" }> {
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
501
|
+
const inherited = inheritedModes(meta);
|
|
502
|
+
// A plan the caller stated completely is not a question: asking it would offer to
|
|
503
|
+
// overrule the one party that knows what the call wants.
|
|
504
|
+
if (inherited.response !== undefined && inherited.execution !== undefined) {
|
|
505
|
+
return resolvedModes(inherited, {});
|
|
592
506
|
}
|
|
593
507
|
const started = Date.now();
|
|
594
508
|
const answers = await this.#judge(
|
|
595
509
|
scope.ctx,
|
|
596
510
|
{ focus: this.#focus(scope), relation: relation ?? "new" },
|
|
597
|
-
{
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
instructions: text("judge.response.instructions"),
|
|
603
|
-
criteria: {
|
|
604
|
-
none: text("judge.response.none"),
|
|
605
|
-
async: text("judge.response.async"),
|
|
606
|
-
await: text("judge.response.await"),
|
|
607
|
-
},
|
|
608
|
-
},
|
|
609
|
-
}
|
|
610
|
-
: {}),
|
|
611
|
-
...(execution === undefined
|
|
612
|
-
? {
|
|
613
|
-
execution: {
|
|
614
|
-
type: "choice",
|
|
615
|
-
instructions: text("judge.execution.instructions"),
|
|
616
|
-
criteria: {
|
|
617
|
-
existing: isReply
|
|
618
|
-
? text("judge.execution.existing.reply")
|
|
619
|
-
: text("judge.execution.existing.new"),
|
|
620
|
-
new: text("judge.execution.new"),
|
|
621
|
-
},
|
|
622
|
-
},
|
|
623
|
-
}
|
|
624
|
-
: {}),
|
|
625
|
-
},
|
|
511
|
+
planQuestions({
|
|
512
|
+
response: inherited.response === undefined,
|
|
513
|
+
execution: inherited.execution === undefined,
|
|
514
|
+
reply: isReply,
|
|
515
|
+
}),
|
|
626
516
|
scope.signal,
|
|
627
517
|
);
|
|
628
518
|
this.#record("outbound.o3a", scope.input, answers, Date.now() - started);
|
|
629
|
-
|
|
630
|
-
const chosenExecution = execution ?? choice(answers, "execution");
|
|
631
|
-
if (chosenResponse !== "none" && chosenResponse !== "async" && chosenResponse !== "await") {
|
|
632
|
-
throw new OnlyneError("decision_unavailable", text("error.response_mode"));
|
|
633
|
-
}
|
|
634
|
-
if (chosenExecution !== "existing" && chosenExecution !== "new") {
|
|
635
|
-
throw new OnlyneError("decision_unavailable", text("error.execution_mode"));
|
|
636
|
-
}
|
|
637
|
-
return { response: chosenResponse, execution: chosenExecution };
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
#inherited(meta: Record<string, Json>, key: string): "await" | "async" | "none" | "existing" | "new" | undefined {
|
|
641
|
-
const value = meta[key];
|
|
642
|
-
if (value === "await" || value === "async" || value === "none" || value === "existing" || value === "new") return value;
|
|
643
|
-
if (value !== undefined) throw new OnlyneError("invalid_request", text("error.meta_mode", { key }));
|
|
644
|
-
return undefined;
|
|
519
|
+
return resolvedModes(inherited, answers);
|
|
645
520
|
}
|
|
646
521
|
|
|
647
522
|
/**
|
|
@@ -672,19 +547,7 @@ export class JudgeDecider {
|
|
|
672
547
|
const answers = await this.#judge(
|
|
673
548
|
scope.ctx,
|
|
674
549
|
{ focus: this.#focus(scope), target_published_contexts: published },
|
|
675
|
-
|
|
676
|
-
context: {
|
|
677
|
-
type: "choice",
|
|
678
|
-
instructions: text("judge.context.instructions"),
|
|
679
|
-
criteria: {
|
|
680
|
-
...Object.fromEntries(published.map(context => [
|
|
681
|
-
context.alias,
|
|
682
|
-
text("judge.context.published", { summary: context.summary, state: context.state }),
|
|
683
|
-
])),
|
|
684
|
-
none: text("judge.context.none"),
|
|
685
|
-
},
|
|
686
|
-
},
|
|
687
|
-
},
|
|
550
|
+
contextQuestions(published),
|
|
688
551
|
scope.signal,
|
|
689
552
|
);
|
|
690
553
|
this.#record("outbound.o3a-ctx", scope.input, answers, Date.now() - started);
|
|
@@ -695,32 +558,6 @@ export class JudgeDecider {
|
|
|
695
558
|
return alias;
|
|
696
559
|
}
|
|
697
560
|
|
|
698
|
-
/**
|
|
699
|
-
* The thread a send names. The thread decides where the message is stored; `to` decides who
|
|
700
|
-
* is addressed. An initiator owns its thread for the whole conversation, so every member
|
|
701
|
-
* writes into it and the client routes each message to that owner. A bare id that names no
|
|
702
|
-
* local thread is the target's thread id, as a receipt reports it.
|
|
703
|
-
*/
|
|
704
|
-
async #sendThread(meta: Record<string, Json> | undefined, to: NodeId): Promise<ThreadRef | undefined> {
|
|
705
|
-
if (meta?.thread === undefined) return undefined;
|
|
706
|
-
const value = meta.thread;
|
|
707
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
708
|
-
return this.#thread(meta);
|
|
709
|
-
}
|
|
710
|
-
if (typeof value !== "string" || value.length === 0) {
|
|
711
|
-
throw new OnlyneError("invalid_request", text("error.thread_reference_shape"));
|
|
712
|
-
}
|
|
713
|
-
const matches = (await this.#namedThreads()).filter(thread => thread.ref.id === value || thread.title === value);
|
|
714
|
-
if (matches.length > 1) throw new OnlyneError("ambiguous_target", text("error.ambiguous_local_thread", { value }));
|
|
715
|
-
if (matches.length === 1) return matches[0]!.ref;
|
|
716
|
-
// The same slash grammar as #threadByAlias; a bare id names a thread the addressee hosts.
|
|
717
|
-
const slash = value.lastIndexOf("/");
|
|
718
|
-
if (slash > 0 && slash < value.length - 1) {
|
|
719
|
-
return { owner: value.slice(0, slash), id: value.slice(slash + 1) };
|
|
720
|
-
}
|
|
721
|
-
return { owner: to, id: value };
|
|
722
|
-
}
|
|
723
|
-
|
|
724
561
|
/**
|
|
725
562
|
* One inbound entry's route. `plan` is the sender's delivery plan when the delivery named
|
|
726
563
|
* one: an explicit `plan.context` is binding, `execution: new` is only a preference.
|
|
@@ -848,16 +685,7 @@ export class JudgeDecider {
|
|
|
848
685
|
current_work: this.#trigger(scope),
|
|
849
686
|
thread_dialogue: await this.#threadEvidenceFor([entry.thread_ref]),
|
|
850
687
|
},
|
|
851
|
-
|
|
852
|
-
needs_answer: {
|
|
853
|
-
type: "choice",
|
|
854
|
-
instructions: text("judge.needs_answer.instructions"),
|
|
855
|
-
criteria: {
|
|
856
|
-
yes: text("judge.needs_answer.yes"),
|
|
857
|
-
no: text("judge.needs_answer.no"),
|
|
858
|
-
},
|
|
859
|
-
},
|
|
860
|
-
},
|
|
688
|
+
needsAnswerQuestions(),
|
|
861
689
|
undefined,
|
|
862
690
|
);
|
|
863
691
|
this.#record("inbound.i0", { delivery_kind: entry.message.delivery_kind, msg: entry.message.msg, meta: entry.message.meta }, answers, Date.now() - started);
|
|
@@ -898,11 +726,6 @@ export class JudgeDecider {
|
|
|
898
726
|
const started = Date.now();
|
|
899
727
|
// `fragment` never starts a turn, so a message that needs an answer can never be one;
|
|
900
728
|
// offering it would be an option whose choice guarantees the obligation is missed.
|
|
901
|
-
const criteria: Record<string, string> = {
|
|
902
|
-
steer: text("judge.insertion.steer"),
|
|
903
|
-
follow_up: text("judge.insertion.follow_up"),
|
|
904
|
-
...(needsAnswer ? {} : { fragment: text("judge.insertion.fragment") }),
|
|
905
|
-
};
|
|
906
729
|
const answers = await this.#judge(
|
|
907
730
|
scope,
|
|
908
731
|
{
|
|
@@ -910,13 +733,7 @@ export class JudgeDecider {
|
|
|
910
733
|
host_running_state: { is_turn_active: !scope.isIdle(), plan_mode: this.#planActive(scope) },
|
|
911
734
|
urgency_signals: { has_stop_or_change: URGENCY_CUES.some(cue => body.includes(cue)) },
|
|
912
735
|
},
|
|
913
|
-
{
|
|
914
|
-
insertion_mode: {
|
|
915
|
-
type: "choice",
|
|
916
|
-
instructions: text("judge.insertion.instructions"),
|
|
917
|
-
criteria,
|
|
918
|
-
},
|
|
919
|
-
},
|
|
736
|
+
insertionQuestions({ fragment: !needsAnswer }),
|
|
920
737
|
);
|
|
921
738
|
this.#record("inbound.i2", { delivery_kind: entry.message.delivery_kind, msg: entry.message.msg, meta: entry.message.meta }, answers, Date.now() - started);
|
|
922
739
|
const mode = choice(answers, "insertion_mode");
|
|
@@ -1071,8 +888,9 @@ export class JudgeDecider {
|
|
|
1071
888
|
}
|
|
1072
889
|
|
|
1073
890
|
/**
|
|
1074
|
-
* Only stable, explicit fields route without a decision.
|
|
1075
|
-
*
|
|
891
|
+
* Only stable, explicit fields route without a decision. A reference is resolved, never
|
|
892
|
+
* judged: `meta.thread`, `meta.to` and `meta.resource` name a record, and whether the
|
|
893
|
+
* message means to use them at all is what the judge answers.
|
|
1076
894
|
*/
|
|
1077
895
|
async #explicit(scope: DecisionScope): Promise<Action | undefined> {
|
|
1078
896
|
const meta = scope.input.meta;
|
|
@@ -1080,8 +898,8 @@ export class JudgeDecider {
|
|
|
1080
898
|
if (meta.scope === "resources") return this.#read(scope, "resources");
|
|
1081
899
|
const op = stringField(meta, "op");
|
|
1082
900
|
if (op !== undefined && isThreadOp(op)) {
|
|
1083
|
-
const thread = await this
|
|
1084
|
-
const to = await this
|
|
901
|
+
const thread = await this.refs.thread(meta);
|
|
902
|
+
const to = await this.refs.target(meta);
|
|
1085
903
|
const title = stringField(meta, "title");
|
|
1086
904
|
if (op === "create" && title === undefined) throw new OnlyneError("invalid_request", text("error.thread_title_required"));
|
|
1087
905
|
if (op !== "create" && op !== "list" && thread === undefined) {
|
|
@@ -1099,22 +917,7 @@ export class JudgeDecider {
|
|
|
1099
917
|
};
|
|
1100
918
|
}
|
|
1101
919
|
|
|
1102
|
-
|
|
1103
|
-
if (resource !== undefined) {
|
|
1104
|
-
const owner = stringField(resource, "owner");
|
|
1105
|
-
const id = stringField(resource, "id");
|
|
1106
|
-
const operation = stringField(meta, "operation");
|
|
1107
|
-
if (owner === undefined || id === undefined || operation === undefined) {
|
|
1108
|
-
throw new OnlyneError("invalid_request", text("error.resource_fields"));
|
|
1109
|
-
}
|
|
1110
|
-
return {
|
|
1111
|
-
kind: "resource",
|
|
1112
|
-
resource: { owner, id },
|
|
1113
|
-
operation,
|
|
1114
|
-
...(meta.data === undefined ? {} : { data: meta.data }),
|
|
1115
|
-
...(typeof meta.expected_revision === "string" ? { expected_revision: meta.expected_revision } : {}),
|
|
1116
|
-
};
|
|
1117
|
-
}
|
|
920
|
+
if (recordField(meta, "resource") !== undefined) return resourceAction(meta);
|
|
1118
921
|
|
|
1119
922
|
const scopeName = stringField(meta, "scope");
|
|
1120
923
|
if (scopeName !== undefined) {
|
|
@@ -1134,7 +937,7 @@ export class JudgeDecider {
|
|
|
1134
937
|
if (this.deps.resourceQuery === undefined) throw new OnlyneError("unavailable", text("error.resource_host_unavailable"));
|
|
1135
938
|
return { kind: "read", scope: name, query: await this.deps.resourceQuery(meta) };
|
|
1136
939
|
}
|
|
1137
|
-
const thread = await this
|
|
940
|
+
const thread = await this.refs.thread(meta);
|
|
1138
941
|
const page_size = pageSize(meta);
|
|
1139
942
|
const cursor = stringField(meta, "cursor");
|
|
1140
943
|
const seq = stringField(meta, "seq");
|
|
@@ -1164,7 +967,7 @@ export class JudgeDecider {
|
|
|
1164
967
|
attention: attention(meta),
|
|
1165
968
|
};
|
|
1166
969
|
}
|
|
1167
|
-
const resolved = await this
|
|
970
|
+
const resolved = await this.refs.message(meta, thread);
|
|
1168
971
|
if (resolved === undefined) {
|
|
1169
972
|
throw new OnlyneError("invalid_request", text("error.read_message_required", { name }));
|
|
1170
973
|
}
|
|
@@ -1185,20 +988,7 @@ export class JudgeDecider {
|
|
|
1185
988
|
const answers = await this.#judge(
|
|
1186
989
|
scope.ctx,
|
|
1187
990
|
{ focus: { delivery_kind: scope.input.delivery_kind, msg: scope.input.msg, meta: meta ?? null } },
|
|
1188
|
-
|
|
1189
|
-
read_scope: {
|
|
1190
|
-
type: "choice",
|
|
1191
|
-
instructions: text("judge.read_scope.instructions"),
|
|
1192
|
-
criteria: {
|
|
1193
|
-
peers: text("judge.read_scope.peers"),
|
|
1194
|
-
inbox: text("judge.read_scope.inbox"),
|
|
1195
|
-
messages: text("judge.read_scope.messages"),
|
|
1196
|
-
evidence: text("judge.read_scope.evidence"),
|
|
1197
|
-
resources: text("judge.read_scope.resources"),
|
|
1198
|
-
none: text("judge.read_scope.none"),
|
|
1199
|
-
},
|
|
1200
|
-
},
|
|
1201
|
-
},
|
|
991
|
+
readScopeQuestions(),
|
|
1202
992
|
scope.signal,
|
|
1203
993
|
);
|
|
1204
994
|
this.#record("outbound.o2b", scope.input, answers, Date.now() - started);
|
|
@@ -1214,7 +1004,7 @@ export class JudgeDecider {
|
|
|
1214
1004
|
throw new OnlyneError("decision_unavailable", text("error.read_scope_unknown", { scope: scopeName }));
|
|
1215
1005
|
}
|
|
1216
1006
|
const name: ActionReadScope = scopeName === "peers" ? "neighbors" : scopeName;
|
|
1217
|
-
const thread = await this
|
|
1007
|
+
const thread = await this.refs.thread(meta);
|
|
1218
1008
|
if (name === "messages") {
|
|
1219
1009
|
const chosen = thread ?? await this.#threadQuestion(scope, "outbound.o3b-th");
|
|
1220
1010
|
return {
|
|
@@ -1228,7 +1018,7 @@ export class JudgeDecider {
|
|
|
1228
1018
|
};
|
|
1229
1019
|
}
|
|
1230
1020
|
if (name === "evidence") {
|
|
1231
|
-
const resolved = await this
|
|
1021
|
+
const resolved = await this.refs.message(meta, thread);
|
|
1232
1022
|
if (resolved === undefined) {
|
|
1233
1023
|
throw new OnlyneError("invalid_request", text("error.read_message_required", { name }));
|
|
1234
1024
|
}
|
|
@@ -1263,21 +1053,7 @@ export class JudgeDecider {
|
|
|
1263
1053
|
next_seq: thread.next_seq,
|
|
1264
1054
|
})),
|
|
1265
1055
|
},
|
|
1266
|
-
{
|
|
1267
|
-
thread: {
|
|
1268
|
-
type: "choice",
|
|
1269
|
-
instructions: text("judge.thread.instructions"),
|
|
1270
|
-
criteria: Object.fromEntries(threads.map(thread => [
|
|
1271
|
-
thread.thread_ref.id,
|
|
1272
|
-
text("judge.thread.item", {
|
|
1273
|
-
title: thread.title,
|
|
1274
|
-
owner: thread.thread_ref.owner,
|
|
1275
|
-
id: thread.thread_ref.id,
|
|
1276
|
-
state: thread.state,
|
|
1277
|
-
}),
|
|
1278
|
-
])),
|
|
1279
|
-
},
|
|
1280
|
-
},
|
|
1056
|
+
threadQuestions(threads.map(thread => ({ ref: thread.thread_ref, title: thread.title, state: thread.state }))),
|
|
1281
1057
|
scope.signal,
|
|
1282
1058
|
);
|
|
1283
1059
|
this.#record(node, scope.input, answers, Date.now() - started);
|
|
@@ -1285,7 +1061,7 @@ export class JudgeDecider {
|
|
|
1285
1061
|
if (alias === undefined || alias === "none") {
|
|
1286
1062
|
throw new OnlyneError("decision_unavailable", text("error.local_thread_choice"));
|
|
1287
1063
|
}
|
|
1288
|
-
return this
|
|
1064
|
+
return this.refs.threadByAlias(alias);
|
|
1289
1065
|
}
|
|
1290
1066
|
|
|
1291
1067
|
/** Node O-2C: the local thread operation. */
|
|
@@ -1294,20 +1070,7 @@ export class JudgeDecider {
|
|
|
1294
1070
|
const answers = await this.#judge(
|
|
1295
1071
|
scope.ctx,
|
|
1296
1072
|
{ focus: this.#focus(scope) },
|
|
1297
|
-
|
|
1298
|
-
thread_op: {
|
|
1299
|
-
type: "choice",
|
|
1300
|
-
instructions: text("judge.thread_op.instructions"),
|
|
1301
|
-
criteria: {
|
|
1302
|
-
create: text("judge.thread_op.create"),
|
|
1303
|
-
close: text("judge.thread_op.close"),
|
|
1304
|
-
list: text("judge.thread_op.list"),
|
|
1305
|
-
describe: text("judge.thread_op.describe"),
|
|
1306
|
-
member_add: text("judge.thread_op.member_add"),
|
|
1307
|
-
member_remove: text("judge.thread_op.member_remove"),
|
|
1308
|
-
},
|
|
1309
|
-
},
|
|
1310
|
-
},
|
|
1073
|
+
threadOpQuestions(),
|
|
1311
1074
|
scope.signal,
|
|
1312
1075
|
);
|
|
1313
1076
|
this.#record("outbound.o2c", scope.input, answers, Date.now() - started);
|
|
@@ -1318,9 +1081,9 @@ export class JudgeDecider {
|
|
|
1318
1081
|
// A dependent second call: which thread the operation names is only decidable once the
|
|
1319
1082
|
// operation is known, and meta.thread settles it without a call (design §4, stepwise).
|
|
1320
1083
|
const thread = op === "create" || op === "list"
|
|
1321
|
-
? await this
|
|
1322
|
-
: await this
|
|
1323
|
-
const to = await this
|
|
1084
|
+
? await this.refs.thread(meta)
|
|
1085
|
+
: await this.refs.thread(meta) ?? await this.#threadQuestion(scope, "outbound.o2c-thread");
|
|
1086
|
+
const to = await this.refs.target(meta);
|
|
1324
1087
|
const title = stringField(meta, "title");
|
|
1325
1088
|
if ((op === "member_add" || op === "member_remove") && to === undefined) {
|
|
1326
1089
|
throw new OnlyneError("decision_unavailable", text("error.thread_op_target", { op }));
|
|
@@ -1343,20 +1106,7 @@ export class JudgeDecider {
|
|
|
1343
1106
|
const answers = await this.#judge(
|
|
1344
1107
|
scope.ctx,
|
|
1345
1108
|
{ focus: this.#focus(scope) },
|
|
1346
|
-
|
|
1347
|
-
domain_op: {
|
|
1348
|
-
type: "choice",
|
|
1349
|
-
instructions: text("judge.domain_op.instructions"),
|
|
1350
|
-
criteria: {
|
|
1351
|
-
create: text("judge.domain_op.create"),
|
|
1352
|
-
join: text("judge.domain_op.join"),
|
|
1353
|
-
leave: text("judge.domain_op.leave"),
|
|
1354
|
-
list: text("judge.domain_op.list"),
|
|
1355
|
-
describe: text("judge.domain_op.describe"),
|
|
1356
|
-
discover: text("judge.domain_op.discover"),
|
|
1357
|
-
},
|
|
1358
|
-
},
|
|
1359
|
-
},
|
|
1109
|
+
domainOpQuestions(),
|
|
1360
1110
|
scope.signal,
|
|
1361
1111
|
);
|
|
1362
1112
|
this.#record("outbound.o2d", scope.input, answers, Date.now() - started);
|
|
@@ -1376,24 +1126,6 @@ export class JudgeDecider {
|
|
|
1376
1126
|
};
|
|
1377
1127
|
}
|
|
1378
1128
|
|
|
1379
|
-
/** The resource branch carries no judge node: meta names every field. */
|
|
1380
|
-
async #resourceAction(meta: Record<string, Json>): Promise<Action> {
|
|
1381
|
-
const resource = recordField(meta, "resource");
|
|
1382
|
-
const owner = resource === undefined ? undefined : stringField(resource, "owner");
|
|
1383
|
-
const id = resource === undefined ? undefined : stringField(resource, "id");
|
|
1384
|
-
const operation = stringField(meta, "operation");
|
|
1385
|
-
if (owner === undefined || id === undefined || operation === undefined) {
|
|
1386
|
-
throw new OnlyneError("invalid_request", text("error.resource_action_fields"));
|
|
1387
|
-
}
|
|
1388
|
-
return {
|
|
1389
|
-
kind: "resource",
|
|
1390
|
-
resource: { owner, id },
|
|
1391
|
-
operation,
|
|
1392
|
-
...(meta.data === undefined ? {} : { data: meta.data }),
|
|
1393
|
-
...(typeof meta.expected_revision === "string" ? { expected_revision: meta.expected_revision } : {}),
|
|
1394
|
-
};
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
1129
|
/** The golden-triage head of every node state: what is being asked, verbatim. */
|
|
1398
1130
|
#focus(scope: DecisionScope): Row {
|
|
1399
1131
|
return {
|
|
@@ -1443,273 +1175,12 @@ export class JudgeDecider {
|
|
|
1443
1175
|
}
|
|
1444
1176
|
|
|
1445
1177
|
|
|
1446
|
-
async #thread(meta: Record<string, Json> | undefined): Promise<ThreadRef | undefined> {
|
|
1447
|
-
if (meta === undefined) return undefined;
|
|
1448
|
-
const value = meta.thread;
|
|
1449
|
-
if (value === undefined) return undefined;
|
|
1450
|
-
if (typeof value === "string") return this.#threadByAlias(value);
|
|
1451
|
-
const record = recordField(meta, "thread");
|
|
1452
|
-
const owner = record === undefined ? undefined : stringField(record, "owner");
|
|
1453
|
-
const id = record === undefined ? undefined : stringField(record, "id");
|
|
1454
|
-
if (owner !== undefined && id !== undefined) return { owner, id };
|
|
1455
|
-
throw new OnlyneError("invalid_request", text("error.thread_reference_shape"));
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
async #threadByAlias(alias: string): Promise<ThreadRef> {
|
|
1459
|
-
const matches = (await this.#namedThreads()).filter(thread => thread.ref.id === alias || thread.title === alias);
|
|
1460
|
-
if (matches.length === 1) return matches[0]!.ref;
|
|
1461
|
-
if (matches.length > 1) throw new OnlyneError("ambiguous_target", text("error.ambiguous_thread", { alias }));
|
|
1462
|
-
// One slash grammar for every textual thread reference: the owner is the part before the
|
|
1463
|
-
// LAST slash, which is safe for urn owners and for ids that never carry a slash.
|
|
1464
|
-
const slash = alias.lastIndexOf("/");
|
|
1465
|
-
if (slash > 0 && slash < alias.length - 1) {
|
|
1466
|
-
return { owner: alias.slice(0, slash), id: alias.slice(slash + 1) };
|
|
1467
|
-
}
|
|
1468
|
-
throw new OnlyneError("not_found", text("error.thread_not_found", { alias }));
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
/**
|
|
1472
|
-
* Every thread this node can name by id or title: the ones it hosts, plus the ones it was
|
|
1473
|
-
* invited into. A joined thread lives on its owner, so naming it here is a reference, never
|
|
1474
|
-
* a claim that this node holds the thread record.
|
|
1475
|
-
*/
|
|
1476
|
-
async #namedThreads(): Promise<Array<{ ref: ThreadRef; title: string | undefined }>> {
|
|
1477
|
-
const [owned, joined] = await Promise.all([this.deps.listThreads(), this.deps.listJoinedThreads()]);
|
|
1478
|
-
return [
|
|
1479
|
-
...owned.map(thread => ({ ref: thread.thread_ref, title: thread.title as string | undefined })),
|
|
1480
|
-
...joined.map(thread => ({ ref: thread.thread_ref, title: thread.title })),
|
|
1481
|
-
];
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
async #target(meta: Record<string, Json> | undefined): Promise<NodeId | undefined> {
|
|
1485
|
-
if (meta === undefined) return undefined;
|
|
1486
|
-
const value = meta.to;
|
|
1487
|
-
if (value === undefined) return undefined;
|
|
1488
|
-
if (typeof value !== "string") {
|
|
1489
|
-
throw new OnlyneError("invalid_request", text("error.meta_to_single"));
|
|
1490
|
-
}
|
|
1491
|
-
if (value.length === 0) throw new OnlyneError("invalid_request", text("error.meta_to_empty"));
|
|
1492
|
-
return this.#peerId(value);
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
1178
|
/**
|
|
1496
1179
|
* RFC 0018 §3.1: the node a mechanical `op` names, resolved against the same peer set a send
|
|
1497
1180
|
* resolves `meta.to` from, so a name that addresses a peer once addresses it everywhere.
|
|
1498
1181
|
*/
|
|
1499
1182
|
async peerId(alias: string): Promise<NodeId> {
|
|
1500
|
-
return this
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
/**
|
|
1504
|
-
* Resolve an explicit target to a node id. Identity comes from the trust table and the
|
|
1505
|
-
* discovery catalog, never from liveness: a peer whose exchange is done is still that peer
|
|
1506
|
-
* when its discovery record goes stale or is deleted. Whether we can reach it is a delivery
|
|
1507
|
-
* question, reported as `unavailable` by the transport, not a reason to claim no such node
|
|
1508
|
-
* exists. A bare alias is accepted only when exactly one known node's last segment names it.
|
|
1509
|
-
*/
|
|
1510
|
-
async #peerId(alias: string): Promise<NodeId> {
|
|
1511
|
-
const ids = await this.#knownIds();
|
|
1512
|
-
if (alias === "self") return this.deps.config.node.id;
|
|
1513
|
-
if (ids.includes(alias)) return alias;
|
|
1514
|
-
const matches = ids.filter(id => lastSegment(id) === alias);
|
|
1515
|
-
if (matches.length === 1) return matches[0]!;
|
|
1516
|
-
if (matches.length > 1) {
|
|
1517
|
-
throw new OnlyneError("ambiguous_target", text("error.alias_ambiguous", { alias, matches: matches.join(", ") }));
|
|
1518
|
-
}
|
|
1519
|
-
throw new OnlyneError("not_found", text("error.alias_not_found", { alias }));
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
/**
|
|
1523
|
-
* Every node this one can name: itself, each peer it holds a live trust row with, and each
|
|
1524
|
-
* peer discovery currently reports. The trust rows come first because they are the ones that
|
|
1525
|
-
* survive a peer moving, restarting or dropping out of the catalog for a refresh window.
|
|
1526
|
-
*/
|
|
1527
|
-
async #knownIds(): Promise<NodeId[]> {
|
|
1528
|
-
const ids = new Set<NodeId>([this.deps.config.node.id]);
|
|
1529
|
-
try {
|
|
1530
|
-
for (const peer of await knownPeers(this.deps)) ids.add(peer.node_id);
|
|
1531
|
-
} catch (error) {
|
|
1532
|
-
// Both sources failing leaves only this node; an alias then resolves to `not_found`,
|
|
1533
|
-
// which is the honest answer when nothing can be read at all.
|
|
1534
|
-
this.deps.log?.("onlyne.judge.peers_unreadable", { error: message(error) });
|
|
1535
|
-
}
|
|
1536
|
-
return [...ids];
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
async #mentions(meta: Record<string, Json> | undefined): Promise<string[] | undefined> {
|
|
1540
|
-
if (meta === undefined) return undefined;
|
|
1541
|
-
const value = meta.mentions;
|
|
1542
|
-
if (value === undefined) return undefined;
|
|
1543
|
-
if (!Array.isArray(value)) throw new OnlyneError("invalid_request", text("error.mentions_array"));
|
|
1544
|
-
const resolved: string[] = [];
|
|
1545
|
-
for (const item of value) {
|
|
1546
|
-
if (typeof item !== "string" || item.length === 0) {
|
|
1547
|
-
throw new OnlyneError("invalid_request", text("error.mention_empty"));
|
|
1548
|
-
}
|
|
1549
|
-
if (item === "@all") {
|
|
1550
|
-
resolved.push(item);
|
|
1551
|
-
continue;
|
|
1552
|
-
}
|
|
1553
|
-
resolved.push(await this.#peerId(item.startsWith("@") ? item.slice(1) : item));
|
|
1554
|
-
}
|
|
1555
|
-
return resolved;
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
async #reply(meta: Record<string, Json> | undefined): Promise<{ ref: MessageRef; authority: NodeId; thread_ref?: ThreadRef } | undefined> {
|
|
1559
|
-
if (meta === undefined) return undefined;
|
|
1560
|
-
const value = meta.in_reply_to;
|
|
1561
|
-
if (value === undefined) return undefined;
|
|
1562
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1563
|
-
throw new OnlyneError("invalid_request", text("error.in_reply_to_object"));
|
|
1564
|
-
}
|
|
1565
|
-
const record = value as Record<string, Json>;
|
|
1566
|
-
const sender = stringField(record, "sender");
|
|
1567
|
-
const id = stringField(record, "id");
|
|
1568
|
-
if (sender !== undefined && id !== undefined) {
|
|
1569
|
-
// The authority is an identity, not the caller's spelling: `caerulea` and
|
|
1570
|
-
// `urn:onlyne:agent:caerulea` name the same node, so both the ref and the authority
|
|
1571
|
-
// carry the resolved id and every later comparison is like-for-like. Comparing a raw
|
|
1572
|
-
// string against a resolved one produced two contradictory errors in a row
|
|
1573
|
-
// (cyanea report 1, problem 1).
|
|
1574
|
-
const authority = await this.#peerId(sender).catch(() => sender);
|
|
1575
|
-
// A reference the node can prove wrong is refused here, before the ref reaches the
|
|
1576
|
-
// wire and dies in the peer's not_found: an id that names a different sender locally
|
|
1577
|
-
// is a definite error, and the error names the message the id really belongs to.
|
|
1578
|
-
const elsewhere = (await this.deps.listInbox()).find(row =>
|
|
1579
|
-
row.attention.entry !== undefined && row.attention.entry.message.id === id
|
|
1580
|
-
&& row.attention.entry.message.sender !== authority);
|
|
1581
|
-
if (elsewhere !== undefined) {
|
|
1582
|
-
const actual = elsewhere.attention.entry!.message.sender;
|
|
1583
|
-
throw new OnlyneError("invalid_request", text("error.reply_reference_conflict", { named: sender, actual }));
|
|
1584
|
-
}
|
|
1585
|
-
return { ref: { sender: authority, id }, authority };
|
|
1586
|
-
}
|
|
1587
|
-
const threadAlias = stringField(record, "thread");
|
|
1588
|
-
const seq = seqField(record, "seq");
|
|
1589
|
-
if (threadAlias === undefined) {
|
|
1590
|
-
throw new OnlyneError("invalid_request", text("error.in_reply_to_shape"));
|
|
1591
|
-
}
|
|
1592
|
-
if (seq === undefined) {
|
|
1593
|
-
throw new OnlyneError("invalid_request", text("error.in_reply_to_seq"));
|
|
1594
|
-
}
|
|
1595
|
-
const thread = await this.#threadByAlias(threadAlias);
|
|
1596
|
-
const entry = await this.#entryAt(thread, seq);
|
|
1597
|
-
if (entry === undefined) throw new OnlyneError("not_found", text("error.message_not_found", { thread: threadAlias, seq }));
|
|
1598
|
-
return {
|
|
1599
|
-
ref: { sender: entry.message.sender, id: entry.message.id },
|
|
1600
|
-
authority: entry.reply_authority,
|
|
1601
|
-
thread_ref: entry.thread_ref,
|
|
1602
|
-
};
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
/**
|
|
1606
|
-
* The message a read names, with the thread it was resolved in. `{sender,id}` carries no
|
|
1607
|
-
* thread here; `{thread,seq}` does, and an explicit meta.thread that contradicts the
|
|
1608
|
-
* reference is refused rather than silently winning.
|
|
1609
|
-
*/
|
|
1610
|
-
async #messageRef(meta: Record<string, Json>, thread?: ThreadRef): Promise<{ ref: MessageRef; thread?: ThreadRef } | undefined> {
|
|
1611
|
-
const value = meta.message;
|
|
1612
|
-
const record = value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1613
|
-
? (value as Record<string, Json>)
|
|
1614
|
-
: undefined;
|
|
1615
|
-
const sender = record === undefined ? undefined : stringField(record, "sender");
|
|
1616
|
-
const id = record === undefined ? undefined : stringField(record, "id");
|
|
1617
|
-
if (sender !== undefined && id !== undefined) {
|
|
1618
|
-
return { ref: { sender, id }, ...(thread === undefined ? {} : { thread }) };
|
|
1619
|
-
}
|
|
1620
|
-
// meta.message takes the same two shapes as meta.in_reply_to, including its own thread
|
|
1621
|
-
// reference: `{thread, seq}` is the form a reader copies out of another reference, and
|
|
1622
|
-
// refusing it while accepting `{sender, id}` made the same fact two spellings (caerulea 4).
|
|
1623
|
-
const alias = record === undefined ? undefined : stringField(record, "thread");
|
|
1624
|
-
const embedded = alias === undefined ? undefined : await this.#threadByAlias(alias);
|
|
1625
|
-
if (thread !== undefined && embedded !== undefined && threadKey(thread) !== threadKey(embedded)) {
|
|
1626
|
-
throw new OnlyneError(
|
|
1627
|
-
"invalid_request",
|
|
1628
|
-
text("error.message_thread_conflict", { thread: threadKey(thread), embedded: threadKey(embedded) }),
|
|
1629
|
-
);
|
|
1630
|
-
}
|
|
1631
|
-
const scope = thread ?? embedded;
|
|
1632
|
-
if (scope === undefined) return undefined;
|
|
1633
|
-
const seq = (record === undefined ? undefined : seqField(record, "seq")) ?? seqField(meta, "seq");
|
|
1634
|
-
if (seq === undefined) return undefined;
|
|
1635
|
-
const entry = await this.#entryAt(scope, seq);
|
|
1636
|
-
if (entry === undefined) throw new OnlyneError("not_found", text("error.message_not_found", { thread: scope.id, seq }));
|
|
1637
|
-
return { ref: { sender: entry.message.sender, id: entry.message.id }, thread: scope };
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
async #entryAt(thread: ThreadRef, seq: string): Promise<Entry | undefined> {
|
|
1641
|
-
let cursor: string | undefined;
|
|
1642
|
-
for (let page = 0; page < MAX_TAIL_PAGES; page++) {
|
|
1643
|
-
const result: Page<Entry> = await this.deps.listMessages(thread, cursor);
|
|
1644
|
-
const found = result.items.find(entry => entry.seq === seq);
|
|
1645
|
-
if (found !== undefined) return found;
|
|
1646
|
-
cursor = result.next_cursor ?? undefined;
|
|
1647
|
-
if (cursor === undefined) return undefined;
|
|
1648
|
-
}
|
|
1649
|
-
return undefined;
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
|
-
/**
|
|
1653
|
-
* A subagent may read only the threads its own context records authorize on
|
|
1654
|
-
* this node. The unfiltered node-wide views stay with the main session.
|
|
1655
|
-
*/
|
|
1656
|
-
async #guard(scope: DecisionScope, action: Action): Promise<void> {
|
|
1657
|
-
if (scope.readOnly !== true) return;
|
|
1658
|
-
if (action.kind !== "read") {
|
|
1659
|
-
throw new OnlyneError(
|
|
1660
|
-
"forbidden",
|
|
1661
|
-
text("error.guard_write"),
|
|
1662
|
-
);
|
|
1663
|
-
}
|
|
1664
|
-
if (action.scope === "resources") throw new OnlyneError("forbidden", text("error.resources_main_only"));
|
|
1665
|
-
if (action.scope === "directory" || action.scope === "neighbors" || action.scope === "threads" || action.scope === "inbox") {
|
|
1666
|
-
throw new OnlyneError(
|
|
1667
|
-
"forbidden",
|
|
1668
|
-
text("error.guard_node_listing"),
|
|
1669
|
-
);
|
|
1670
|
-
}
|
|
1671
|
-
const records = await this.#authorizedRecords(scope.ctx.agent.id);
|
|
1672
|
-
if (action.scope === "evidence") {
|
|
1673
|
-
const ref = action.message;
|
|
1674
|
-
const tied = ref !== undefined && records.some(record =>
|
|
1675
|
-
record.source !== undefined && record.source.sender === ref.sender && record.source.id === ref.id,
|
|
1676
|
-
);
|
|
1677
|
-
if (!tied) {
|
|
1678
|
-
throw new OnlyneError("forbidden", text("error.guard_evidence"));
|
|
1679
|
-
}
|
|
1680
|
-
return;
|
|
1681
|
-
}
|
|
1682
|
-
// `{sender,id}` names no thread, so the record that received that message is the
|
|
1683
|
-
// authorization — the same tie evidence uses.
|
|
1684
|
-
if (action.scope === "message" && action.thread === undefined && action.message !== undefined) {
|
|
1685
|
-
const ref = action.message;
|
|
1686
|
-
const tied = records.some(record =>
|
|
1687
|
-
record.source !== undefined && record.source.sender === ref.sender && record.source.id === ref.id,
|
|
1688
|
-
);
|
|
1689
|
-
if (!tied) {
|
|
1690
|
-
throw new OnlyneError("forbidden", text("error.guard_message"));
|
|
1691
|
-
}
|
|
1692
|
-
return;
|
|
1693
|
-
}
|
|
1694
|
-
if (action.thread === undefined) {
|
|
1695
|
-
throw new OnlyneError("forbidden", text("error.guard_thread_required"));
|
|
1696
|
-
}
|
|
1697
|
-
const key = threadKey(action.thread);
|
|
1698
|
-
if (!records.some(record => record.thread_ref !== undefined && threadKey(record.thread_ref) === key)) {
|
|
1699
|
-
throw new OnlyneError("forbidden", text("error.guard_no_slice", { thread: key }));
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
|
-
async #authorizedRecords(agentId: string): Promise<ContextRecord[]> {
|
|
1704
|
-
let records: ContextRecord[];
|
|
1705
|
-
try {
|
|
1706
|
-
records = await this.deps.contexts.list();
|
|
1707
|
-
} catch (error) {
|
|
1708
|
-
throw new OnlyneError("unavailable", text("error.registry_unreadable", { error: message(error) }));
|
|
1709
|
-
}
|
|
1710
|
-
return records.filter(record =>
|
|
1711
|
-
record.node_id === this.deps.config.node.id && record.agent_id === agentId && record.thread_ref !== undefined,
|
|
1712
|
-
);
|
|
1183
|
+
return this.refs.peerId(alias);
|
|
1713
1184
|
}
|
|
1714
1185
|
|
|
1715
1186
|
async #contextRows(): Promise<Source<Row[]>> {
|
|
@@ -1874,12 +1345,6 @@ function actionFacts(action: Action): Record<string, string> {
|
|
|
1874
1345
|
return facts;
|
|
1875
1346
|
}
|
|
1876
1347
|
|
|
1877
|
-
function choice(answers: Answers, key: string): string | undefined {
|
|
1878
|
-
const answer = answers[key];
|
|
1879
|
-
if (answer === undefined || answer === null || typeof answer !== "object") return undefined;
|
|
1880
|
-
return typeof answer.choice === "string" ? answer.choice : undefined;
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
1348
|
function stringField(record: Record<string, Json>, key: string): string | undefined {
|
|
1884
1349
|
const value = record[key];
|
|
1885
1350
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
@@ -1935,10 +1400,6 @@ function branchOf(ctx: ExtensionContext): readonly SessionEntry[] {
|
|
|
1935
1400
|
return ctx.sessionManager.getBranch();
|
|
1936
1401
|
}
|
|
1937
1402
|
|
|
1938
|
-
function threadKey(ref: ThreadRef): string {
|
|
1939
|
-
return `${ref.owner}/${ref.id}`;
|
|
1940
|
-
}
|
|
1941
|
-
|
|
1942
1403
|
/**
|
|
1943
1404
|
* A wording hint about whether the sender expects an answer: metadata says reply, or the body
|
|
1944
1405
|
* greets or asks. It is evidence for node I-0, never the decision — a completed reply also
|
|
@@ -1951,13 +1412,6 @@ export function needsReplyOf(entry: Entry): boolean {
|
|
|
1951
1412
|
return REPLY_CUES.some(cue => body.includes(cue.toLowerCase())) || /\?|?|\bplease\b|期待/.test(entry.message.msg);
|
|
1952
1413
|
}
|
|
1953
1414
|
|
|
1954
|
-
/** A node id's last URN segment; plain ids are their own segment. */
|
|
1955
|
-
function lastSegment(nodeId: NodeId): string {
|
|
1956
|
-
const trimmed = nodeId.replace(/\/+$/, "");
|
|
1957
|
-
const parts = trimmed.split(/[:/]/).filter(part => part.length > 0);
|
|
1958
|
-
return parts.at(-1) ?? trimmed;
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
1415
|
function entryFacts(entry: Entry): Row {
|
|
1962
1416
|
return {
|
|
1963
1417
|
thread_ref: entry.thread_ref,
|