omp-onlyne 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/messages.ts CHANGED
@@ -1,13 +1,14 @@
1
- import { OnlyneError, type DeliveryPlan, type Entry, type Message, type NotifyEvent, type Runtime, type TraceEvent } from "@onlyne/lib";
1
+ import { OnlyneError, type DeliveryPlan, type Entry, type Message, type NotifyEvent, type Runtime, type TraceEvent, type WorkRef } from "@onlyne/lib";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
3
3
  import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
4
4
  import { refreshMailCards, renderMailMessage, type OnlyneMessageDetails } from "./ui/mail-message.ts";
5
5
  import type { InboundDecision, JudgeDecider } from "./decider.ts";
6
- import type { ContextRegistry } from "./store.ts";
7
- import type { ContextRecord } from "./types.ts";
6
+ import type { ContextRegistry } from "@onlyne/node-host/store";
7
+ import type { ContextRecord } from "@onlyne/node-host/types";
8
8
  import { AWAIT_CONSUMED, pendingAwaitResults } from "./tool.ts";
9
9
  import { applyMessageTodo, type TodoPlanner } from "./todo.ts";
10
10
  import { getRuntime } from "./runtime.ts";
11
+ import { handshakeFacts } from "./handshake.ts";
11
12
  import { bridgeText as t } from "./bridge-text.ts";
12
13
 
13
14
  export const INBOUND = "onlyne.inbound";
@@ -20,6 +21,8 @@ export interface BridgeDeps {
20
21
  decider: JudgeDecider;
21
22
  contexts: ContextRegistry;
22
23
  nodeId: string;
24
+ /** Wall clock, so the escalation window can be tested without waiting for it. */
25
+ now?: () => number;
23
26
  }
24
27
  /**
25
28
  * One inbound entry is judged once per process. A retry replays the recorded decision
@@ -31,8 +34,44 @@ interface InboundAttempt {
31
34
  decision?: InboundDecision;
32
35
  failure?: OnlyneError;
33
36
  landed?: string;
37
+ /** Maintenance passes this entry has been reported landed without being confirmed. */
38
+ passes?: number;
39
+ /** Whether the held delivery has already been forced through as a trigger. */
40
+ escalated?: boolean;
41
+ /** Set once the second window passed with no confirmation; the warning is written once. */
42
+ unconfirmed?: boolean;
43
+ /** How the delivery was submitted: a trigger starts work, a next-turn fragment waits. */
44
+ delivery?: { via: "trigger" | "nextTurn"; at: number };
34
45
  }
46
+
47
+ /**
48
+ * How long a next-turn fragment may stay unread before the host offers it again. A turn takes at
49
+ * least one provider round trip, so a window measured in maintenance passes fires while the
50
+ * session is still working and duplicates the delivery (dpdp report, follow_up queue).
51
+ */
52
+ const NEXT_TURN_ESCALATE_MS = 60_000;
53
+ /** Times an entry may be decided again after its binding went away before it is delivered instead. */
54
+ const MAX_REDECISIONS = 2;
35
55
  const inboundAttempts = new Map<string, InboundAttempt>();
56
+ /**
57
+ * Deliveries attempted per entry, kept outside the attempt record: dropping that record is what
58
+ * makes the next pass decide again, and it must not reset the budget that bounds the deciding.
59
+ */
60
+ const deliveryAttempts = new Map<string, number>();
61
+
62
+ function countedAttempt(key: string): number {
63
+ const next = (deliveryAttempts.get(key) ?? 0) + 1;
64
+ deliveryAttempts.set(key, next);
65
+ return next;
66
+ }
67
+
68
+ /** The end of the line for an entry nothing can hold: the main session takes it once, visibly. */
69
+ function escalate(deps: BridgeDeps, entry: Entry, key: string, reason: string) {
70
+ const token = registerPending(entry, deps.main.agent.id, "fragment");
71
+ deps.pi.sendMessage(payload(entry, INBOUND, token), { triggerTurn: true });
72
+ deps.pi.logger.info("onlyne.delivery.escalated", { entry: key, reason, attempts: deliveryAttempts.get(key) });
73
+ return { context: deps.main.agent.id, consumed: false, outcome: "delivered" as const };
74
+ }
36
75
 
37
76
  function inboundKey(entry: Entry): string {
38
77
  return `${entry.thread_ref.owner}/${entry.thread_ref.id}/${entry.seq}`;
@@ -41,6 +80,7 @@ function inboundKey(entry: Entry): string {
41
80
  const TRACE_DEBUG = new Set(["inbound.inject", "inbound.duplicate"]);
42
81
 
43
82
  export function createRuntimeBridge(deps: BridgeDeps): Runtime {
83
+ const now = deps.now ?? Date.now;
44
84
  return {
45
85
  /**
46
86
  * Admission is the last cheap place to refuse a plan this host cannot honor (RFC 0005
@@ -56,8 +96,16 @@ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
56
96
  const fields = { ...event } as Record<string, unknown>;
57
97
  const name = String(fields.event);
58
98
  delete fields.event;
59
- const thread = fields.thread as { owner?: unknown; id?: unknown } | undefined;
60
- if (thread !== undefined) fields.thread = `${String(thread.owner)}/${String(thread.id)}`;
99
+ // `reconcile.failed` is the one event that names its thread as a bare id beside the
100
+ // owner, so formatting it as a reference logged `undefined/undefined` and dropped the
101
+ // id entirely. Every other event carries a real ThreadRef.
102
+ if (name === "reconcile.failed") {
103
+ fields.thread = `${String(fields.owner)}/${String(fields.thread)}`;
104
+ delete fields.owner;
105
+ } else {
106
+ const thread = fields.thread as { owner?: unknown; id?: unknown } | undefined;
107
+ if (thread !== undefined) fields.thread = `${String(thread.owner)}/${String(thread.id)}`;
108
+ }
61
109
  if (TRACE_DEBUG.has(name)) deps.pi.logger.debug(`onlyne.${name}`, fields);
62
110
  else deps.pi.logger.info(`onlyne.${name}`, fields);
63
111
  },
@@ -87,23 +135,68 @@ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
87
135
  });
88
136
  }
89
137
  if (attempt.failure !== undefined) {
138
+ // A lost binding cannot be re-decided into existence: past the budget the entry is
139
+ // delivered to the main session instead, so the judgement stops burning model calls
140
+ // and the message stops being invisible.
141
+ if (attempt.failure.error.code === "context_lost" && countedAttempt(key) > MAX_REDECISIONS) {
142
+ return escalate(deps, entry, key, "context-lost");
143
+ }
90
144
  deps.pi.logger.debug("onlyne.delivery.held", { entry: key, reason: "judge-failed", error: attempt.failure.message });
91
145
  throw attempt.failure;
92
146
  }
93
147
  const decision = attempt.decision;
94
148
  if (decision === undefined) throw new OnlyneError("decision_unavailable", t("noDecision"));
95
- if (decision.action === "new_subagent") {
96
- attempt.landed ??= await startSubagent(deps, entry);
97
- // A spawn is a submission, not a read: the child's prompt carries the token and its
98
- // first provider request is the proof, so nothing is consumed here.
99
- return { context: attempt.landed, consumed: false, outcome: "delivered" as const };
100
- }
101
149
  // The body is already where the decision put it; re-sending it would append a
102
150
  // second copy that the cursor can only confirm once.
103
151
  if (attempt.landed !== undefined) {
152
+ // RFC 0008 §5: a delivery is landed only while the context holding it can still
153
+ // confirm it. A subagent session that ended leaves the entry unconfirmed forever,
154
+ // and reporting it delivered on every maintenance pass is a retry storm, not a
155
+ // delivery. Mark the dead binding lost and let the next pass decide again.
156
+ const holder = (await deps.contexts.list()).find(record => record.agent_id === attempt.landed);
157
+ if (holder !== undefined && holder.state !== "idle" && holder.state !== "running") {
158
+ if (countedAttempt(key) > MAX_REDECISIONS) return escalate(deps, entry, key, "context-lost");
159
+ await deps.contexts.updateState(holder.alias, "lost").catch(() => undefined);
160
+ inboundAttempts.delete(key);
161
+ throw new OnlyneError("context_lost", t("contextLost", { alias: holder.alias }), { context: holder.alias });
162
+ }
163
+ // A delivery nobody confirmed has two shapes and one bound. A next-turn fragment waits
164
+ // for an assembly the session may never reach. A trigger starts its own turn, and that
165
+ // turn can die before the model reads the payload — a provider response at or above 400
166
+ // drops the observation it recorded — leaving the entry unconfirmed while every pass
167
+ // reports it delivered. Both are retried once, after a window long enough to be sure the
168
+ // session is not simply working, and only while it is idle right now.
169
+ attempt.passes = (attempt.passes ?? 0) + 1;
170
+ const delivery = attempt.delivery;
171
+ const waiting = delivery !== undefined && now() - delivery.at >= NEXT_TURN_ESCALATE_MS;
172
+ if (attempt.landed === deps.main.agent.id && waiting && delivery !== undefined && deps.main.isIdle()) {
173
+ if (attempt.escalated !== true) {
174
+ attempt.escalated = true;
175
+ // The retry opens its own window; measuring the second one from the first send
176
+ // would report it as unconfirmed on the very next pass.
177
+ attempt.delivery = { via: "trigger", at: now() };
178
+ const token = registerPending(entry, deps.main.agent.id, "fragment");
179
+ deps.pi.sendMessage(payload(entry, INBOUND, token), { triggerTurn: true });
180
+ deps.pi.logger.info("onlyne.delivery.escalated", {
181
+ entry: key, reason: "unconfirmed", via: delivery.via, waited_ms: now() - delivery.at,
182
+ });
183
+ } else if (attempt.unconfirmed !== true) {
184
+ // One warning per entry: the reason is recorded once, and the pass stops shouting.
185
+ attempt.unconfirmed = true;
186
+ deps.pi.logger.warn("onlyne.delivery.unconfirmed", {
187
+ entry: key, via: delivery.via, passes: attempt.passes, waited_ms: now() - delivery.at, context: attempt.landed,
188
+ });
189
+ }
190
+ }
104
191
  deps.pi.logger.debug("onlyne.delivery.held", { entry: key, reason: "already-delivered", context: attempt.landed });
105
192
  return { context: attempt.landed, consumed: false, outcome: "delivered" as const };
106
193
  }
194
+ if (decision.action === "new_subagent") {
195
+ attempt.landed = await startSubagent(deps, entry);
196
+ // A spawn is a submission, not a read: the child's prompt carries the token and its
197
+ // first provider request is the proof, so nothing is consumed here.
198
+ return { context: attempt.landed, consumed: false, outcome: "delivered" as const };
199
+ }
107
200
  if (decision.action === "context") {
108
201
  const alias = decision.context;
109
202
  if (alias === undefined) throw new OnlyneError("decision_unavailable", t("noContextNamed"));
@@ -137,8 +230,15 @@ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
137
230
  payload(entry, INBOUND, token),
138
231
  mode === undefined ? { triggerTurn: true } : { deliverAs: mode === "steer" ? "steer" : "followUp" },
139
232
  );
233
+ attempt.delivery = { via: "trigger", at: now() };
140
234
  } else {
141
- deps.pi.sendMessage(payload(entry, INBOUND, token), { deliverAs: "nextTurn" });
235
+ // RFC 0008 §7: a fragment enters the next context assembly without starting a turn.
236
+ // An idle session never reaches that assembly by itself, so the entry would sit in
237
+ // the queue as `injected: false` forever; idle means there is no turn to wait for.
238
+ const idle = deps.main.isIdle();
239
+ deps.pi.sendMessage(payload(entry, INBOUND, token), idle ? { triggerTurn: true } : { deliverAs: "nextTurn" });
240
+ if (idle) deps.pi.logger.info("onlyne.delivery.escalated", { entry: key, reason: "idle-fragment" });
241
+ attempt.delivery = { via: idle ? "trigger" : "nextTurn", at: now() };
142
242
  }
143
243
  attempt.landed = deps.main.agent.id;
144
244
  deps.pi.logger.info("onlyne.delivery.sent", {
@@ -149,6 +249,13 @@ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
149
249
  },
150
250
  todo: async (entry, context) => {
151
251
  inboundAttempts.delete(inboundKey(entry));
252
+ deliveryAttempts.delete(inboundKey(entry));
253
+ // RFC 0018 §3: a handshake status update is delivered for the record, not as work.
254
+ // Only an entry that owes a decision takes the todo decision path.
255
+ if (handshakeFacts(entry.message.meta)?.pending === false) {
256
+ refreshMailCards();
257
+ return;
258
+ }
152
259
  const planner: TodoPlanner = {
153
260
  decideTodo: (incoming, current) => deps.decider.decideTodo(deps.main, incoming, current),
154
261
  };
@@ -172,8 +279,7 @@ const pending = new Map<string, PendingObservation>();
172
279
  const toolResults = new Map<string, PendingObservation>();
173
280
  const observed = new Map<string, string[]>();
174
281
 
175
- function registerPending(entry: Entry, context: string, kind: PendingObservation["kind"]): string {
176
- const token = `onlyne-${crypto.randomUUID()}`;
282
+ function registerPending(entry: Entry, context: string, kind: PendingObservation["kind"], token = `onlyne-${crypto.randomUUID()}`): string {
177
283
  pending.set(token, { entry, context, kind });
178
284
  return token;
179
285
  }
@@ -283,7 +389,9 @@ async function startSubagent(deps: BridgeDeps, entry: Entry): Promise<string> {
283
389
  await deps.contexts.bind(record.alias, { agent_id: child, ...(parentId === undefined ? {} : { parent_id: parentId }) });
284
390
  // The token is already in the child's prompt, so it can only be registered once the child's
285
391
  // identity is known; until then there is no provider boundary to observe it at.
286
- registerPending(entry, child, "fragment");
392
+ // It is registered as the token the prompt carries: a fresh one would never appear in any
393
+ // provider payload, and the delivery could never be confirmed.
394
+ registerPending(entry, child, "fragment", token);
287
395
  return child;
288
396
  }
289
397
  async function findContextBySource(contexts: ContextRegistry, entry: Entry): Promise<ContextRecord | undefined> {
@@ -314,6 +422,7 @@ function payload(entry: Entry, customType: string, token: string): MessagePayloa
314
422
  from: entry.authenticated_author, at: entry.at, subject: entry.message.delivery_kind, body: entry.message.msg,
315
423
  ...(entry.message.meta?.relation === undefined ? {} : { relation: entry.message.meta.relation }),
316
424
  ...(entry.message.meta?.in_reply_to === undefined ? {} : { in_reply_to: entry.message.meta.in_reply_to }),
425
+ ...(entry.message.meta?.work === undefined ? {} : { work: entry.message.meta.work }),
317
426
  },
318
427
  } };
319
428
  }
@@ -323,5 +432,14 @@ export function registerMessageRenderers(pi: ExtensionAPI): void {
323
432
  function renderEntry(entry: Entry, customType: string): string {
324
433
  const label = customType === SUPPLEMENT ? t("labelSupplement") : t("labelInbound");
325
434
  const relation = entry.message.meta?.relation === undefined ? "" : ` · ${entry.message.meta.relation}`;
326
- return `${entry.thread_ref.id}/${entry.seq} · ${label} · ${entry.authenticated_author}${relation}\n${entry.message.msg.split(/\r?\n/).map(line => ` > ${line}`).join("\n")}`;
435
+ const work = workLabel(entry.message.meta?.work);
436
+ return `${entry.thread_ref.id}/${entry.seq} · ${label} · ${entry.authenticated_author}${relation}${work}\n${entry.message.msg.split(/\r?\n/).map(line => ` > ${line}`).join("\n")}`;
437
+ }
438
+
439
+ /** The work unit a sender named, so the receiver sees the dedupe key its answer belongs to. */
440
+ export function workLabel(work: WorkRef | undefined): string {
441
+ if (work === undefined) return "";
442
+ const parent = work.parent === undefined || work.parent === null ? "" : ` · parent ${work.parent}`;
443
+ const attempt = work.attempt === undefined ? "" : ` · attempt ${work.attempt}`;
444
+ return ` · work ${work.unit}${parent}${attempt}`;
327
445
  }
package/src/plan.ts ADDED
@@ -0,0 +1,133 @@
1
+ import { OnlyneError, type Action, type Json, type NodeId, type ThreadRef } from "@onlyne/lib";
2
+ import { text } from "./decider-text.ts";
3
+ import { choice, type Answers } from "./questions.ts";
4
+ import { reuseThread, type ReplyReference, type ReferenceDeps } from "./resolve.ts";
5
+
6
+ export type ResponseMode = "await" | "async" | "none";
7
+ export type ExecutionMode = "existing" | "new";
8
+
9
+ /** The plan fields the caller already fixed in meta. */
10
+ export interface InheritedModes {
11
+ response?: ResponseMode;
12
+ execution?: ExecutionMode;
13
+ }
14
+
15
+ export interface ResourceFields {
16
+ owner: NodeId;
17
+ id: string;
18
+ operation: string;
19
+ }
20
+
21
+ function stringField(record: Record<string, Json>, key: string): string | undefined {
22
+ const value = record[key];
23
+ return typeof value === "string" && value.length > 0 ? value : undefined;
24
+ }
25
+
26
+ function recordField(record: Record<string, Json>, key: string): Record<string, Json> | undefined {
27
+ const value = record[key];
28
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, Json>) : undefined;
29
+ }
30
+
31
+ /**
32
+ * A plan field the caller stated is binding and is never asked again; a value outside the two
33
+ * vocabularies is a caller error, never a reason to ask the judge. Anything the caller left out
34
+ * is decided by the O-3A question, which is why the omission is returned as `undefined` here
35
+ * rather than filled with a default (audit §2.5).
36
+ */
37
+ export function inheritedModes(meta: Record<string, Json> | undefined): InheritedModes {
38
+ if (meta === undefined) return {};
39
+ const response = meta.response;
40
+ const execution = meta.execution;
41
+ if (response !== undefined && response !== "await" && response !== "async" && response !== "none") {
42
+ throw new OnlyneError("invalid_request", text("error.meta_mode", { key: "response" }));
43
+ }
44
+ if (execution !== undefined && execution !== "existing" && execution !== "new") {
45
+ throw new OnlyneError("invalid_request", text("error.meta_mode", { key: "execution" }));
46
+ }
47
+ return {
48
+ ...(response === undefined ? {} : { response }),
49
+ ...(execution === undefined ? {} : { execution }),
50
+ };
51
+ }
52
+
53
+ /** The complete plan: what the caller fixed, plus what the judge answered for the rest. */
54
+ export function resolvedModes(inherited: InheritedModes, answers: Answers): { response: ResponseMode; execution: ExecutionMode } {
55
+ const response = inherited.response ?? choice(answers, "response");
56
+ const execution = inherited.execution ?? choice(answers, "execution");
57
+ if (response !== "none" && response !== "async" && response !== "await") {
58
+ throw new OnlyneError("decision_unavailable", text("error.response_mode"));
59
+ }
60
+ if (execution !== "existing" && execution !== "new") {
61
+ throw new OnlyneError("decision_unavailable", text("error.execution_mode"));
62
+ }
63
+ return { response, execution };
64
+ }
65
+
66
+ /**
67
+ * The fields a resource operation needs, or `undefined` when the call does not name a complete
68
+ * one. A partial `meta.resource` is not a resource action, and it is not a reason to fall back
69
+ * to communication either: the caller named a resource and must finish naming it.
70
+ */
71
+ export function resourceFields(meta: Record<string, Json> | undefined): ResourceFields | undefined {
72
+ if (meta === undefined) return undefined;
73
+ const resource = recordField(meta, "resource");
74
+ const owner = resource === undefined ? undefined : stringField(resource, "owner");
75
+ const id = resource === undefined ? undefined : stringField(resource, "id");
76
+ const operation = stringField(meta, "operation");
77
+ return owner === undefined || id === undefined || operation === undefined ? undefined : { owner, id, operation };
78
+ }
79
+
80
+ /** The resource action both the explicit and the judged branch build. */
81
+ export function resourceAction(meta: Record<string, Json> | undefined): Action {
82
+ const fields = resourceFields(meta);
83
+ if (fields === undefined) throw new OnlyneError("invalid_request", text("error.resource_action_fields"));
84
+ const data = meta === undefined ? undefined : meta.data;
85
+ const revision = meta === undefined ? undefined : meta.expected_revision;
86
+ return {
87
+ kind: "resource",
88
+ resource: { owner: fields.owner, id: fields.id },
89
+ operation: fields.operation,
90
+ ...(data === undefined ? {} : { data }),
91
+ ...(typeof revision === "string" ? { expected_revision: revision } : {}),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * The thread a send belongs to when the caller named none.
97
+ *
98
+ * A reply lands on the thread that holds its original (the client resolves it), and a plan
99
+ * that says `new` opens a topic. Otherwise the message continues the conversation this node
100
+ * already has with its addressee: leaving it unnamed makes the client open a thread per
101
+ * message, and asking the model for an alias it cannot know is the mistake the design already
102
+ * recorded. This is a lookup over local records, not a judgement (RFC 0016 §5).
103
+ */
104
+ export async function continueThread(deps: ReferenceDeps, action: Action): Promise<Action> {
105
+ if (action.kind !== "send" || action.thread !== undefined) return action;
106
+ if (action.in_reply_to !== undefined || action.execution === "new") return action;
107
+ const thread = await reuseThread(deps, action.to);
108
+ return thread === undefined ? action : { ...action, thread };
109
+ }
110
+
111
+ /** One send action from its resolved references and judged plan. */
112
+ export function sendAction(parts: {
113
+ to: NodeId;
114
+ response: "await" | "async" | "none";
115
+ execution: "existing" | "new";
116
+ thread?: ThreadRef;
117
+ context?: string;
118
+ reply?: ReplyReference;
119
+ relation?: "reply" | "followup";
120
+ mentions?: string[];
121
+ }): Action {
122
+ return {
123
+ kind: "send",
124
+ to: parts.to,
125
+ ...(parts.thread === undefined ? {} : { thread: parts.thread }),
126
+ ...(parts.context === undefined ? {} : { context: parts.context }),
127
+ ...(parts.reply === undefined ? {} : { in_reply_to: parts.reply.ref }),
128
+ ...(parts.relation === undefined ? {} : { relation: parts.relation }),
129
+ ...(parts.mentions === undefined ? {} : { mentions: parts.mentions }),
130
+ response: parts.response,
131
+ execution: parts.execution,
132
+ };
133
+ }
@@ -0,0 +1,250 @@
1
+ import type { NodeId, ThreadRef } from "@onlyne/lib";
2
+ import { text } from "./decider-text.ts";
3
+ import type { PeerRecord } from "./resolve.ts";
4
+
5
+ /**
6
+ * The choice catalogs of the decision nodes. Each builder returns the question the judge
7
+ * answers, with only the options that can actually execute: an option whose choice is
8
+ * guaranteed to fail is a question the model cannot answer correctly.
9
+ */
10
+ export interface ChoiceQuestion {
11
+ type: "choice";
12
+ instructions: string;
13
+ criteria: Record<string, string>;
14
+ }
15
+
16
+ export type Questions = Record<string, ChoiceQuestion>;
17
+
18
+ export interface ChoiceAnswer {
19
+ type?: string;
20
+ choice?: string;
21
+ }
22
+
23
+ export type Answers = Record<string, ChoiceAnswer | undefined>;
24
+
25
+ /** The one answer a choice question carries, or `undefined` when the judge gave none. */
26
+ export function choice(answers: Answers, key: string): string | undefined {
27
+ const answer = answers[key];
28
+ if (answer === undefined || answer === null || typeof answer !== "object") return undefined;
29
+ return typeof answer.choice === "string" ? answer.choice : undefined;
30
+ }
31
+
32
+ /** Node O-1. `resource` is offered only when meta names a complete resource action. */
33
+ export function intentQuestions(options: { resource: boolean }): Questions {
34
+ return {
35
+ intent: {
36
+ type: "choice",
37
+ instructions: text("judge.o1.instructions"),
38
+ criteria: {
39
+ communicate: text("judge.o1.communicate"),
40
+ inspect: text("judge.o1.inspect"),
41
+ manage_thread: text("judge.o1.manage_thread"),
42
+ manage_domain: text("judge.o1.manage_domain"),
43
+ ...(options.resource ? { operate_resource: text("judge.o1.operate_resource") } : {}),
44
+ },
45
+ },
46
+ };
47
+ }
48
+
49
+ /** Node O-2A, question 1: the relation a reference carries. */
50
+ export function relationQuestions(): Questions {
51
+ return {
52
+ relation: {
53
+ type: "choice",
54
+ instructions: text("judge.relation.instructions"),
55
+ criteria: {
56
+ reply: text("judge.relation.reply"),
57
+ followup: text("judge.relation.followup"),
58
+ },
59
+ },
60
+ };
61
+ }
62
+
63
+ /** Node O-3A: whichever of response and execution the caller left open. */
64
+ export function planQuestions(open: { response: boolean; execution: boolean; reply: boolean }): Questions {
65
+ return {
66
+ ...(open.response
67
+ ? {
68
+ response: {
69
+ type: "choice" as const,
70
+ instructions: text("judge.response.instructions"),
71
+ criteria: {
72
+ none: text("judge.response.none"),
73
+ async: text("judge.response.async"),
74
+ await: text("judge.response.await"),
75
+ },
76
+ },
77
+ }
78
+ : {}),
79
+ ...(open.execution
80
+ ? {
81
+ execution: {
82
+ type: "choice" as const,
83
+ instructions: text("judge.execution.instructions"),
84
+ criteria: {
85
+ existing: open.reply
86
+ ? text("judge.execution.existing.reply")
87
+ : text("judge.execution.existing.new"),
88
+ new: text("judge.execution.new"),
89
+ },
90
+ },
91
+ }
92
+ : {}),
93
+ };
94
+ }
95
+
96
+ /** Node O-3A-Ctx: which published context of the target continues the work. */
97
+ export function contextQuestions(published: Array<{ alias: string; summary: string; state: string }>): Questions {
98
+ return {
99
+ context: {
100
+ type: "choice",
101
+ instructions: text("judge.context.instructions"),
102
+ criteria: {
103
+ ...Object.fromEntries(published.map(context => [
104
+ context.alias,
105
+ text("judge.context.published", { summary: context.summary, state: context.state }),
106
+ ])),
107
+ none: text("judge.context.none"),
108
+ },
109
+ },
110
+ };
111
+ }
112
+
113
+ /** Node O-2A, question 2: the recipient among the peers this node can name. */
114
+ export function targetQuestions(peers: PeerRecord[]): Questions {
115
+ return {
116
+ target: {
117
+ type: "choice",
118
+ instructions: text("judge.target.instructions"),
119
+ criteria: {
120
+ // The abstain exit: an unnamed recipient must surface as ambiguous_target,
121
+ // never as a low-confidence random pick among peers.
122
+ none: text("judge.target.none"),
123
+ ...Object.fromEntries(peers.map(peer => [
124
+ peer.node_id,
125
+ text("judge.target.peer", {
126
+ node: peer.node_id,
127
+ base_url: peer.base_url,
128
+ instance: peer.instance_id,
129
+ last_seen: peer.last_seen,
130
+ }),
131
+ ])),
132
+ },
133
+ },
134
+ };
135
+ }
136
+
137
+ /** Node O-3B-Th: which local thread a read or thread operation names. */
138
+ export function threadQuestions(threads: Array<{ ref: ThreadRef; title: string; state: string }>): Questions {
139
+ return {
140
+ thread: {
141
+ type: "choice",
142
+ instructions: text("judge.thread.instructions"),
143
+ criteria: Object.fromEntries(threads.map(thread => [
144
+ thread.ref.id,
145
+ text("judge.thread.item", {
146
+ title: thread.title,
147
+ owner: thread.ref.owner,
148
+ id: thread.ref.id,
149
+ state: thread.state,
150
+ }),
151
+ ])),
152
+ },
153
+ };
154
+ }
155
+
156
+ /** Node O-2A: which unanswered inbound message this call answers, or none. */
157
+ export function originalQuestions(
158
+ facts: Array<{ key: string; from: NodeId; kind: string; thread: string; seq: string; body: string }>,
159
+ ): Questions {
160
+ const criteria: Record<string, string> = { new_message: text("judge.original.new") };
161
+ for (const fact of facts) {
162
+ criteria[fact.key] = text("judge.original.candidate", {
163
+ from: fact.from, kind: fact.kind, thread: fact.thread, seq: fact.seq, body: fact.body,
164
+ });
165
+ }
166
+ return { original: { type: "choice", instructions: text("judge.original.instructions"), criteria } };
167
+ }
168
+
169
+ /** Node O-2B: which readable channel the message asks about. */
170
+ export function readScopeQuestions(): Questions {
171
+ return {
172
+ read_scope: {
173
+ type: "choice",
174
+ instructions: text("judge.read_scope.instructions"),
175
+ criteria: {
176
+ peers: text("judge.read_scope.peers"),
177
+ inbox: text("judge.read_scope.inbox"),
178
+ messages: text("judge.read_scope.messages"),
179
+ evidence: text("judge.read_scope.evidence"),
180
+ resources: text("judge.read_scope.resources"),
181
+ none: text("judge.read_scope.none"),
182
+ },
183
+ },
184
+ };
185
+ }
186
+
187
+ /** Node O-2C: the local thread operation. */
188
+ export function threadOpQuestions(): Questions {
189
+ return {
190
+ thread_op: {
191
+ type: "choice",
192
+ instructions: text("judge.thread_op.instructions"),
193
+ criteria: {
194
+ create: text("judge.thread_op.create"),
195
+ close: text("judge.thread_op.close"),
196
+ list: text("judge.thread_op.list"),
197
+ describe: text("judge.thread_op.describe"),
198
+ member_add: text("judge.thread_op.member_add"),
199
+ member_remove: text("judge.thread_op.member_remove"),
200
+ },
201
+ },
202
+ };
203
+ }
204
+
205
+ /** Node O-2D: the domain topology operation. */
206
+ export function domainOpQuestions(): Questions {
207
+ return {
208
+ domain_op: {
209
+ type: "choice",
210
+ instructions: text("judge.domain_op.instructions"),
211
+ criteria: {
212
+ create: text("judge.domain_op.create"),
213
+ join: text("judge.domain_op.join"),
214
+ leave: text("judge.domain_op.leave"),
215
+ list: text("judge.domain_op.list"),
216
+ describe: text("judge.domain_op.describe"),
217
+ discover: text("judge.domain_op.discover"),
218
+ },
219
+ },
220
+ };
221
+ }
222
+
223
+ /** Node I-0: whether this node owes the sender an answer. */
224
+ export function needsAnswerQuestions(): Questions {
225
+ return {
226
+ needs_answer: {
227
+ type: "choice",
228
+ instructions: text("judge.needs_answer.instructions"),
229
+ criteria: {
230
+ yes: text("judge.needs_answer.yes"),
231
+ no: text("judge.needs_answer.no"),
232
+ },
233
+ },
234
+ };
235
+ }
236
+
237
+ /** Node I-2: how a main-session delivery enters the agent's work. */
238
+ export function insertionQuestions(options: { fragment: boolean }): Questions {
239
+ return {
240
+ insertion_mode: {
241
+ type: "choice",
242
+ instructions: text("judge.insertion.instructions"),
243
+ criteria: {
244
+ steer: text("judge.insertion.steer"),
245
+ follow_up: text("judge.insertion.follow_up"),
246
+ ...(options.fragment ? { fragment: text("judge.insertion.fragment") } : {}),
247
+ },
248
+ },
249
+ };
250
+ }