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/README.md +16 -8
- package/package.json +3 -3
- package/src/bridge-text.ts +40 -8
- package/src/config.ts +39 -5
- package/src/decider-text.ts +36 -21
- package/src/decider.ts +253 -548
- package/src/drafts.ts +68 -19
- package/src/guard.ts +107 -0
- package/src/handshake.ts +291 -0
- package/src/messages.ts +134 -16
- package/src/plan.ts +133 -0
- package/src/questions.ts +250 -0
- package/src/resolve.ts +387 -0
- package/src/resources.ts +1 -1
- package/src/runtime.ts +454 -18
- package/src/tool-text.ts +64 -30
- package/src/tool.ts +136 -21
- package/src/types.ts +65 -57
- package/src/ui/bootstrap-text.ts +24 -0
- package/src/ui/bootstrap.ts +82 -3
- package/src/ui/index.ts +6 -2
- package/src/ui/mail-message.ts +5 -1
- package/src/ui/viewer-text.ts +116 -4
- package/src/ui/viewer.ts +650 -61
- package/src/urls-text.ts +24 -0
- package/src/urls.ts +206 -33
- package/src/work.ts +10 -0
- package/src/host-text.ts +0 -86
- package/src/node.ts +0 -127
- package/src/store.ts +0 -189
package/src/decider.ts
CHANGED
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
type Action,
|
|
9
9
|
type DeliveryPlan,
|
|
10
10
|
type Entry,
|
|
11
|
+
type JoinedThread,
|
|
11
12
|
type Json,
|
|
12
|
-
type MessageRef,
|
|
13
13
|
type MsgInput,
|
|
14
14
|
type NodeId,
|
|
15
15
|
type Page,
|
|
@@ -19,26 +19,42 @@ import {
|
|
|
19
19
|
OnlyneError,
|
|
20
20
|
} from "@onlyne/lib";
|
|
21
21
|
import { getLocale } from "@onlyne/node-host/i18n";
|
|
22
|
-
import
|
|
23
|
-
import {
|
|
22
|
+
import { isTrustThread } from "./handshake.ts";
|
|
23
|
+
import type { ContextRecord, OnlyneConfig } from "@onlyne/node-host/types";
|
|
24
|
+
import { canAnswer, type ContextRegistry } from "@onlyne/node-host/store";
|
|
24
25
|
import { text } from "./decider-text.ts";
|
|
26
|
+
import { ActionGuard, settleIntent, type OutboundIntent } from "./guard.ts";
|
|
27
|
+
import { continueThread, inheritedModes, resolvedModes, resourceAction, resourceFields, sendAction } from "./plan.ts";
|
|
28
|
+
import {
|
|
29
|
+
choice,
|
|
30
|
+
contextQuestions,
|
|
31
|
+
domainOpQuestions,
|
|
32
|
+
insertionQuestions,
|
|
33
|
+
intentQuestions,
|
|
34
|
+
needsAnswerQuestions,
|
|
35
|
+
originalQuestions,
|
|
36
|
+
planQuestions,
|
|
37
|
+
readScopeQuestions,
|
|
38
|
+
relationQuestions,
|
|
39
|
+
targetQuestions,
|
|
40
|
+
threadOpQuestions,
|
|
41
|
+
threadQuestions,
|
|
42
|
+
type Answers,
|
|
43
|
+
type Questions,
|
|
44
|
+
} from "./questions.ts";
|
|
45
|
+
import {
|
|
46
|
+
References,
|
|
47
|
+
knownPeers,
|
|
48
|
+
lastSegment,
|
|
49
|
+
threadKey,
|
|
50
|
+
type PeerRecord,
|
|
51
|
+
type ReplyReference,
|
|
52
|
+
} from "./resolve.ts";
|
|
25
53
|
|
|
26
54
|
type Row = Record<string, unknown>;
|
|
27
55
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
instructions: string;
|
|
31
|
-
criteria: Record<string, string>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
type Questions = Record<string, ChoiceQuestion>;
|
|
35
|
-
|
|
36
|
-
interface ChoiceAnswer {
|
|
37
|
-
type?: string;
|
|
38
|
-
choice?: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
type Answers = Record<string, ChoiceAnswer | undefined>;
|
|
56
|
+
export type { Answers, Questions } from "./questions.ts";
|
|
57
|
+
export type { PeerRecord } from "./resolve.ts";
|
|
42
58
|
|
|
43
59
|
/** A decision input that either carries its records or states why it has none. */
|
|
44
60
|
type Source<T> = { available: true; value: T } | { available: false; reason: string };
|
|
@@ -57,14 +73,6 @@ function valueOf<T>(source: unknown): T | undefined {
|
|
|
57
73
|
return row.value as T;
|
|
58
74
|
}
|
|
59
75
|
|
|
60
|
-
/** One discovered peer, as the library's discovery records it. */
|
|
61
|
-
export interface PeerRecord {
|
|
62
|
-
node_id: NodeId;
|
|
63
|
-
base_url: string;
|
|
64
|
-
instance_id: string;
|
|
65
|
-
last_seen: string;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
76
|
/** One execution context a receiving node publishes for a target-qualified delivery. */
|
|
69
77
|
export interface ReceiverContext {
|
|
70
78
|
alias: string;
|
|
@@ -106,6 +114,8 @@ export function currentDecision(): DecisionScope | undefined {
|
|
|
106
114
|
return decisionScope.getStore();
|
|
107
115
|
}
|
|
108
116
|
|
|
117
|
+
export { knownPeers, reuseThread } from "./resolve.ts";
|
|
118
|
+
|
|
109
119
|
export interface DeciderDeps {
|
|
110
120
|
config: OnlyneConfig;
|
|
111
121
|
/** Registry id of this node's main session; children are bound under it. */
|
|
@@ -113,12 +123,22 @@ export interface DeciderDeps {
|
|
|
113
123
|
contexts: ContextRegistry;
|
|
114
124
|
log?(message: string, fields: Record<string, unknown>): void;
|
|
115
125
|
listThreads(): Promise<Thread[]>;
|
|
126
|
+
/** The threads this node was invited into; they live on their owner, never here. */
|
|
127
|
+
listJoinedThreads(): Promise<JoinedThread[]>;
|
|
116
128
|
listMessages(thread: ThreadRef, cursor?: string): Promise<Page<Entry>>;
|
|
129
|
+
/** The peers one thread already lists as members; a send reuses the thread a peer belongs to. */
|
|
130
|
+
threadMembers(thread: ThreadRef): Promise<NodeId[]>;
|
|
117
131
|
/** Peers from the library's live discovery records, never a static configuration. */
|
|
118
132
|
listPeers(): Promise<PeerRecord[]>;
|
|
133
|
+
/** Every trust row this node holds, in any phase. Identity outlives a discovery record. */
|
|
134
|
+
listTrust(): Promise<Array<{ node_id: NodeId; phase: string; base_url?: string }>>;
|
|
135
|
+
/** The inbound records this node holds; O-2A's candidate set reads the entries from them. */
|
|
136
|
+
listInbox(): Promise<Array<{ context?: string; attention: { entry?: Entry; disposition?: string } }>>;
|
|
119
137
|
/** Contexts the target node publishes for `execution: existing`. */
|
|
120
138
|
listReceiverContexts(nodeId: NodeId): Promise<ReceiverContext[]>;
|
|
121
139
|
resourceQuery?(meta: Record<string, Json>): Promise<ResourceReadQuery>;
|
|
140
|
+
/** Test seam: answers the judge's questions without a model. Production never sets it. */
|
|
141
|
+
judge?: (state: Row, questions: Questions) => Promise<Answers>;
|
|
122
142
|
}
|
|
123
143
|
|
|
124
144
|
const TODO_ENTRY = "mouriya.omp.todo-more";
|
|
@@ -139,6 +159,8 @@ const INBOUND_CONTEXT_CAP = 3;
|
|
|
139
159
|
/** Threads and entries a target question scans for recent unanswered inbound messages. */
|
|
140
160
|
const RECENT_INBOUND_THREADS = 3;
|
|
141
161
|
const RECENT_INBOUND_PER_THREAD = 3;
|
|
162
|
+
/** The O-2A candidate list is capped; the judge reads one line per candidate. */
|
|
163
|
+
const REPLY_CANDIDATES = 5;
|
|
142
164
|
/** Newest branch rows that may reach the trigger derivation; older work is history, not decision material. */
|
|
143
165
|
const HISTORY_ROWS = 40;
|
|
144
166
|
/** One row's text cap: a pasted terminal dump must not crowd out the rest of the state. */
|
|
@@ -180,13 +202,21 @@ export function fitState(state: Row): { fitted: Row; dropped: string[]; chars: n
|
|
|
180
202
|
|
|
181
203
|
/** Decisions go through OMP's `judge` role; the plugin never picks a target or a plan itself. */
|
|
182
204
|
export class JudgeDecider {
|
|
183
|
-
|
|
205
|
+
/** Deterministic reference resolution: one rule per reference, shared by every entry point. */
|
|
206
|
+
readonly refs: References;
|
|
207
|
+
private readonly guard: ActionGuard;
|
|
208
|
+
|
|
209
|
+
constructor(private readonly deps: DeciderDeps) {
|
|
210
|
+
this.refs = new References(this.deps);
|
|
211
|
+
this.guard = new ActionGuard({ contexts: deps.contexts, nodeId: deps.config.node.id });
|
|
212
|
+
}
|
|
184
213
|
|
|
185
214
|
async decide(scope: DecisionScope): Promise<Action> {
|
|
186
215
|
const explicit = await this.#explicit(scope);
|
|
187
216
|
if (explicit !== undefined) {
|
|
188
|
-
await this
|
|
189
|
-
|
|
217
|
+
const filled = await continueThread(this.deps, explicit);
|
|
218
|
+
await this.#allow(scope, filled);
|
|
219
|
+
return filled;
|
|
190
220
|
}
|
|
191
221
|
|
|
192
222
|
// A subagent classifies exactly as the main session does and `#guard` decides what may run.
|
|
@@ -194,7 +224,13 @@ export class JudgeDecider {
|
|
|
194
224
|
// asked for its own slice was told it had no rights at all (caerulea report 4, problem 1).
|
|
195
225
|
// Classification costs one local judge call; a write is refused either way.
|
|
196
226
|
const meta = scope.input.meta ?? {};
|
|
197
|
-
const
|
|
227
|
+
const settlement = settleIntent(await this.#intent(scope), meta);
|
|
228
|
+
if (settlement.override !== undefined) {
|
|
229
|
+
// The judge named an intent whose fields the call does not carry. Naming the fallback
|
|
230
|
+
// in the record is what makes a misread visible instead of a silent rewrite.
|
|
231
|
+
this.deps.log?.("onlyne.judge.overridden", { direction: "outbound", ...settlement.override });
|
|
232
|
+
}
|
|
233
|
+
const intent = settlement.intent;
|
|
198
234
|
const action = intent === "communicate"
|
|
199
235
|
? await this.#communicate(scope, meta)
|
|
200
236
|
: intent === "inspect"
|
|
@@ -203,14 +239,28 @@ export class JudgeDecider {
|
|
|
203
239
|
? await this.#threadAction(scope, meta)
|
|
204
240
|
: intent === "manage_domain"
|
|
205
241
|
? await this.#domainAction(scope, meta)
|
|
206
|
-
: await
|
|
207
|
-
await this
|
|
208
|
-
this
|
|
209
|
-
|
|
242
|
+
: await resourceAction(meta);
|
|
243
|
+
const filled = await continueThread(this.deps, action);
|
|
244
|
+
await this.#allow(scope, filled);
|
|
245
|
+
this.deps.log?.("onlyne.judge.action", { direction: "outbound", ...actionFacts(filled) });
|
|
246
|
+
return filled;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The one guard every decided action passes before the client commits it. */
|
|
250
|
+
async #allow(scope: DecisionScope, action: Action): Promise<void> {
|
|
251
|
+
await this.guard.assert({ readOnly: scope.readOnly === true, agentId: scope.ctx.agent.id }, action);
|
|
210
252
|
}
|
|
211
253
|
|
|
212
|
-
/**
|
|
213
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Node O-1: one question, only the options this call can execute, no lists of peers,
|
|
256
|
+
* threads, or history.
|
|
257
|
+
*
|
|
258
|
+
* `operate_resource` is offered only when the call names a complete resource action. The
|
|
259
|
+
* criterion that says a message mentioning a file is not this intent is still prose the
|
|
260
|
+
* model may misread; withholding the option makes the misread unanswerable rather than
|
|
261
|
+
* lethal (issue: outbound-intent-resource-false-positive).
|
|
262
|
+
*/
|
|
263
|
+
async #intent(scope: DecisionScope): Promise<OutboundIntent> {
|
|
214
264
|
const started = Date.now();
|
|
215
265
|
const answers = await this.#judge(
|
|
216
266
|
scope.ctx,
|
|
@@ -219,19 +269,7 @@ export class JudgeDecider {
|
|
|
219
269
|
trigger: this.#trigger(scope.ctx),
|
|
220
270
|
host_env: { session_type: scope.ctx.agent.kind, read_only: scope.readOnly === true },
|
|
221
271
|
},
|
|
222
|
-
{
|
|
223
|
-
intent: {
|
|
224
|
-
type: "choice",
|
|
225
|
-
instructions: text("judge.o1.instructions"),
|
|
226
|
-
criteria: {
|
|
227
|
-
communicate: text("judge.o1.communicate"),
|
|
228
|
-
inspect: text("judge.o1.inspect"),
|
|
229
|
-
manage_thread: text("judge.o1.manage_thread"),
|
|
230
|
-
manage_domain: text("judge.o1.manage_domain"),
|
|
231
|
-
operate_resource: text("judge.o1.operate_resource"),
|
|
232
|
-
},
|
|
233
|
-
},
|
|
234
|
-
},
|
|
272
|
+
intentQuestions({ resource: resourceFields(scope.input.meta) !== undefined }),
|
|
235
273
|
scope.signal,
|
|
236
274
|
);
|
|
237
275
|
const intent = choice(answers, "intent");
|
|
@@ -244,8 +282,20 @@ export class JudgeDecider {
|
|
|
244
282
|
|
|
245
283
|
/** Nodes O-2A / O-3A / O-3A-Ctx for a send, reply or followup. */
|
|
246
284
|
async #communicate(scope: DecisionScope, meta: Record<string, Json>): Promise<Action> {
|
|
247
|
-
|
|
285
|
+
// Every reference this send names — recipient, thread, mentions, the message it answers —
|
|
286
|
+
// is resolved here, once, before the delivery plan is chosen. A plan built on a
|
|
287
|
+
// half-resolved reference is how the same spelling came to mean two things on two paths
|
|
288
|
+
// (audit §2.7).
|
|
289
|
+
const refs = await this.refs.forSend(meta);
|
|
290
|
+
let reply = refs.reply;
|
|
248
291
|
const explicitRelation = relation(meta);
|
|
292
|
+
if (reply === undefined && explicitRelation === undefined) {
|
|
293
|
+
// Node O-2A: no reference and no relation. The judge still answers one question —
|
|
294
|
+
// whether this call answers one of the inbound messages this context can see — and
|
|
295
|
+
// the plugin copies the exact ref from the record it names. Candidates are evidence
|
|
296
|
+
// for the question only: recency or being the sole pending message never binds one.
|
|
297
|
+
reply = await this.#originalQuestion(scope);
|
|
298
|
+
}
|
|
249
299
|
const lockedRelation = reply !== undefined
|
|
250
300
|
? explicitRelation ?? await this.#relationQuestion(scope, meta)
|
|
251
301
|
: undefined;
|
|
@@ -256,23 +306,21 @@ export class JudgeDecider {
|
|
|
256
306
|
if (reply !== undefined) {
|
|
257
307
|
// O-2A pre-pruning: the reply authority is the only target; meta.to and the judge's
|
|
258
308
|
// answer must not move a reply to another node (caerulea report 3).
|
|
259
|
-
|
|
260
|
-
if (explicitTarget !== undefined && explicitTarget !== reply.authority) {
|
|
309
|
+
if (refs.target !== undefined && refs.target !== reply.authority) {
|
|
261
310
|
throw new OnlyneError(
|
|
262
311
|
"invalid_request",
|
|
263
|
-
text("error.reply_authority", { authority: reply.authority, target:
|
|
312
|
+
text("error.reply_authority", { authority: reply.authority, target: refs.target }),
|
|
264
313
|
);
|
|
265
314
|
}
|
|
266
315
|
to = reply.authority;
|
|
267
316
|
} else {
|
|
268
|
-
to =
|
|
317
|
+
to = refs.target ?? await this.#targetQuestion(scope, meta);
|
|
269
318
|
if (to.length === 0) {
|
|
270
319
|
throw new OnlyneError("ambiguous_target", text("error.delivery_target"));
|
|
271
320
|
}
|
|
272
321
|
}
|
|
273
322
|
|
|
274
323
|
const plan = await this.#deliveryPlan(scope, meta, lockedRelation, reply !== undefined);
|
|
275
|
-
const thread = await this.#sendThread(meta, to);
|
|
276
324
|
const context = plan.execution === "new" ? undefined : await this.#receiverContext(scope, to, meta);
|
|
277
325
|
if (plan.execution === "existing" && context === undefined) {
|
|
278
326
|
// RFC 0005 §1: `existing` disambiguates or refuses. Quietly opening a new context made
|
|
@@ -282,36 +330,123 @@ export class JudgeDecider {
|
|
|
282
330
|
text("error.execution_context_missing", { to }),
|
|
283
331
|
);
|
|
284
332
|
}
|
|
285
|
-
|
|
286
|
-
return {
|
|
287
|
-
kind: "send",
|
|
333
|
+
return sendAction({
|
|
288
334
|
to,
|
|
289
|
-
...(thread === undefined ? {} : { thread }),
|
|
335
|
+
...(refs.thread === undefined ? {} : { thread: refs.thread }),
|
|
290
336
|
...(context === undefined ? {} : { context }),
|
|
291
|
-
...(reply === undefined ? {} : {
|
|
337
|
+
...(reply === undefined ? {} : { reply }),
|
|
292
338
|
...(lockedRelation === undefined ? {} : { relation: lockedRelation }),
|
|
293
|
-
...(mentions === undefined ? {} : { mentions }),
|
|
339
|
+
...(refs.mentions === undefined ? {} : { mentions: refs.mentions }),
|
|
294
340
|
response: plan.response,
|
|
295
341
|
execution: plan.execution,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Node O-2A: the original this call answers. The judge chooses among the inbound records
|
|
347
|
+
* this context can see and nobody has answered yet, plus `new_message`; the plugin copies
|
|
348
|
+
* the exact MessageRef and the owner-attested reply authority from the record it names, so
|
|
349
|
+
* no message id ever travels from the model to the wire.
|
|
350
|
+
*/
|
|
351
|
+
async #originalQuestion(scope: DecisionScope): Promise<ReplyReference | undefined> {
|
|
352
|
+
const candidates = await this.#replyCandidates(scope);
|
|
353
|
+
if (candidates.length === 0) return undefined;
|
|
354
|
+
const started = Date.now();
|
|
355
|
+
const keyOf = (index: number): string => `answer_${index + 1}`;
|
|
356
|
+
const facts = candidates.map((entry, index) => {
|
|
357
|
+
return {
|
|
358
|
+
key: keyOf(index),
|
|
359
|
+
from: entry.message.sender,
|
|
360
|
+
kind: entry.message.delivery_kind,
|
|
361
|
+
thread: threadKey(entry.thread_ref),
|
|
362
|
+
seq: entry.seq,
|
|
363
|
+
body: boundText(entry.message.msg),
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
const answers = await this.#judge(
|
|
367
|
+
scope.ctx,
|
|
368
|
+
{ focus: this.#focus(scope), trigger: this.#trigger(scope.ctx), candidates: facts },
|
|
369
|
+
originalQuestions(facts),
|
|
370
|
+
scope.signal,
|
|
371
|
+
);
|
|
372
|
+
this.#record("outbound.o2a_original", scope.input, answers, Date.now() - started);
|
|
373
|
+
const picked = choice(answers, "original");
|
|
374
|
+
if (picked === undefined || picked === "new_message") return undefined;
|
|
375
|
+
const entry = candidates[Number(picked.slice("answer_".length)) - 1];
|
|
376
|
+
if (entry === undefined) {
|
|
377
|
+
throw new OnlyneError("decision_unavailable", text("error.reply_relation"));
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
ref: { sender: entry.message.sender, id: entry.message.id },
|
|
381
|
+
authority: entry.reply_authority,
|
|
382
|
+
thread_ref: entry.thread_ref,
|
|
296
383
|
};
|
|
297
384
|
}
|
|
298
385
|
|
|
386
|
+
/**
|
|
387
|
+
* The inbound records this call can see and nobody has answered: rows delivered to this
|
|
388
|
+
* session's context (the main session also sees rows no context claimed), minus the host's
|
|
389
|
+
* handshake bookkeeping, minus entries a local reply already references. This set is what
|
|
390
|
+
* O-2A offers the judge; it is never an answer by itself (RFC 0016 §7).
|
|
391
|
+
*/
|
|
392
|
+
async #replyCandidates(scope: DecisionScope): Promise<Entry[]> {
|
|
393
|
+
if (scope.readOnly === true) return [];
|
|
394
|
+
try {
|
|
395
|
+
const here = await this.#decisionContext(scope.ctx);
|
|
396
|
+
if (here === undefined) return [];
|
|
397
|
+
const rows = await this.deps.listInbox();
|
|
398
|
+
const candidates: Entry[] = [];
|
|
399
|
+
for (const row of rows) {
|
|
400
|
+
const entry = row.attention.entry;
|
|
401
|
+
if (entry === undefined || row.attention.disposition === "expired") continue;
|
|
402
|
+
if (entry.message.sender === this.deps.config.node.id) continue;
|
|
403
|
+
if (isTrustThread(this.deps.config.node.id, entry.thread_ref)) continue;
|
|
404
|
+
const deliveredHere = here.main
|
|
405
|
+
? row.context === undefined || row.context === "main"
|
|
406
|
+
: row.context === here.alias;
|
|
407
|
+
if (!deliveredHere) continue;
|
|
408
|
+
if (await this.#answered(entry)) continue;
|
|
409
|
+
candidates.push(entry);
|
|
410
|
+
if (candidates.length >= REPLY_CANDIDATES) break;
|
|
411
|
+
}
|
|
412
|
+
return candidates;
|
|
413
|
+
} catch (error) {
|
|
414
|
+
// A failed read narrows the question, not the decision: the call proceeds as a new message.
|
|
415
|
+
this.deps.log?.("onlyne.judge.hint_unavailable", { what: "reply_candidates", error: message(error) });
|
|
416
|
+
return [];
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The context this session answers in: the main session, or a registered subagent context. */
|
|
421
|
+
async #decisionContext(ctx: ExtensionContext): Promise<{ alias?: string; main: boolean } | undefined> {
|
|
422
|
+
if (ctx.agent.id === this.deps.mainAgentId) return { main: true };
|
|
423
|
+
const record = (await this.deps.contexts.list()).find(row => row.agent_id === ctx.agent.id);
|
|
424
|
+
return record === undefined ? undefined : { alias: record.alias, main: false };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Whether this node already answered the entry in the thread that holds it. */
|
|
428
|
+
async #answered(entry: Entry): Promise<boolean> {
|
|
429
|
+
let cursor: string | undefined;
|
|
430
|
+
for (let page = 0; page < MAX_TAIL_PAGES; page++) {
|
|
431
|
+
const result = await this.deps.listMessages(entry.thread_ref, cursor);
|
|
432
|
+
for (const row of result.items) {
|
|
433
|
+
const ref = row.message.meta?.in_reply_to;
|
|
434
|
+
if (row.message.sender === this.deps.config.node.id && ref !== undefined
|
|
435
|
+
&& ref.sender === entry.message.sender && ref.id === entry.message.id) return true;
|
|
436
|
+
}
|
|
437
|
+
cursor = result.next_cursor ?? undefined;
|
|
438
|
+
if (cursor === undefined) return false;
|
|
439
|
+
}
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
|
|
299
443
|
/** Node O-2A question 1: the reply/followup/new relation, asked only when meta left it open. */
|
|
300
444
|
async #relationQuestion(scope: DecisionScope, meta: Record<string, Json>): Promise<"reply" | "followup"> {
|
|
301
445
|
const started = Date.now();
|
|
302
446
|
const answers = await this.#judge(
|
|
303
447
|
scope.ctx,
|
|
304
448
|
{ focus: this.#focus(scope), in_reply_to: meta.in_reply_to ?? null },
|
|
305
|
-
|
|
306
|
-
relation: {
|
|
307
|
-
type: "choice",
|
|
308
|
-
instructions: text("judge.relation.instructions"),
|
|
309
|
-
criteria: {
|
|
310
|
-
reply: text("judge.relation.reply"),
|
|
311
|
-
followup: text("judge.relation.followup"),
|
|
312
|
-
},
|
|
313
|
-
},
|
|
314
|
-
},
|
|
449
|
+
relationQuestions(),
|
|
315
450
|
scope.signal,
|
|
316
451
|
);
|
|
317
452
|
this.#record("outbound.o2a", scope.input, answers, Date.now() - started);
|
|
@@ -324,40 +459,25 @@ export class JudgeDecider {
|
|
|
324
459
|
|
|
325
460
|
/** Node O-2A question 2: the delivery target among live peers, asked only when meta.to is absent. */
|
|
326
461
|
async #targetQuestion(scope: DecisionScope, meta: Record<string, Json>): Promise<NodeId> {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (peers.
|
|
333
|
-
if (peers.value.length === 1) return peers.value[0]!.node_id;
|
|
462
|
+
// The same set `#peerId` resolves against: a peer this node holds a trust row with is a
|
|
463
|
+
// candidate even when discovery has nothing fresh to say about its address.
|
|
464
|
+
const peers = await knownPeers(this.deps);
|
|
465
|
+
// One known peer is the answer; none means the caller must name a target.
|
|
466
|
+
if (peers.length === 0) throw new OnlyneError("not_found", text("error.no_receiver"));
|
|
467
|
+
if (peers.length === 1) return peers[0]!.node_id;
|
|
334
468
|
const started = Date.now();
|
|
335
469
|
const answers = await this.#judge(
|
|
336
470
|
scope.ctx,
|
|
337
471
|
{
|
|
338
472
|
focus: { ...this.#focus(scope), meta_to: meta.to ?? null },
|
|
339
|
-
candidate_peers: peers.
|
|
473
|
+
candidate_peers: peers.map(peer => ({
|
|
340
474
|
node_id: peer.node_id,
|
|
341
475
|
alias: lastSegment(peer.node_id),
|
|
342
476
|
last_seen: peer.last_seen,
|
|
343
477
|
})),
|
|
344
478
|
recent_inbound_unanswered: await this.#unanswered(scope.ctx),
|
|
345
479
|
},
|
|
346
|
-
|
|
347
|
-
target: {
|
|
348
|
-
type: "choice",
|
|
349
|
-
instructions: text("judge.target.instructions"),
|
|
350
|
-
criteria: Object.fromEntries(peers.value.map(peer => [
|
|
351
|
-
peer.node_id,
|
|
352
|
-
text("judge.target.peer", {
|
|
353
|
-
node: peer.node_id,
|
|
354
|
-
base_url: peer.base_url,
|
|
355
|
-
instance: peer.instance_id,
|
|
356
|
-
last_seen: peer.last_seen,
|
|
357
|
-
}),
|
|
358
|
-
])),
|
|
359
|
-
},
|
|
360
|
-
},
|
|
480
|
+
targetQuestions(peers),
|
|
361
481
|
scope.signal,
|
|
362
482
|
);
|
|
363
483
|
this.#record("outbound.o2a", scope.input, answers, Date.now() - started);
|
|
@@ -365,7 +485,7 @@ export class JudgeDecider {
|
|
|
365
485
|
if (chosen === undefined || chosen === "none") {
|
|
366
486
|
throw new OnlyneError("ambiguous_target", text("error.delivery_target"));
|
|
367
487
|
}
|
|
368
|
-
if (!peers.
|
|
488
|
+
if (!peers.some(peer => peer.node_id === chosen)) {
|
|
369
489
|
throw new OnlyneError("decision_unavailable", text("error.target_not_published", { chosen }));
|
|
370
490
|
}
|
|
371
491
|
return chosen;
|
|
@@ -378,63 +498,25 @@ export class JudgeDecider {
|
|
|
378
498
|
relation: "reply" | "followup" | undefined,
|
|
379
499
|
isReply: boolean,
|
|
380
500
|
): Promise<{ response: "await" | "async" | "none"; execution: "existing" | "new" }> {
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
501
|
+
const inherited = inheritedModes(meta);
|
|
502
|
+
// A plan the caller stated completely is not a question: asking it would offer to
|
|
503
|
+
// overrule the one party that knows what the call wants.
|
|
504
|
+
if (inherited.response !== undefined && inherited.execution !== undefined) {
|
|
505
|
+
return resolvedModes(inherited, {});
|
|
385
506
|
}
|
|
386
507
|
const started = Date.now();
|
|
387
508
|
const answers = await this.#judge(
|
|
388
509
|
scope.ctx,
|
|
389
510
|
{ focus: this.#focus(scope), relation: relation ?? "new" },
|
|
390
|
-
{
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
instructions: text("judge.response.instructions"),
|
|
396
|
-
criteria: {
|
|
397
|
-
none: text("judge.response.none"),
|
|
398
|
-
async: text("judge.response.async"),
|
|
399
|
-
await: text("judge.response.await"),
|
|
400
|
-
},
|
|
401
|
-
},
|
|
402
|
-
}
|
|
403
|
-
: {}),
|
|
404
|
-
...(execution === undefined
|
|
405
|
-
? {
|
|
406
|
-
execution: {
|
|
407
|
-
type: "choice",
|
|
408
|
-
instructions: text("judge.execution.instructions"),
|
|
409
|
-
criteria: {
|
|
410
|
-
existing: isReply
|
|
411
|
-
? text("judge.execution.existing.reply")
|
|
412
|
-
: text("judge.execution.existing.new"),
|
|
413
|
-
new: text("judge.execution.new"),
|
|
414
|
-
},
|
|
415
|
-
},
|
|
416
|
-
}
|
|
417
|
-
: {}),
|
|
418
|
-
},
|
|
511
|
+
planQuestions({
|
|
512
|
+
response: inherited.response === undefined,
|
|
513
|
+
execution: inherited.execution === undefined,
|
|
514
|
+
reply: isReply,
|
|
515
|
+
}),
|
|
419
516
|
scope.signal,
|
|
420
517
|
);
|
|
421
518
|
this.#record("outbound.o3a", scope.input, answers, Date.now() - started);
|
|
422
|
-
|
|
423
|
-
const chosenExecution = execution ?? choice(answers, "execution");
|
|
424
|
-
if (chosenResponse !== "none" && chosenResponse !== "async" && chosenResponse !== "await") {
|
|
425
|
-
throw new OnlyneError("decision_unavailable", text("error.response_mode"));
|
|
426
|
-
}
|
|
427
|
-
if (chosenExecution !== "existing" && chosenExecution !== "new") {
|
|
428
|
-
throw new OnlyneError("decision_unavailable", text("error.execution_mode"));
|
|
429
|
-
}
|
|
430
|
-
return { response: chosenResponse, execution: chosenExecution };
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
#inherited(meta: Record<string, Json>, key: string): "await" | "async" | "none" | "existing" | "new" | undefined {
|
|
434
|
-
const value = meta[key];
|
|
435
|
-
if (value === "await" || value === "async" || value === "none" || value === "existing" || value === "new") return value;
|
|
436
|
-
if (value !== undefined) throw new OnlyneError("invalid_request", text("error.meta_mode", { key }));
|
|
437
|
-
return undefined;
|
|
519
|
+
return resolvedModes(inherited, answers);
|
|
438
520
|
}
|
|
439
521
|
|
|
440
522
|
/**
|
|
@@ -465,19 +547,7 @@ export class JudgeDecider {
|
|
|
465
547
|
const answers = await this.#judge(
|
|
466
548
|
scope.ctx,
|
|
467
549
|
{ focus: this.#focus(scope), target_published_contexts: published },
|
|
468
|
-
|
|
469
|
-
context: {
|
|
470
|
-
type: "choice",
|
|
471
|
-
instructions: text("judge.context.instructions"),
|
|
472
|
-
criteria: {
|
|
473
|
-
...Object.fromEntries(published.map(context => [
|
|
474
|
-
context.alias,
|
|
475
|
-
text("judge.context.published", { summary: context.summary, state: context.state }),
|
|
476
|
-
])),
|
|
477
|
-
none: text("judge.context.none"),
|
|
478
|
-
},
|
|
479
|
-
},
|
|
480
|
-
},
|
|
550
|
+
contextQuestions(published),
|
|
481
551
|
scope.signal,
|
|
482
552
|
);
|
|
483
553
|
this.#record("outbound.o3a-ctx", scope.input, answers, Date.now() - started);
|
|
@@ -488,31 +558,6 @@ export class JudgeDecider {
|
|
|
488
558
|
return alias;
|
|
489
559
|
}
|
|
490
560
|
|
|
491
|
-
/**
|
|
492
|
-
* The thread a send names. The thread decides where the message is stored; `to` decides who
|
|
493
|
-
* is addressed. An initiator owns its thread for the whole conversation, so every member
|
|
494
|
-
* writes into it and the client routes each message to that owner. A bare id that names no
|
|
495
|
-
* local thread is the target's thread id, as a receipt reports it.
|
|
496
|
-
*/
|
|
497
|
-
async #sendThread(meta: Record<string, Json> | undefined, to: NodeId): Promise<ThreadRef | undefined> {
|
|
498
|
-
if (meta?.thread === undefined) return undefined;
|
|
499
|
-
const value = meta.thread;
|
|
500
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
501
|
-
return this.#thread(meta);
|
|
502
|
-
}
|
|
503
|
-
if (typeof value !== "string" || value.length === 0) {
|
|
504
|
-
throw new OnlyneError("invalid_request", text("error.thread_reference_shape"));
|
|
505
|
-
}
|
|
506
|
-
const matches = (await this.deps.listThreads()).filter(thread => thread.thread_ref.id === value || thread.title === value);
|
|
507
|
-
if (matches.length > 1) throw new OnlyneError("ambiguous_target", text("error.ambiguous_local_thread", { value }));
|
|
508
|
-
if (matches.length === 1) return matches[0]!.thread_ref;
|
|
509
|
-
const slash = value.indexOf("/");
|
|
510
|
-
if (slash > 0 && slash < value.length - 1) {
|
|
511
|
-
return { owner: value.slice(0, slash), id: value.slice(slash + 1) };
|
|
512
|
-
}
|
|
513
|
-
return { owner: to, id: value };
|
|
514
|
-
}
|
|
515
|
-
|
|
516
561
|
/**
|
|
517
562
|
* One inbound entry's route. `plan` is the sender's delivery plan when the delivery named
|
|
518
563
|
* one: an explicit `plan.context` is binding, `execution: new` is only a preference.
|
|
@@ -640,16 +685,7 @@ export class JudgeDecider {
|
|
|
640
685
|
current_work: this.#trigger(scope),
|
|
641
686
|
thread_dialogue: await this.#threadEvidenceFor([entry.thread_ref]),
|
|
642
687
|
},
|
|
643
|
-
|
|
644
|
-
needs_answer: {
|
|
645
|
-
type: "choice",
|
|
646
|
-
instructions: text("judge.needs_answer.instructions"),
|
|
647
|
-
criteria: {
|
|
648
|
-
yes: text("judge.needs_answer.yes"),
|
|
649
|
-
no: text("judge.needs_answer.no"),
|
|
650
|
-
},
|
|
651
|
-
},
|
|
652
|
-
},
|
|
688
|
+
needsAnswerQuestions(),
|
|
653
689
|
undefined,
|
|
654
690
|
);
|
|
655
691
|
this.#record("inbound.i0", { delivery_kind: entry.message.delivery_kind, msg: entry.message.msg, meta: entry.message.meta }, answers, Date.now() - started);
|
|
@@ -690,11 +726,6 @@ export class JudgeDecider {
|
|
|
690
726
|
const started = Date.now();
|
|
691
727
|
// `fragment` never starts a turn, so a message that needs an answer can never be one;
|
|
692
728
|
// offering it would be an option whose choice guarantees the obligation is missed.
|
|
693
|
-
const criteria: Record<string, string> = {
|
|
694
|
-
steer: text("judge.insertion.steer"),
|
|
695
|
-
follow_up: text("judge.insertion.follow_up"),
|
|
696
|
-
...(needsAnswer ? {} : { fragment: text("judge.insertion.fragment") }),
|
|
697
|
-
};
|
|
698
729
|
const answers = await this.#judge(
|
|
699
730
|
scope,
|
|
700
731
|
{
|
|
@@ -702,13 +733,7 @@ export class JudgeDecider {
|
|
|
702
733
|
host_running_state: { is_turn_active: !scope.isIdle(), plan_mode: this.#planActive(scope) },
|
|
703
734
|
urgency_signals: { has_stop_or_change: URGENCY_CUES.some(cue => body.includes(cue)) },
|
|
704
735
|
},
|
|
705
|
-
{
|
|
706
|
-
insertion_mode: {
|
|
707
|
-
type: "choice",
|
|
708
|
-
instructions: text("judge.insertion.instructions"),
|
|
709
|
-
criteria,
|
|
710
|
-
},
|
|
711
|
-
},
|
|
736
|
+
insertionQuestions({ fragment: !needsAnswer }),
|
|
712
737
|
);
|
|
713
738
|
this.#record("inbound.i2", { delivery_kind: entry.message.delivery_kind, msg: entry.message.msg, meta: entry.message.meta }, answers, Date.now() - started);
|
|
714
739
|
const mode = choice(answers, "insertion_mode");
|
|
@@ -833,6 +858,7 @@ export class JudgeDecider {
|
|
|
833
858
|
throw new OnlyneError("decision_unavailable", text("error.state_budget", { chars, cap: STATE_BUDGET_CHARS }));
|
|
834
859
|
}
|
|
835
860
|
if (dropped.length > 0) this.deps.log?.("onlyne.judge.trimmed", { dropped, chars });
|
|
861
|
+
if (this.deps.judge !== undefined) return await this.deps.judge(fitted as Row, questions);
|
|
836
862
|
const settings = (await Settings.current) ?? (await Settings.loadReadOnly({ cwd: scope.cwd }));
|
|
837
863
|
const judge = resolveJudge({
|
|
838
864
|
settings,
|
|
@@ -862,8 +888,9 @@ export class JudgeDecider {
|
|
|
862
888
|
}
|
|
863
889
|
|
|
864
890
|
/**
|
|
865
|
-
* Only stable, explicit fields route without a decision.
|
|
866
|
-
*
|
|
891
|
+
* Only stable, explicit fields route without a decision. A reference is resolved, never
|
|
892
|
+
* judged: `meta.thread`, `meta.to` and `meta.resource` name a record, and whether the
|
|
893
|
+
* message means to use them at all is what the judge answers.
|
|
867
894
|
*/
|
|
868
895
|
async #explicit(scope: DecisionScope): Promise<Action | undefined> {
|
|
869
896
|
const meta = scope.input.meta;
|
|
@@ -871,8 +898,8 @@ export class JudgeDecider {
|
|
|
871
898
|
if (meta.scope === "resources") return this.#read(scope, "resources");
|
|
872
899
|
const op = stringField(meta, "op");
|
|
873
900
|
if (op !== undefined && isThreadOp(op)) {
|
|
874
|
-
const thread = await this
|
|
875
|
-
const to = await this
|
|
901
|
+
const thread = await this.refs.thread(meta);
|
|
902
|
+
const to = await this.refs.target(meta);
|
|
876
903
|
const title = stringField(meta, "title");
|
|
877
904
|
if (op === "create" && title === undefined) throw new OnlyneError("invalid_request", text("error.thread_title_required"));
|
|
878
905
|
if (op !== "create" && op !== "list" && thread === undefined) {
|
|
@@ -890,22 +917,7 @@ export class JudgeDecider {
|
|
|
890
917
|
};
|
|
891
918
|
}
|
|
892
919
|
|
|
893
|
-
|
|
894
|
-
if (resource !== undefined) {
|
|
895
|
-
const owner = stringField(resource, "owner");
|
|
896
|
-
const id = stringField(resource, "id");
|
|
897
|
-
const operation = stringField(meta, "operation");
|
|
898
|
-
if (owner === undefined || id === undefined || operation === undefined) {
|
|
899
|
-
throw new OnlyneError("invalid_request", text("error.resource_fields"));
|
|
900
|
-
}
|
|
901
|
-
return {
|
|
902
|
-
kind: "resource",
|
|
903
|
-
resource: { owner, id },
|
|
904
|
-
operation,
|
|
905
|
-
...(meta.data === undefined ? {} : { data: meta.data }),
|
|
906
|
-
...(typeof meta.expected_revision === "string" ? { expected_revision: meta.expected_revision } : {}),
|
|
907
|
-
};
|
|
908
|
-
}
|
|
920
|
+
if (recordField(meta, "resource") !== undefined) return resourceAction(meta);
|
|
909
921
|
|
|
910
922
|
const scopeName = stringField(meta, "scope");
|
|
911
923
|
if (scopeName !== undefined) {
|
|
@@ -925,7 +937,7 @@ export class JudgeDecider {
|
|
|
925
937
|
if (this.deps.resourceQuery === undefined) throw new OnlyneError("unavailable", text("error.resource_host_unavailable"));
|
|
926
938
|
return { kind: "read", scope: name, query: await this.deps.resourceQuery(meta) };
|
|
927
939
|
}
|
|
928
|
-
const thread = await this
|
|
940
|
+
const thread = await this.refs.thread(meta);
|
|
929
941
|
const page_size = pageSize(meta);
|
|
930
942
|
const cursor = stringField(meta, "cursor");
|
|
931
943
|
const seq = stringField(meta, "seq");
|
|
@@ -955,7 +967,7 @@ export class JudgeDecider {
|
|
|
955
967
|
attention: attention(meta),
|
|
956
968
|
};
|
|
957
969
|
}
|
|
958
|
-
const resolved = await this
|
|
970
|
+
const resolved = await this.refs.message(meta, thread);
|
|
959
971
|
if (resolved === undefined) {
|
|
960
972
|
throw new OnlyneError("invalid_request", text("error.read_message_required", { name }));
|
|
961
973
|
}
|
|
@@ -976,20 +988,7 @@ export class JudgeDecider {
|
|
|
976
988
|
const answers = await this.#judge(
|
|
977
989
|
scope.ctx,
|
|
978
990
|
{ focus: { delivery_kind: scope.input.delivery_kind, msg: scope.input.msg, meta: meta ?? null } },
|
|
979
|
-
|
|
980
|
-
read_scope: {
|
|
981
|
-
type: "choice",
|
|
982
|
-
instructions: text("judge.read_scope.instructions"),
|
|
983
|
-
criteria: {
|
|
984
|
-
peers: text("judge.read_scope.peers"),
|
|
985
|
-
inbox: text("judge.read_scope.inbox"),
|
|
986
|
-
messages: text("judge.read_scope.messages"),
|
|
987
|
-
evidence: text("judge.read_scope.evidence"),
|
|
988
|
-
resources: text("judge.read_scope.resources"),
|
|
989
|
-
none: text("judge.read_scope.none"),
|
|
990
|
-
},
|
|
991
|
-
},
|
|
992
|
-
},
|
|
991
|
+
readScopeQuestions(),
|
|
993
992
|
scope.signal,
|
|
994
993
|
);
|
|
995
994
|
this.#record("outbound.o2b", scope.input, answers, Date.now() - started);
|
|
@@ -1005,7 +1004,7 @@ export class JudgeDecider {
|
|
|
1005
1004
|
throw new OnlyneError("decision_unavailable", text("error.read_scope_unknown", { scope: scopeName }));
|
|
1006
1005
|
}
|
|
1007
1006
|
const name: ActionReadScope = scopeName === "peers" ? "neighbors" : scopeName;
|
|
1008
|
-
const thread = await this
|
|
1007
|
+
const thread = await this.refs.thread(meta);
|
|
1009
1008
|
if (name === "messages") {
|
|
1010
1009
|
const chosen = thread ?? await this.#threadQuestion(scope, "outbound.o3b-th");
|
|
1011
1010
|
return {
|
|
@@ -1019,7 +1018,7 @@ export class JudgeDecider {
|
|
|
1019
1018
|
};
|
|
1020
1019
|
}
|
|
1021
1020
|
if (name === "evidence") {
|
|
1022
|
-
const resolved = await this
|
|
1021
|
+
const resolved = await this.refs.message(meta, thread);
|
|
1023
1022
|
if (resolved === undefined) {
|
|
1024
1023
|
throw new OnlyneError("invalid_request", text("error.read_message_required", { name }));
|
|
1025
1024
|
}
|
|
@@ -1054,21 +1053,7 @@ export class JudgeDecider {
|
|
|
1054
1053
|
next_seq: thread.next_seq,
|
|
1055
1054
|
})),
|
|
1056
1055
|
},
|
|
1057
|
-
{
|
|
1058
|
-
thread: {
|
|
1059
|
-
type: "choice",
|
|
1060
|
-
instructions: text("judge.thread.instructions"),
|
|
1061
|
-
criteria: Object.fromEntries(threads.map(thread => [
|
|
1062
|
-
thread.thread_ref.id,
|
|
1063
|
-
text("judge.thread.item", {
|
|
1064
|
-
title: thread.title,
|
|
1065
|
-
owner: thread.thread_ref.owner,
|
|
1066
|
-
id: thread.thread_ref.id,
|
|
1067
|
-
state: thread.state,
|
|
1068
|
-
}),
|
|
1069
|
-
])),
|
|
1070
|
-
},
|
|
1071
|
-
},
|
|
1056
|
+
threadQuestions(threads.map(thread => ({ ref: thread.thread_ref, title: thread.title, state: thread.state }))),
|
|
1072
1057
|
scope.signal,
|
|
1073
1058
|
);
|
|
1074
1059
|
this.#record(node, scope.input, answers, Date.now() - started);
|
|
@@ -1076,7 +1061,7 @@ export class JudgeDecider {
|
|
|
1076
1061
|
if (alias === undefined || alias === "none") {
|
|
1077
1062
|
throw new OnlyneError("decision_unavailable", text("error.local_thread_choice"));
|
|
1078
1063
|
}
|
|
1079
|
-
return this
|
|
1064
|
+
return this.refs.threadByAlias(alias);
|
|
1080
1065
|
}
|
|
1081
1066
|
|
|
1082
1067
|
/** Node O-2C: the local thread operation. */
|
|
@@ -1085,20 +1070,7 @@ export class JudgeDecider {
|
|
|
1085
1070
|
const answers = await this.#judge(
|
|
1086
1071
|
scope.ctx,
|
|
1087
1072
|
{ focus: this.#focus(scope) },
|
|
1088
|
-
|
|
1089
|
-
thread_op: {
|
|
1090
|
-
type: "choice",
|
|
1091
|
-
instructions: text("judge.thread_op.instructions"),
|
|
1092
|
-
criteria: {
|
|
1093
|
-
create: text("judge.thread_op.create"),
|
|
1094
|
-
close: text("judge.thread_op.close"),
|
|
1095
|
-
list: text("judge.thread_op.list"),
|
|
1096
|
-
describe: text("judge.thread_op.describe"),
|
|
1097
|
-
member_add: text("judge.thread_op.member_add"),
|
|
1098
|
-
member_remove: text("judge.thread_op.member_remove"),
|
|
1099
|
-
},
|
|
1100
|
-
},
|
|
1101
|
-
},
|
|
1073
|
+
threadOpQuestions(),
|
|
1102
1074
|
scope.signal,
|
|
1103
1075
|
);
|
|
1104
1076
|
this.#record("outbound.o2c", scope.input, answers, Date.now() - started);
|
|
@@ -1109,9 +1081,9 @@ export class JudgeDecider {
|
|
|
1109
1081
|
// A dependent second call: which thread the operation names is only decidable once the
|
|
1110
1082
|
// operation is known, and meta.thread settles it without a call (design §4, stepwise).
|
|
1111
1083
|
const thread = op === "create" || op === "list"
|
|
1112
|
-
? await this
|
|
1113
|
-
: await this
|
|
1114
|
-
const to = await this
|
|
1084
|
+
? await this.refs.thread(meta)
|
|
1085
|
+
: await this.refs.thread(meta) ?? await this.#threadQuestion(scope, "outbound.o2c-thread");
|
|
1086
|
+
const to = await this.refs.target(meta);
|
|
1115
1087
|
const title = stringField(meta, "title");
|
|
1116
1088
|
if ((op === "member_add" || op === "member_remove") && to === undefined) {
|
|
1117
1089
|
throw new OnlyneError("decision_unavailable", text("error.thread_op_target", { op }));
|
|
@@ -1134,20 +1106,7 @@ export class JudgeDecider {
|
|
|
1134
1106
|
const answers = await this.#judge(
|
|
1135
1107
|
scope.ctx,
|
|
1136
1108
|
{ focus: this.#focus(scope) },
|
|
1137
|
-
|
|
1138
|
-
domain_op: {
|
|
1139
|
-
type: "choice",
|
|
1140
|
-
instructions: text("judge.domain_op.instructions"),
|
|
1141
|
-
criteria: {
|
|
1142
|
-
create: text("judge.domain_op.create"),
|
|
1143
|
-
join: text("judge.domain_op.join"),
|
|
1144
|
-
leave: text("judge.domain_op.leave"),
|
|
1145
|
-
list: text("judge.domain_op.list"),
|
|
1146
|
-
describe: text("judge.domain_op.describe"),
|
|
1147
|
-
discover: text("judge.domain_op.discover"),
|
|
1148
|
-
},
|
|
1149
|
-
},
|
|
1150
|
-
},
|
|
1109
|
+
domainOpQuestions(),
|
|
1151
1110
|
scope.signal,
|
|
1152
1111
|
);
|
|
1153
1112
|
this.#record("outbound.o2d", scope.input, answers, Date.now() - started);
|
|
@@ -1167,24 +1126,6 @@ export class JudgeDecider {
|
|
|
1167
1126
|
};
|
|
1168
1127
|
}
|
|
1169
1128
|
|
|
1170
|
-
/** The resource branch carries no judge node: meta names every field. */
|
|
1171
|
-
async #resourceAction(meta: Record<string, Json>): Promise<Action> {
|
|
1172
|
-
const resource = recordField(meta, "resource");
|
|
1173
|
-
const owner = resource === undefined ? undefined : stringField(resource, "owner");
|
|
1174
|
-
const id = resource === undefined ? undefined : stringField(resource, "id");
|
|
1175
|
-
const operation = stringField(meta, "operation");
|
|
1176
|
-
if (owner === undefined || id === undefined || operation === undefined) {
|
|
1177
|
-
throw new OnlyneError("invalid_request", text("error.resource_action_fields"));
|
|
1178
|
-
}
|
|
1179
|
-
return {
|
|
1180
|
-
kind: "resource",
|
|
1181
|
-
resource: { owner, id },
|
|
1182
|
-
operation,
|
|
1183
|
-
...(meta.data === undefined ? {} : { data: meta.data }),
|
|
1184
|
-
...(typeof meta.expected_revision === "string" ? { expected_revision: meta.expected_revision } : {}),
|
|
1185
|
-
};
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
1129
|
/** The golden-triage head of every node state: what is being asked, verbatim. */
|
|
1189
1130
|
#focus(scope: DecisionScope): Row {
|
|
1190
1131
|
return {
|
|
@@ -1234,224 +1175,12 @@ export class JudgeDecider {
|
|
|
1234
1175
|
}
|
|
1235
1176
|
|
|
1236
1177
|
|
|
1237
|
-
async #thread(meta: Record<string, Json> | undefined): Promise<ThreadRef | undefined> {
|
|
1238
|
-
if (meta === undefined) return undefined;
|
|
1239
|
-
const value = meta.thread;
|
|
1240
|
-
if (value === undefined) return undefined;
|
|
1241
|
-
if (typeof value === "string") return this.#threadByAlias(value);
|
|
1242
|
-
const record = recordField(meta, "thread");
|
|
1243
|
-
const owner = record === undefined ? undefined : stringField(record, "owner");
|
|
1244
|
-
const id = record === undefined ? undefined : stringField(record, "id");
|
|
1245
|
-
if (owner !== undefined && id !== undefined) return { owner, id };
|
|
1246
|
-
throw new OnlyneError("invalid_request", text("error.thread_reference_shape"));
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
async #threadByAlias(alias: string): Promise<ThreadRef> {
|
|
1250
|
-
const threads = await this.deps.listThreads();
|
|
1251
|
-
const matches = threads.filter(thread => thread.thread_ref.id === alias || thread.title === alias);
|
|
1252
|
-
if (matches.length === 1) return matches[0]!.thread_ref;
|
|
1253
|
-
if (matches.length > 1) throw new OnlyneError("ambiguous_target", text("error.ambiguous_thread", { alias }));
|
|
1254
|
-
if (alias.includes("/")) {
|
|
1255
|
-
const [owner, id] = alias.split("/", 2);
|
|
1256
|
-
if (owner !== undefined && id !== undefined && owner.length > 0 && id.length > 0) return { owner, id };
|
|
1257
|
-
}
|
|
1258
|
-
throw new OnlyneError("not_found", text("error.thread_not_found", { alias }));
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
async #target(meta: Record<string, Json> | undefined): Promise<NodeId | undefined> {
|
|
1262
|
-
if (meta === undefined) return undefined;
|
|
1263
|
-
const value = meta.to;
|
|
1264
|
-
if (value === undefined) return undefined;
|
|
1265
|
-
if (typeof value !== "string") {
|
|
1266
|
-
throw new OnlyneError("invalid_request", text("error.meta_to_single"));
|
|
1267
|
-
}
|
|
1268
|
-
if (value.length === 0) throw new OnlyneError("invalid_request", text("error.meta_to_empty"));
|
|
1269
|
-
return this.#peerId(value);
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
/**
|
|
1273
|
-
* Resolve an explicit target against live discovery. A bare alias is accepted
|
|
1274
|
-
* only when exactly one known node's last URN segment names it.
|
|
1275
|
-
*/
|
|
1276
|
-
async #peerId(alias: string): Promise<NodeId> {
|
|
1277
|
-
const peers = await this.#peers();
|
|
1278
|
-
if (!peers.available) {
|
|
1279
|
-
throw new OnlyneError("unavailable", text("error.target_unresolved", { reason: peers.reason }));
|
|
1280
|
-
}
|
|
1281
|
-
const ids = [this.deps.config.node.id, ...peers.value.map(peer => peer.node_id)];
|
|
1282
|
-
if (alias === "self") return this.deps.config.node.id;
|
|
1283
|
-
if (ids.includes(alias)) return alias;
|
|
1284
|
-
const matches = ids.filter(id => lastSegment(id) === alias);
|
|
1285
|
-
if (matches.length === 1) return matches[0]!;
|
|
1286
|
-
if (matches.length > 1) {
|
|
1287
|
-
throw new OnlyneError("ambiguous_target", text("error.alias_ambiguous", { alias, matches: matches.join(", ") }));
|
|
1288
|
-
}
|
|
1289
|
-
throw new OnlyneError("not_found", text("error.alias_not_found", { alias }));
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
async #mentions(meta: Record<string, Json> | undefined): Promise<string[] | undefined> {
|
|
1293
|
-
if (meta === undefined) return undefined;
|
|
1294
|
-
const value = meta.mentions;
|
|
1295
|
-
if (value === undefined) return undefined;
|
|
1296
|
-
if (!Array.isArray(value)) throw new OnlyneError("invalid_request", text("error.mentions_array"));
|
|
1297
|
-
const resolved: string[] = [];
|
|
1298
|
-
for (const item of value) {
|
|
1299
|
-
if (typeof item !== "string" || item.length === 0) {
|
|
1300
|
-
throw new OnlyneError("invalid_request", text("error.mention_empty"));
|
|
1301
|
-
}
|
|
1302
|
-
if (item === "@all") {
|
|
1303
|
-
resolved.push(item);
|
|
1304
|
-
continue;
|
|
1305
|
-
}
|
|
1306
|
-
resolved.push(await this.#peerId(item.startsWith("@") ? item.slice(1) : item));
|
|
1307
|
-
}
|
|
1308
|
-
return resolved;
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
async #reply(meta: Record<string, Json> | undefined): Promise<{ ref: MessageRef; authority: NodeId; thread_ref?: ThreadRef } | undefined> {
|
|
1312
|
-
if (meta === undefined) return undefined;
|
|
1313
|
-
const value = meta.in_reply_to;
|
|
1314
|
-
if (value === undefined) return undefined;
|
|
1315
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1316
|
-
throw new OnlyneError("invalid_request", text("error.in_reply_to_object"));
|
|
1317
|
-
}
|
|
1318
|
-
const record = value as Record<string, Json>;
|
|
1319
|
-
const sender = stringField(record, "sender");
|
|
1320
|
-
const id = stringField(record, "id");
|
|
1321
|
-
if (sender !== undefined && id !== undefined) {
|
|
1322
|
-
// The authority is an identity, not the caller's spelling: `caerulea` and
|
|
1323
|
-
// `urn:onlyne:agent:caerulea` name the same node, and the delivery target is always
|
|
1324
|
-
// resolved. Comparing a raw string against a resolved id produced two contradictory
|
|
1325
|
-
// errors in a row (cyanea report 1, problem 1).
|
|
1326
|
-
const authority = await this.#peerId(sender).catch(() => sender);
|
|
1327
|
-
return { ref: { sender, id }, authority };
|
|
1328
|
-
}
|
|
1329
|
-
const threadAlias = stringField(record, "thread");
|
|
1330
|
-
const seq = seqField(record, "seq");
|
|
1331
|
-
if (threadAlias === undefined) {
|
|
1332
|
-
throw new OnlyneError("invalid_request", text("error.in_reply_to_shape"));
|
|
1333
|
-
}
|
|
1334
|
-
if (seq === undefined) {
|
|
1335
|
-
throw new OnlyneError("invalid_request", text("error.in_reply_to_seq"));
|
|
1336
|
-
}
|
|
1337
|
-
const thread = await this.#threadByAlias(threadAlias);
|
|
1338
|
-
const entry = await this.#entryAt(thread, seq);
|
|
1339
|
-
if (entry === undefined) throw new OnlyneError("not_found", text("error.message_not_found", { thread: threadAlias, seq }));
|
|
1340
|
-
return {
|
|
1341
|
-
ref: { sender: entry.message.sender, id: entry.message.id },
|
|
1342
|
-
authority: entry.reply_authority,
|
|
1343
|
-
thread_ref: entry.thread_ref,
|
|
1344
|
-
};
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
/**
|
|
1348
|
-
* The message a read names, with the thread it was resolved in. `{sender,id}` carries no
|
|
1349
|
-
* thread here; `{thread,seq}` does, and an explicit meta.thread that contradicts the
|
|
1350
|
-
* reference is refused rather than silently winning.
|
|
1351
|
-
*/
|
|
1352
|
-
async #messageRef(meta: Record<string, Json>, thread?: ThreadRef): Promise<{ ref: MessageRef; thread?: ThreadRef } | undefined> {
|
|
1353
|
-
const value = meta.message;
|
|
1354
|
-
const record = value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1355
|
-
? (value as Record<string, Json>)
|
|
1356
|
-
: undefined;
|
|
1357
|
-
const sender = record === undefined ? undefined : stringField(record, "sender");
|
|
1358
|
-
const id = record === undefined ? undefined : stringField(record, "id");
|
|
1359
|
-
if (sender !== undefined && id !== undefined) {
|
|
1360
|
-
return { ref: { sender, id }, ...(thread === undefined ? {} : { thread }) };
|
|
1361
|
-
}
|
|
1362
|
-
// meta.message takes the same two shapes as meta.in_reply_to, including its own thread
|
|
1363
|
-
// reference: `{thread, seq}` is the form a reader copies out of another reference, and
|
|
1364
|
-
// refusing it while accepting `{sender, id}` made the same fact two spellings (caerulea 4).
|
|
1365
|
-
const alias = record === undefined ? undefined : stringField(record, "thread");
|
|
1366
|
-
const embedded = alias === undefined ? undefined : await this.#threadByAlias(alias);
|
|
1367
|
-
if (thread !== undefined && embedded !== undefined && threadKey(thread) !== threadKey(embedded)) {
|
|
1368
|
-
throw new OnlyneError(
|
|
1369
|
-
"invalid_request",
|
|
1370
|
-
text("error.message_thread_conflict", { thread: threadKey(thread), embedded: threadKey(embedded) }),
|
|
1371
|
-
);
|
|
1372
|
-
}
|
|
1373
|
-
const scope = thread ?? embedded;
|
|
1374
|
-
if (scope === undefined) return undefined;
|
|
1375
|
-
const seq = (record === undefined ? undefined : seqField(record, "seq")) ?? seqField(meta, "seq");
|
|
1376
|
-
if (seq === undefined) return undefined;
|
|
1377
|
-
const entry = await this.#entryAt(scope, seq);
|
|
1378
|
-
if (entry === undefined) throw new OnlyneError("not_found", text("error.message_not_found", { thread: scope.id, seq }));
|
|
1379
|
-
return { ref: { sender: entry.message.sender, id: entry.message.id }, thread: scope };
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
async #entryAt(thread: ThreadRef, seq: string): Promise<Entry | undefined> {
|
|
1383
|
-
let cursor: string | undefined;
|
|
1384
|
-
for (let page = 0; page < MAX_TAIL_PAGES; page++) {
|
|
1385
|
-
const result: Page<Entry> = await this.deps.listMessages(thread, cursor);
|
|
1386
|
-
const found = result.items.find(entry => entry.seq === seq);
|
|
1387
|
-
if (found !== undefined) return found;
|
|
1388
|
-
cursor = result.next_cursor ?? undefined;
|
|
1389
|
-
if (cursor === undefined) return undefined;
|
|
1390
|
-
}
|
|
1391
|
-
return undefined;
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
1178
|
/**
|
|
1395
|
-
*
|
|
1396
|
-
*
|
|
1179
|
+
* RFC 0018 §3.1: the node a mechanical `op` names, resolved against the same peer set a send
|
|
1180
|
+
* resolves `meta.to` from, so a name that addresses a peer once addresses it everywhere.
|
|
1397
1181
|
*/
|
|
1398
|
-
async
|
|
1399
|
-
|
|
1400
|
-
if (action.kind !== "read") {
|
|
1401
|
-
throw new OnlyneError(
|
|
1402
|
-
"forbidden",
|
|
1403
|
-
text("error.guard_write"),
|
|
1404
|
-
);
|
|
1405
|
-
}
|
|
1406
|
-
if (action.scope === "resources") throw new OnlyneError("forbidden", text("error.resources_main_only"));
|
|
1407
|
-
if (action.scope === "directory" || action.scope === "neighbors" || action.scope === "threads" || action.scope === "inbox") {
|
|
1408
|
-
throw new OnlyneError(
|
|
1409
|
-
"forbidden",
|
|
1410
|
-
text("error.guard_node_listing"),
|
|
1411
|
-
);
|
|
1412
|
-
}
|
|
1413
|
-
const records = await this.#authorizedRecords(scope.ctx.agent.id);
|
|
1414
|
-
if (action.scope === "evidence") {
|
|
1415
|
-
const ref = action.message;
|
|
1416
|
-
const tied = ref !== undefined && records.some(record =>
|
|
1417
|
-
record.source !== undefined && record.source.sender === ref.sender && record.source.id === ref.id,
|
|
1418
|
-
);
|
|
1419
|
-
if (!tied) {
|
|
1420
|
-
throw new OnlyneError("forbidden", text("error.guard_evidence"));
|
|
1421
|
-
}
|
|
1422
|
-
return;
|
|
1423
|
-
}
|
|
1424
|
-
// `{sender,id}` names no thread, so the record that received that message is the
|
|
1425
|
-
// authorization — the same tie evidence uses.
|
|
1426
|
-
if (action.scope === "message" && action.thread === undefined && action.message !== undefined) {
|
|
1427
|
-
const ref = action.message;
|
|
1428
|
-
const tied = records.some(record =>
|
|
1429
|
-
record.source !== undefined && record.source.sender === ref.sender && record.source.id === ref.id,
|
|
1430
|
-
);
|
|
1431
|
-
if (!tied) {
|
|
1432
|
-
throw new OnlyneError("forbidden", text("error.guard_message"));
|
|
1433
|
-
}
|
|
1434
|
-
return;
|
|
1435
|
-
}
|
|
1436
|
-
if (action.thread === undefined) {
|
|
1437
|
-
throw new OnlyneError("forbidden", text("error.guard_thread_required"));
|
|
1438
|
-
}
|
|
1439
|
-
const key = threadKey(action.thread);
|
|
1440
|
-
if (!records.some(record => record.thread_ref !== undefined && threadKey(record.thread_ref) === key)) {
|
|
1441
|
-
throw new OnlyneError("forbidden", text("error.guard_no_slice", { thread: key }));
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
async #authorizedRecords(agentId: string): Promise<ContextRecord[]> {
|
|
1446
|
-
let records: ContextRecord[];
|
|
1447
|
-
try {
|
|
1448
|
-
records = await this.deps.contexts.list();
|
|
1449
|
-
} catch (error) {
|
|
1450
|
-
throw new OnlyneError("unavailable", text("error.registry_unreadable", { error: message(error) }));
|
|
1451
|
-
}
|
|
1452
|
-
return records.filter(record =>
|
|
1453
|
-
record.node_id === this.deps.config.node.id && record.agent_id === agentId && record.thread_ref !== undefined,
|
|
1454
|
-
);
|
|
1182
|
+
async peerId(alias: string): Promise<NodeId> {
|
|
1183
|
+
return this.refs.peerId(alias);
|
|
1455
1184
|
}
|
|
1456
1185
|
|
|
1457
1186
|
async #contextRows(): Promise<Source<Row[]>> {
|
|
@@ -1484,14 +1213,6 @@ export class JudgeDecider {
|
|
|
1484
1213
|
}
|
|
1485
1214
|
|
|
1486
1215
|
|
|
1487
|
-
async #peers(): Promise<Source<PeerRecord[]>> {
|
|
1488
|
-
try {
|
|
1489
|
-
return ok(await this.deps.listPeers());
|
|
1490
|
-
} catch (error) {
|
|
1491
|
-
return none(text("reason.discovery_unreadable", { error: message(error) }));
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
1216
|
async #receiverContexts(nodeId: NodeId): Promise<Source<ReceiverContext[]>> {
|
|
1496
1217
|
try {
|
|
1497
1218
|
return ok(await this.deps.listReceiverContexts(nodeId));
|
|
@@ -1624,12 +1345,6 @@ function actionFacts(action: Action): Record<string, string> {
|
|
|
1624
1345
|
return facts;
|
|
1625
1346
|
}
|
|
1626
1347
|
|
|
1627
|
-
function choice(answers: Answers, key: string): string | undefined {
|
|
1628
|
-
const answer = answers[key];
|
|
1629
|
-
if (answer === undefined || answer === null || typeof answer !== "object") return undefined;
|
|
1630
|
-
return typeof answer.choice === "string" ? answer.choice : undefined;
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
1348
|
function stringField(record: Record<string, Json>, key: string): string | undefined {
|
|
1634
1349
|
const value = record[key];
|
|
1635
1350
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
@@ -1663,7 +1378,8 @@ function pageSize(meta: Record<string, Json>): number | undefined {
|
|
|
1663
1378
|
|
|
1664
1379
|
function attention(meta: Record<string, Json>): "inbox" | "thread" {
|
|
1665
1380
|
const value = meta.attention;
|
|
1666
|
-
|
|
1381
|
+
// RFC 0005 §1: reads default to the attention list; `thread` is the switch that widens one.
|
|
1382
|
+
if (value === undefined) return "inbox";
|
|
1667
1383
|
if (value === "inbox" || value === "thread") return value;
|
|
1668
1384
|
throw new OnlyneError("invalid_request", text("error.attention"));
|
|
1669
1385
|
}
|
|
@@ -1684,10 +1400,6 @@ function branchOf(ctx: ExtensionContext): readonly SessionEntry[] {
|
|
|
1684
1400
|
return ctx.sessionManager.getBranch();
|
|
1685
1401
|
}
|
|
1686
1402
|
|
|
1687
|
-
function threadKey(ref: ThreadRef): string {
|
|
1688
|
-
return `${ref.owner}/${ref.id}`;
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
1403
|
/**
|
|
1692
1404
|
* A wording hint about whether the sender expects an answer: metadata says reply, or the body
|
|
1693
1405
|
* greets or asks. It is evidence for node I-0, never the decision — a completed reply also
|
|
@@ -1700,13 +1412,6 @@ export function needsReplyOf(entry: Entry): boolean {
|
|
|
1700
1412
|
return REPLY_CUES.some(cue => body.includes(cue.toLowerCase())) || /\?|?|\bplease\b|期待/.test(entry.message.msg);
|
|
1701
1413
|
}
|
|
1702
1414
|
|
|
1703
|
-
/** A node id's last URN segment; plain ids are their own segment. */
|
|
1704
|
-
function lastSegment(nodeId: NodeId): string {
|
|
1705
|
-
const trimmed = nodeId.replace(/\/+$/, "");
|
|
1706
|
-
const parts = trimmed.split(/[:/]/).filter(part => part.length > 0);
|
|
1707
|
-
return parts.at(-1) ?? trimmed;
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
1415
|
function entryFacts(entry: Entry): Row {
|
|
1711
1416
|
return {
|
|
1712
1417
|
thread_ref: entry.thread_ref,
|