@ixo/oracle-runtime-workers 0.25.0 → 0.27.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.
Files changed (36) hide show
  1. package/package.json +3 -2
  2. package/src/attachments/extract.ts +108 -14
  3. package/src/attachments/index.ts +15 -0
  4. package/src/attachments/pdf-text.ts +99 -0
  5. package/src/attachments/pipeline.ts +47 -4
  6. package/src/attachments/sanitize.ts +14 -0
  7. package/src/attachments/view-cache.ts +95 -0
  8. package/src/attachments/view.ts +308 -3
  9. package/src/channels/auth.ts +2 -1
  10. package/src/channels/contract.ts +104 -0
  11. package/src/channels/replies.ts +58 -0
  12. package/src/channels/routing.ts +222 -0
  13. package/src/channels/turns.ts +1252 -16
  14. package/src/core/env.ts +17 -0
  15. package/src/do/contracts.ts +19 -0
  16. package/src/do/room-mirror.ts +8 -1
  17. package/src/do/run-coordinator.ts +27 -0
  18. package/src/do/transcript.ts +14 -11
  19. package/src/do/turn-messages.ts +54 -0
  20. package/src/do/user-oracle-do.ts +321 -135
  21. package/src/memory/session-history-indexer.ts +442 -103
  22. package/src/memory/user-context-fetcher.ts +13 -0
  23. package/src/memory/user-context-invalidation.ts +8 -2
  24. package/src/plugins/action-proposals/evidence.ts +7 -8
  25. package/src/plugins/memory/document-chunker.ts +313 -0
  26. package/src/plugins/memory/document-source.ts +229 -0
  27. package/src/plugins/memory/memory-engine-client.ts +352 -0
  28. package/src/plugins/memory/memory.plugin.ts +25 -2
  29. package/src/plugins/memory/oracle-document-tools.ts +677 -0
  30. package/src/plugins/tasks/middleware.ts +4 -9
  31. package/src/plugins/vfs/index.ts +11 -2
  32. package/src/plugins/vfs/vfs-content.ts +86 -22
  33. package/src/plugins/vfs/vfs.plugin.ts +28 -19
  34. package/src/shell/app.ts +70 -0
  35. package/src/sqlite/serialization.ts +20 -0
  36. package/src/sqlite/sessions-store.ts +43 -0
@@ -1,18 +1,37 @@
1
1
  /* eslint-disable no-console -- console IS the logger on Workers (Logs/observability). */
2
+ import type { z } from 'zod';
2
3
  import type { DoSqliteDatabase } from '../sqlite/database';
4
+ import { UNTITLED_SESSION } from '../sqlite/sessions-store';
3
5
  import type { RunRecord } from '../do/run-store';
4
6
  import type { TurnIdentity, TurnRequest } from '../do/contracts';
5
7
  import { parseReplyPlan, planText } from '../delivery/schema';
6
8
  import type { ReplyPlan } from '../delivery/types';
7
9
  import {
8
10
  ChannelError,
11
+ type ChannelControlResult,
12
+ StoredChannelControlResult,
13
+ type ChannelControlInput,
14
+ type ChannelReplyContext,
9
15
  channelRequestHash,
10
16
  channelRunId,
11
17
  StaleSessionError,
18
+ type ChannelTopic,
12
19
  type ChannelTurnInput,
13
20
  type ChannelTurnResponse,
14
21
  } from './contract';
15
22
  import { isRoomForbidden } from './failures';
23
+ import {
24
+ ambiguousThreadText,
25
+ channelSessionDecision,
26
+ CHANNEL_THREAD_LIMIT,
27
+ decideChannelRoute,
28
+ routeAssignmentSchema,
29
+ selectChannelCandidates,
30
+ type ChannelRouteAssignment,
31
+ type ChannelRouteObservation,
32
+ type ChannelRoutingOptions,
33
+ type ChannelThreadCandidate,
34
+ } from './routing';
16
35
 
17
36
  interface ReceiptRow extends Record<string, string | number | null> {
18
37
  binding_id: string;
@@ -35,6 +54,7 @@ interface ReceiptRow extends Record<string, string | number | null> {
35
54
  mirror_retry_at: number | null;
36
55
  /** Epoch ms the receipt was written (rows from before the column: its migration). */
37
56
  created_at: number | null;
57
+ routing_json: string | null;
38
58
  }
39
59
 
40
60
  /**
@@ -66,7 +86,131 @@ const TERMINAL_STATUSES: ReadonlySet<string> = new Set([
66
86
  const errorText = (error: unknown): string =>
67
87
  error instanceof Error ? error.message : String(error);
68
88
 
89
+ /**
90
+ * How long one routed submission may hold its binding's lane. Classifier
91
+ * and Matrix calls are bounded on their own; this keeps one that hangs
92
+ * anyway from holding every later message of the binding.
93
+ */
94
+ const ROUTING_LANE_DEADLINE_MS = 20_000;
95
+
96
+ /**
97
+ * A JSON column written by this module, read back. A row that no longer
98
+ * parses (written by another version, say) is treated as absent instead of
99
+ * failing every request that reads it; the warning names only the kind.
100
+ */
101
+ function readStored<T>(
102
+ schema: z.ZodType<T>,
103
+ raw: string,
104
+ kind: string,
105
+ ): T | undefined {
106
+ let value: unknown;
107
+ try {
108
+ value = JSON.parse(raw);
109
+ } catch {
110
+ value = undefined;
111
+ }
112
+ const parsed = schema.safeParse(value);
113
+ if (parsed.success) return parsed.data;
114
+ console.warn(`[channels] ignoring an unreadable stored ${kind}`);
115
+ return undefined;
116
+ }
117
+
118
+ /** `work`, refused with a 503 once `ms` passed; the timer never outlives it. */
119
+ async function withDeadline<T>(work: Promise<T>, ms: number): Promise<T> {
120
+ let timer: ReturnType<typeof setTimeout> | undefined;
121
+ try {
122
+ return await Promise.race([
123
+ work,
124
+ new Promise<never>((_resolve, reject) => {
125
+ timer = setTimeout(
126
+ () =>
127
+ reject(
128
+ new ChannelError(
129
+ 503,
130
+ 'The channel is busy with an earlier message; try again',
131
+ 'unavailable',
132
+ ),
133
+ ),
134
+ ms,
135
+ );
136
+ }),
137
+ ]);
138
+ } finally {
139
+ if (timer !== undefined) clearTimeout(timer);
140
+ }
141
+ }
142
+
143
+ /** What the sessions store holds about one session. */
144
+ export interface ChannelSessionInfo {
145
+ /** The stored title, if any: 'Untitled' and blank ones count as none. */
146
+ title: string | null;
147
+ /** Epoch ms. */
148
+ createdAt: number;
149
+ /** Epoch ms of the last activity. */
150
+ updatedAt: number;
151
+ }
152
+
153
+ /** The most sessions `topics` lists. */
154
+ const CHANNEL_TOPICS_LIMIT = 20;
155
+ /** The most binding rows `topics` reads, the most recently bound first: the rest are not listed. */
156
+ const CHANNEL_TOPICS_SCAN = 200;
157
+ /** Code points a title or a last line may have on the wire. */
158
+ const CHANNEL_TOPIC_LINE_LIMIT = 200;
159
+ const CHANNEL_TITLE_LIMIT = 80;
160
+ /** Receipts looked through, newest first, for one that is not a routing question. */
161
+ const CHANNEL_TOPIC_LOOKBACK = 5;
162
+ /** Ids per statement: a Durable Object binds at most 100 parameters. */
163
+ const CHANNEL_IDS_PER_STATEMENT = 50;
164
+
165
+ function clipCodePoints(text: string, limit: number): string {
166
+ const points = Array.from(text);
167
+ return points.length > limit ? points.slice(0, limit).join('') : text;
168
+ }
169
+
170
+ /**
171
+ * Text as a single line of plain characters: whitespace runs become one
172
+ * space; control, format and line or paragraph separator characters (bidi
173
+ * overrides, zero-width marks) are removed.
174
+ */
175
+ function plainLine(text: string): string {
176
+ return text
177
+ .replace(/\s+/gu, ' ')
178
+ .replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '')
179
+ .replace(/\s+/gu, ' ')
180
+ .trim();
181
+ }
182
+
183
+ /** A line of a conversation as `topics` shows it, or null when nothing is left. */
184
+ function topicLine(text: string | null | undefined): string | null {
185
+ const line = clipCodePoints(plainLine(text ?? ''), CHANNEL_TOPIC_LINE_LIMIT);
186
+ return line.length > 0 ? line : null;
187
+ }
188
+
189
+ function isPlaceholderTitle(title: string): boolean {
190
+ return title.toLowerCase() === UNTITLED_SESSION.toLowerCase();
191
+ }
192
+
193
+ /** A stored title as the wire carries it: null when untitled, at most 200 code points. */
194
+ function wireTitle(title: string | null | undefined): string | null {
195
+ const line = plainLine(title ?? '');
196
+ return line.length > 0 && !isPlaceholderTitle(line)
197
+ ? clipCodePoints(line, CHANNEL_TOPIC_LINE_LIMIT)
198
+ : null;
199
+ }
200
+
201
+ /**
202
+ * A person's new title: one plain line of 1..80 code points that is not the
203
+ * placeholder, which the auto-titler would overwrite.
204
+ */
205
+ function cleanTitle(title: string): string {
206
+ const cleaned = clipCodePoints(plainLine(title), CHANNEL_TITLE_LIMIT);
207
+ if (cleaned.length === 0 || isPlaceholderTitle(cleaned))
208
+ throw new ChannelError(400, 'Invalid title', 'invalid_request');
209
+ return cleaned;
210
+ }
211
+
69
212
  export interface ChannelTurnsHost {
213
+ routing?: ChannelRoutingOptions;
70
214
  createSession(identity: TurnIdentity, markerTxnId: string): Promise<string>;
71
215
  /**
72
216
  * Throws `StaleSessionError` when the object holds no such session (it
@@ -79,6 +223,18 @@ export interface ChannelTurnsHost {
79
223
  * usable delegation for this oracle: none, or one too close to expiry.
80
224
  */
81
225
  requireDelegation(identity: TurnIdentity): Promise<void>;
226
+ /**
227
+ * Abort the named runs (unknown or ended ones are skipped). Signals all
228
+ * of them first, then waits at most one shared grace; a run may still be
229
+ * winding down when it resolves.
230
+ */
231
+ stopRuns(runIds: readonly string[]): Promise<void>;
232
+ /** The sessions store's rows for `sessionIds`; a session it holds none for (deleted) is absent. */
233
+ describeSessions(
234
+ sessionIds: readonly string[],
235
+ ): Promise<Map<string, ChannelSessionInfo>>;
236
+ /** Set a session's title, replacing any; false when it holds no such session. */
237
+ renameSession(sessionId: string, title: string): Promise<boolean>;
82
238
  getRun(runId: string): Promise<RunRecord | undefined>;
83
239
  /** The Reply Plan (JSON) a finished run was built into, if any. */
84
240
  getPlan(runId: string): Promise<string | null>;
@@ -106,7 +262,16 @@ export interface ChannelTurnsHost {
106
262
  /** What admission found under the lock, before any Matrix I/O. */
107
263
  type Received =
108
264
  | { kind: 'run'; response: ChannelTurnResponse }
109
- | { kind: 'new'; sessionId: string | null };
265
+ | {
266
+ kind: 'new';
267
+ sessionId: string | null;
268
+ /** The receipt's stored route assignment, as step 1 read it. */
269
+ routingJson: string | null;
270
+ };
271
+ interface PreparedChannelTurn {
272
+ sessionId: string;
273
+ routing?: ChannelRouteAssignment;
274
+ }
110
275
 
111
276
  /**
112
277
  * Durable channel requests. A request is admitted in three steps, so the
@@ -135,12 +300,31 @@ export class ChannelTurns {
135
300
  private draining: Promise<void> | null = null;
136
301
  /** A pass was asked for while one ran: run another when it ends. */
137
302
  private drainAgain = false;
303
+ /**
304
+ * Routed submissions, ordered per binding: the lane's tail, and the
305
+ * submission in flight per request id, which a retry of the same request
306
+ * joins instead of queueing behind itself.
307
+ */
308
+ private readonly routingLanes = new Map<
309
+ string,
310
+ {
311
+ tail: Promise<unknown>;
312
+ requests: Map<
313
+ string,
314
+ { requestHash: string; response: Promise<ChannelTurnResponse> }
315
+ >;
316
+ }
317
+ >();
138
318
 
139
319
  constructor(
140
320
  private readonly db: DoSqliteDatabase,
141
321
  private readonly host: ChannelTurnsHost,
142
322
  ) {}
143
323
 
324
+ private get routing(): ChannelRoutingOptions | undefined {
325
+ return this.host.routing;
326
+ }
327
+
144
328
  private now(): number {
145
329
  return this.host.now?.() ?? Date.now();
146
330
  }
@@ -186,6 +370,8 @@ export class ChannelTurns {
186
370
  );
187
371
  await add('mirror_failures', 'INTEGER NOT NULL DEFAULT 0');
188
372
  await add('mirror_retry_at', 'INTEGER');
373
+ await add('routing_json', 'TEXT');
374
+ await add('routing_observation', 'TEXT');
189
375
  if (await add('created_at', 'INTEGER'))
190
376
  await this.db.run(
191
377
  'UPDATE channel_requests SET created_at = ? WHERE created_at IS NULL',
@@ -197,9 +383,73 @@ export class ChannelTurns {
197
383
  await this.db.run(
198
384
  'CREATE INDEX IF NOT EXISTS idx_channel_requests_created ON channel_requests(created_at)',
199
385
  );
386
+ await this.db.run(
387
+ 'CREATE INDEX IF NOT EXISTS idx_channel_requests_session ON channel_requests(binding_id, session_id)',
388
+ );
389
+ await this.db.run(`CREATE TABLE IF NOT EXISTS channel_controls (
390
+ binding_id TEXT NOT NULL, request_id TEXT NOT NULL, request_hash TEXT NOT NULL,
391
+ result TEXT, replacement_session_id TEXT, created_at INTEGER NOT NULL,
392
+ PRIMARY KEY(binding_id, request_id)
393
+ )`);
394
+ if (
395
+ !(
396
+ await this.db.exec<{ name: string }>(
397
+ 'PRAGMA table_info(channel_controls)',
398
+ )
399
+ ).some((column) => column.name === 'replacement_session_id')
400
+ )
401
+ await this.db.run(
402
+ 'ALTER TABLE channel_controls ADD COLUMN replacement_session_id TEXT',
403
+ );
404
+ await this.db.run(`CREATE TABLE IF NOT EXISTS channel_cancellations (
405
+ binding_id TEXT NOT NULL, request_id TEXT NOT NULL, created_at INTEGER NOT NULL,
406
+ PRIMARY KEY(binding_id, request_id)
407
+ )`);
408
+ await this.db.run(
409
+ 'CREATE INDEX IF NOT EXISTS idx_channel_controls_created ON channel_controls(created_at)',
410
+ );
411
+ await this.db.run(
412
+ 'CREATE INDEX IF NOT EXISTS idx_channel_cancellations_created ON channel_cancellations(created_at)',
413
+ );
414
+ // The sessions a binding has used, per consent revision: what `/resume`
415
+ // may return to. Not seeded from older data: a session bound before
416
+ // this table existed is recognised by its request receipts instead
417
+ // (`usedByBinding`), which hold the same evidence.
418
+ await this.db.run(`CREATE TABLE IF NOT EXISTS channel_session_bindings (
419
+ binding_id TEXT NOT NULL, binding_revision INTEGER NOT NULL, session_id TEXT NOT NULL,
420
+ created_at INTEGER NOT NULL,
421
+ PRIMARY KEY(binding_id, binding_revision, session_id)
422
+ )`);
200
423
  await this.db.run(`CREATE TABLE IF NOT EXISTS channel_sessions (
201
424
  binding_id TEXT PRIMARY KEY, session_id TEXT NOT NULL
202
425
  )`);
426
+ const sessionColumns = await this.db.exec<{ name: string }>(
427
+ 'PRAGMA table_info(channel_sessions)',
428
+ );
429
+ for (const [name, definition] of [
430
+ ['generation', 'INTEGER NOT NULL DEFAULT 0'],
431
+ ['force_next', 'INTEGER NOT NULL DEFAULT 0'],
432
+ ]) {
433
+ if (!sessionColumns.some((column) => column.name === name))
434
+ await this.db.run(
435
+ `ALTER TABLE channel_sessions ADD COLUMN ${name} ${definition}`,
436
+ );
437
+ }
438
+ if (this.routing?.mode !== undefined && this.routing.mode !== 'off') {
439
+ await this.db.run(`CREATE TABLE IF NOT EXISTS channel_thread_index (
440
+ binding_id TEXT NOT NULL, binding_revision INTEGER NOT NULL, session_id TEXT NOT NULL,
441
+ topic TEXT NOT NULL, recent_user TEXT NOT NULL, recent_reply TEXT NOT NULL,
442
+ last_run_id TEXT NOT NULL, updated_at INTEGER NOT NULL,
443
+ PRIMARY KEY(binding_id, binding_revision, session_id)
444
+ )`);
445
+ // How far `channel_requests` was read into the thread index, per
446
+ // binding and revision: later turns read only the receipts after it.
447
+ await this.db.run(`CREATE TABLE IF NOT EXISTS channel_thread_seeded (
448
+ binding_id TEXT NOT NULL, binding_revision INTEGER NOT NULL,
449
+ seeded_rowid INTEGER NOT NULL,
450
+ PRIMARY KEY(binding_id, binding_revision)
451
+ )`);
452
+ }
203
453
  })().catch((error: unknown) => {
204
454
  this.setupPromise = undefined;
205
455
  throw error;
@@ -207,6 +457,492 @@ export class ChannelTurns {
207
457
  return this.setupPromise;
208
458
  }
209
459
 
460
+ /** SQLite admission is serialized; stopping runs and Matrix I/O never hold that lock. */
461
+ async control(
462
+ identity: TurnIdentity,
463
+ input: ChannelControlInput,
464
+ requestHash: string,
465
+ ): Promise<ChannelControlResult> {
466
+ if (
467
+ !identity.channel ||
468
+ identity.channel.bindingId !== input.bindingId ||
469
+ identity.channel.bindingRevision !== input.bindingRevision ||
470
+ identity.channel.provider !== input.provider
471
+ )
472
+ throw new ChannelError(
473
+ 403,
474
+ 'Channel identity does not match this request',
475
+ 'identity_mismatch',
476
+ );
477
+ if (
478
+ (input.command.kind === 'status' ||
479
+ input.command.kind === 'topics' ||
480
+ input.command.kind === 'rename') &&
481
+ input.cancelRequestIds.length
482
+ )
483
+ throw new ChannelError(
484
+ 400,
485
+ `A ${input.command.kind} request cannot cancel work`,
486
+ 'invalid_request',
487
+ );
488
+ const renamed =
489
+ input.command.kind === 'rename'
490
+ ? cleanTitle(input.command.title)
491
+ : undefined;
492
+ const saved = await this.serialize(async () => {
493
+ await this.setup();
494
+ const row = await this.db.get<{
495
+ request_hash: string;
496
+ result: string | null;
497
+ replacement_session_id: string | null;
498
+ }>(
499
+ 'SELECT request_hash,result,replacement_session_id FROM channel_controls WHERE binding_id=? AND request_id=?',
500
+ [input.bindingId, input.requestId],
501
+ );
502
+ if (row && row.request_hash !== requestHash)
503
+ throw new ChannelError(
504
+ 409,
505
+ 'Control request changed',
506
+ 'request_conflict',
507
+ );
508
+ const result = row?.result
509
+ ? readStored(StoredChannelControlResult, row.result, 'control result')
510
+ : undefined;
511
+ if (result) return { result };
512
+ await this.assertNotCancelled(input);
513
+ if (!row) {
514
+ await this.db.run(
515
+ 'INSERT INTO channel_controls(binding_id,request_id,request_hash,created_at) VALUES(?,?,?,?)',
516
+ [input.bindingId, input.requestId, requestHash, this.now()],
517
+ );
518
+ await this.host.changed();
519
+ }
520
+ return { replacement: row?.replacement_session_id ?? undefined };
521
+ });
522
+ if (saved.result) return saved.result;
523
+ // Refused destinations leave the existing binding and its work
524
+ // untouched, and take their receipt with them: a corrected body under
525
+ // the same id is the same control, not a conflict.
526
+ try {
527
+ if (input.command.kind === 'resume') {
528
+ await this.host.assertSession(identity, input.command.sessionId);
529
+ if (!(await this.usedByBinding(input, input.command.sessionId)))
530
+ throw new ChannelError(
531
+ 404,
532
+ 'Oracle session not found',
533
+ 'session_not_found',
534
+ );
535
+ }
536
+ if (input.command.kind === 'new')
537
+ await this.host.requireDelegation(identity);
538
+ if (input.command.kind === 'rename') {
539
+ if (!(await this.boundSession(input.bindingId)))
540
+ throw new ChannelError(
541
+ 404,
542
+ 'This conversation has no topic to rename',
543
+ 'session_not_found',
544
+ );
545
+ }
546
+ } catch (error) {
547
+ await this.serialize(async () => {
548
+ const dropped = await this.db.run(
549
+ 'DELETE FROM channel_controls WHERE binding_id=? AND request_id=? AND result IS NULL',
550
+ [input.bindingId, input.requestId],
551
+ );
552
+ if (dropped.changes > 0) await this.host.changed();
553
+ });
554
+ throw error;
555
+ }
556
+ await this.serialize(async () => {
557
+ await this.assertNotCancelled(input);
558
+ let reserved = 0;
559
+ for (const requestId of input.cancelRequestIds)
560
+ reserved += (
561
+ await this.db.run(
562
+ 'INSERT OR IGNORE INTO channel_cancellations(binding_id,request_id,created_at) VALUES(?,?,?)',
563
+ [input.bindingId, requestId, this.now()],
564
+ )
565
+ ).changes;
566
+ if (reserved > 0) await this.host.changed();
567
+ });
568
+ // The reservations above are durable and checked before every attempt,
569
+ // so nothing named can start any more. A run that is still winding down
570
+ // (a tool call in flight) is not waited for past one shared grace: a
571
+ // retry of this control could not stop it any sooner.
572
+ if (input.cancelRequestIds.length)
573
+ await this.host.stopRuns(
574
+ await Promise.all(
575
+ input.cancelRequestIds.map((requestId) =>
576
+ channelRunId(input.bindingId, requestId),
577
+ ),
578
+ ),
579
+ );
580
+ let replacement: string | undefined;
581
+ if (input.command.kind === 'new') {
582
+ replacement = saved.replacement;
583
+ if (!replacement) {
584
+ replacement = await this.host.createSession(
585
+ identity,
586
+ `channel-reset-${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`,
587
+ );
588
+ // A retry reuses the session instead of asking Matrix again.
589
+ const created = replacement;
590
+ await this.serialize(async () => {
591
+ const stored = await this.db.run(
592
+ 'UPDATE channel_controls SET replacement_session_id=? WHERE binding_id=? AND request_id=? AND replacement_session_id IS NULL',
593
+ [created, input.bindingId, input.requestId],
594
+ );
595
+ if (stored.changes > 0) await this.host.changed();
596
+ });
597
+ }
598
+ await this.host.assertSession(identity, replacement);
599
+ } else if (input.command.kind === 'resume')
600
+ replacement = input.command.sessionId;
601
+ const listing =
602
+ input.command.kind === 'topics'
603
+ ? await this.listTopics(input)
604
+ : undefined;
605
+ return this.serialize(async () => {
606
+ // A later stop can cancel this control while its Matrix call is outstanding.
607
+ await this.assertNotCancelled(input);
608
+ const completed = await this.db.get<{ result: string | null }>(
609
+ 'SELECT result FROM channel_controls WHERE binding_id=? AND request_id=?',
610
+ [input.bindingId, input.requestId],
611
+ );
612
+ const already = completed?.result
613
+ ? readStored(
614
+ StoredChannelControlResult,
615
+ completed.result,
616
+ 'control result',
617
+ )
618
+ : undefined;
619
+ if (already) return already;
620
+ if (replacement) {
621
+ await this.db.run(
622
+ 'INSERT INTO channel_sessions(binding_id,session_id,generation,force_next) VALUES(?,?,1,1) ON CONFLICT(binding_id) DO UPDATE SET session_id=excluded.session_id,generation=channel_sessions.generation+1,force_next=1',
623
+ [input.bindingId, replacement],
624
+ );
625
+ await this.recordSessionBinding(input, replacement);
626
+ }
627
+ const sessionId = listing
628
+ ? listing.bound
629
+ : (replacement ?? (await this.boundSession(input.bindingId)));
630
+ if (renamed !== undefined) {
631
+ // Bound now, not as it was when the title was checked: a reset in
632
+ // between moves the name to the topic the person is in.
633
+ if (
634
+ !sessionId ||
635
+ !(await this.host.renameSession(sessionId, renamed))
636
+ ) {
637
+ // Refused after the checks passed: the receipt goes with it, so a
638
+ // corrected retry under the same id is not a conflict.
639
+ const dropped = await this.db.run(
640
+ 'DELETE FROM channel_controls WHERE binding_id=? AND request_id=? AND result IS NULL',
641
+ [input.bindingId, input.requestId],
642
+ );
643
+ if (dropped.changes > 0) await this.host.changed();
644
+ throw new ChannelError(
645
+ 404,
646
+ 'This conversation has no topic to rename',
647
+ 'session_not_found',
648
+ );
649
+ }
650
+ await this.host.changed();
651
+ }
652
+ const result: ChannelControlResult = {
653
+ requestId: input.requestId,
654
+ sessionId,
655
+ command: input.command.kind,
656
+ title: await this.titleOf(sessionId),
657
+ ...(listing ? { topics: listing.topics } : {}),
658
+ };
659
+ await this.db.run(
660
+ 'UPDATE channel_controls SET result=? WHERE binding_id=? AND request_id=?',
661
+ [JSON.stringify(result), input.bindingId, input.requestId],
662
+ );
663
+ await this.host.changed();
664
+ return result;
665
+ });
666
+ }
667
+
668
+ /**
669
+ * A session's title for use inside `*…*`: one plain line without the
670
+ * characters that would end or restyle the markup, at most 80 code points;
671
+ * null when untitled or unknown.
672
+ */
673
+ private async boldTitle(sessionId: string | null): Promise<string | null> {
674
+ if (!sessionId) return null;
675
+ const title = wireTitle(
676
+ (await this.host.describeSessions([sessionId])).get(sessionId)?.title,
677
+ );
678
+ if (!title) return null;
679
+ const plain = clipCodePoints(
680
+ plainLine(title.replace(/[*_~`]/gu, '')),
681
+ CHANNEL_TITLE_LIMIT,
682
+ );
683
+ return plain.length > 0 ? plain : null;
684
+ }
685
+
686
+ /** The title of a session as the wire carries it: null when untitled or unknown. */
687
+ private async titleOf(sessionId: string | null): Promise<string | null> {
688
+ if (!sessionId) return null;
689
+ const found = await this.host.describeSessions([sessionId]);
690
+ return wireTitle(found.get(sessionId)?.title);
691
+ }
692
+
693
+ /**
694
+ * The sessions this binding used at its current consent revision (the
695
+ * bound one among them), most recent activity first, and the bound session
696
+ * the list was built from. Read-only apart from forgetting the binding
697
+ * rows of sessions the user deleted.
698
+ */
699
+ private async listTopics(
700
+ input: Pick<ChannelTurnInput, 'bindingId' | 'bindingRevision'>,
701
+ ): Promise<{ bound: string | null; topics: ChannelTopic[] }> {
702
+ const used = await this.db.exec<{ session_id: string }>(
703
+ 'SELECT session_id FROM channel_session_bindings WHERE binding_id=? AND binding_revision=? ORDER BY created_at DESC, rowid DESC LIMIT ?',
704
+ [input.bindingId, input.bindingRevision, CHANNEL_TOPICS_SCAN],
705
+ );
706
+ const bound = await this.boundSession(input.bindingId);
707
+ const ids = new Set(used.map((row) => row.session_id));
708
+ if (bound) ids.add(bound);
709
+ const described = await this.host.describeSessions([...ids]);
710
+ const deleted = [...ids].filter((sessionId) => !described.has(sessionId));
711
+ if (deleted.length)
712
+ await this.serialize(async () => {
713
+ let removed = 0;
714
+ for (let at = 0; at < deleted.length; at += CHANNEL_IDS_PER_STATEMENT) {
715
+ const chunk = deleted.slice(at, at + CHANNEL_IDS_PER_STATEMENT);
716
+ removed += (
717
+ await this.db.run(
718
+ `DELETE FROM channel_session_bindings WHERE binding_id=? AND session_id IN (${chunk.map(() => '?').join(', ')})`,
719
+ [input.bindingId, ...chunk],
720
+ )
721
+ ).changes;
722
+ }
723
+ if (removed > 0) await this.host.changed();
724
+ });
725
+ const byActivity = [...described]
726
+ .map(([sessionId, info]) => ({ sessionId, info }))
727
+ .sort((a, b) => b.info.updatedAt - a.info.updatedAt);
728
+ let kept = byActivity.slice(0, CHANNEL_TOPICS_LIMIT);
729
+ const boundEntry = byActivity.find((entry) => entry.sessionId === bound);
730
+ if (boundEntry && !kept.includes(boundEntry))
731
+ kept = [...kept.slice(0, CHANNEL_TOPICS_LIMIT - 1), boundEntry].sort(
732
+ (a, b) => b.info.updatedAt - a.info.updatedAt,
733
+ );
734
+ const newest = await this.newestReceipts(
735
+ input.bindingId,
736
+ kept.map((entry) => entry.sessionId),
737
+ );
738
+ const topics: ChannelTopic[] = [];
739
+ for (const { sessionId, info } of kept) {
740
+ const line = await this.topicLines(
741
+ input,
742
+ sessionId,
743
+ newest.get(sessionId),
744
+ );
745
+ topics.push({
746
+ sessionId,
747
+ title: wireTitle(info.title),
748
+ createdAt: info.createdAt,
749
+ updatedAt: info.updatedAt,
750
+ ...line,
751
+ });
752
+ }
753
+ return { bound, topics };
754
+ }
755
+
756
+ /** The run of the newest request receipt of each session, in one statement. */
757
+ private async newestReceipts(
758
+ bindingId: string,
759
+ sessionIds: readonly string[],
760
+ ): Promise<Map<string, string>> {
761
+ const newest = new Map<string, string>();
762
+ if (sessionIds.length === 0) return newest;
763
+ const marks = sessionIds.map(() => '?').join(', ');
764
+ const rows = await this.db.exec<{ session_id: string; run_id: string }>(
765
+ `SELECT session_id, run_id FROM channel_requests WHERE binding_id=? AND rowid IN (SELECT MAX(rowid) FROM channel_requests WHERE binding_id=? AND session_id IN (${marks}) GROUP BY session_id)`,
766
+ [bindingId, bindingId, ...sessionIds],
767
+ );
768
+ for (const row of rows) newest.set(row.session_id, row.run_id);
769
+ return newest;
770
+ }
771
+
772
+ /**
773
+ * What the person last said in a session and the reply to it, from the
774
+ * newest receipt that is not a routing question (those are the oracle's
775
+ * words, not the topic's). Null lines when there is none, when it was sent
776
+ * under another consent revision, or when its stored request is unreadable.
777
+ */
778
+ private async topicLines(
779
+ input: Pick<ChannelTurnInput, 'bindingId' | 'bindingRevision'>,
780
+ sessionId: string,
781
+ newestRunId: string | undefined,
782
+ ): Promise<{ lastUser: string | null; lastReply: string | null }> {
783
+ const none = { lastUser: null, lastReply: null };
784
+ if (!newestRunId) return none;
785
+ let found = await this.topicReceipt(newestRunId);
786
+ if (found?.request.channel?.routingClarification) {
787
+ found = undefined;
788
+ const recent = await this.db.exec<{ run_id: string }>(
789
+ 'SELECT run_id FROM channel_requests WHERE binding_id=? AND session_id=? ORDER BY rowid DESC LIMIT ?',
790
+ [input.bindingId, sessionId, CHANNEL_TOPIC_LOOKBACK],
791
+ );
792
+ for (const row of recent) {
793
+ const candidate = await this.topicReceipt(row.run_id);
794
+ if (candidate && !candidate.request.channel?.routingClarification) {
795
+ found = candidate;
796
+ break;
797
+ }
798
+ }
799
+ }
800
+ // Text from before the person's latest consent is not quoted.
801
+ if (
802
+ !found ||
803
+ found.request.identity.channel?.bindingRevision !== input.bindingRevision
804
+ )
805
+ return none;
806
+ return {
807
+ lastUser: topicLine(found.request.message),
808
+ lastReply: topicLine(
809
+ found.run.status === 'finished' ? found.run.partialText : null,
810
+ ),
811
+ };
812
+ }
813
+
814
+ /** A run and its stored request; undefined when either is missing or unreadable. */
815
+ private async topicReceipt(
816
+ runId: string,
817
+ ): Promise<{ run: RunRecord; request: TurnRequest } | undefined> {
818
+ const run = await this.host.getRun(runId);
819
+ if (!run) return undefined;
820
+ try {
821
+ return { run, request: this.host.requestOf(run) };
822
+ } catch {
823
+ return undefined;
824
+ }
825
+ }
826
+
827
+ /**
828
+ * Note that the binding, at its current consent revision, uses `sessionId`,
829
+ * and when it last did: `created_at` is the time of the latest binding, so
830
+ * `topics` reads the most recently bound sessions.
831
+ */
832
+ private async recordSessionBinding(
833
+ input: Pick<ChannelTurnInput, 'bindingId' | 'bindingRevision'>,
834
+ sessionId: string,
835
+ ): Promise<void> {
836
+ const recorded = await this.db.run(
837
+ 'INSERT INTO channel_session_bindings(binding_id,binding_revision,session_id,created_at) VALUES(?,?,?,?) ON CONFLICT(binding_id,binding_revision,session_id) DO UPDATE SET created_at=excluded.created_at WHERE excluded.created_at > channel_session_bindings.created_at',
838
+ [input.bindingId, input.bindingRevision, sessionId, this.now()],
839
+ );
840
+ if (recorded.changes > 0) await this.host.changed();
841
+ }
842
+
843
+ /**
844
+ * Whether this binding used `sessionId` at its current consent revision:
845
+ * the only sessions `/resume` may return to. A Portal conversation, or
846
+ * one this binding used under an earlier consent, is not one of them.
847
+ */
848
+ private async usedByBinding(
849
+ input: Pick<ChannelTurnInput, 'bindingId' | 'bindingRevision'>,
850
+ sessionId: string,
851
+ ): Promise<boolean> {
852
+ if (
853
+ await this.db.get(
854
+ 'SELECT 1 FROM channel_session_bindings WHERE binding_id=? AND binding_revision=? AND session_id=?',
855
+ [input.bindingId, input.bindingRevision, sessionId],
856
+ )
857
+ )
858
+ return true;
859
+ // A session bound before `channel_session_bindings` existed: the stored
860
+ // request of the binding's latest receipt in it carries the revision.
861
+ // Revisions only grow, so no older receipt can match when it does not.
862
+ const latest = await this.db.get<{ run_id: string }>(
863
+ 'SELECT run_id FROM channel_requests WHERE binding_id=? AND session_id=? ORDER BY rowid DESC LIMIT 1',
864
+ [input.bindingId, sessionId],
865
+ );
866
+ const run = latest ? await this.host.getRun(latest.run_id) : undefined;
867
+ return (
868
+ run !== undefined &&
869
+ this.host.requestOf(run).identity.channel?.bindingRevision ===
870
+ input.bindingRevision
871
+ );
872
+ }
873
+
874
+ async isCancelled(
875
+ input: Pick<ChannelTurnInput, 'bindingId' | 'requestId'>,
876
+ ): Promise<boolean> {
877
+ await this.setup();
878
+ return Boolean(
879
+ await this.db.get(
880
+ 'SELECT 1 FROM channel_cancellations WHERE binding_id=? AND request_id=?',
881
+ [input.bindingId, input.requestId],
882
+ ),
883
+ );
884
+ }
885
+
886
+ private async assertNotCancelled(
887
+ input: Pick<ChannelTurnInput, 'bindingId' | 'requestId'>,
888
+ ): Promise<void> {
889
+ if (await this.isCancelled(input))
890
+ throw new ChannelError(
891
+ 409,
892
+ 'This message was cancelled by its owner',
893
+ 'request_cancelled',
894
+ );
895
+ }
896
+
897
+ private async replyContext(
898
+ input: ChannelTurnInput,
899
+ sessionId: string,
900
+ ): Promise<ChannelReplyContext | undefined> {
901
+ const reference = input.replyTo;
902
+ if (!reference) return undefined;
903
+ const unknown: ChannelReplyContext = {
904
+ kind: 'unavailable',
905
+ reason: 'unknown',
906
+ };
907
+ if (reference.kind === 'unavailable') return unknown;
908
+ const receipt = await this.receipt({
909
+ ...input,
910
+ requestId: reference.requestId,
911
+ });
912
+ if (!receipt?.session_id) return unknown;
913
+ // The quote is this binding's, but its conversation is not the one the
914
+ // turn runs in (a routed quote whose session could not be selected
915
+ // lands here too): its text stays out of this conversation.
916
+ if (receipt.session_id !== sessionId)
917
+ return { kind: 'unavailable', reason: 'other_conversation' };
918
+ const run = await this.host.getRun(receipt.run_id);
919
+ if (!run) return unknown;
920
+ let text: string;
921
+ if (reference.author === 'user') text = this.host.requestOf(run).message;
922
+ else {
923
+ if (run.status !== 'finished') return unknown;
924
+ const reply = await this.reply(run.runId, run.partialText ?? '');
925
+ const chunk = reference.partId
926
+ ? /^(.*)\.([1-9]\d*)$/.exec(reference.partId)
927
+ : null;
928
+ const matched =
929
+ reference.partId && reply.plan
930
+ ? (reply.plan.parts.find((p) => p.partId === reference.partId) ??
931
+ (chunk?.[1]
932
+ ? reply.plan.parts.find((p) => p.partId === chunk[1])
933
+ : undefined))
934
+ : undefined;
935
+ if (reference.partId && reply.plan && !matched) return unknown;
936
+ text = matched ? planText({ parts: [matched] }) : reply.rendered;
937
+ }
938
+ return {
939
+ kind: 'resolved',
940
+ requestId: reference.requestId,
941
+ author: reference.author,
942
+ text: text.slice(0, 16000),
943
+ };
944
+ }
945
+
210
946
  /**
211
947
  * The SQLite steps of admissions and session releases run one at a time.
212
948
  * Nothing that waits on Matrix runs under it.
@@ -217,7 +953,51 @@ export class ChannelTurns {
217
953
  return next;
218
954
  }
219
955
 
220
- async submit(
956
+ submit(
957
+ identity: TurnIdentity,
958
+ input: ChannelTurnInput,
959
+ requestHash: string,
960
+ ): Promise<ChannelTurnResponse> {
961
+ if (!this.routingEnabled(input))
962
+ return this.submitTurn(identity, input, requestHash);
963
+ // Classifier/Matrix I/O is ordered per binding, outside the admission
964
+ // lock. Controls and other bindings can still make progress.
965
+ let lane = this.routingLanes.get(input.bindingId);
966
+ if (!lane) {
967
+ lane = { tail: Promise.resolve(), requests: new Map() };
968
+ this.routingLanes.set(input.bindingId, lane);
969
+ }
970
+ // The gateway retries a request it timed out on: the retry answers with
971
+ // the submission still in flight instead of queueing behind it.
972
+ const inFlight = lane.requests.get(input.requestId);
973
+ if (inFlight?.requestHash === requestHash) return inFlight.response;
974
+ const owner = lane;
975
+ const next = owner.tail
976
+ .catch(() => undefined)
977
+ .then(() =>
978
+ withDeadline(
979
+ this.submitTurn(identity, input, requestHash),
980
+ ROUTING_LANE_DEADLINE_MS,
981
+ ),
982
+ );
983
+ owner.tail = next;
984
+ if (!inFlight)
985
+ owner.requests.set(input.requestId, { requestHash, response: next });
986
+ void next
987
+ .finally(() => {
988
+ if (owner.requests.get(input.requestId)?.response === next)
989
+ owner.requests.delete(input.requestId);
990
+ if (
991
+ owner.tail === next &&
992
+ this.routingLanes.get(input.bindingId) === owner
993
+ )
994
+ this.routingLanes.delete(input.bindingId);
995
+ })
996
+ .catch(() => undefined);
997
+ return next;
998
+ }
999
+
1000
+ private async submitTurn(
221
1001
  identity: TurnIdentity,
222
1002
  input: ChannelTurnInput,
223
1003
  requestHash: string,
@@ -242,9 +1022,14 @@ export class ChannelTurns {
242
1022
  this.receive(input, requestHash, runId),
243
1023
  );
244
1024
  if (received.kind === 'run') return received.response;
245
- let sessionId: string;
1025
+ let prepared: PreparedChannelTurn;
246
1026
  try {
247
- sessionId = await this.prepare(identity, input, received.sessionId);
1027
+ prepared = await this.prepare(
1028
+ identity,
1029
+ input,
1030
+ received.sessionId,
1031
+ received.routingJson,
1032
+ );
248
1033
  } catch (error) {
249
1034
  const stale =
250
1035
  error instanceof StaleSessionError && received.sessionId !== null;
@@ -263,7 +1048,7 @@ export class ChannelTurns {
263
1048
  throw error;
264
1049
  }
265
1050
  return this.serialize(() =>
266
- this.start(identity, input, requestHash, runId, sessionId),
1051
+ this.start(identity, input, requestHash, runId, prepared),
267
1052
  );
268
1053
  }
269
1054
  }
@@ -313,7 +1098,48 @@ export class ChannelTurns {
313
1098
  text: string,
314
1099
  ): Promise<{ plan: ReplyPlan | null; rendered: string }> {
315
1100
  const plan = parseReplyPlan(await this.host.getPlan(runId));
316
- return { plan, rendered: plan ? planText(plan) : text };
1101
+ const row = await this.db.get<{ routing_json: string | null }>(
1102
+ 'SELECT routing_json FROM channel_requests WHERE run_id=?',
1103
+ [runId],
1104
+ );
1105
+ const route = row?.routing_json
1106
+ ? readStored(routeAssignmentSchema, row.routing_json, 'route assignment')
1107
+ : undefined;
1108
+ let notice: string | undefined;
1109
+ if (route?.mode === 'on' && route.destination.kind === 'new') {
1110
+ const before = await this.boldTitle(route.baseline);
1111
+ notice = before
1112
+ ? `Starting a fresh topic for this. Send /resume and pick *${before}* to go back.`
1113
+ : 'Starting a fresh topic for this. Send /resume to go back to an earlier topic.';
1114
+ } else if (
1115
+ route?.mode === 'on' &&
1116
+ route.destination.kind === 'session' &&
1117
+ route.destination.sessionId !== route.baseline
1118
+ ) {
1119
+ const to = await this.boldTitle(route.destination.sessionId);
1120
+ notice = `Back to ${to ? `*${to}*` : 'an earlier topic'}. Send /resume to pick another topic.`;
1121
+ }
1122
+ if (!notice) return { plan, rendered: plan ? planText(plan) : text };
1123
+ const decorated =
1124
+ plan && plan.parts.length < 999
1125
+ ? {
1126
+ ...plan,
1127
+ parts: [
1128
+ {
1129
+ partId: 'channel_thread_notice',
1130
+ kind: 'text' as const,
1131
+ text: notice,
1132
+ },
1133
+ ...plan.parts,
1134
+ ],
1135
+ }
1136
+ : null;
1137
+ return {
1138
+ plan: decorated,
1139
+ rendered: decorated
1140
+ ? planText(decorated)
1141
+ : `${notice}\n\n${plan ? planText(plan) : text}`,
1142
+ };
317
1143
  }
318
1144
 
319
1145
  /**
@@ -327,6 +1153,22 @@ export class ChannelTurns {
327
1153
  // A first message still opening a session must not hand the deleted
328
1154
  // one to the binding's next message.
329
1155
  this.opening.clear();
1156
+ if (
1157
+ await this.db.get(
1158
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='channel_thread_index'",
1159
+ )
1160
+ ) {
1161
+ const removed = await this.db.run(
1162
+ 'DELETE FROM channel_thread_index WHERE session_id=?',
1163
+ [sessionId],
1164
+ );
1165
+ if (removed.changes) await this.host.changed();
1166
+ }
1167
+ const unbound = await this.db.run(
1168
+ 'DELETE FROM channel_session_bindings WHERE session_id=?',
1169
+ [sessionId],
1170
+ );
1171
+ if (unbound.changes) await this.host.changed();
330
1172
  const released = await this.db.run(
331
1173
  'DELETE FROM channel_sessions WHERE session_id = ?',
332
1174
  [sessionId],
@@ -367,6 +1209,15 @@ export class ChannelTurns {
367
1209
  )`,
368
1210
  [before, CHANNEL_PRUNE_BATCH],
369
1211
  );
1212
+ let controlsPruned = 0;
1213
+ for (const table of ['channel_controls', 'channel_cancellations']) {
1214
+ const pruned = await this.db.run(
1215
+ `DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM ${table} WHERE created_at<? LIMIT ?)`,
1216
+ [before, CHANNEL_PRUNE_BATCH],
1217
+ );
1218
+ controlsPruned += pruned.changes;
1219
+ }
1220
+ if (controlsPruned) await this.host.changed();
370
1221
  const tombstones = await this.host.pruneTombstones(
371
1222
  before,
372
1223
  CHANNEL_PRUNE_BATCH,
@@ -383,6 +1234,7 @@ export class ChannelTurns {
383
1234
  const owedAt = typeof owed?.at === 'number' ? owed.at : null;
384
1235
  if (owedAt !== null && owedAt <= now) this.kick();
385
1236
  if (
1237
+ controlsPruned >= CHANNEL_PRUNE_BATCH ||
386
1238
  receipts.changes >= CHANNEL_PRUNE_BATCH ||
387
1239
  tombstones >= CHANNEL_PRUNE_BATCH
388
1240
  )
@@ -407,6 +1259,280 @@ export class ChannelTurns {
407
1259
  return binding?.session_id ?? null;
408
1260
  }
409
1261
 
1262
+ private routingEnabled(input: ChannelTurnInput): boolean {
1263
+ return Boolean(
1264
+ this.routing &&
1265
+ this.routing.mode !== 'off' &&
1266
+ (!this.routing.bindings.length ||
1267
+ this.routing.bindings.includes(input.bindingId)),
1268
+ );
1269
+ }
1270
+
1271
+ private async routingState(
1272
+ bindingId: string,
1273
+ ): Promise<{ generation: number; force_next: number }> {
1274
+ return (
1275
+ (await this.db.get<{ generation: number; force_next: number }>(
1276
+ 'SELECT generation,force_next FROM channel_sessions WHERE binding_id=?',
1277
+ [bindingId],
1278
+ )) ?? { generation: 0, force_next: 0 }
1279
+ );
1280
+ }
1281
+
1282
+ private async indexThread(
1283
+ input: ChannelTurnInput,
1284
+ sessionId: string,
1285
+ runId: string,
1286
+ at = this.now(),
1287
+ ): Promise<void> {
1288
+ await this.db.run(
1289
+ `INSERT INTO channel_thread_index
1290
+ (binding_id,binding_revision,session_id,topic,recent_user,recent_reply,last_run_id,updated_at)
1291
+ VALUES(?,?,?,?,?,'',?,?) ON CONFLICT(binding_id,binding_revision,session_id)
1292
+ DO UPDATE SET recent_user=excluded.recent_user,last_run_id=excluded.last_run_id,updated_at=excluded.updated_at`,
1293
+ [
1294
+ input.bindingId,
1295
+ input.bindingRevision,
1296
+ sessionId,
1297
+ input.message.slice(0, 300),
1298
+ input.message.slice(0, 800),
1299
+ runId,
1300
+ at,
1301
+ ],
1302
+ );
1303
+ await this.db.run(
1304
+ `DELETE FROM channel_thread_index WHERE binding_id=? AND rowid NOT IN
1305
+ (SELECT rowid FROM channel_thread_index WHERE binding_id=? ORDER BY updated_at DESC,rowid DESC LIMIT ?)`,
1306
+ [input.bindingId, input.bindingId, CHANNEL_THREAD_LIMIT],
1307
+ );
1308
+ await this.host.changed();
1309
+ }
1310
+
1311
+ private async routingCandidates(
1312
+ input: ChannelTurnInput,
1313
+ baseline: string,
1314
+ ): Promise<ChannelThreadCandidate[]> {
1315
+ const routing = this.routing;
1316
+ if (!routing) return [];
1317
+ // Index the receipts written since the binding's last read: all of them
1318
+ // when an operator first enables routing, then the ones written while
1319
+ // routing was off (routed turns index themselves as they start). The
1320
+ // canonical stored request supplies revision isolation for older receipts.
1321
+ const seeded = await this.db.get<{ seeded_rowid: number }>(
1322
+ 'SELECT seeded_rowid FROM channel_thread_seeded WHERE binding_id=? AND binding_revision=?',
1323
+ [input.bindingId, input.bindingRevision],
1324
+ );
1325
+ const after = Number(seeded?.seeded_rowid ?? 0);
1326
+ const newest = await this.db.get<{ last: number | null }>(
1327
+ 'SELECT MAX(rowid) AS last FROM channel_requests WHERE binding_id=?',
1328
+ [input.bindingId],
1329
+ );
1330
+ const upTo = Number(newest?.last ?? 0);
1331
+ const prior =
1332
+ upTo > after
1333
+ ? await this.db.exec<{ session_id: string; run_id: string }>(
1334
+ `SELECT session_id,run_id,MAX(rowid) AS last_row FROM channel_requests
1335
+ WHERE binding_id=? AND rowid>? AND session_id IS NOT NULL GROUP BY session_id
1336
+ ORDER BY last_row DESC LIMIT ?`,
1337
+ [input.bindingId, after, CHANNEL_THREAD_LIMIT],
1338
+ )
1339
+ : [];
1340
+ for (const row of prior.reverse()) {
1341
+ const indexed = await this.db.get<{ last_run_id: string }>(
1342
+ 'SELECT last_run_id FROM channel_thread_index WHERE binding_id=? AND binding_revision=? AND session_id=?',
1343
+ [input.bindingId, input.bindingRevision, row.session_id],
1344
+ );
1345
+ if (indexed?.last_run_id === row.run_id) continue;
1346
+ const run = await this.host.getRun(row.run_id);
1347
+ if (!run) continue;
1348
+ const request = this.host.requestOf(run);
1349
+ if (request.channel?.routingClarification) continue;
1350
+ if (
1351
+ request.identity.channel?.bindingRevision !== input.bindingRevision ||
1352
+ request.channel?.bindingId !== input.bindingId
1353
+ )
1354
+ continue;
1355
+ await this.indexThread(
1356
+ { ...input, message: request.message },
1357
+ row.session_id,
1358
+ row.run_id,
1359
+ Date.parse(run.updatedAt),
1360
+ );
1361
+ }
1362
+ if (upTo > after) {
1363
+ await this.db.run(
1364
+ `INSERT INTO channel_thread_seeded(binding_id,binding_revision,seeded_rowid) VALUES(?,?,?)
1365
+ ON CONFLICT(binding_id,binding_revision) DO UPDATE SET seeded_rowid=excluded.seeded_rowid`,
1366
+ [input.bindingId, input.bindingRevision, upTo],
1367
+ );
1368
+ await this.host.changed();
1369
+ }
1370
+ const indexed = await this.db.exec<{
1371
+ session_id: string;
1372
+ topic: string;
1373
+ recent_user: string;
1374
+ recent_reply: string;
1375
+ last_run_id: string;
1376
+ }>(
1377
+ 'SELECT * FROM channel_thread_index WHERE binding_id=? AND binding_revision=? ORDER BY updated_at DESC,rowid DESC LIMIT ?',
1378
+ [input.bindingId, input.bindingRevision, CHANNEL_THREAD_LIMIT],
1379
+ );
1380
+ const candidates: ChannelThreadCandidate[] = [];
1381
+ for (const row of indexed) {
1382
+ if (!(await routing.ownsSession(row.session_id))) continue;
1383
+ const run = await this.host.getRun(row.last_run_id);
1384
+ candidates.push({
1385
+ sessionId: row.session_id,
1386
+ topic: row.topic,
1387
+ recentUser: row.recent_user,
1388
+ recentReply:
1389
+ run?.status === 'finished'
1390
+ ? (run.partialText ?? '').slice(0, 800)
1391
+ : row.recent_reply,
1392
+ status: run?.status ?? 'history',
1393
+ });
1394
+ }
1395
+ return selectChannelCandidates(candidates, input.message, baseline);
1396
+ }
1397
+
1398
+ /**
1399
+ * The request's route: the one stored on its receipt, else (routing on
1400
+ * for the binding) a new one, stored before anything runs. `storedJson`
1401
+ * is the receipt's assignment as admission read it, so a turn the router
1402
+ * does not handle (the default) reads nothing more, while an assigned
1403
+ * request keeps its destination across a flag change.
1404
+ */
1405
+ private async assignRoute(
1406
+ input: ChannelTurnInput,
1407
+ baseline: string,
1408
+ storedJson: string | null,
1409
+ ): Promise<ChannelRouteAssignment | undefined> {
1410
+ const routing = this.routing;
1411
+ if (!this.routingEnabled(input) || !routing || routing.mode === 'off')
1412
+ return storedJson
1413
+ ? readStored(routeAssignmentSchema, storedJson, 'route assignment')
1414
+ : undefined;
1415
+ // Routed turns of a binding run one at a time (`submit`), so the
1416
+ // receipt read here is current.
1417
+ const receipt = await this.receipt(input);
1418
+ if (receipt?.routing_json)
1419
+ return readStored(
1420
+ routeAssignmentSchema,
1421
+ receipt.routing_json,
1422
+ 'route assignment',
1423
+ );
1424
+ const mode = routing.mode;
1425
+ const state = await this.routingState(input.bindingId);
1426
+ const assignment: ChannelRouteAssignment = {
1427
+ mode,
1428
+ baseline,
1429
+ generation: state.generation,
1430
+ destination: { kind: 'session', sessionId: baseline },
1431
+ reason: 'first-turn',
1432
+ };
1433
+ const recordObservation = async (observation: ChannelRouteObservation) => {
1434
+ const scoped = { ...observation, mode };
1435
+ await this.db.run(
1436
+ 'UPDATE channel_requests SET routing_observation=? WHERE binding_id=? AND request_id=?',
1437
+ [JSON.stringify(scoped), input.bindingId, input.requestId],
1438
+ );
1439
+ await this.host.changed();
1440
+ routing.observe({ ...scoped, requestId: input.requestId });
1441
+ };
1442
+ let evaluate: (() => Promise<ChannelRouteObservation>) | undefined;
1443
+ if (state.force_next || input.sessionPolicy === 'continue')
1444
+ assignment.reason = 'explicit-control';
1445
+ else if (input.replyTo) {
1446
+ assignment.reason = 'quoted-message';
1447
+ const referenced =
1448
+ input.replyTo.kind === 'known'
1449
+ ? await this.receipt({ ...input, requestId: input.replyTo.requestId })
1450
+ : undefined;
1451
+ const run = referenced
1452
+ ? await this.host.getRun(referenced.run_id)
1453
+ : undefined;
1454
+ const request = run ? this.host.requestOf(run) : undefined;
1455
+ const session = referenced?.session_id;
1456
+ if (
1457
+ session &&
1458
+ request?.identity.channel?.bindingRevision === input.bindingRevision &&
1459
+ (await routing.ownsSession(session)) &&
1460
+ (await this.replyContext(input, session))?.kind === 'resolved'
1461
+ ) {
1462
+ if (mode === 'on')
1463
+ assignment.destination = { kind: 'session', sessionId: session };
1464
+ } else if (mode === 'on')
1465
+ assignment.destination = { kind: 'clarify', text: ambiguousThreadText };
1466
+ } else {
1467
+ const candidates = await this.routingCandidates(input, baseline);
1468
+ if (candidates.length) {
1469
+ const evidence = {
1470
+ message: input.message,
1471
+ currentSessionId: baseline,
1472
+ candidates,
1473
+ };
1474
+ const evidenceHash = await channelRequestHash(
1475
+ JSON.stringify(channelSessionDecision.prepare(evidence)),
1476
+ );
1477
+ const evaluateCandidates =
1478
+ async (): Promise<ChannelRouteObservation> => {
1479
+ const started = this.now();
1480
+ try {
1481
+ const evaluation = await routing.evaluator.evaluate(
1482
+ channelSessionDecision,
1483
+ evidence,
1484
+ );
1485
+ return {
1486
+ ...decideChannelRoute(
1487
+ evaluation,
1488
+ candidates,
1489
+ routing.minProbability,
1490
+ ),
1491
+ evidenceHash,
1492
+ latencyMs: this.now() - started,
1493
+ };
1494
+ } catch {
1495
+ // A classifier outage (error, timeout, unusable answer) must
1496
+ // not hold the user's message: it stays in the current
1497
+ // conversation, as with routing off.
1498
+ return {
1499
+ mode,
1500
+ reason: 'provider-unavailable',
1501
+ evidenceHash,
1502
+ latencyMs: this.now() - started,
1503
+ destination: { kind: 'session', sessionId: baseline },
1504
+ };
1505
+ }
1506
+ };
1507
+ evaluate = evaluateCandidates;
1508
+ if (mode === 'on') {
1509
+ const observation = await evaluateCandidates();
1510
+ assignment.reason = observation.reason;
1511
+ assignment.destination = observation.destination ?? {
1512
+ kind: 'clarify',
1513
+ text: ambiguousThreadText,
1514
+ };
1515
+ await recordObservation(observation);
1516
+ } else assignment.reason = 'shadow';
1517
+ }
1518
+ }
1519
+ await this.serialize(async () => {
1520
+ await this.assertNotCancelled(input);
1521
+ await this.db.run(
1522
+ 'UPDATE channel_requests SET routing_json=? WHERE binding_id=? AND request_id=? AND routing_json IS NULL',
1523
+ [JSON.stringify(assignment), input.bindingId, input.requestId],
1524
+ );
1525
+ await this.host.changed();
1526
+ });
1527
+ if (mode === 'shadow' && evaluate)
1528
+ this.host.keepAlive(
1529
+ evaluate()
1530
+ .then(recordObservation)
1531
+ .catch(() => undefined),
1532
+ );
1533
+ return assignment;
1534
+ }
1535
+
410
1536
  /** Step 1 (under the lock): the receipt, and the run if there is one. */
411
1537
  private async receive(
412
1538
  input: ChannelTurnInput,
@@ -414,6 +1540,24 @@ export class ChannelTurns {
414
1540
  runId: string,
415
1541
  ): Promise<Received> {
416
1542
  await this.setup();
1543
+ // A request that has its run is answered from it, also once a stop
1544
+ // named it: a stop that arrived after the run finished must not hide
1545
+ // the reply the user is owed.
1546
+ const existing = await this.receipt(input);
1547
+ if (existing?.session_id && existing.request_hash === requestHash) {
1548
+ const record = await this.host.getRun(runId);
1549
+ if (record)
1550
+ return {
1551
+ kind: 'run',
1552
+ response: await this.respond(
1553
+ input,
1554
+ existing,
1555
+ existing.session_id,
1556
+ record,
1557
+ ),
1558
+ };
1559
+ }
1560
+ await this.assertNotCancelled(input);
417
1561
  // Checked before a receipt is written: the receipt of a pruned run is
418
1562
  // gone (run-store.ts), and a new one must not take its place.
419
1563
  if (await this.host.wasPruned(runId))
@@ -449,10 +1593,20 @@ export class ChannelTurns {
449
1593
  record,
450
1594
  ),
451
1595
  };
452
- return { kind: 'new', sessionId: receipt.session_id };
1596
+ return {
1597
+ kind: 'new',
1598
+ sessionId: receipt.session_id,
1599
+ routingJson: receipt.routing_json,
1600
+ };
453
1601
  }
454
1602
  const bound = await this.boundSession(input.bindingId);
455
- if (bound && input.sessionId && bound !== input.sessionId) {
1603
+ if (
1604
+ bound &&
1605
+ input.sessionId &&
1606
+ bound !== input.sessionId &&
1607
+ !receipt.routing_json &&
1608
+ !(this.routingEnabled(input) && this.routing?.mode === 'on')
1609
+ ) {
456
1610
  await this.dropReceipt(runId);
457
1611
  throw new ChannelError(
458
1612
  409,
@@ -460,7 +1614,11 @@ export class ChannelTurns {
460
1614
  'session_conflict',
461
1615
  );
462
1616
  }
463
- return { kind: 'new', sessionId: bound ?? input.sessionId ?? null };
1617
+ return {
1618
+ kind: 'new',
1619
+ sessionId: bound ?? input.sessionId ?? null,
1620
+ routingJson: receipt.routing_json,
1621
+ };
464
1622
  }
465
1623
 
466
1624
  /**
@@ -472,14 +1630,39 @@ export class ChannelTurns {
472
1630
  identity: TurnIdentity,
473
1631
  input: ChannelTurnInput,
474
1632
  sessionId: string | null,
475
- ): Promise<string> {
1633
+ storedRouting: string | null,
1634
+ ): Promise<PreparedChannelTurn> {
476
1635
  await this.host.requireDelegation(identity);
477
1636
  const session =
478
1637
  sessionId ??
479
1638
  (await this.boundSession(input.bindingId)) ??
480
1639
  (await this.openSession(identity, input));
481
1640
  await this.host.assertSession(identity, session);
482
- return session;
1641
+ const routing = await this.assignRoute(input, session, storedRouting);
1642
+ if (!routing) return { sessionId: session };
1643
+ const destination = routing.destination;
1644
+ const selected =
1645
+ destination.kind === 'new'
1646
+ ? ((await this.receipt(input))?.session_id ??
1647
+ (await this.host.createSession(
1648
+ identity,
1649
+ `channel-route-new-${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`,
1650
+ )))
1651
+ : destination.kind === 'session'
1652
+ ? destination.sessionId
1653
+ : routing.baseline;
1654
+ try {
1655
+ await this.host.assertSession(identity, selected);
1656
+ } catch (error) {
1657
+ if (error instanceof StaleSessionError)
1658
+ throw new ChannelError(
1659
+ 404,
1660
+ 'The selected conversation no longer exists; send a new message',
1661
+ 'session_not_found',
1662
+ );
1663
+ throw error;
1664
+ }
1665
+ return { sessionId: selected, routing };
483
1666
  }
484
1667
 
485
1668
  /**
@@ -516,13 +1699,16 @@ export class ChannelTurns {
516
1699
  private async refuse(runId: string): Promise<boolean> {
517
1700
  await this.setup();
518
1701
  if (await this.host.getRun(runId)) return true;
519
- await this.dropReceipt(runId);
1702
+ await this.dropReceipt(runId, true);
520
1703
  return false;
521
1704
  }
522
1705
 
523
- private async dropReceipt(runId: string): Promise<void> {
1706
+ private async dropReceipt(
1707
+ runId: string,
1708
+ preserveRouting = false,
1709
+ ): Promise<void> {
524
1710
  const dropped = await this.db.run(
525
- 'DELETE FROM channel_requests WHERE run_id = ?',
1711
+ `DELETE FROM channel_requests WHERE run_id = ?${preserveRouting ? ' AND routing_json IS NULL' : ''}`,
526
1712
  [runId],
527
1713
  );
528
1714
  if (dropped.changes > 0) await this.host.changed();
@@ -568,9 +1754,12 @@ export class ChannelTurns {
568
1754
  input: ChannelTurnInput,
569
1755
  requestHash: string,
570
1756
  runId: string,
571
- sessionId: string,
1757
+ prepared: PreparedChannelTurn,
572
1758
  ): Promise<ChannelTurnResponse> {
1759
+ const { sessionId } = prepared;
1760
+ let routing = prepared.routing;
573
1761
  await this.setup();
1762
+ await this.assertNotCancelled(input);
574
1763
  let receipt = await this.receipt(input);
575
1764
  if (!receipt) {
576
1765
  // A concurrent copy of this request was refused and dropped the
@@ -589,7 +1778,29 @@ export class ChannelTurns {
589
1778
  'request_conflict',
590
1779
  );
591
1780
  let session = receipt.session_id ?? sessionId;
592
- if (!receipt.session_id) {
1781
+ if (
1782
+ !receipt.session_id &&
1783
+ routing?.mode === 'on' &&
1784
+ (await this.routingState(input.bindingId)).generation !==
1785
+ routing.generation
1786
+ ) {
1787
+ // An explicit /new or /resume completed while this request was being
1788
+ // classified: the user's choice wins. The request runs as with
1789
+ // routing off, in the session the control bound.
1790
+ await this.db.run(
1791
+ 'UPDATE channel_requests SET routing_json=NULL WHERE run_id=?',
1792
+ [runId],
1793
+ );
1794
+ await this.host.changed();
1795
+ routing = undefined;
1796
+ session = (await this.boundSession(input.bindingId)) ?? sessionId;
1797
+ } else if (!receipt.session_id && routing?.mode === 'on') {
1798
+ await this.db.run(
1799
+ 'INSERT INTO channel_sessions(binding_id,session_id) VALUES(?,?) ON CONFLICT(binding_id) DO UPDATE SET session_id=excluded.session_id,force_next=0',
1800
+ [input.bindingId, sessionId],
1801
+ );
1802
+ } else if (!receipt.session_id) {
1803
+ // Routing off, or in shadow (which never switches the session).
593
1804
  await this.db.run(
594
1805
  'INSERT OR IGNORE INTO channel_sessions (binding_id, session_id) VALUES (?, ?)',
595
1806
  [input.bindingId, sessionId],
@@ -630,6 +1841,14 @@ export class ChannelTurns {
630
1841
  [session, runId],
631
1842
  );
632
1843
  await this.host.changed();
1844
+ await this.recordSessionBinding(input, session);
1845
+ await this.db.run(
1846
+ 'UPDATE channel_sessions SET force_next=0 WHERE binding_id=? AND force_next<>0',
1847
+ [input.bindingId],
1848
+ );
1849
+ if (this.routingEnabled(input) && routing?.destination.kind !== 'clarify')
1850
+ await this.indexThread(input, session, runId);
1851
+ const replyContext = await this.replyContext(input, session);
633
1852
  const started = await this.host.begin(runId, {
634
1853
  identity,
635
1854
  sessionId: session,
@@ -642,6 +1861,11 @@ export class ChannelTurns {
642
1861
  bindingId: input.bindingId,
643
1862
  bindingRevision: input.bindingRevision,
644
1863
  remoteMessageRef: input.remoteMessageRef,
1864
+ ...(replyContext ? { replyContext } : {}),
1865
+ ...(input.replyTo ? { replyTo: input.replyTo } : {}),
1866
+ ...(routing?.destination.kind === 'clarify'
1867
+ ? { routingClarification: routing.destination.text }
1868
+ : {}),
645
1869
  },
646
1870
  });
647
1871
  this.kick();
@@ -678,6 +1902,7 @@ export class ChannelTurns {
678
1902
  requestId: input.requestId,
679
1903
  runId: record.runId,
680
1904
  sessionId,
1905
+ sessionTitle: await this.titleOf(sessionId),
681
1906
  status: record.status,
682
1907
  ...(record.messageId ? { messageId: record.messageId } : {}),
683
1908
  // `text` is the whole reply as one message for gateways that predate
@@ -747,6 +1972,17 @@ export class ChannelTurns {
747
1972
  // run in that gap. Left as it is; a receipt whose run never began is
748
1973
  // pruned with the others (`housekeeping`).
749
1974
  if (!record) continue;
1975
+ if (
1976
+ this.routing &&
1977
+ this.routing.mode !== 'off' &&
1978
+ record.status === 'finished'
1979
+ ) {
1980
+ const refreshed = await this.db.run(
1981
+ 'UPDATE channel_thread_index SET recent_reply=? WHERE last_run_id=?',
1982
+ [(record.partialText ?? '').slice(0, 800), row.run_id],
1983
+ );
1984
+ if (refreshed.changes > 0) await this.host.changed();
1985
+ }
750
1986
  const request = this.host.requestOf(record);
751
1987
  let author: 'user' | 'oracle' = 'user';
752
1988
  try {