omp-onlyne 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/package.json +3 -3
- package/src/acquire.ts +182 -0
- package/src/bridge-text.ts +36 -0
- package/src/config.ts +35 -3
- package/src/decider-text.ts +33 -15
- package/src/decider.ts +306 -55
- package/src/handshake.ts +291 -0
- package/src/host-text.ts +10 -4
- package/src/messages.ts +110 -10
- package/src/node.ts +15 -3
- package/src/runtime.ts +268 -8
- package/src/tool-text.ts +64 -30
- package/src/tool.ts +125 -19
- package/src/types.ts +14 -0
- package/src/ui/bootstrap.ts +6 -1
- package/src/ui/viewer-text.ts +4 -0
- package/src/ui/viewer.ts +21 -4
- package/src/urls-text.ts +18 -0
- package/src/urls.ts +170 -29
package/src/decider.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type Action,
|
|
9
9
|
type DeliveryPlan,
|
|
10
10
|
type Entry,
|
|
11
|
+
type JoinedThread,
|
|
11
12
|
type Json,
|
|
12
13
|
type MessageRef,
|
|
13
14
|
type MsgInput,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
OnlyneError,
|
|
20
21
|
} from "@onlyne/lib";
|
|
21
22
|
import { getLocale } from "@onlyne/node-host/i18n";
|
|
23
|
+
import { isTrustThread } from "./handshake.ts";
|
|
22
24
|
import type { ContextRecord, OnlyneConfig } from "./types.ts";
|
|
23
25
|
import { canAnswer, type ContextRegistry } from "./store.ts";
|
|
24
26
|
import { text } from "./decider-text.ts";
|
|
@@ -31,14 +33,14 @@ interface ChoiceQuestion {
|
|
|
31
33
|
criteria: Record<string, string>;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
type Questions = Record<string, ChoiceQuestion>;
|
|
36
|
+
export type Questions = Record<string, ChoiceQuestion>;
|
|
35
37
|
|
|
36
38
|
interface ChoiceAnswer {
|
|
37
39
|
type?: string;
|
|
38
40
|
choice?: string;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
type Answers = Record<string, ChoiceAnswer | undefined>;
|
|
43
|
+
export type Answers = Record<string, ChoiceAnswer | undefined>;
|
|
42
44
|
|
|
43
45
|
/** A decision input that either carries its records or states why it has none. */
|
|
44
46
|
type Source<T> = { available: true; value: T } | { available: false; reason: string };
|
|
@@ -106,6 +108,73 @@ export function currentDecision(): DecisionScope | undefined {
|
|
|
106
108
|
return decisionScope.getStore();
|
|
107
109
|
}
|
|
108
110
|
|
|
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
|
+
}
|
|
177
|
+
|
|
109
178
|
export interface DeciderDeps {
|
|
110
179
|
config: OnlyneConfig;
|
|
111
180
|
/** Registry id of this node's main session; children are bound under it. */
|
|
@@ -113,12 +182,22 @@ export interface DeciderDeps {
|
|
|
113
182
|
contexts: ContextRegistry;
|
|
114
183
|
log?(message: string, fields: Record<string, unknown>): void;
|
|
115
184
|
listThreads(): Promise<Thread[]>;
|
|
185
|
+
/** The threads this node was invited into; they live on their owner, never here. */
|
|
186
|
+
listJoinedThreads(): Promise<JoinedThread[]>;
|
|
116
187
|
listMessages(thread: ThreadRef, cursor?: string): Promise<Page<Entry>>;
|
|
188
|
+
/** The peers one thread already lists as members; a send reuses the thread a peer belongs to. */
|
|
189
|
+
threadMembers(thread: ThreadRef): Promise<NodeId[]>;
|
|
117
190
|
/** Peers from the library's live discovery records, never a static configuration. */
|
|
118
191
|
listPeers(): Promise<PeerRecord[]>;
|
|
192
|
+
/** Every trust row this node holds, in any phase. Identity outlives a discovery record. */
|
|
193
|
+
listTrust(): Promise<Array<{ node_id: NodeId; phase: string; base_url?: string }>>;
|
|
194
|
+
/** The inbound records this node holds; O-2A's candidate set reads the entries from them. */
|
|
195
|
+
listInbox(): Promise<Array<{ context?: string; attention: { entry?: Entry; disposition?: string } }>>;
|
|
119
196
|
/** Contexts the target node publishes for `execution: existing`. */
|
|
120
197
|
listReceiverContexts(nodeId: NodeId): Promise<ReceiverContext[]>;
|
|
121
198
|
resourceQuery?(meta: Record<string, Json>): Promise<ResourceReadQuery>;
|
|
199
|
+
/** Test seam: answers the judge's questions without a model. Production never sets it. */
|
|
200
|
+
judge?: (state: Row, questions: Questions) => Promise<Answers>;
|
|
122
201
|
}
|
|
123
202
|
|
|
124
203
|
const TODO_ENTRY = "mouriya.omp.todo-more";
|
|
@@ -139,6 +218,8 @@ const INBOUND_CONTEXT_CAP = 3;
|
|
|
139
218
|
/** Threads and entries a target question scans for recent unanswered inbound messages. */
|
|
140
219
|
const RECENT_INBOUND_THREADS = 3;
|
|
141
220
|
const RECENT_INBOUND_PER_THREAD = 3;
|
|
221
|
+
/** The O-2A candidate list is capped; the judge reads one line per candidate. */
|
|
222
|
+
const REPLY_CANDIDATES = 5;
|
|
142
223
|
/** Newest branch rows that may reach the trigger derivation; older work is history, not decision material. */
|
|
143
224
|
const HISTORY_ROWS = 40;
|
|
144
225
|
/** One row's text cap: a pasted terminal dump must not crowd out the rest of the state. */
|
|
@@ -185,8 +266,9 @@ export class JudgeDecider {
|
|
|
185
266
|
async decide(scope: DecisionScope): Promise<Action> {
|
|
186
267
|
const explicit = await this.#explicit(scope);
|
|
187
268
|
if (explicit !== undefined) {
|
|
188
|
-
await this.#
|
|
189
|
-
|
|
269
|
+
const filled = await this.#threaded(explicit);
|
|
270
|
+
await this.#guard(scope, filled);
|
|
271
|
+
return filled;
|
|
190
272
|
}
|
|
191
273
|
|
|
192
274
|
// A subagent classifies exactly as the main session does and `#guard` decides what may run.
|
|
@@ -204,9 +286,27 @@ export class JudgeDecider {
|
|
|
204
286
|
: intent === "manage_domain"
|
|
205
287
|
? await this.#domainAction(scope, meta)
|
|
206
288
|
: await this.#resourceAction(meta);
|
|
207
|
-
await this.#
|
|
208
|
-
this
|
|
209
|
-
|
|
289
|
+
const filled = await this.#threaded(action);
|
|
290
|
+
await this.#guard(scope, filled);
|
|
291
|
+
this.deps.log?.("onlyne.judge.action", { direction: "outbound", ...actionFacts(filled) });
|
|
292
|
+
return filled;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The thread a send belongs to, decided once for every path that produces one.
|
|
297
|
+
*
|
|
298
|
+
* A reply lands on the thread that holds its original (the client resolves it), and a plan
|
|
299
|
+
* that says `new` opens a topic. Otherwise the message continues the conversation this node
|
|
300
|
+
* already has with its addressee: leaving it unnamed makes the client open a thread per
|
|
301
|
+
* message, and asking the model for an alias it cannot know is the mistake the design already
|
|
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.
|
|
304
|
+
*/
|
|
305
|
+
async #threaded(action: Action): Promise<Action> {
|
|
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 };
|
|
210
310
|
}
|
|
211
311
|
|
|
212
312
|
/** Node O-1: one question, five options, no lists of peers, threads, or history. */
|
|
@@ -244,8 +344,15 @@ export class JudgeDecider {
|
|
|
244
344
|
|
|
245
345
|
/** Nodes O-2A / O-3A / O-3A-Ctx for a send, reply or followup. */
|
|
246
346
|
async #communicate(scope: DecisionScope, meta: Record<string, Json>): Promise<Action> {
|
|
247
|
-
|
|
347
|
+
let reply = await this.#reply(meta);
|
|
248
348
|
const explicitRelation = relation(meta);
|
|
349
|
+
if (reply === undefined && explicitRelation === undefined) {
|
|
350
|
+
// Node O-2A: no reference and no relation. The judge still answers one question —
|
|
351
|
+
// whether this call answers one of the inbound messages this context can see — and
|
|
352
|
+
// the plugin copies the exact ref from the record it names. Candidates are evidence
|
|
353
|
+
// for the question only: recency or being the sole pending message never binds one.
|
|
354
|
+
reply = await this.#originalQuestion(scope);
|
|
355
|
+
}
|
|
249
356
|
const lockedRelation = reply !== undefined
|
|
250
357
|
? explicitRelation ?? await this.#relationQuestion(scope, meta)
|
|
251
358
|
: undefined;
|
|
@@ -296,6 +403,102 @@ export class JudgeDecider {
|
|
|
296
403
|
};
|
|
297
404
|
}
|
|
298
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Node O-2A: the original this call answers. The judge chooses among the inbound records
|
|
408
|
+
* this context can see and nobody has answered yet, plus `new_message`; the plugin copies
|
|
409
|
+
* the exact MessageRef and the owner-attested reply authority from the record it names, so
|
|
410
|
+
* no message id ever travels from the model to the wire.
|
|
411
|
+
*/
|
|
412
|
+
async #originalQuestion(scope: DecisionScope): Promise<{ ref: MessageRef; authority: NodeId; thread_ref?: ThreadRef } | undefined> {
|
|
413
|
+
const candidates = await this.#replyCandidates(scope);
|
|
414
|
+
if (candidates.length === 0) return undefined;
|
|
415
|
+
const started = Date.now();
|
|
416
|
+
const keyOf = (index: number): string => `answer_${index + 1}`;
|
|
417
|
+
const criteria: Record<string, string> = { new_message: text("judge.original.new") };
|
|
418
|
+
const facts = candidates.map((entry, index) => {
|
|
419
|
+
criteria[keyOf(index)] = text("judge.original.candidate", {
|
|
420
|
+
from: entry.message.sender, kind: entry.message.delivery_kind,
|
|
421
|
+
thread: threadKey(entry.thread_ref), seq: entry.seq, body: boundText(entry.message.msg),
|
|
422
|
+
});
|
|
423
|
+
return { key: keyOf(index), from: entry.message.sender, kind: entry.message.delivery_kind, thread: threadKey(entry.thread_ref), seq: entry.seq };
|
|
424
|
+
});
|
|
425
|
+
const answers = await this.#judge(
|
|
426
|
+
scope.ctx,
|
|
427
|
+
{ focus: this.#focus(scope), trigger: this.#trigger(scope.ctx), candidates: facts },
|
|
428
|
+
{ original: { type: "choice", instructions: text("judge.original.instructions"), criteria } },
|
|
429
|
+
scope.signal,
|
|
430
|
+
);
|
|
431
|
+
this.#record("outbound.o2a_original", scope.input, answers, Date.now() - started);
|
|
432
|
+
const picked = choice(answers, "original");
|
|
433
|
+
if (picked === undefined || picked === "new_message") return undefined;
|
|
434
|
+
const entry = candidates[Number(picked.slice("answer_".length)) - 1];
|
|
435
|
+
if (entry === undefined) {
|
|
436
|
+
throw new OnlyneError("decision_unavailable", text("error.reply_relation"));
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
ref: { sender: entry.message.sender, id: entry.message.id },
|
|
440
|
+
authority: entry.reply_authority,
|
|
441
|
+
thread_ref: entry.thread_ref,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The inbound records this call can see and nobody has answered: rows delivered to this
|
|
447
|
+
* session's context (the main session also sees rows no context claimed), minus the host's
|
|
448
|
+
* handshake bookkeeping, minus entries a local reply already references. This set is what
|
|
449
|
+
* O-2A offers the judge; it is never an answer by itself (RFC 0016 §7).
|
|
450
|
+
*/
|
|
451
|
+
async #replyCandidates(scope: DecisionScope): Promise<Entry[]> {
|
|
452
|
+
if (scope.readOnly === true) return [];
|
|
453
|
+
try {
|
|
454
|
+
const here = await this.#decisionContext(scope.ctx);
|
|
455
|
+
if (here === undefined) return [];
|
|
456
|
+
const rows = await this.deps.listInbox();
|
|
457
|
+
const candidates: Entry[] = [];
|
|
458
|
+
for (const row of rows) {
|
|
459
|
+
const entry = row.attention.entry;
|
|
460
|
+
if (entry === undefined || row.attention.disposition === "expired") continue;
|
|
461
|
+
if (entry.message.sender === this.deps.config.node.id) continue;
|
|
462
|
+
if (isTrustThread(this.deps.config.node.id, entry.thread_ref)) continue;
|
|
463
|
+
const deliveredHere = here.main
|
|
464
|
+
? row.context === undefined || row.context === "main"
|
|
465
|
+
: row.context === here.alias;
|
|
466
|
+
if (!deliveredHere) continue;
|
|
467
|
+
if (await this.#answered(entry)) continue;
|
|
468
|
+
candidates.push(entry);
|
|
469
|
+
if (candidates.length >= REPLY_CANDIDATES) break;
|
|
470
|
+
}
|
|
471
|
+
return candidates;
|
|
472
|
+
} catch (error) {
|
|
473
|
+
// A failed read narrows the question, not the decision: the call proceeds as a new message.
|
|
474
|
+
this.deps.log?.("onlyne.judge.hint_unavailable", { what: "reply_candidates", error: message(error) });
|
|
475
|
+
return [];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** The context this session answers in: the main session, or a registered subagent context. */
|
|
480
|
+
async #decisionContext(ctx: ExtensionContext): Promise<{ alias?: string; main: boolean } | undefined> {
|
|
481
|
+
if (ctx.agent.id === this.deps.mainAgentId) return { main: true };
|
|
482
|
+
const record = (await this.deps.contexts.list()).find(row => row.agent_id === ctx.agent.id);
|
|
483
|
+
return record === undefined ? undefined : { alias: record.alias, main: false };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Whether this node already answered the entry in the thread that holds it. */
|
|
487
|
+
async #answered(entry: Entry): Promise<boolean> {
|
|
488
|
+
let cursor: string | undefined;
|
|
489
|
+
for (let page = 0; page < MAX_TAIL_PAGES; page++) {
|
|
490
|
+
const result = await this.deps.listMessages(entry.thread_ref, cursor);
|
|
491
|
+
for (const row of result.items) {
|
|
492
|
+
const ref = row.message.meta?.in_reply_to;
|
|
493
|
+
if (row.message.sender === this.deps.config.node.id && ref !== undefined
|
|
494
|
+
&& ref.sender === entry.message.sender && ref.id === entry.message.id) return true;
|
|
495
|
+
}
|
|
496
|
+
cursor = result.next_cursor ?? undefined;
|
|
497
|
+
if (cursor === undefined) return false;
|
|
498
|
+
}
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
|
|
299
502
|
/** Node O-2A question 1: the reply/followup/new relation, asked only when meta left it open. */
|
|
300
503
|
async #relationQuestion(scope: DecisionScope, meta: Record<string, Json>): Promise<"reply" | "followup"> {
|
|
301
504
|
const started = Date.now();
|
|
@@ -324,19 +527,18 @@ export class JudgeDecider {
|
|
|
324
527
|
|
|
325
528
|
/** Node O-2A question 2: the delivery target among live peers, asked only when meta.to is absent. */
|
|
326
529
|
async #targetQuestion(scope: DecisionScope, meta: Record<string, Json>): Promise<NodeId> {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (peers.
|
|
333
|
-
if (peers.value.length === 1) return peers.value[0]!.node_id;
|
|
530
|
+
// The same set `#peerId` resolves against: a peer this node holds a trust row with is a
|
|
531
|
+
// candidate even when discovery has nothing fresh to say about its address.
|
|
532
|
+
const peers = await knownPeers(this.deps);
|
|
533
|
+
// One known peer is the answer; none means the caller must name a target.
|
|
534
|
+
if (peers.length === 0) throw new OnlyneError("not_found", text("error.no_receiver"));
|
|
535
|
+
if (peers.length === 1) return peers[0]!.node_id;
|
|
334
536
|
const started = Date.now();
|
|
335
537
|
const answers = await this.#judge(
|
|
336
538
|
scope.ctx,
|
|
337
539
|
{
|
|
338
540
|
focus: { ...this.#focus(scope), meta_to: meta.to ?? null },
|
|
339
|
-
candidate_peers: peers.
|
|
541
|
+
candidate_peers: peers.map(peer => ({
|
|
340
542
|
node_id: peer.node_id,
|
|
341
543
|
alias: lastSegment(peer.node_id),
|
|
342
544
|
last_seen: peer.last_seen,
|
|
@@ -347,15 +549,20 @@ export class JudgeDecider {
|
|
|
347
549
|
target: {
|
|
348
550
|
type: "choice",
|
|
349
551
|
instructions: text("judge.target.instructions"),
|
|
350
|
-
criteria:
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
+
},
|
|
359
566
|
},
|
|
360
567
|
},
|
|
361
568
|
scope.signal,
|
|
@@ -365,7 +572,7 @@ export class JudgeDecider {
|
|
|
365
572
|
if (chosen === undefined || chosen === "none") {
|
|
366
573
|
throw new OnlyneError("ambiguous_target", text("error.delivery_target"));
|
|
367
574
|
}
|
|
368
|
-
if (!peers.
|
|
575
|
+
if (!peers.some(peer => peer.node_id === chosen)) {
|
|
369
576
|
throw new OnlyneError("decision_unavailable", text("error.target_not_published", { chosen }));
|
|
370
577
|
}
|
|
371
578
|
return chosen;
|
|
@@ -503,10 +710,11 @@ export class JudgeDecider {
|
|
|
503
710
|
if (typeof value !== "string" || value.length === 0) {
|
|
504
711
|
throw new OnlyneError("invalid_request", text("error.thread_reference_shape"));
|
|
505
712
|
}
|
|
506
|
-
const matches = (await this
|
|
713
|
+
const matches = (await this.#namedThreads()).filter(thread => thread.ref.id === value || thread.title === value);
|
|
507
714
|
if (matches.length > 1) throw new OnlyneError("ambiguous_target", text("error.ambiguous_local_thread", { value }));
|
|
508
|
-
if (matches.length === 1) return matches[0]!.
|
|
509
|
-
|
|
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("/");
|
|
510
718
|
if (slash > 0 && slash < value.length - 1) {
|
|
511
719
|
return { owner: value.slice(0, slash), id: value.slice(slash + 1) };
|
|
512
720
|
}
|
|
@@ -833,6 +1041,7 @@ export class JudgeDecider {
|
|
|
833
1041
|
throw new OnlyneError("decision_unavailable", text("error.state_budget", { chars, cap: STATE_BUDGET_CHARS }));
|
|
834
1042
|
}
|
|
835
1043
|
if (dropped.length > 0) this.deps.log?.("onlyne.judge.trimmed", { dropped, chars });
|
|
1044
|
+
if (this.deps.judge !== undefined) return await this.deps.judge(fitted as Row, questions);
|
|
836
1045
|
const settings = (await Settings.current) ?? (await Settings.loadReadOnly({ cwd: scope.cwd }));
|
|
837
1046
|
const judge = resolveJudge({
|
|
838
1047
|
settings,
|
|
@@ -1247,17 +1456,31 @@ export class JudgeDecider {
|
|
|
1247
1456
|
}
|
|
1248
1457
|
|
|
1249
1458
|
async #threadByAlias(alias: string): Promise<ThreadRef> {
|
|
1250
|
-
const
|
|
1251
|
-
|
|
1252
|
-
if (matches.length === 1) return matches[0]!.thread_ref;
|
|
1459
|
+
const matches = (await this.#namedThreads()).filter(thread => thread.ref.id === alias || thread.title === alias);
|
|
1460
|
+
if (matches.length === 1) return matches[0]!.ref;
|
|
1253
1461
|
if (matches.length > 1) throw new OnlyneError("ambiguous_target", text("error.ambiguous_thread", { alias }));
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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) };
|
|
1257
1467
|
}
|
|
1258
1468
|
throw new OnlyneError("not_found", text("error.thread_not_found", { alias }));
|
|
1259
1469
|
}
|
|
1260
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
|
+
|
|
1261
1484
|
async #target(meta: Record<string, Json> | undefined): Promise<NodeId | undefined> {
|
|
1262
1485
|
if (meta === undefined) return undefined;
|
|
1263
1486
|
const value = meta.to;
|
|
@@ -1270,15 +1493,22 @@ export class JudgeDecider {
|
|
|
1270
1493
|
}
|
|
1271
1494
|
|
|
1272
1495
|
/**
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1496
|
+
* RFC 0018 §3.1: the node a mechanical `op` names, resolved against the same peer set a send
|
|
1497
|
+
* resolves `meta.to` from, so a name that addresses a peer once addresses it everywhere.
|
|
1498
|
+
*/
|
|
1499
|
+
async peerId(alias: string): Promise<NodeId> {
|
|
1500
|
+
return this.#peerId(alias);
|
|
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.
|
|
1275
1509
|
*/
|
|
1276
1510
|
async #peerId(alias: string): Promise<NodeId> {
|
|
1277
|
-
const
|
|
1278
|
-
if (!peers.available) {
|
|
1279
|
-
throw new OnlyneError("unavailable", text("error.target_unresolved", { reason: peers.reason }));
|
|
1280
|
-
}
|
|
1281
|
-
const ids = [this.deps.config.node.id, ...peers.value.map(peer => peer.node_id)];
|
|
1511
|
+
const ids = await this.#knownIds();
|
|
1282
1512
|
if (alias === "self") return this.deps.config.node.id;
|
|
1283
1513
|
if (ids.includes(alias)) return alias;
|
|
1284
1514
|
const matches = ids.filter(id => lastSegment(id) === alias);
|
|
@@ -1289,6 +1519,23 @@ export class JudgeDecider {
|
|
|
1289
1519
|
throw new OnlyneError("not_found", text("error.alias_not_found", { alias }));
|
|
1290
1520
|
}
|
|
1291
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
|
+
|
|
1292
1539
|
async #mentions(meta: Record<string, Json> | undefined): Promise<string[] | undefined> {
|
|
1293
1540
|
if (meta === undefined) return undefined;
|
|
1294
1541
|
const value = meta.mentions;
|
|
@@ -1320,11 +1567,22 @@ export class JudgeDecider {
|
|
|
1320
1567
|
const id = stringField(record, "id");
|
|
1321
1568
|
if (sender !== undefined && id !== undefined) {
|
|
1322
1569
|
// The authority is an identity, not the caller's spelling: `caerulea` and
|
|
1323
|
-
// `urn:onlyne:agent:caerulea` name the same node,
|
|
1324
|
-
// resolved
|
|
1325
|
-
//
|
|
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).
|
|
1326
1574
|
const authority = await this.#peerId(sender).catch(() => sender);
|
|
1327
|
-
|
|
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 };
|
|
1328
1586
|
}
|
|
1329
1587
|
const threadAlias = stringField(record, "thread");
|
|
1330
1588
|
const seq = seqField(record, "seq");
|
|
@@ -1484,14 +1742,6 @@ export class JudgeDecider {
|
|
|
1484
1742
|
}
|
|
1485
1743
|
|
|
1486
1744
|
|
|
1487
|
-
async #peers(): Promise<Source<PeerRecord[]>> {
|
|
1488
|
-
try {
|
|
1489
|
-
return ok(await this.deps.listPeers());
|
|
1490
|
-
} catch (error) {
|
|
1491
|
-
return none(text("reason.discovery_unreadable", { error: message(error) }));
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
1745
|
async #receiverContexts(nodeId: NodeId): Promise<Source<ReceiverContext[]>> {
|
|
1496
1746
|
try {
|
|
1497
1747
|
return ok(await this.deps.listReceiverContexts(nodeId));
|
|
@@ -1663,7 +1913,8 @@ function pageSize(meta: Record<string, Json>): number | undefined {
|
|
|
1663
1913
|
|
|
1664
1914
|
function attention(meta: Record<string, Json>): "inbox" | "thread" {
|
|
1665
1915
|
const value = meta.attention;
|
|
1666
|
-
|
|
1916
|
+
// RFC 0005 §1: reads default to the attention list; `thread` is the switch that widens one.
|
|
1917
|
+
if (value === undefined) return "inbox";
|
|
1667
1918
|
if (value === "inbox" || value === "thread") return value;
|
|
1668
1919
|
throw new OnlyneError("invalid_request", text("error.attention"));
|
|
1669
1920
|
}
|