omp-onlyne 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/drafts.ts CHANGED
@@ -11,11 +11,17 @@ interface Draft {
11
11
  prepared: PreparedSend;
12
12
  }
13
13
 
14
+ /** How a caller names the draft it wants sent: by id, or by the message it answers. */
15
+ export interface DraftRequest {
16
+ id?: string;
17
+ in_reply_to?: Json;
18
+ }
19
+
14
20
  export async function createDraft(runtime: PluginRuntime, ctx: ExtensionContext, input: MsgInput, signal?: AbortSignal): Promise<unknown> {
15
21
  const meta = { ...input.meta };
16
22
  delete meta.op;
17
23
  if (meta.relation !== "reply" && meta.relation !== "followup") throw new OnlyneError("invalid_request", t("draftNeedsRelation"));
18
- const original = await resolveSource(runtime, meta.in_reply_to);
24
+ const original = await sourceReference(runtime, meta.in_reply_to);
19
25
  if (ctx.agent.kind === "sub") {
20
26
  const authorized = (await runtime.contexts.list()).some(record => record.agent_id === ctx.agent.id
21
27
  && record.source?.sender === original.sender && record.source.id === original.id);
@@ -30,28 +36,71 @@ export async function createDraft(runtime: PluginRuntime, ctx: ExtensionContext,
30
36
  in_reply_to: original, body: prepared.message.msg, state: "draft", send: { meta: { draft_id: draft.id } } };
31
37
  }
32
38
 
33
- export async function sendDraft(runtime: PluginRuntime, ctx: ExtensionContext, id: string, signal?: AbortSignal): Promise<unknown> {
39
+ /**
40
+ * Sends a prepared reply. The caller may name the draft by id or by the message it answers; a
41
+ * call that names neither continues with the most recent draft this session created, because
42
+ * the draft already carries the one decision that must not be taken twice — its plan.
43
+ */
44
+ export async function sendDraft(runtime: PluginRuntime, ctx: ExtensionContext, request: DraftRequest, signal?: AbortSignal): Promise<unknown> {
34
45
  if (ctx.agent.kind === "sub") throw new OnlyneError("forbidden", t("draftChildCannotSend"));
35
- const draft = await runtime.storage.transaction(tx => tx.get<Draft>(`draft/${id}`));
36
- if (draft === undefined) throw new OnlyneError("not_found", t("draftNotFound", { id }));
46
+ const draft = await findDraft(runtime, ctx, request);
37
47
  const { to, message, plan } = draft.prepared;
38
48
  return runtime.node.client.send(to, message, plan, { context: ctx.agent.id, signal });
39
49
  }
40
50
 
41
- async function resolveSource(runtime: PluginRuntime, value: Json | undefined): Promise<MessageRef> {
42
- if (value === null || typeof value !== "object" || Array.isArray(value)) throw new OnlyneError("invalid_request", t("draftNeedsReference"));
43
- if (typeof value.sender === "string" && typeof value.id === "string") return { sender: value.sender, id: value.id };
44
- if (typeof value.thread === "string" && (typeof value.seq === "string" || typeof value.seq === "number")) {
45
- const slash = value.thread.lastIndexOf("/");
46
- const thread = slash < 0 ? { owner: runtime.config.node.id, id: value.thread }
47
- : { owner: value.thread.slice(0, slash), id: value.thread.slice(slash + 1) };
48
- let cursor: string | undefined;
49
- do {
50
- const result = await runtime.node.messages.list(runtime.node.auth, thread, { attention: "thread", page_size: 100, cursor });
51
- const found = result.items.find(row => row.seq === String(value.seq));
52
- if (found !== undefined) return { sender: found.message.sender, id: found.message.id };
53
- cursor = result.next_cursor ?? undefined;
54
- } while (cursor !== undefined);
51
+ /**
52
+ * Draft lookup. The index is (created_by, in_reply_to): a reference names the draft that
53
+ * answered that message, and a session with no reference of its own sends the newest draft it
54
+ * created. Several drafts can answer one message — a child drafted, then the main session
55
+ * answered something else — so the newest wins; the draft's own plan is what makes the choice
56
+ * safe, since no wording is decided again here.
57
+ */
58
+ async function findDraft(runtime: PluginRuntime, ctx: ExtensionContext, request: DraftRequest): Promise<Draft> {
59
+ if (request.id !== undefined) {
60
+ const draft = await runtime.storage.transaction(tx => tx.get<Draft>(`draft/${request.id}`));
61
+ if (draft === undefined) throw new OnlyneError("not_found", t("draftNotFound", { id: request.id }));
62
+ return draft;
63
+ }
64
+ // The store is the only index: drafts outlive the session that created them, and a scan of
65
+ // a handful of rows costs less than a second index that could disagree with it.
66
+ const stored = await runtime.storage.transaction(tx => tx.scan<Draft>("draft/"));
67
+ const drafts = stored.map(row => row.value);
68
+ const wanted = request.in_reply_to === undefined ? undefined : await sourceReference(runtime, request.in_reply_to);
69
+ const matching = wanted === undefined
70
+ ? drafts
71
+ : drafts.filter(draft => {
72
+ const ref = draft.prepared.message.meta?.in_reply_to;
73
+ return ref !== undefined && ref.sender === wanted.sender && ref.id === wanted.id;
74
+ });
75
+ // This session's own draft is preferred; a child's draft it was just handed still answers,
76
+ // because the reference, not the author, is what the caller named.
77
+ const own = matching.filter(draft => draft.created_by === ctx.agent.id);
78
+ const pool = wanted === undefined ? own : own.length > 0 ? own : matching;
79
+ const newest = pool.sort((left, right) =>
80
+ right.created_at.localeCompare(left.created_at) || right.id.localeCompare(left.id))[0];
81
+ if (newest !== undefined) return newest;
82
+ throw wanted === undefined
83
+ ? new OnlyneError("not_found", t("draftNoRecent"))
84
+ : new OnlyneError("not_found", t("draftNoMatch", { sender: wanted.sender, id: wanted.id }));
85
+ }
86
+
87
+ /** The message a draft answers, in the two shapes a caller can copy from a receipt or a read. */
88
+ async function sourceReference(runtime: PluginRuntime, value: Json | undefined): Promise<MessageRef> {
89
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
90
+ throw new OnlyneError("invalid_request", t("draftNeedsReference"));
91
+ }
92
+ const sender = typeof value.sender === "string" && value.sender.length > 0 ? value.sender : undefined;
93
+ const id = typeof value.id === "string" && value.id.length > 0 ? value.id : undefined;
94
+ if (sender !== undefined && id !== undefined) {
95
+ return { sender: await runtime.decider.refs.peerId(sender).catch(() => sender), id };
96
+ }
97
+ const thread = typeof value.thread === "string" && value.thread.length > 0 ? value.thread : undefined;
98
+ const seq = typeof value.seq === "number" ? String(value.seq) : typeof value.seq === "string" && value.seq.length > 0 ? value.seq : undefined;
99
+ if (thread !== undefined && seq !== undefined) {
100
+ const ref = await runtime.decider.refs.threadByAlias(thread);
101
+ const entry = await runtime.decider.refs.entryAt(ref, seq);
102
+ if (entry !== undefined) return { sender: entry.message.sender, id: entry.message.id };
103
+ throw new OnlyneError("not_found", t("draftSourceUnavailable"));
55
104
  }
56
- throw new OnlyneError("not_found", t("draftSourceUnavailable"));
105
+ throw new OnlyneError("invalid_request", t("draftNeedsReference"));
57
106
  }
package/src/guard.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { OnlyneError, type Action, type Json, type NodeId } from "@onlyne/lib";
2
+ import type { ContextRegistry } from "@onlyne/node-host/store";
3
+ import type { ContextRecord } from "@onlyne/node-host/types";
4
+ import { text } from "./decider-text.ts";
5
+ import { resourceFields } from "./plan.ts";
6
+ import { threadKey } from "./resolve.ts";
7
+
8
+ /** The five intents node O-1 chooses among. */
9
+ export type OutboundIntent = "communicate" | "inspect" | "manage_thread" | "manage_domain" | "operate_resource";
10
+
11
+ export interface IntentSettlement {
12
+ intent: OutboundIntent;
13
+ /** Set when a judged intent could not execute and a deterministic rule replaced it. */
14
+ override?: { chosen: OutboundIntent; fallback: OutboundIntent; reason: string };
15
+ }
16
+
17
+ /** A call that names a recipient: `meta.to`, or the message it answers. */
18
+ function addressed(meta: Record<string, Json> | undefined): boolean {
19
+ if (meta === undefined) return false;
20
+ if (typeof meta.to === "string" && meta.to.length > 0) return true;
21
+ return meta.in_reply_to !== null && typeof meta.in_reply_to === "object" && !Array.isArray(meta.in_reply_to);
22
+ }
23
+
24
+ /**
25
+ * A resource intent is executable only when the fields it needs are present. The O-1 criteria
26
+ * already say a message that merely mentions a file is not a resource operation, but a criterion
27
+ * is prose the model may violate; this is the same rule in code.
28
+ *
29
+ * When a message names an explicit recipient and carries no complete resource action, the
30
+ * resource choice is a misread of a communication: letting it through can only fail on missing
31
+ * fields, and the error would send the caller to add `meta.resource` — turning a legitimate
32
+ * message into an operation on somebody's resource (issue: outbound-intent-resource-false-
33
+ * positive). With no recipient named the resource branch keeps its own hard error, because
34
+ * nothing in the call says the message was meant for anyone.
35
+ */
36
+ export function settleIntent(intent: OutboundIntent, meta: Record<string, Json> | undefined): IntentSettlement {
37
+ if (intent !== "operate_resource") return { intent };
38
+ if (resourceFields(meta) !== undefined) return { intent };
39
+ if (!addressed(meta)) return { intent };
40
+ return {
41
+ intent: "communicate",
42
+ override: {
43
+ chosen: "operate_resource",
44
+ fallback: "communicate",
45
+ reason: "the message names a recipient and carries no complete meta.resource",
46
+ },
47
+ };
48
+ }
49
+
50
+ /**
51
+ * A subagent may read only the threads its own context records authorize on this node. The
52
+ * unfiltered node-wide views stay with the main session.
53
+ */
54
+ export class ActionGuard {
55
+ constructor(private readonly deps: { contexts: ContextRegistry; nodeId: NodeId }) {}
56
+
57
+ async assert(scope: { readOnly?: boolean; agentId: string }, action: Action): Promise<void> {
58
+ if (scope.readOnly !== true) return;
59
+ if (action.kind !== "read") {
60
+ throw new OnlyneError("forbidden", text("error.guard_write"));
61
+ }
62
+ if (action.scope === "resources") throw new OnlyneError("forbidden", text("error.resources_main_only"));
63
+ if (action.scope === "directory" || action.scope === "neighbors" || action.scope === "threads" || action.scope === "inbox") {
64
+ throw new OnlyneError("forbidden", text("error.guard_node_listing"));
65
+ }
66
+ const records = await this.authorized(scope.agentId);
67
+ if (action.scope === "evidence") {
68
+ const ref = action.message;
69
+ const tied = ref !== undefined && records.some(record =>
70
+ record.source !== undefined && record.source.sender === ref.sender && record.source.id === ref.id,
71
+ );
72
+ if (!tied) throw new OnlyneError("forbidden", text("error.guard_evidence"));
73
+ return;
74
+ }
75
+ // `{sender,id}` names no thread, so the record that received that message is the
76
+ // authorization — the same tie evidence uses.
77
+ if (action.scope === "message" && action.thread === undefined && action.message !== undefined) {
78
+ const ref = action.message;
79
+ const tied = records.some(record =>
80
+ record.source !== undefined && record.source.sender === ref.sender && record.source.id === ref.id,
81
+ );
82
+ if (!tied) throw new OnlyneError("forbidden", text("error.guard_message"));
83
+ return;
84
+ }
85
+ if (action.thread === undefined) {
86
+ throw new OnlyneError("forbidden", text("error.guard_thread_required"));
87
+ }
88
+ const key = threadKey(action.thread);
89
+ if (!records.some(record => record.thread_ref !== undefined && threadKey(record.thread_ref) === key)) {
90
+ throw new OnlyneError("forbidden", text("error.guard_no_slice", { thread: key }));
91
+ }
92
+ }
93
+
94
+ private async authorized(agentId: string): Promise<ContextRecord[]> {
95
+ let records: ContextRecord[];
96
+ try {
97
+ records = await this.deps.contexts.list();
98
+ } catch (error) {
99
+ throw new OnlyneError("unavailable", text("error.registry_unreadable", {
100
+ error: error instanceof Error ? error.message : String(error),
101
+ }));
102
+ }
103
+ return records.filter(record =>
104
+ record.node_id === this.deps.nodeId && record.agent_id === agentId && record.thread_ref !== undefined,
105
+ );
106
+ }
107
+ }
package/src/handshake.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { OnlyneError, type Json, type KeyExchange, type Message, type NodeId, type Storage, type ThreadRef, type TrustEntry, type TrustPhase } from "@onlyne/lib";
2
- import { evaluateAcquisition, type AcquireInput, type AcquireVerdict, type AcquisitionPolicy } from "./acquire.ts";
2
+ import { evaluateAcquisition, type AcquireInput, type AcquireVerdict, type AcquisitionPolicy } from "@onlyne/node-host/acquire";
3
3
  import { bridgeText as t } from "./bridge-text.ts";
4
- import { hostText } from "./host-text.ts";
4
+ import { hostText } from "@onlyne/node-host/host-text";
5
5
 
6
6
  /**
7
7
  * RFC 0018 §3.2: the handshake entries live in one thread this node owns alone, so nothing in
package/src/messages.ts CHANGED
@@ -1,10 +1,10 @@
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";
@@ -96,8 +96,16 @@ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
96
96
  const fields = { ...event } as Record<string, unknown>;
97
97
  const name = String(fields.event);
98
98
  delete fields.event;
99
- const thread = fields.thread as { owner?: unknown; id?: unknown } | undefined;
100
- 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
+ }
101
109
  if (TRACE_DEBUG.has(name)) deps.pi.logger.debug(`onlyne.${name}`, fields);
102
110
  else deps.pi.logger.info(`onlyne.${name}`, fields);
103
111
  },
@@ -414,6 +422,7 @@ function payload(entry: Entry, customType: string, token: string): MessagePayloa
414
422
  from: entry.authenticated_author, at: entry.at, subject: entry.message.delivery_kind, body: entry.message.msg,
415
423
  ...(entry.message.meta?.relation === undefined ? {} : { relation: entry.message.meta.relation }),
416
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 }),
417
426
  },
418
427
  } };
419
428
  }
@@ -423,5 +432,14 @@ export function registerMessageRenderers(pi: ExtensionAPI): void {
423
432
  function renderEntry(entry: Entry, customType: string): string {
424
433
  const label = customType === SUPPLEMENT ? t("labelSupplement") : t("labelInbound");
425
434
  const relation = entry.message.meta?.relation === undefined ? "" : ` · ${entry.message.meta.relation}`;
426
- 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}`;
427
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
+ }