@ixo/oracle-runtime-workers 0.26.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.
@@ -2,8 +2,8 @@
2
2
  * Drops a room's prefetched user context (`UserContextFetcher.invalidate`)
3
3
  * after a tool call that changed the user's memory succeeded, so the next
4
4
  * session in the room gathers again instead of reading the pre-write answer
5
- * for the rest of the cache TTL. It never changes the current thread: a
6
- * thread keeps the context it got on first contact.
5
+ * for the rest of the cache TTL. Channel turns adopt the refreshed context
6
+ * on a later turn; other clients keep their first-contact context.
7
7
  *
8
8
  * Applied as a tool middleware to the main agent and every sub-agent; a
9
9
  * failed call (thrown, or an error `ToolMessage`) leaves the cache alone.
@@ -1,8 +1,5 @@
1
1
  import type { BaseMessage } from '@langchain/core/messages';
2
- import {
3
- stripTurnTimeNote,
4
- TURN_TIME_NOTE_KWARG,
5
- } from '../../core/turn-time-note';
2
+ import { userContent } from '../../channels/replies';
6
3
  import { contentToText } from '../../do/transcript';
7
4
 
8
5
  /** How many of the user's own messages count as evidence, and how much of each. */
@@ -18,8 +15,10 @@ export const NEGATION =
18
15
 
19
16
  /**
20
17
  * The user's own words from the last few turns. The runtime's time note
21
- * (`Current time: … 14:05 …`) rides on every message the user sent; it is
22
- * stripped here so its digits can never stand in for an amount.
18
+ * (`Current time: … 14:05 …`) rides on every message the user sent, and a
19
+ * WhatsApp reply carries the quoted message; both are stripped here so
20
+ * neither the note's digits nor an address or amount the user merely quoted
21
+ * can stand in for what they typed.
23
22
  */
24
23
  export function userEvidence(messages: readonly BaseMessage[]): string[] {
25
24
  return messages
@@ -27,9 +26,9 @@ export function userEvidence(messages: readonly BaseMessage[]): string[] {
27
26
  .slice(-EVIDENCE_MESSAGES)
28
27
  .map((m) =>
29
28
  contentToText(
30
- stripTurnTimeNote(
29
+ userContent(
31
30
  m.content,
32
- m.additional_kwargs?.[TURN_TIME_NOTE_KWARG],
31
+ m.additional_kwargs as Record<string, unknown> | undefined,
33
32
  ),
34
33
  ).slice(0, EVIDENCE_MESSAGE_CHARS),
35
34
  );
@@ -23,10 +23,7 @@ import type { BaseMessage } from '@langchain/core/messages';
23
23
  import { createMiddleware, type AgentMiddleware } from 'langchain';
24
24
  import type { Logger, OracleTasksSurface } from '../../plugin-api/types';
25
25
  import { pendingApprovalOf } from '../../tasks/store';
26
- import {
27
- stripTurnTimeNote,
28
- TURN_TIME_NOTE_KWARG,
29
- } from '../../core/turn-time-note';
26
+ import { userContent } from '../../channels/replies';
30
27
 
31
28
  const APPROVE = new Set([
32
29
  'yes',
@@ -151,17 +148,15 @@ export function userDidFromContext(context: unknown): string | undefined {
151
148
 
152
149
  /**
153
150
  * Text of the latest HumanMessage — string content or joined text parts —
154
- * as the user sent it: without the turn time note the runtime puts in front.
151
+ * as the user sent it: without the turn time note the runtime puts in front,
152
+ * and without a quoted WhatsApp message (a quoted "yes" is not an approval).
155
153
  */
156
154
  export function lastHumanText(messages: BaseMessage[]): string | null {
157
155
  for (let i = messages.length - 1; i >= 0; i--) {
158
156
  const message = messages[i];
159
157
  if (!message || message.type !== 'human') continue;
160
158
  return textOfContent(
161
- stripTurnTimeNote(
162
- message.content,
163
- message.additional_kwargs?.[TURN_TIME_NOTE_KWARG],
164
- ),
159
+ userContent(message.content, message.additional_kwargs),
165
160
  );
166
161
  }
167
162
  return null;
package/src/shell/app.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import {
20
20
  ChannelError,
21
21
  ChannelTurnBody,
22
+ ChannelControlBody,
22
23
  channelRequestHash,
23
24
  readChannelBody,
24
25
  } from '../channels/contract';
@@ -247,6 +248,7 @@ export function rateLimitKey(
247
248
  | 'user'
248
249
  | 'poll'
249
250
  | 'channel'
251
+ | 'channel-control'
250
252
  | 'action'
251
253
  | 'artifact'
252
254
  | 'socket'
@@ -455,6 +457,74 @@ export function createShell(
455
457
  );
456
458
  });
457
459
 
460
+ app.post('/channels/control', async (c) => {
461
+ try {
462
+ if (parseChannelServiceDids(c.env.CHANNEL_SERVICE_DID).length === 0)
463
+ throw new ChannelError(
464
+ 503,
465
+ 'Channels are not configured',
466
+ 'not_configured',
467
+ );
468
+ const raw = await readChannelBody(c.req.raw);
469
+ let json: unknown;
470
+ try {
471
+ json = JSON.parse(raw);
472
+ } catch {
473
+ throw new ChannelError(400, 'Invalid JSON request', 'invalid_request');
474
+ }
475
+ const parsed = ChannelControlBody.safeParse(json);
476
+ if (!parsed.success)
477
+ throw new ChannelError(
478
+ 400,
479
+ 'Invalid channel control',
480
+ 'invalid_request',
481
+ );
482
+ const identity = await authenticateChannel(
483
+ c.req.raw.headers,
484
+ raw,
485
+ parsed.data,
486
+ channelAuthConfig(c.env),
487
+ );
488
+ if (
489
+ c.env.RATE_LIMIT &&
490
+ !(
491
+ await c.env.RATE_LIMIT.limit({
492
+ key: rateLimitKey(c.env, 'channel-control', identity.userDid),
493
+ })
494
+ ).success
495
+ )
496
+ throw new ChannelError(429, 'Too many requests', 'rate_limited');
497
+ await assertActiveChannelBindingCached(identity, c.env);
498
+ const outcome = await userStub(c.env, identity.userDid).channelControl(
499
+ identity,
500
+ parsed.data,
501
+ await channelRequestHash(raw),
502
+ );
503
+ return outcome.ok
504
+ ? c.json(outcome.result)
505
+ : c.json(
506
+ { code: outcome.code, message: outcome.message },
507
+ outcome.status,
508
+ );
509
+ } catch (error) {
510
+ if (error instanceof ChannelError)
511
+ return c.json(
512
+ { code: error.code, message: error.message },
513
+ error.status,
514
+ );
515
+ console.error(
516
+ `[channels] control failed: ${describeChannelFailure(error)}`,
517
+ );
518
+ return c.json(
519
+ {
520
+ code: 'unavailable',
521
+ message: 'Channel control is temporarily unavailable',
522
+ },
523
+ 503,
524
+ );
525
+ }
526
+ });
527
+
458
528
  app.use('/api/internal/actions/outcomes', limitBody(64 * 1024));
459
529
  // Auth Hub's signed outcome callbacks: on whenever Hub actions are
460
530
  // configured. The signature is checked against the Hub's own did:web key
@@ -13,6 +13,7 @@
13
13
  * circular-safe variant, typed.
14
14
  */
15
15
  import { TURN_TIME_NOTE_KWARG } from '../core/turn-time-note';
16
+ import { CHANNEL_REPLY_NOTE_KWARG } from '../channels/replies';
16
17
 
17
18
  /**
18
19
  * `additional_kwargs` key of the tool calls a mid-turn summary carries for
@@ -259,6 +260,11 @@ export interface CleanAdditionalKwargs {
259
260
  lc_source?: string;
260
261
  /** The exact time note the turn's content starts with (see core/turn-time-note.ts). */
261
262
  turn_time_note?: string;
263
+ /** The exact quoted-reply note after it (see channels/replies.ts). */
264
+ channel_reply_note?: string;
265
+ /** Where a channel message came from, and what it replied to, as the turn recorded them. */
266
+ 'org.ixo.qi.origin'?: unknown;
267
+ 'org.ixo.qi.reply'?: unknown;
262
268
  /** Tool calls of the turn a mid-turn summary condensed (newest kept, capped). */
263
269
  turn_carry?: unknown[];
264
270
  reasoning?: string;
@@ -361,6 +367,20 @@ export function cleanAdditionalKwargs(
361
367
  const turnTimeNote = additionalKwargs[TURN_TIME_NOTE_KWARG];
362
368
  if (typeof turnTimeNote === 'string')
363
369
  cleaned[TURN_TIME_NOTE_KWARG] = turnTimeNote;
370
+ // Kept for the same reason: without it the quoted text would read as the
371
+ // person's own words in the transcript, in memory indexing and in the
372
+ // evidence a proposal is checked against.
373
+ const replyNote = additionalKwargs[CHANNEL_REPLY_NOTE_KWARG];
374
+ if (typeof replyNote === 'string')
375
+ cleaned[CHANNEL_REPLY_NOTE_KWARG] = replyNote;
376
+ // Channel provenance, read back typed by the transcript (ChannelOrigin,
377
+ // ChannelReplyReference); anything malformed is dropped there.
378
+ for (const key of ['org.ixo.qi.origin', 'org.ixo.qi.reply'] as const)
379
+ if (
380
+ typeof additionalKwargs[key] === 'object' &&
381
+ additionalKwargs[key] !== null
382
+ )
383
+ cleaned[key] = additionalKwargs[key];
364
384
  // Kept so a run resumed after the summary still knows what this turn ran.
365
385
  const carry = cappedTurnCarry(additionalKwargs[TURN_CARRY_KWARG]);
366
386
  if (carry !== undefined) cleaned[TURN_CARRY_KWARG] = carry;
@@ -65,6 +65,9 @@ type SessionRow = {
65
65
  slack_thread_ts: string | null;
66
66
  };
67
67
 
68
+ /** Ids per statement in `summaries`. */
69
+ const SUMMARY_CHUNK = 50;
70
+
68
71
  type SessionRowWithTotal = SessionRow & { total: number };
69
72
 
70
73
  const SESSION_COLUMNS = `session_id, title, last_updated_at, created_at, oracle_name,
@@ -242,6 +245,46 @@ export class SessionsStore {
242
245
  return rooms;
243
246
  }
244
247
 
248
+ /**
249
+ * Title and times of many sessions, without their heavier columns, in
250
+ * statements of at most `SUMMARY_CHUNK` ids (a Durable Object binds at most
251
+ * 100 parameters). Sessions without a row are absent from the map.
252
+ */
253
+ async summaries(
254
+ sessionIds: readonly string[],
255
+ ): Promise<
256
+ Map<
257
+ string,
258
+ { title: string | null; createdAt: string; lastUpdatedAt: string }
259
+ >
260
+ > {
261
+ const found = new Map<
262
+ string,
263
+ { title: string | null; createdAt: string; lastUpdatedAt: string }
264
+ >();
265
+ if (sessionIds.length === 0) return found;
266
+ await this.setup();
267
+ for (let at = 0; at < sessionIds.length; at += SUMMARY_CHUNK) {
268
+ const chunk = sessionIds.slice(at, at + SUMMARY_CHUNK);
269
+ const rows = await this.db.exec<{
270
+ session_id: string;
271
+ title: string | null;
272
+ created_at: string;
273
+ last_updated_at: string;
274
+ }>(
275
+ `SELECT session_id, title, created_at, last_updated_at FROM sessions WHERE session_id IN (${chunk.map(() => '?').join(', ')})`,
276
+ [...chunk],
277
+ );
278
+ for (const row of rows)
279
+ found.set(row.session_id, {
280
+ title: row.title,
281
+ createdAt: row.created_at,
282
+ lastUpdatedAt: row.last_updated_at,
283
+ });
284
+ }
285
+ return found;
286
+ }
287
+
245
288
  async getSession(sessionId: string): Promise<SessionRecord | undefined> {
246
289
  await this.setup();
247
290
  const row = await this.db.get<SessionRow>(