omp-onlyne 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/decider.ts ADDED
@@ -0,0 +1,2233 @@
1
+ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
2
+ import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
3
+ import { journalJudgmentUsage, resolveJudge } from "@oh-my-pi/pi-coding-agent/judgment";
4
+ import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
5
+ import type { SessionEntry } from "@oh-my-pi/pi-coding-agent/session/session-entries";
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ import {
8
+ type Action,
9
+ type DeliveryPlan,
10
+ type Entry,
11
+ type JoinedThread,
12
+ type Json,
13
+ type MessageRef,
14
+ type MsgInput,
15
+ type NodeId,
16
+ type Page,
17
+ type ResourceReadQuery,
18
+ type Thread,
19
+ type ThreadRef,
20
+ OnlyneError,
21
+ } from "@onlyne/lib";
22
+ import { getLocale } from "@onlyne/node-host/i18n";
23
+ import { isTrustThread } from "./handshake.ts";
24
+ import type { ContextRecord, OnlyneConfig } from "./types.ts";
25
+ import { canAnswer, type ContextRegistry } from "./store.ts";
26
+ import { text } from "./decider-text.ts";
27
+
28
+ type Row = Record<string, unknown>;
29
+
30
+ interface ChoiceQuestion {
31
+ type: "choice";
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>;
44
+
45
+ /** A decision input that either carries its records or states why it has none. */
46
+ type Source<T> = { available: true; value: T } | { available: false; reason: string };
47
+
48
+ function ok<T>(value: T): Source<T> {
49
+ return { available: true, value };
50
+ }
51
+
52
+ function none<T>(reason: string): Source<T> {
53
+ return { available: false, reason };
54
+ }
55
+
56
+ function valueOf<T>(source: unknown): T | undefined {
57
+ const row = asRecord(source);
58
+ if (row?.available !== true) return undefined;
59
+ return row.value as T;
60
+ }
61
+
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
+ /** One execution context a receiving node publishes for a target-qualified delivery. */
71
+ export interface ReceiverContext {
72
+ alias: string;
73
+ summary: string;
74
+ state: string;
75
+ }
76
+
77
+ /** The OMP session a decision is made for. */
78
+ export interface DecisionScope {
79
+ ctx: ExtensionContext;
80
+ input: MsgInput;
81
+ signal?: AbortSignal;
82
+ /** Subagent sessions may inspect their own slice but must not write to the network. */
83
+ readOnly?: boolean;
84
+ }
85
+
86
+ export interface InboundDecision {
87
+ action: "fragment" | "wake" | "context" | "new_subagent";
88
+ context?: string;
89
+ /** Node I-2's insertion mode for a main-session delivery: steer interrupts the running turn, follow_up waits for it to end. */
90
+ insertion?: "steer" | "follow_up";
91
+ }
92
+
93
+ export interface TodoDecision {
94
+ action: "append" | "associate" | "none";
95
+ title?: string;
96
+ explanation?: string;
97
+ }
98
+
99
+ const decisionScope = new AsyncLocalStorage<DecisionScope>();
100
+
101
+ /** Bind one tool call to the session whose judge role answers its decision. */
102
+ export function runDecision<T>(scope: DecisionScope, work: () => Promise<T>): Promise<T> {
103
+ return decisionScope.run(scope, work);
104
+ }
105
+
106
+ /** The scope of the `msg` call currently being decided, if any. */
107
+ export function currentDecision(): DecisionScope | undefined {
108
+ return decisionScope.getStore();
109
+ }
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
+
178
+ export interface DeciderDeps {
179
+ config: OnlyneConfig;
180
+ /** Registry id of this node's main session; children are bound under it. */
181
+ mainAgentId: string;
182
+ contexts: ContextRegistry;
183
+ log?(message: string, fields: Record<string, unknown>): void;
184
+ listThreads(): Promise<Thread[]>;
185
+ /** The threads this node was invited into; they live on their owner, never here. */
186
+ listJoinedThreads(): Promise<JoinedThread[]>;
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[]>;
190
+ /** Peers from the library's live discovery records, never a static configuration. */
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 } }>>;
196
+ /** Contexts the target node publishes for `execution: existing`. */
197
+ listReceiverContexts(nodeId: NodeId): Promise<ReceiverContext[]>;
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>;
201
+ }
202
+
203
+ const TODO_ENTRY = "mouriya.omp.todo-more";
204
+ /** Upper bound on pages scanned for one thread tail; exceeding it is reported, never hidden. */
205
+ const MAX_TAIL_PAGES = 100;
206
+ /**
207
+ * One decision prompt is capped at 16384 tokens by the Clef backend, which serializes the
208
+ * state with sorted keys and keeps only the leading characters. A long `history` therefore
209
+ * spends the budget first and the current `request` is dropped, so the answer describes an
210
+ * input the model never saw. Keep the state under a conservative character budget by
211
+ * discarding whole background sections, newest rows last; the request and the questions
212
+ * are never cut, and a state that still does not fit fails instead of being truncated.
213
+ */
214
+ const STATE_BUDGET_CHARS = 40_000;
215
+ const TRIM_ORDER = ["history", "task_dispatches", "context_note"] as const;
216
+ /** Registered contexts offered to I-1; the option count stays within the design's bound. */
217
+ const INBOUND_CONTEXT_CAP = 3;
218
+ /** Threads and entries a target question scans for recent unanswered inbound messages. */
219
+ const RECENT_INBOUND_THREADS = 3;
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;
223
+ /** Newest branch rows that may reach the trigger derivation; older work is history, not decision material. */
224
+ const HISTORY_ROWS = 40;
225
+ /** One row's text cap: a pasted terminal dump must not crowd out the rest of the state. */
226
+ const TEXT_CHARS = 4_000;
227
+ /** How many characters of one branch message reach a decision prompt. */
228
+ const TRIGGER_CHARS = 800;
229
+ /** Sender wording that establishes a reply obligation (design §5.1 condition B). */
230
+ const REPLY_CUES = ["你好", "您好", "在吗", "请问", "能否", "期待回复", "期待你的回复"];
231
+ /** Sender wording that signals an urgent correction, for I-2's urgency signal. */
232
+ const URGENCY_CUES = [
233
+ "停下", "停止", "中止", "回滚", "不要", "改用", "改成", "改方案", "紧急", "立刻", "立即",
234
+ "stop", "abort", "revert", "instead", "interrupt", "urgent", "immediately", "do not", "don't",
235
+ ];
236
+
237
+ function stateSize(state: Row): number {
238
+ return JSON.stringify(state).length;
239
+ }
240
+
241
+ /** Applies the budget above; the caller fails the decision when `chars` still exceeds it. */
242
+ export function fitState(state: Row): { fitted: Row; dropped: string[]; chars: number } {
243
+ let fitted = state;
244
+ const dropped: string[] = [];
245
+ for (const key of TRIM_ORDER) {
246
+ if (stateSize(fitted) <= STATE_BUDGET_CHARS) break;
247
+ const value = fitted[key];
248
+ if (value === undefined) continue;
249
+ if (Array.isArray(value)) {
250
+ // Rows are oldest first, so dropping the front keeps the most recent evidence.
251
+ let kept: unknown[] = value;
252
+ while (kept.length > 0 && stateSize({ ...fitted, [key]: kept }) > STATE_BUDGET_CHARS) kept = kept.slice(Math.ceil(kept.length / 2));
253
+ fitted = { ...fitted, [key]: kept };
254
+ } else {
255
+ fitted = { ...fitted, [key]: null };
256
+ }
257
+ dropped.push(key);
258
+ }
259
+ return { fitted, dropped, chars: stateSize(fitted) };
260
+ }
261
+
262
+ /** Decisions go through OMP's `judge` role; the plugin never picks a target or a plan itself. */
263
+ export class JudgeDecider {
264
+ constructor(private readonly deps: DeciderDeps) {}
265
+
266
+ async decide(scope: DecisionScope): Promise<Action> {
267
+ const explicit = await this.#explicit(scope);
268
+ if (explicit !== undefined) {
269
+ const filled = await this.#threaded(explicit);
270
+ await this.#guard(scope, filled);
271
+ return filled;
272
+ }
273
+
274
+ // A subagent classifies exactly as the main session does and `#guard` decides what may run.
275
+ // Refusing before classification closed the reads as well as the writes, so a subagent that
276
+ // asked for its own slice was told it had no rights at all (caerulea report 4, problem 1).
277
+ // Classification costs one local judge call; a write is refused either way.
278
+ const meta = scope.input.meta ?? {};
279
+ const intent = await this.#intent(scope);
280
+ const action = intent === "communicate"
281
+ ? await this.#communicate(scope, meta)
282
+ : intent === "inspect"
283
+ ? await this.#inspect(scope, meta)
284
+ : intent === "manage_thread"
285
+ ? await this.#threadAction(scope, meta)
286
+ : intent === "manage_domain"
287
+ ? await this.#domainAction(scope, meta)
288
+ : await this.#resourceAction(meta);
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 };
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"> {
314
+ const started = Date.now();
315
+ const answers = await this.#judge(
316
+ scope.ctx,
317
+ {
318
+ focus: this.#focus(scope),
319
+ trigger: this.#trigger(scope.ctx),
320
+ host_env: { session_type: scope.ctx.agent.kind, read_only: scope.readOnly === true },
321
+ },
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
+ },
335
+ scope.signal,
336
+ );
337
+ const intent = choice(answers, "intent");
338
+ this.#record("outbound.o1", scope.input, answers, Date.now() - started);
339
+ if (intent !== "communicate" && intent !== "inspect" && intent !== "manage_thread" && intent !== "manage_domain" && intent !== "operate_resource") {
340
+ throw new OnlyneError("decision_unavailable", text("error.outbound_intent"));
341
+ }
342
+ return intent;
343
+ }
344
+
345
+ /** Nodes O-2A / O-3A / O-3A-Ctx for a send, reply or followup. */
346
+ async #communicate(scope: DecisionScope, meta: Record<string, Json>): Promise<Action> {
347
+ let reply = await this.#reply(meta);
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
+ }
356
+ const lockedRelation = reply !== undefined
357
+ ? explicitRelation ?? await this.#relationQuestion(scope, meta)
358
+ : undefined;
359
+ if (reply === undefined && (explicitRelation !== undefined || lockedRelation !== undefined)) {
360
+ throw new OnlyneError("invalid_request", text("error.relation_needs_reference"));
361
+ }
362
+ let to: NodeId;
363
+ if (reply !== undefined) {
364
+ // O-2A pre-pruning: the reply authority is the only target; meta.to and the judge's
365
+ // answer must not move a reply to another node (caerulea report 3).
366
+ const explicitTarget = await this.#target(meta);
367
+ if (explicitTarget !== undefined && explicitTarget !== reply.authority) {
368
+ throw new OnlyneError(
369
+ "invalid_request",
370
+ text("error.reply_authority", { authority: reply.authority, target: explicitTarget }),
371
+ );
372
+ }
373
+ to = reply.authority;
374
+ } else {
375
+ to = await this.#target(meta) ?? await this.#targetQuestion(scope, meta);
376
+ if (to.length === 0) {
377
+ throw new OnlyneError("ambiguous_target", text("error.delivery_target"));
378
+ }
379
+ }
380
+
381
+ const plan = await this.#deliveryPlan(scope, meta, lockedRelation, reply !== undefined);
382
+ const thread = await this.#sendThread(meta, to);
383
+ const context = plan.execution === "new" ? undefined : await this.#receiverContext(scope, to, meta);
384
+ if (plan.execution === "existing" && context === undefined) {
385
+ // RFC 0005 §1: `existing` disambiguates or refuses. Quietly opening a new context made
386
+ // the sender's own plan a lie: the receipt said existing and the work started fresh.
387
+ throw new OnlyneError(
388
+ "ambiguous_target",
389
+ text("error.execution_context_missing", { to }),
390
+ );
391
+ }
392
+ const mentions = await this.#mentions(meta);
393
+ return {
394
+ kind: "send",
395
+ to,
396
+ ...(thread === undefined ? {} : { thread }),
397
+ ...(context === undefined ? {} : { context }),
398
+ ...(reply === undefined ? {} : { in_reply_to: reply.ref }),
399
+ ...(lockedRelation === undefined ? {} : { relation: lockedRelation }),
400
+ ...(mentions === undefined ? {} : { mentions }),
401
+ response: plan.response,
402
+ execution: plan.execution,
403
+ };
404
+ }
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
+
502
+ /** Node O-2A question 1: the reply/followup/new relation, asked only when meta left it open. */
503
+ async #relationQuestion(scope: DecisionScope, meta: Record<string, Json>): Promise<"reply" | "followup"> {
504
+ const started = Date.now();
505
+ const answers = await this.#judge(
506
+ scope.ctx,
507
+ { 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
+ },
518
+ scope.signal,
519
+ );
520
+ this.#record("outbound.o2a", scope.input, answers, Date.now() - started);
521
+ const relation = choice(answers, "relation");
522
+ if (relation !== "reply" && relation !== "followup") {
523
+ throw new OnlyneError("decision_unavailable", text("error.reply_relation"));
524
+ }
525
+ return relation;
526
+ }
527
+
528
+ /** Node O-2A question 2: the delivery target among live peers, asked only when meta.to is absent. */
529
+ async #targetQuestion(scope: DecisionScope, meta: Record<string, Json>): Promise<NodeId> {
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;
536
+ const started = Date.now();
537
+ const answers = await this.#judge(
538
+ scope.ctx,
539
+ {
540
+ focus: { ...this.#focus(scope), meta_to: meta.to ?? null },
541
+ candidate_peers: peers.map(peer => ({
542
+ node_id: peer.node_id,
543
+ alias: lastSegment(peer.node_id),
544
+ last_seen: peer.last_seen,
545
+ })),
546
+ recent_inbound_unanswered: await this.#unanswered(scope.ctx),
547
+ },
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
+ },
568
+ scope.signal,
569
+ );
570
+ this.#record("outbound.o2a", scope.input, answers, Date.now() - started);
571
+ const chosen = choice(answers, "target");
572
+ if (chosen === undefined || chosen === "none") {
573
+ throw new OnlyneError("ambiguous_target", text("error.delivery_target"));
574
+ }
575
+ if (!peers.some(peer => peer.node_id === chosen)) {
576
+ throw new OnlyneError("decision_unavailable", text("error.target_not_published", { chosen }));
577
+ }
578
+ return chosen;
579
+ }
580
+
581
+ /** Node O-3A: response and execution, inheriting any meta value the caller already fixed. */
582
+ async #deliveryPlan(
583
+ scope: DecisionScope,
584
+ meta: Record<string, Json>,
585
+ relation: "reply" | "followup" | undefined,
586
+ isReply: boolean,
587
+ ): Promise<{ response: "await" | "async" | "none"; execution: "existing" | "new" }> {
588
+ const response = this.#inherited(meta, "response");
589
+ const execution = this.#inherited(meta, "execution");
590
+ if (response !== undefined && execution !== undefined) {
591
+ return { response: response as "await" | "async" | "none", execution: execution as "existing" | "new" };
592
+ }
593
+ const started = Date.now();
594
+ const answers = await this.#judge(
595
+ scope.ctx,
596
+ { focus: this.#focus(scope), relation: relation ?? "new" },
597
+ {
598
+ ...(response === undefined
599
+ ? {
600
+ response: {
601
+ type: "choice",
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
+ },
626
+ scope.signal,
627
+ );
628
+ this.#record("outbound.o3a", scope.input, answers, Date.now() - started);
629
+ const chosenResponse = response ?? choice(answers, "response");
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;
645
+ }
646
+
647
+ /**
648
+ * Node O-3A-Ctx: pick among the target's published contexts. A lost or never-bound record is
649
+ * not a candidate, the target's own main session is the fallback every host publishes, and
650
+ * an unreadable list is reported rather than read as "no context" (RFC 0005 §1).
651
+ */
652
+ async #receiverContext(scope: DecisionScope, to: NodeId, meta: Record<string, Json>): Promise<string | undefined> {
653
+ const receiver = await this.#receiverContexts(to);
654
+ const explicit = stringField(meta, "context");
655
+ if (!receiver.available) {
656
+ if (explicit !== undefined) return explicit;
657
+ throw new OnlyneError("unavailable", text("error.contexts_unreadable", { reason: receiver.reason }));
658
+ }
659
+ const published = receiver.value.filter(context => context.state !== "lost" && context.state !== "unbound");
660
+ if (explicit !== undefined) {
661
+ if (!published.some(context => context.alias === explicit)) {
662
+ throw new OnlyneError("invalid_request", text("error.context_not_published", { alias: explicit }));
663
+ }
664
+ return explicit;
665
+ }
666
+ if (published.length === 0) return undefined;
667
+ // The receiver's main session is the one context every host publishes: continuing work
668
+ // there needs no disambiguation.
669
+ const main = published.find(context => context.alias === "main")?.alias;
670
+ if (published.length === 1) return published[0]!.alias;
671
+ const started = Date.now();
672
+ const answers = await this.#judge(
673
+ scope.ctx,
674
+ { 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
+ },
688
+ scope.signal,
689
+ );
690
+ this.#record("outbound.o3a-ctx", scope.input, answers, Date.now() - started);
691
+ const alias = choice(answers, "context");
692
+ // The plan already says `existing`: with no context named, the target's own main session
693
+ // is where work continues, and the caller refuses only when there is no such context.
694
+ if (alias === undefined || alias === "none" || !published.some(context => context.alias === alias)) return main;
695
+ return alias;
696
+ }
697
+
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
+ /**
725
+ * One inbound entry's route. `plan` is the sender's delivery plan when the delivery named
726
+ * one: an explicit `plan.context` is binding, `execution: new` is only a preference.
727
+ */
728
+ async decideInbound(scope: ExtensionContext, entry: Entry, plan?: DeliveryPlan): Promise<InboundDecision> {
729
+ // The binding is checked first: a plan that names a context needs no obligation question,
730
+ // and a lost one must fail without spending a judge call on every maintenance pass.
731
+ const bound = await this.#boundContext(plan);
732
+ if (bound !== undefined) return bound;
733
+ const needsAnswer = await this.#needsAnswer(scope, entry, plan);
734
+ // The sender asked for a fresh context and expects nothing back: that is exactly what a
735
+ // subagent is, so no routing question can improve on it.
736
+ if (plan?.execution === "new" && !needsAnswer) return { action: "new_subagent" };
737
+ if (plan?.execution === "new") {
738
+ // A new-context preference cannot hold an obligation: a subagent has no send right, so
739
+ // an answer owed here would never leave this node (dpdp report 4, host shape).
740
+ this.deps.log?.("onlyne.judge.overridden", {
741
+ direction: "inbound",
742
+ chosen: "new_subagent",
743
+ fallback: "main_agent",
744
+ reason: "a new-context request cannot hold a message that needs an answer",
745
+ });
746
+ const insertion = await this.#insertionMode(scope, entry, needsAnswer);
747
+ // A message that needs an answer never gets the silent mode; the option is withheld
748
+ // above, and this keeps the decision type honest if that ever changes.
749
+ return { action: "wake", insertion: insertion === "fragment" ? "follow_up" : insertion };
750
+ }
751
+ // Deterministic prefilter (design §5.1): a disposed or never-bound session is a delivery
752
+ // black hole, and a message that needs an answer can only land where a reply can be sent.
753
+ // Pruned candidates never reach the prompt, so the question only offers positive options.
754
+ const registry = valueOf<Row[]>(await this.#contextRows()) ?? [];
755
+ const valid = registry
756
+ .filter(row =>
757
+ row.agent_id !== null && row.agent_id !== undefined && row.live === true
758
+ && (!needsAnswer || row.can_answer === true)
759
+ // The main session is its own I-1 option; its registry row is not a context candidate.
760
+ && row.alias !== "main")
761
+ .slice(0, INBOUND_CONTEXT_CAP);
762
+ const started = Date.now();
763
+ const sameThread = (ref: unknown): boolean => {
764
+ const thread = ref as ThreadRef | null | undefined;
765
+ return thread !== null && thread !== undefined
766
+ && thread.owner === entry.thread_ref.owner && thread.id === entry.thread_ref.id;
767
+ };
768
+ const criteria: Record<string, string> = {
769
+ main_agent: text("judge.inbound.main_agent"),
770
+ ...Object.fromEntries(valid.map(row => [
771
+ String(row.alias),
772
+ text(sameThread(row.thread_ref) ? "judge.inbound.context_same_thread" : "judge.inbound.context", {
773
+ summary: String(row.summary),
774
+ state: String(row.state),
775
+ agent: String(row.agent_id),
776
+ }),
777
+ ])),
778
+ ...(needsAnswer ? {} : { new_subagent: text("judge.inbound.new_subagent") }),
779
+ };
780
+ // A pruned candidate set can leave one route; asking would be a question with one answer.
781
+ const forced = Object.keys(criteria);
782
+ const answers: Answers = forced.length === 1
783
+ ? { routing_target: { choice: forced[0] } }
784
+ : await this.#judge(
785
+ scope,
786
+ {
787
+ focus: { node: this.deps.config.node.id, incoming: entryFacts(entry) },
788
+ needs_answer: needsAnswer,
789
+ plan: plan ?? null,
790
+ valid_contexts: valid.map(row => ({
791
+ alias: row.alias,
792
+ summary: row.summary,
793
+ state: row.state,
794
+ agent_id: row.agent_id,
795
+ can_answer: row.can_answer,
796
+ thread_ref: row.thread_ref ?? null,
797
+ })),
798
+ thread_dialogue: await this.#threadEvidenceFor([entry.thread_ref]),
799
+ },
800
+ {
801
+ routing_target: {
802
+ type: "choice",
803
+ instructions: text("judge.inbound.instructions"),
804
+ criteria,
805
+ },
806
+ },
807
+ );
808
+ this.#record("inbound.i1", { delivery_kind: entry.message.delivery_kind, msg: entry.message.msg, meta: entry.message.meta }, answers, Date.now() - started);
809
+ const override = (chosen: string, reason: string): InboundDecision => {
810
+ // The judge is offered only viable targets; when it names another one anyway, the
811
+ // delivery goes to the main session and the override is logged, so judge tuning has
812
+ // evidence instead of a silent rewrite (caerulea report 3, problem 1).
813
+ this.deps.log?.("onlyne.judge.overridden", { direction: "inbound", chosen, fallback: "main_agent", reason });
814
+ return { action: "wake", insertion: "follow_up" };
815
+ };
816
+ const chosen = choice(answers, "routing_target");
817
+ if (chosen === undefined) throw new OnlyneError("decision_unavailable", text("error.routing_target"));
818
+ if (chosen === "new_subagent") {
819
+ if (needsAnswer) return override(chosen, "a subagent cannot answer a message that needs a reply");
820
+ return { action: "new_subagent" };
821
+ }
822
+ if (chosen !== "main_agent") {
823
+ const row = valid.find(candidate => candidate.alias === chosen);
824
+ if (row === undefined) return override(chosen, "the decision named a context the prefilter did not offer");
825
+ return { action: "context", context: chosen };
826
+ }
827
+ const insertion = await this.#insertionMode(scope, entry, needsAnswer);
828
+ if (insertion === "fragment") return { action: "fragment" };
829
+ return { action: "wake", insertion };
830
+ }
831
+
832
+ /**
833
+ * Node I-0: whether this node owes the sender an answer. Routing used to infer it from
834
+ * wording, so a completed reply read as a question and a greeting read as work; the
835
+ * obligation is a semantic question now, and a failed judge is a failure, never a guess.
836
+ */
837
+ async #needsAnswer(scope: ExtensionContext, entry: Entry, plan?: DeliveryPlan): Promise<boolean> {
838
+ const started = Date.now();
839
+ const answers = await this.#judge(
840
+ scope,
841
+ {
842
+ focus: { node: this.deps.config.node.id, incoming: entryFacts(entry) },
843
+ plan: plan ?? null,
844
+ relation: entry.message.meta?.relation ?? null,
845
+ in_reply_to: entry.message.meta?.in_reply_to ?? null,
846
+ // Wording is evidence, not the decision: it names the cases a judge may weigh.
847
+ wording_hint: needsReplyOf(entry),
848
+ current_work: this.#trigger(scope),
849
+ thread_dialogue: await this.#threadEvidenceFor([entry.thread_ref]),
850
+ },
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
+ },
861
+ undefined,
862
+ );
863
+ this.#record("inbound.i0", { delivery_kind: entry.message.delivery_kind, msg: entry.message.msg, meta: entry.message.meta }, answers, Date.now() - started);
864
+ const answer = choice(answers, "needs_answer");
865
+ if (answer !== "yes" && answer !== "no") {
866
+ throw new OnlyneError("decision_unavailable", text("error.needs_answer"));
867
+ }
868
+ return answer === "yes";
869
+ }
870
+
871
+ /**
872
+ * RFC 0005 §1: a plan that names a context is binding. A record that is missing, never
873
+ * bound, or no longer bound is `context_lost`: the delivery neither opens a new context
874
+ * nor reroutes, and the entry stays owed for an explicit new decision.
875
+ */
876
+ async #boundContext(plan: DeliveryPlan | undefined): Promise<InboundDecision | undefined> {
877
+ const alias = plan?.context;
878
+ if (alias === undefined) return undefined;
879
+ let record: ContextRecord | undefined;
880
+ try {
881
+ record = await this.deps.contexts.find(alias);
882
+ } catch (error) {
883
+ throw new OnlyneError("unavailable", text("error.registry_unreadable", { error: message(error) }));
884
+ }
885
+ const dead = record === undefined || record.agent_id === undefined
886
+ || record.state === "lost" || record.state === "unbound";
887
+ if (!dead) return { action: "context", context: alias };
888
+ // A record that once held a binding has lost it; one that never bound stays unbound.
889
+ if (record !== undefined && record.agent_id !== undefined && record.bound_at !== undefined && record.state !== "lost") {
890
+ await this.deps.contexts.updateState(alias, "lost").catch(() => undefined);
891
+ }
892
+ throw new OnlyneError("context_lost", text("error.context_lost", { alias }), { context: alias });
893
+ }
894
+
895
+ /** Node I-2: when a main-session delivery enters the agent's work. */
896
+ async #insertionMode(scope: ExtensionContext, entry: Entry, needsAnswer: boolean): Promise<"steer" | "follow_up" | "fragment"> {
897
+ const body = entry.message.msg.toLowerCase();
898
+ const started = Date.now();
899
+ // `fragment` never starts a turn, so a message that needs an answer can never be one;
900
+ // 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
+ const answers = await this.#judge(
907
+ scope,
908
+ {
909
+ focus: { incoming: entryFacts(entry) },
910
+ host_running_state: { is_turn_active: !scope.isIdle(), plan_mode: this.#planActive(scope) },
911
+ urgency_signals: { has_stop_or_change: URGENCY_CUES.some(cue => body.includes(cue)) },
912
+ },
913
+ {
914
+ insertion_mode: {
915
+ type: "choice",
916
+ instructions: text("judge.insertion.instructions"),
917
+ criteria,
918
+ },
919
+ },
920
+ );
921
+ this.#record("inbound.i2", { delivery_kind: entry.message.delivery_kind, msg: entry.message.msg, meta: entry.message.meta }, answers, Date.now() - started);
922
+ const mode = choice(answers, "insertion_mode");
923
+ if (mode === "fragment" && needsAnswer) {
924
+ this.deps.log?.("onlyne.judge.overridden", { direction: "inbound", chosen: "fragment", fallback: "follow_up", reason: "a silent fragment cannot answer a message that needs a reply" });
925
+ return "follow_up";
926
+ }
927
+ if (mode !== "steer" && mode !== "follow_up" && mode !== "fragment") {
928
+ throw new OnlyneError("decision_unavailable", text("error.insertion_mode"));
929
+ }
930
+ return mode;
931
+ }
932
+
933
+ #planActive(ctx: ExtensionContext): boolean {
934
+ const ref = AgentRegistry.global().get(ctx.agent?.id ?? "");
935
+ return ref?.session?.getPlanModeState?.()?.enabled === true;
936
+ }
937
+
938
+ async decideTodo(ctx: ExtensionContext, entry: Entry, current: unknown): Promise<TodoDecision> {
939
+ const snapshot = current === undefined || current === null ? this.#todo(ctx) : normalizeTodo(current);
940
+ const candidates = todoCandidates(entry);
941
+ const existing = todoTitles(snapshot);
942
+ const focus = { node: this.deps.config.node.id, incoming: entryFacts(entry) };
943
+ const input = { delivery_kind: entry.message.delivery_kind, msg: entry.message.msg, meta: entry.message.meta };
944
+ // Node T-1: what this message does to the list. Only viable actions are offered: an
945
+ // empty list has nothing to associate with, and an empty body has nothing to append.
946
+ const actionCriteria: Record<string, string> = {
947
+ none: text("judge.todo.action.none"),
948
+ };
949
+ if (existing.length > 0) actionCriteria.associate = text("judge.todo.action.associate");
950
+ if (candidates.titles.length > 0) actionCriteria.append = text("judge.todo.action.append");
951
+ const t1 = Date.now();
952
+ // With nothing to associate and nothing to append, `none` is the only answer.
953
+ const forcedActions = Object.keys(actionCriteria);
954
+ const answers: Answers = forcedActions.length === 1
955
+ ? { action: { choice: forcedActions[0] } }
956
+ : await this.#judge(
957
+ ctx,
958
+ { focus, current_todo_summary: todoSummary(snapshot) },
959
+ {
960
+ action: {
961
+ type: "choice",
962
+ instructions: text("judge.todo.action.instructions"),
963
+ criteria: actionCriteria,
964
+ },
965
+ },
966
+ );
967
+ this.#record("todo.t1", input, answers, Date.now() - t1);
968
+ const action = choice(answers, "action");
969
+ if (action === "none") return { action: "none" };
970
+ if (action === "associate") {
971
+ // Node T-2 (associate): which existing item this message continues.
972
+ const t2 = Date.now();
973
+ const follow: Answers = existing.length === 1
974
+ ? { task: { choice: existing[0] } }
975
+ : await this.#judge(
976
+ ctx,
977
+ { focus, current_todo_items: existing },
978
+ {
979
+ task: {
980
+ type: "choice",
981
+ instructions: text("judge.todo.associate.instructions"),
982
+ criteria: Object.fromEntries(existing.map(title => [title, text("judge.todo.associate.item")])),
983
+ },
984
+ },
985
+ );
986
+ this.#record("todo.t2", input, follow, Date.now() - t2);
987
+ const title = choice(follow, "task");
988
+ if (title === undefined || title === "none" || !existing.includes(title)) {
989
+ throw new OnlyneError("decision_unavailable", text("error.todo_associate"));
990
+ }
991
+ return { action: "associate", title };
992
+ }
993
+ if (action === "append") {
994
+ // Node T-2 (append): the title and explanation, both taken from the message itself.
995
+ const t2 = Date.now();
996
+ // A one-candidate title is the answer; only a real choice is asked.
997
+ const questions: Questions = {
998
+ ...(candidates.titles.length === 1
999
+ ? {}
1000
+ : {
1001
+ title: {
1002
+ type: "choice" as const,
1003
+ instructions: text("judge.todo.title.instructions"),
1004
+ criteria: Object.fromEntries(candidates.titles.map(title => [title, text("judge.todo.title.item")])),
1005
+ },
1006
+ }),
1007
+ ...(candidates.explanations.length === 0
1008
+ ? {}
1009
+ : {
1010
+ explanation: {
1011
+ type: "choice" as const,
1012
+ instructions: text("judge.todo.explanation.instructions"),
1013
+ criteria: {
1014
+ ...Object.fromEntries(candidates.explanations.map(explanation => [explanation, text("judge.todo.explanation.item")])),
1015
+ none: text("judge.todo.explanation.none"),
1016
+ },
1017
+ },
1018
+ }),
1019
+ };
1020
+ const follow: Answers = Object.keys(questions).length === 0
1021
+ ? {}
1022
+ : await this.#judge(ctx, { focus, title_candidates: candidates.titles, explanation_candidates: candidates.explanations }, questions);
1023
+ this.#record("todo.t2", input, follow, Date.now() - t2);
1024
+ const title = candidates.titles.length === 1 ? candidates.titles[0] : choice(follow, "title");
1025
+ if (title === undefined || title === "none" || !candidates.titles.includes(title)) {
1026
+ throw new OnlyneError("decision_unavailable", text("error.todo_append"));
1027
+ }
1028
+ const explanation = choice(follow, "explanation");
1029
+ const chosen = explanation !== undefined && explanation !== "none" && candidates.explanations.includes(explanation)
1030
+ ? explanation
1031
+ : undefined;
1032
+ return { action: "append", title, ...(chosen === undefined ? {} : { explanation: chosen }) };
1033
+ }
1034
+ throw new OnlyneError("decision_unavailable", text("error.todo_action"));
1035
+ }
1036
+
1037
+ async #judge(scope: ExtensionContext, state: unknown, questions: Questions, signal?: AbortSignal): Promise<Answers> {
1038
+ try {
1039
+ const { fitted, dropped, chars } = fitState(state as Row);
1040
+ if (chars > STATE_BUDGET_CHARS) {
1041
+ throw new OnlyneError("decision_unavailable", text("error.state_budget", { chars, cap: STATE_BUDGET_CHARS }));
1042
+ }
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);
1045
+ const settings = (await Settings.current) ?? (await Settings.loadReadOnly({ cwd: scope.cwd }));
1046
+ const judge = resolveJudge({
1047
+ settings,
1048
+ registry: scope.modelRegistry,
1049
+ sessionModel: scope.model,
1050
+ sessionId: scope.sessionManager.getSessionId(),
1051
+ purpose: "onlyne",
1052
+ onUsage: journalJudgmentUsage(scope.sessionManager),
1053
+ });
1054
+ const result = await judge.judge({ state: jsonState(fitted), questions }, signal === undefined ? {} : { signal });
1055
+ return result.answers as Answers;
1056
+ } catch (error) {
1057
+ throw new OnlyneError(
1058
+ "decision_unavailable",
1059
+ text("error.judge_failed", { error: error instanceof Error ? error.message : String(error) }),
1060
+ );
1061
+ }
1062
+ }
1063
+
1064
+ /**
1065
+ * One line per judged message, at info: the intent, every answer the judge gave, and the
1066
+ * time it took. Tuning the judge needs this record; without it a misrouted send can only be
1067
+ * reconstructed from its receipt (issue report, problem 5).
1068
+ */
1069
+ #record(direction: string, input: { delivery_kind: string; msg: string; meta?: unknown }, answers: Answers, ms: number): void {
1070
+ this.deps.log?.("onlyne.judge.decided", judgeRecord(direction, input, answers, ms));
1071
+ }
1072
+
1073
+ /**
1074
+ * Only stable, explicit fields route without a decision. Everything semantic,
1075
+ * including a bare thread reference, goes to the judge.
1076
+ */
1077
+ async #explicit(scope: DecisionScope): Promise<Action | undefined> {
1078
+ const meta = scope.input.meta;
1079
+ if (meta === undefined) return undefined;
1080
+ if (meta.scope === "resources") return this.#read(scope, "resources");
1081
+ const op = stringField(meta, "op");
1082
+ if (op !== undefined && isThreadOp(op)) {
1083
+ const thread = await this.#thread(meta);
1084
+ const to = await this.#target(meta);
1085
+ const title = stringField(meta, "title");
1086
+ if (op === "create" && title === undefined) throw new OnlyneError("invalid_request", text("error.thread_title_required"));
1087
+ if (op !== "create" && op !== "list" && thread === undefined) {
1088
+ throw new OnlyneError("invalid_request", text("error.thread_reference_required", { op }));
1089
+ }
1090
+ if ((op === "member_add" || op === "member_remove") && to === undefined) {
1091
+ throw new OnlyneError("invalid_request", text("error.thread_target_required", { op }));
1092
+ }
1093
+ return {
1094
+ kind: "thread",
1095
+ op,
1096
+ ...(thread === undefined ? {} : { thread }),
1097
+ ...(to === undefined ? {} : { to }),
1098
+ ...(title === undefined ? {} : { title }),
1099
+ };
1100
+ }
1101
+
1102
+ const resource = recordField(meta, "resource");
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
+ }
1118
+
1119
+ const scopeName = stringField(meta, "scope");
1120
+ if (scopeName !== undefined) {
1121
+ // `peers` is the name a model reaches for; the library's scope is `neighbors`, and
1122
+ // the alias keeps one word from meaning two channels (dpdp report 4, navigation).
1123
+ const name = scopeName === "peers" ? "neighbors" : scopeName;
1124
+ if (isReadScope(name)) return this.#read(scope, name);
1125
+ }
1126
+
1127
+ return undefined;
1128
+ }
1129
+
1130
+ async #read(scope: DecisionScope, name: ActionReadScope): Promise<Action> {
1131
+ const meta = scope.input.meta ?? {};
1132
+ if (name === "resources") {
1133
+ if (scope.readOnly === true) throw new OnlyneError("forbidden", text("error.resources_main_only"));
1134
+ if (this.deps.resourceQuery === undefined) throw new OnlyneError("unavailable", text("error.resource_host_unavailable"));
1135
+ return { kind: "read", scope: name, query: await this.deps.resourceQuery(meta) };
1136
+ }
1137
+ const thread = await this.#thread(meta);
1138
+ const page_size = pageSize(meta);
1139
+ const cursor = stringField(meta, "cursor");
1140
+ const seq = stringField(meta, "seq");
1141
+ if (name === "directory") return { kind: "read", scope: name };
1142
+ if (name === "neighbors") return { kind: "read", scope: name };
1143
+ if (name === "threads") return { kind: "read", scope: name };
1144
+ if (name === "inbox") {
1145
+ return {
1146
+ kind: "read",
1147
+ scope: name,
1148
+ ...(thread === undefined ? {} : { thread }),
1149
+ ...(cursor === undefined ? {} : { cursor }),
1150
+ ...(page_size === undefined ? {} : { page_size }),
1151
+ };
1152
+ }
1153
+ if (thread === undefined && name !== "message" && name !== "evidence") {
1154
+ throw new OnlyneError("invalid_request", text("error.read_thread_required", { name }));
1155
+ }
1156
+ if (name === "messages") {
1157
+ return {
1158
+ kind: "read",
1159
+ scope: name,
1160
+ thread,
1161
+ ...(seq === undefined ? {} : { seq }),
1162
+ ...(cursor === undefined ? {} : { cursor }),
1163
+ ...(page_size === undefined ? {} : { page_size }),
1164
+ attention: attention(meta),
1165
+ };
1166
+ }
1167
+ const resolved = await this.#messageRef(meta, thread);
1168
+ if (resolved === undefined) {
1169
+ throw new OnlyneError("invalid_request", text("error.read_message_required", { name }));
1170
+ }
1171
+ // The thread the message was read in travels with the action: a subagent's guard
1172
+ // authorizes a thread slice, and `{thread,seq}` already names that slice.
1173
+ const owner = resolved.thread ?? thread;
1174
+ return {
1175
+ kind: "read",
1176
+ scope: name,
1177
+ ...(owner === undefined ? {} : { thread: owner }),
1178
+ message: resolved.ref,
1179
+ };
1180
+ }
1181
+
1182
+ /** Node O-2B: which readable channel the message asks about; O-3B-Th joins when messages has no thread. */
1183
+ async #inspect(scope: DecisionScope, meta: Record<string, Json>): Promise<Action> {
1184
+ const started = Date.now();
1185
+ const answers = await this.#judge(
1186
+ scope.ctx,
1187
+ { 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
+ },
1202
+ scope.signal,
1203
+ );
1204
+ this.#record("outbound.o2b", scope.input, answers, Date.now() - started);
1205
+ const scopeName = choice(answers, "read_scope");
1206
+ if (scopeName === undefined || scopeName === "none") {
1207
+ throw new OnlyneError("decision_unavailable", text("error.read_scope_missing"));
1208
+ }
1209
+ // The design's `peers` is the library's `neighbors`: the nodes this one can address, the
1210
+ // same rows onlyne://neighbors renders. A caller that names a library channel explicitly
1211
+ // in meta.scope still gets it verbatim.
1212
+ if (scopeName === "resources") return this.#read(scope, "resources");
1213
+ if (scopeName !== "peers" && scopeName !== "inbox" && scopeName !== "messages" && scopeName !== "evidence") {
1214
+ throw new OnlyneError("decision_unavailable", text("error.read_scope_unknown", { scope: scopeName }));
1215
+ }
1216
+ const name: ActionReadScope = scopeName === "peers" ? "neighbors" : scopeName;
1217
+ const thread = await this.#thread(meta);
1218
+ if (name === "messages") {
1219
+ const chosen = thread ?? await this.#threadQuestion(scope, "outbound.o3b-th");
1220
+ return {
1221
+ kind: "read",
1222
+ scope: name,
1223
+ thread: chosen,
1224
+ ...(seqField(meta, "seq") === undefined ? {} : { seq: seqField(meta, "seq") }),
1225
+ ...(stringField(meta, "cursor") === undefined ? {} : { cursor: stringField(meta, "cursor") }),
1226
+ ...(pageSize(meta) === undefined ? {} : { page_size: pageSize(meta) }),
1227
+ attention: attention(meta),
1228
+ };
1229
+ }
1230
+ if (name === "evidence") {
1231
+ const resolved = await this.#messageRef(meta, thread);
1232
+ if (resolved === undefined) {
1233
+ throw new OnlyneError("invalid_request", text("error.read_message_required", { name }));
1234
+ }
1235
+ const owner = resolved.thread ?? thread;
1236
+ return { kind: "read", scope: name, ...(owner === undefined ? {} : { thread: owner }), message: resolved.ref };
1237
+ }
1238
+ return {
1239
+ kind: "read",
1240
+ scope: name,
1241
+ ...(name === "inbox" && thread !== undefined ? { thread } : {}),
1242
+ ...(stringField(meta, "cursor") === undefined ? {} : { cursor: stringField(meta, "cursor") }),
1243
+ ...(name === "inbox" && pageSize(meta) !== undefined ? { page_size: pageSize(meta) } : {}),
1244
+ };
1245
+ }
1246
+
1247
+ /** Node O-3B-Th: pick the local thread a read or thread operation names. */
1248
+ async #threadQuestion(scope: DecisionScope, node: string): Promise<ThreadRef> {
1249
+ const threads = await this.deps.listThreads();
1250
+ // A question needs a real choice: one local thread is the answer, none is an error.
1251
+ if (threads.length === 0) throw new OnlyneError("not_found", text("error.no_local_thread"));
1252
+ if (threads.length === 1) return threads[0]!.thread_ref;
1253
+ const started = Date.now();
1254
+ const answers = await this.#judge(
1255
+ scope.ctx,
1256
+ {
1257
+ focus: this.#focus(scope),
1258
+ local_threads: threads.map(thread => ({
1259
+ id: thread.thread_ref.id,
1260
+ title: thread.title,
1261
+ owner: thread.thread_ref.owner,
1262
+ state: thread.state,
1263
+ next_seq: thread.next_seq,
1264
+ })),
1265
+ },
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
+ },
1281
+ scope.signal,
1282
+ );
1283
+ this.#record(node, scope.input, answers, Date.now() - started);
1284
+ const alias = choice(answers, "thread");
1285
+ if (alias === undefined || alias === "none") {
1286
+ throw new OnlyneError("decision_unavailable", text("error.local_thread_choice"));
1287
+ }
1288
+ return this.#threadByAlias(alias);
1289
+ }
1290
+
1291
+ /** Node O-2C: the local thread operation. */
1292
+ async #threadAction(scope: DecisionScope, meta: Record<string, Json>): Promise<Action> {
1293
+ const started = Date.now();
1294
+ const answers = await this.#judge(
1295
+ scope.ctx,
1296
+ { 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
+ },
1311
+ scope.signal,
1312
+ );
1313
+ this.#record("outbound.o2c", scope.input, answers, Date.now() - started);
1314
+ const op = choice(answers, "thread_op");
1315
+ if (op === undefined || !isThreadOp(op)) {
1316
+ throw new OnlyneError("decision_unavailable", text("error.thread_op"));
1317
+ }
1318
+ // A dependent second call: which thread the operation names is only decidable once the
1319
+ // operation is known, and meta.thread settles it without a call (design §4, stepwise).
1320
+ const thread = op === "create" || op === "list"
1321
+ ? await this.#thread(meta)
1322
+ : await this.#thread(meta) ?? await this.#threadQuestion(scope, "outbound.o2c-thread");
1323
+ const to = await this.#target(meta);
1324
+ const title = stringField(meta, "title");
1325
+ if ((op === "member_add" || op === "member_remove") && to === undefined) {
1326
+ throw new OnlyneError("decision_unavailable", text("error.thread_op_target", { op }));
1327
+ }
1328
+ if (op === "create" && title === undefined) {
1329
+ throw new OnlyneError("invalid_request", text("error.thread_title_required"));
1330
+ }
1331
+ return {
1332
+ kind: "thread",
1333
+ op,
1334
+ ...(thread === undefined ? {} : { thread }),
1335
+ ...(to === undefined ? {} : { to }),
1336
+ ...(title === undefined ? {} : { title }),
1337
+ };
1338
+ }
1339
+
1340
+ /** Node O-2D: the domain topology operation. */
1341
+ async #domainAction(scope: DecisionScope, meta: Record<string, Json>): Promise<Action> {
1342
+ const started = Date.now();
1343
+ const answers = await this.#judge(
1344
+ scope.ctx,
1345
+ { 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
+ },
1360
+ scope.signal,
1361
+ );
1362
+ this.#record("outbound.o2d", scope.input, answers, Date.now() - started);
1363
+ const op = choice(answers, "domain_op");
1364
+ if (op === undefined || !isDomainOp(op)) {
1365
+ throw new OnlyneError("decision_unavailable", text("error.domain_op"));
1366
+ }
1367
+ const domainId = stringField(meta, "domain_id");
1368
+ if (op !== "create" && op !== "list" && domainId === undefined) {
1369
+ throw new OnlyneError("invalid_request", text("error.domain_id", { op }));
1370
+ }
1371
+ return {
1372
+ kind: "domain",
1373
+ op,
1374
+ ...(domainId === undefined ? {} : { domain_id: domainId }),
1375
+ ...(typeof meta.expected_revision === "string" ? { expected_revision: meta.expected_revision } : {}),
1376
+ };
1377
+ }
1378
+
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
+ /** The golden-triage head of every node state: what is being asked, verbatim. */
1398
+ #focus(scope: DecisionScope): Row {
1399
+ return {
1400
+ delivery_kind: scope.input.delivery_kind,
1401
+ msg: scope.input.msg,
1402
+ meta_keys: scope.input.meta === undefined ? [] : Object.keys(scope.input.meta).sort(),
1403
+ };
1404
+ }
1405
+
1406
+ /** The user's latest direction behind the call, the one thing a bare msg cannot carry. */
1407
+ #trigger(ctx: ExtensionContext): Row {
1408
+ const rows = this.#history(ctx);
1409
+ const role = (row: Row): string | undefined => asRecord(row.message)?.role as string | undefined;
1410
+ const latest = (kind: string): Row | undefined => rows.filter(row => row.type === "message" && role(row) === kind).at(-1);
1411
+ const prompt = latest("user");
1412
+ const thought = latest("assistant");
1413
+ return {
1414
+ recent_user_prompt: prompt === undefined ? null : snippet(asRecord(prompt.message)?.content),
1415
+ last_thought: thought === undefined ? null : snippet(asRecord(thought.message)?.content),
1416
+ };
1417
+ }
1418
+
1419
+ /** Inbound messages this node has not answered yet, newest first (O-2A's reply hint). */
1420
+ async #unanswered(ctx: ExtensionContext): Promise<Row[]> {
1421
+ const rows: Row[] = [];
1422
+ try {
1423
+ const threads = (await this.deps.listThreads()).slice(-RECENT_INBOUND_THREADS);
1424
+ for (const thread of threads) {
1425
+ const page = await this.deps.listMessages(thread.thread_ref, undefined);
1426
+ for (const entry of page.items.slice(-RECENT_INBOUND_PER_THREAD)) {
1427
+ if (entry.message.sender === this.deps.config.node.id) continue;
1428
+ if (entry.message.meta?.in_reply_to !== undefined) continue;
1429
+ rows.push({
1430
+ thread_ref: entry.thread_ref,
1431
+ seq: entry.seq,
1432
+ from: entry.message.sender,
1433
+ delivery_kind: entry.message.delivery_kind,
1434
+ msg: boundText(entry.message.msg),
1435
+ });
1436
+ }
1437
+ }
1438
+ } catch (error) {
1439
+ // The hint is optional context; a failed read narrows the question, not the decision.
1440
+ this.deps.log?.("onlyne.judge.hint_unavailable", { what: "recent_inbound_unanswered", error: message(error) });
1441
+ }
1442
+ return rows.slice(-RECENT_INBOUND_THREADS * RECENT_INBOUND_PER_THREAD).reverse();
1443
+ }
1444
+
1445
+
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
+ /**
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.
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
+ );
1713
+ }
1714
+
1715
+ async #contextRows(): Promise<Source<Row[]>> {
1716
+ try {
1717
+ const records = (await this.deps.contexts.list()).filter(record => record.node_id === this.deps.config.node.id);
1718
+ return ok(records.map(record => {
1719
+ // A subagent session cannot send, so a reply routed there can never be answered.
1720
+ // The judge sees that fact instead of a bare agent id (caerulea report 3, problem 1).
1721
+ const isMain = canAnswer(record);
1722
+ return {
1723
+ alias: record.alias,
1724
+ summary: record.summary,
1725
+ state: record.state,
1726
+ kind: isMain ? "main" : "subagent",
1727
+ can_answer: isMain,
1728
+ // A lost or never-bound record cannot receive: offering it guarantees a failure.
1729
+ live: record.state !== "lost" && record.state !== "unbound",
1730
+ agent_id: record.agent_id ?? null,
1731
+ main_agent_id: record.main_agent_id,
1732
+ node_id: record.node_id,
1733
+ thread_ref: record.thread_ref ?? null,
1734
+ source: record.source ?? null,
1735
+ created_at: record.created_at,
1736
+ bound_at: record.bound_at ?? null,
1737
+ };
1738
+ }));
1739
+ } catch (error) {
1740
+ return none(text("reason.registry_unreadable", { error: message(error) }));
1741
+ }
1742
+ }
1743
+
1744
+
1745
+ async #receiverContexts(nodeId: NodeId): Promise<Source<ReceiverContext[]>> {
1746
+ try {
1747
+ return ok(await this.deps.listReceiverContexts(nodeId));
1748
+ } catch (error) {
1749
+ return none(text("reason.receiver_contexts_unreadable", { error: message(error) }));
1750
+ }
1751
+ }
1752
+
1753
+ /** The active branch as ordered, id-bearing rows: the newest rows, each text bounded. */
1754
+ #history(ctx: ExtensionContext): Row[] {
1755
+ const rows: Row[] = [];
1756
+ for (const entry of branchOf(ctx)) {
1757
+ const row = entryRow(entry);
1758
+ if (row !== undefined) rows.push(row);
1759
+ }
1760
+ return rows.slice(-HISTORY_ROWS);
1761
+ }
1762
+
1763
+ /**
1764
+ * The todo-more snapshot on the active branch. It survives a reset boundary
1765
+ * (RFC 0016 §2.2); an empty snapshot ends the search so older lists never
1766
+ * resurrect, and a malformed newest snapshot is reported instead of skipped.
1767
+ */
1768
+ #todo(ctx: ExtensionContext): unknown {
1769
+ const branch = branchOf(ctx);
1770
+ for (let index = branch.length - 1; index >= 0; index--) {
1771
+ const row = asRecord(branch[index]);
1772
+ if (row?.type !== "custom" || row.customType !== TODO_ENTRY) continue;
1773
+ const data = asRecord(row.data);
1774
+ if (data === undefined) return none(text("reason.todo_no_data"));
1775
+ const phases = data.phases;
1776
+ if (!Array.isArray(phases)) return none(text("reason.todo_no_phases"));
1777
+ return normalizeTodo({ goal: data.goal ?? null, phases });
1778
+ }
1779
+ return { present: false, reason: text("reason.todo_absent") };
1780
+ }
1781
+
1782
+ async #threadEvidenceFor(refs: ThreadRef[]): Promise<Row[]> {
1783
+ const seen = new Map<string, ThreadRef>();
1784
+ for (const ref of refs) {
1785
+ const key = threadKey(ref);
1786
+ if (!seen.has(key)) seen.set(key, ref);
1787
+ }
1788
+ const rows: Row[] = [];
1789
+ for (const ref of seen.values()) rows.push(await this.#threadTail(ref));
1790
+ return rows;
1791
+ }
1792
+
1793
+ async #threadTail(ref: ThreadRef): Promise<Row> {
1794
+ try {
1795
+ let cursor: string | undefined;
1796
+ let last: Entry[] = [];
1797
+ let scanned = 0;
1798
+ let truncated = false;
1799
+ for (;;) {
1800
+ const page = await this.deps.listMessages(ref, cursor);
1801
+ last = page.items;
1802
+ scanned += 1;
1803
+ const next = page.next_cursor ?? undefined;
1804
+ if (next === undefined) break;
1805
+ cursor = next;
1806
+ if (scanned >= MAX_TAIL_PAGES) {
1807
+ truncated = true;
1808
+ break;
1809
+ }
1810
+ }
1811
+ return {
1812
+ thread_ref: ref,
1813
+ entries: last.map(entryRowOf),
1814
+ window: "tail",
1815
+ pages_scanned: scanned,
1816
+ truncated,
1817
+ next_cursor: truncated ? cursor ?? null : null,
1818
+ };
1819
+ } catch (error) {
1820
+ return { thread_ref: ref, unavailable: message(error) };
1821
+ }
1822
+ }
1823
+
1824
+ }
1825
+
1826
+ type ActionReadScope = Extract<Action, { kind: "read" }>["scope"];
1827
+
1828
+ function isReadScope(value: string): value is ActionReadScope {
1829
+ return value === "directory" || value === "neighbors" || value === "threads" || value === "inbox" || value === "messages" || value === "message" || value === "evidence" || value === "resources";
1830
+ }
1831
+
1832
+ function isThreadOp(value: string): value is Extract<Action, { kind: "thread" }>["op"] {
1833
+ return value === "create" || value === "close" || value === "list" || value === "describe" || value === "member_add" || value === "member_remove";
1834
+ }
1835
+
1836
+ function isDomainOp(value: string): value is Extract<Action, { kind: "domain" }>["op"] {
1837
+ return value === "create" || value === "join" || value === "leave" || value === "list" || value === "describe" || value === "discover";
1838
+ }
1839
+
1840
+ /** The fields of one judged message: intent, every answer the judge gave, and the time. */
1841
+ export function judgeRecord(
1842
+ direction: string,
1843
+ input: { delivery_kind: string; msg: string; meta?: unknown },
1844
+ answers: Answers,
1845
+ ms: number,
1846
+ ): Record<string, unknown> {
1847
+ const picked: Record<string, string> = {};
1848
+ for (const [key, value] of Object.entries(answers)) {
1849
+ const answer = value !== null && typeof value === "object" && "choice" in value ? (value as { choice?: unknown }).choice : undefined;
1850
+ if (typeof answer === "string" && answer.length > 0) picked[key] = answer;
1851
+ }
1852
+ return {
1853
+ direction,
1854
+ intent: input.delivery_kind,
1855
+ msg: input.msg.length > 120 ? `${input.msg.slice(0, 120)}…` : input.msg,
1856
+ answers: picked,
1857
+ language: getLocale(),
1858
+ ms,
1859
+ };
1860
+ }
1861
+
1862
+ /** The fields of a decided action worth one log line. */
1863
+ function actionFacts(action: Action): Record<string, string> {
1864
+ const facts: Record<string, string> = { action: action.kind };
1865
+ const record = action as unknown as Record<string, unknown>;
1866
+ for (const key of ["to", "context", "response", "execution", "relation", "scope", "op", "operation"]) {
1867
+ const value = record[key];
1868
+ if (typeof value === "string" && value.length > 0) facts[key] = value;
1869
+ }
1870
+ const thread = record.thread as { owner?: unknown; id?: unknown } | undefined;
1871
+ if (thread !== undefined && typeof thread.owner === "string" && typeof thread.id === "string") facts.thread = `${thread.owner}/${thread.id}`;
1872
+ const resource = record.resource as { owner?: unknown; id?: unknown } | undefined;
1873
+ if (resource !== undefined && typeof resource.owner === "string" && typeof resource.id === "string") facts.resource = `${resource.owner}/${resource.id}`;
1874
+ return facts;
1875
+ }
1876
+
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
+ function stringField(record: Record<string, Json>, key: string): string | undefined {
1884
+ const value = record[key];
1885
+ return typeof value === "string" && value.length > 0 ? value : undefined;
1886
+ }
1887
+
1888
+ /**
1889
+ * A sequence number as the model writes it: `"1"` and `1` both mean the same seq. The judge
1890
+ * schema says `record(string, unknown)`, so nothing tells the caller to quote it (caerulea
1891
+ * report 3, problem 2).
1892
+ */
1893
+ function seqField(record: Record<string, Json>, key: string): string | undefined {
1894
+ const value = record[key];
1895
+ if (typeof value === "string" && value.length > 0) return value;
1896
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return String(value);
1897
+ return undefined;
1898
+ }
1899
+
1900
+ function recordField(record: Record<string, Json>, key: string): Record<string, Json> | undefined {
1901
+ const value = record[key];
1902
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, Json>) : undefined;
1903
+ }
1904
+
1905
+ function pageSize(meta: Record<string, Json>): number | undefined {
1906
+ const value = meta.page_size;
1907
+ if (value === undefined) return undefined;
1908
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 100) {
1909
+ throw new OnlyneError("invalid_request", text("error.page_size"));
1910
+ }
1911
+ return value;
1912
+ }
1913
+
1914
+ function attention(meta: Record<string, Json>): "inbox" | "thread" {
1915
+ const value = meta.attention;
1916
+ // RFC 0005 §1: reads default to the attention list; `thread` is the switch that widens one.
1917
+ if (value === undefined) return "inbox";
1918
+ if (value === "inbox" || value === "thread") return value;
1919
+ throw new OnlyneError("invalid_request", text("error.attention"));
1920
+ }
1921
+
1922
+ function relation(meta: Record<string, Json> | undefined): "reply" | "followup" | undefined {
1923
+ if (meta === undefined) return undefined;
1924
+ const value = meta.relation;
1925
+ if (value === undefined) return undefined;
1926
+ if (value === "reply" || value === "followup") return value;
1927
+ throw new OnlyneError("invalid_request", text("error.relation_value"));
1928
+ }
1929
+
1930
+ function asRecord(value: unknown): Row | undefined {
1931
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Row) : undefined;
1932
+ }
1933
+
1934
+ function branchOf(ctx: ExtensionContext): readonly SessionEntry[] {
1935
+ return ctx.sessionManager.getBranch();
1936
+ }
1937
+
1938
+ function threadKey(ref: ThreadRef): string {
1939
+ return `${ref.owner}/${ref.id}`;
1940
+ }
1941
+
1942
+ /**
1943
+ * A wording hint about whether the sender expects an answer: metadata says reply, or the body
1944
+ * greets or asks. It is evidence for node I-0, never the decision — a completed reply also
1945
+ * mentions a greeting, and a status note also asks nothing (dpdp report 4, host shape).
1946
+ */
1947
+ export function needsReplyOf(entry: Entry): boolean {
1948
+ if (entry.message.meta?.in_reply_to !== undefined) return true;
1949
+ if (entry.message.meta?.relation === "reply") return true;
1950
+ const body = entry.message.msg.toLowerCase();
1951
+ return REPLY_CUES.some(cue => body.includes(cue.toLowerCase())) || /\?|?|\bplease\b|期待/.test(entry.message.msg);
1952
+ }
1953
+
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
+ function entryFacts(entry: Entry): Row {
1962
+ return {
1963
+ thread_ref: entry.thread_ref,
1964
+ seq: entry.seq,
1965
+ authenticated_author: entry.authenticated_author,
1966
+ reply_authority: entry.reply_authority,
1967
+ at: entry.at,
1968
+ message: {
1969
+ id: entry.message.id,
1970
+ sender: entry.message.sender,
1971
+ delivery_kind: entry.message.delivery_kind,
1972
+ msg: entry.message.msg,
1973
+ meta: entry.message.meta ?? null,
1974
+ },
1975
+ };
1976
+ }
1977
+
1978
+ function entryRowOf(entry: Entry): Row {
1979
+ return {
1980
+ seq: entry.seq,
1981
+ at: entry.at,
1982
+ authenticated_author: entry.authenticated_author,
1983
+ reply_authority: entry.reply_authority,
1984
+ message: {
1985
+ id: entry.message.id,
1986
+ sender: entry.message.sender,
1987
+ delivery_kind: entry.message.delivery_kind,
1988
+ msg: entry.message.msg,
1989
+ meta: entry.message.meta ?? null,
1990
+ },
1991
+ };
1992
+ }
1993
+
1994
+ function entryRow(entry: SessionEntry): Row | undefined {
1995
+ const row = asRecord(entry);
1996
+ if (row === undefined) return undefined;
1997
+ const id = typeof row.id === "string" ? row.id : undefined;
1998
+ const type = typeof row.type === "string" ? row.type : "unknown";
1999
+ const parent = typeof row.parentId === "string" ? row.parentId : null;
2000
+ const at = typeof row.timestamp === "string" ? row.timestamp : undefined;
2001
+ switch (type) {
2002
+ case "message": {
2003
+ const message = asRecord(row.message);
2004
+ if (message === undefined) return { id, type, parent_id: parent, at };
2005
+ const role = typeof message.role === "string" ? message.role : "unknown";
2006
+ const out: Row = {
2007
+ id,
2008
+ type,
2009
+ parent_id: parent,
2010
+ at,
2011
+ message: { role, content: contentOf(message.content) },
2012
+ };
2013
+ const nested = out.message as Row;
2014
+ if (typeof message.toolName === "string") nested.tool_name = message.toolName;
2015
+ if (typeof message.toolCallId === "string") nested.tool_call_id = message.toolCallId;
2016
+ if (typeof message.isError === "boolean") nested.is_error = message.isError;
2017
+ if (typeof message.stopReason === "string") nested.stop_reason = message.stopReason;
2018
+ return out;
2019
+ }
2020
+ case "custom_message":
2021
+ return {
2022
+ id,
2023
+ type,
2024
+ parent_id: parent,
2025
+ at,
2026
+ custom_type: row.customType ?? null,
2027
+ content: contentOf(row.content),
2028
+ display: row.display ?? null,
2029
+ attribution: row.attribution ?? null,
2030
+ };
2031
+ case "custom":
2032
+ return { id, type, parent_id: parent, at, custom_type: row.customType ?? null, data: row.data ?? null };
2033
+ case "compaction":
2034
+ return {
2035
+ id,
2036
+ type,
2037
+ parent_id: parent,
2038
+ at,
2039
+ summary: row.summary ?? null,
2040
+ first_kept_entry_id: row.firstKeptEntryId ?? null,
2041
+ tokens_before: row.tokensBefore ?? null,
2042
+ };
2043
+ case "branch_summary":
2044
+ return { id, type, parent_id: parent, at, summary: row.summary ?? null, from_id: row.fromId ?? null };
2045
+ case "mode_change":
2046
+ return { id, type, parent_id: parent, at, mode: row.mode ?? null, data: row.data ?? null };
2047
+ case "session_init":
2048
+ return { id, type, parent_id: parent, at, agent: row.agent ?? null, task: row.task ?? null };
2049
+ case "reset_boundary":
2050
+ return { id, type, parent_id: parent, at };
2051
+ case "model_usage":
2052
+ return undefined;
2053
+ default:
2054
+ return { id, type, parent_id: parent, at };
2055
+ }
2056
+ }
2057
+
2058
+ /** One pasted terminal dump must not become the whole decision prompt. */
2059
+ function boundText(value: string): string {
2060
+ return cut(value, TEXT_CHARS);
2061
+ }
2062
+
2063
+ function cut(value: string, limit: number): string {
2064
+ return value.length <= limit ? value : `${value.slice(0, limit)}${text("prose.cut", { more: value.length - limit })}`;
2065
+ }
2066
+
2067
+ /** One branch message as trigger material: its text, cut to the trigger budget. */
2068
+ function snippet(content: unknown): string | null {
2069
+ if (typeof content === "string") return cut(content, TRIGGER_CHARS);
2070
+ if (!Array.isArray(content)) return null;
2071
+ const joined = content
2072
+ .map(part => asRecord(part))
2073
+ .filter(part => typeof part?.text === "string")
2074
+ .map(part => String(part!.text))
2075
+ .join("\n");
2076
+ return joined.length === 0 ? null : cut(joined, TRIGGER_CHARS);
2077
+ }
2078
+
2079
+ /** Content parts keep their text; image bytes are not decision material and are named, not embedded. */
2080
+ function contentOf(value: unknown): unknown {
2081
+ if (typeof value === "string") return boundText(value);
2082
+ if (!Array.isArray(value)) return value ?? null;
2083
+ return value.map(part => {
2084
+ const row = asRecord(part);
2085
+ if (row === undefined) return part;
2086
+ if (row.type === "image") {
2087
+ return {
2088
+ type: "image",
2089
+ mime_type: row.mimeType ?? row.mime_type ?? null,
2090
+ bytes: text("prose.image_omitted"),
2091
+ };
2092
+ }
2093
+ if (typeof row.text === "string") return { ...row, text: boundText(row.text) };
2094
+ return row;
2095
+ });
2096
+ }
2097
+
2098
+ interface TodoView {
2099
+ goal: unknown;
2100
+ phases: Array<{ name: string; items: Array<{ title: string; status?: string; explanation?: string }> }>;
2101
+ }
2102
+
2103
+ /**
2104
+ * A todo snapshot as the judge sees it. Raw `mouriya.omp.todo-more` snapshots and
2105
+ * omp-todo-more's `readTodoMore` state are both accepted; anything unrecognized is
2106
+ * reported, never read as an empty list.
2107
+ */
2108
+ function normalizeTodo(current: unknown): Source<TodoView> | { present: false; reason: string } {
2109
+ if (current === undefined) return none(text("reason.todo_missing"));
2110
+ const row = asRecord(current);
2111
+ if (row === undefined) return none(text("reason.todo_not_object"));
2112
+ if (row.present === false) {
2113
+ return { present: false, reason: typeof row.reason === "string" ? row.reason : text("reason.todo_source_absent") };
2114
+ }
2115
+ if (row.kind === "empty") return ok({ goal: null, phases: [] });
2116
+ const phases = row.phases;
2117
+ if (!Array.isArray(phases)) return none(text("reason.todo_phases_missing"));
2118
+ const goal = asRecord(row.goal);
2119
+ const goalValue = goal?.kind === "set"
2120
+ ? goal.goal ?? null
2121
+ : goal?.kind === "missing"
2122
+ ? null
2123
+ : row.goal ?? null;
2124
+ const normalized: TodoView["phases"] = [];
2125
+ for (const phase of phases) {
2126
+ const phaseRow = asRecord(phase);
2127
+ const name = typeof phaseRow?.name === "string" ? phaseRow.name : undefined;
2128
+ const items = phaseRow?.items;
2129
+ if (name === undefined || !Array.isArray(items)) return none(text("reason.todo_phase_invalid"));
2130
+ const rows: TodoView["phases"][number]["items"] = [];
2131
+ for (const item of items) {
2132
+ const itemRow = asRecord(item);
2133
+ const title = typeof itemRow?.title === "string" ? itemRow.title : undefined;
2134
+ if (title === undefined) return none(text("reason.todo_item_title_missing"));
2135
+ const explanationRow = asRecord(itemRow?.explanation);
2136
+ const explanation = typeof itemRow?.explanation === "string"
2137
+ ? itemRow.explanation
2138
+ : explanationRow?.kind === "explained" && typeof explanationRow.text === "string"
2139
+ ? explanationRow.text
2140
+ : undefined;
2141
+ rows.push({
2142
+ title,
2143
+ ...(typeof itemRow?.status === "string" ? { status: itemRow.status } : {}),
2144
+ ...(explanation === undefined ? {} : { explanation }),
2145
+ });
2146
+ }
2147
+ normalized.push({ name, items: rows });
2148
+ }
2149
+ return ok({ goal: goalValue, phases: normalized });
2150
+ }
2151
+
2152
+ function todoTitles(source: unknown): string[] {
2153
+ const view = valueOf<TodoView>(source);
2154
+ if (view !== undefined) return view.phases.flatMap(phase => phase.items.map(item => item.title));
2155
+ const raw = asRecord(source);
2156
+ if (raw === undefined) return [];
2157
+ const phases = raw.phases;
2158
+ if (!Array.isArray(phases)) return [];
2159
+ return phases.flatMap(phase => {
2160
+ const items = asRecord(phase)?.items;
2161
+ if (!Array.isArray(items)) return [];
2162
+ return items.flatMap(item => {
2163
+ const title = asRecord(item)?.title;
2164
+ return typeof title === "string" ? [title] : [];
2165
+ });
2166
+ });
2167
+ }
2168
+
2169
+ /** The T-1 view of the todo list: titles and statuses only, no bodies. */
2170
+ function todoSummary(source: unknown): Array<{ title: string; status: string }> {
2171
+ const view = valueOf<TodoView>(source);
2172
+ if (view !== undefined) {
2173
+ return view.phases.flatMap(phase => phase.items.map(item => ({ title: item.title, status: item.status ?? "open" })));
2174
+ }
2175
+ const raw = asRecord(source);
2176
+ if (raw === undefined) return [];
2177
+ const phases = raw.phases;
2178
+ if (!Array.isArray(phases)) return [];
2179
+ return phases.flatMap(phase => {
2180
+ const items = asRecord(phase)?.items;
2181
+ if (!Array.isArray(items)) return [];
2182
+ return items.flatMap(item => {
2183
+ const row = asRecord(item);
2184
+ return typeof row?.title === "string" ? [{ title: row.title, status: typeof row.status === "string" ? row.status : "open" }] : [];
2185
+ });
2186
+ });
2187
+ }
2188
+
2189
+ /** Titles and explanations offered to the judge are the message's own text, never invented. */
2190
+ function todoCandidates(entry: Entry): { titles: string[]; explanations: string[] } {
2191
+ const body = entry.message.msg;
2192
+ const delivery = entry.message.delivery_kind.trim();
2193
+ const lines = body.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0);
2194
+ const first = lines[0];
2195
+ const paragraph = body.split(/\r?\n\s*\r?\n/)[0]?.trim();
2196
+ const sentence = first === undefined ? undefined : (/^(.+?[.!?。!?])(\s|$)/.exec(first)?.[1] ?? first);
2197
+ const titles = unique([
2198
+ first,
2199
+ sentence,
2200
+ delivery,
2201
+ first === undefined || delivery.length === 0 ? undefined : `${delivery}: ${first}`,
2202
+ ]);
2203
+ const explanations = unique([
2204
+ paragraph,
2205
+ body.trim(),
2206
+ first === undefined || delivery.length === 0 ? undefined : `${delivery} — ${first}`,
2207
+ ]);
2208
+ return { titles, explanations };
2209
+ }
2210
+
2211
+ function unique(values: Array<string | undefined>): string[] {
2212
+ const seen = new Set<string>();
2213
+ const out: string[] = [];
2214
+ for (const value of values) {
2215
+ if (value === undefined || value.length === 0 || value === "none" || seen.has(value)) continue;
2216
+ seen.add(value);
2217
+ out.push(value);
2218
+ }
2219
+ return out;
2220
+ }
2221
+
2222
+
2223
+ type JudgeJson = string | number | boolean | null | JudgeJson[] | { [key: string]: JudgeJson };
2224
+ type JudgeState = { [key: string]: JudgeJson };
2225
+
2226
+ /** The judge state must be plain JSON: strip undefined and non-finite values. */
2227
+ function jsonState(value: unknown): JudgeState {
2228
+ return JSON.parse(JSON.stringify(value)) as JudgeState;
2229
+ }
2230
+
2231
+ function message(error: unknown): string {
2232
+ return error instanceof Error ? error.message : String(error);
2233
+ }