@kuralle-agents/engagement 0.8.0 → 0.9.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 CHANGED
@@ -96,6 +96,8 @@ import { whatsappPolicy, webPolicy, instagramPolicy } from '@kuralle-agents/enga
96
96
  - **`sessionConsentStore` / `consentGate`** — opt-in/opt-out and STOP handling (customer-keyed).
97
97
  - **`sessionOwnershipStore` / `ownershipGate`** — bot vs human thread ownership; human-owned inbound does not run flows.
98
98
  - **`createBroadcasts` / `createDrip`** — campaign ledger idempotency and drip schedules (see exports in `src/index.ts`).
99
+ - **`createOwnershipEscalationHandler` / `resolveEscalation`** — the channel side of `HarnessConfig.escalation`: claim the thread for the human + notify on escalation; release + resume the bot (with the resolution in context) when the human is done.
100
+ - The `Scheduler` contract is owned by `@kuralle-agents/core` (re-exported here) — drips, broadcasts, and runtime wake turns (`RunOptions.wake`) share backends: in-process timers in dev, Cloudflare DO alarms via `@kuralle-agents/cf-agent`.
99
101
 
100
102
  ## Example
101
103
 
@@ -0,0 +1,38 @@
1
+ import type { EscalationHandler, EscalationOutcome, EscalationRequest } from '@kuralle-agents/core';
2
+ import type { OwnershipStore } from '@kuralle-agents/messaging';
3
+ /**
4
+ * Channel-side escalation bridge: when the runtime escalates, claim thread
5
+ * ownership for the human (so `ownershipGate` suppresses bot sends) and
6
+ * notify the human channel; when the human is done, `resolveEscalation`
7
+ * releases ownership and hands the conversation back to the bot with the
8
+ * resolution in context.
9
+ */
10
+ export interface EscalationBridgeOptions {
11
+ ownership: OwnershipStore;
12
+ /** Map the escalation to the channel threadId to claim. Default: `request.sessionId`. */
13
+ threadIdFor?: (request: EscalationRequest) => string;
14
+ /**
15
+ * Notify the human side (inbox, Slack, pager). The returned outcome is
16
+ * surfaced to the runtime; returning nothing defaults to
17
+ * `{ status: 'queued', queueId: sessionId }`.
18
+ */
19
+ notify?: (request: EscalationRequest) => Promise<EscalationOutcome | void>;
20
+ }
21
+ export declare function createOwnershipEscalationHandler(options: EscalationBridgeOptions): EscalationHandler;
22
+ /** The runtime surface `resolveEscalation` needs (satisfied by `Runtime`). */
23
+ export interface EscalationResumable {
24
+ resumeFromEscalation(sessionId: string, opts?: {
25
+ resolutionSummary?: string;
26
+ }): Promise<void>;
27
+ }
28
+ export interface ResolveEscalationOptions {
29
+ runtime: EscalationResumable;
30
+ ownership: OwnershipStore;
31
+ sessionId: string;
32
+ /** Channel threadId that was claimed. Default: `sessionId`. */
33
+ threadId?: string;
34
+ /** What the human did — appended to the conversation so the bot resumes with context. */
35
+ resolutionSummary?: string;
36
+ }
37
+ /** Human is done: release the thread back to the bot and resume the run with context. */
38
+ export declare function resolveEscalation(options: ResolveEscalationOptions): Promise<void>;
@@ -0,0 +1,20 @@
1
+ export function createOwnershipEscalationHandler(options) {
2
+ return async (request) => {
3
+ const threadId = options.threadIdFor?.(request) ?? request.sessionId;
4
+ await options.ownership.claim(threadId, 'human');
5
+ if (options.notify) {
6
+ const outcome = await options.notify(request);
7
+ if (outcome) {
8
+ return outcome;
9
+ }
10
+ }
11
+ return { status: 'queued', queueId: request.sessionId };
12
+ };
13
+ }
14
+ /** Human is done: release the thread back to the bot and resume the run with context. */
15
+ export async function resolveEscalation(options) {
16
+ await options.ownership.release(options.threadId ?? options.sessionId);
17
+ await options.runtime.resumeFromEscalation(options.sessionId, {
18
+ resolutionSummary: options.resolutionSummary,
19
+ });
20
+ }
package/dist/index.d.ts CHANGED
@@ -16,6 +16,7 @@ export { aiTemplateSelector } from './selector.js';
16
16
  export type { OutboundTemplateComponent } from '@kuralle-agents/messaging';
17
17
  export { webPolicy } from './policies/web.js';
18
18
  export { sessionOwnershipStore, ownershipGate, OWNERSHIP_WM_KEY, } from './ownership.js';
19
+ export { createOwnershipEscalationHandler, resolveEscalation, type EscalationBridgeOptions, type EscalationResumable, type ResolveEscalationOptions, } from './escalation.js';
19
20
  export { sessionConsentStore, consentGate, CONSENT_WM_KEY } from './consent.js';
20
21
  export { createInProcessScheduler, type Scheduler, type SendJob, } from './scheduler.js';
21
22
  export { createInMemoryBroadcastLedger, createRedisBroadcastLedger, type BroadcastLedger, } from './broadcast-ledger.js';
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export { whatsappTemplateCatalog, mapTemplateInfoToDescriptor, isApprovedNonPaus
15
15
  export { aiTemplateSelector } from './selector.js';
16
16
  export { webPolicy } from './policies/web.js';
17
17
  export { sessionOwnershipStore, ownershipGate, OWNERSHIP_WM_KEY, } from './ownership.js';
18
+ export { createOwnershipEscalationHandler, resolveEscalation, } from './escalation.js';
18
19
  export { sessionConsentStore, consentGate, CONSENT_WM_KEY } from './consent.js';
19
20
  export { createInProcessScheduler, } from './scheduler.js';
20
21
  export { createInMemoryBroadcastLedger, createRedisBroadcastLedger, } from './broadcast-ledger.js';
@@ -1,19 +1,4 @@
1
- import type { ChoiceOption } from '@kuralle-agents/core';
2
- import type { InteractiveMessage, OutboundMiddleware } from '@kuralle-agents/messaging';
1
+ import type { OutboundMiddleware } from '@kuralle-agents/messaging';
3
2
  import type { ChannelPolicy } from './policy.js';
4
- /** WhatsApp reply-button title limit (R-11). */
5
- export declare const BUTTON_TITLE_MAX = 20;
6
- /** WhatsApp list row title limit (R-11). */
7
- export declare const LIST_ROW_TITLE_MAX = 24;
8
- /** WhatsApp reply buttons per message. */
9
- export declare const BUTTON_COUNT_MAX = 3;
10
- /** WhatsApp list rows per message. */
11
- export declare const LIST_ROW_COUNT_MAX = 10;
12
- /**
13
- * Renders author choices to a channel-neutral `InteractiveMessage`, enforcing
14
- * WhatsApp limits (R-11). URL options map to a single-button reply shape because
15
- * `InteractiveMessage` has no dedicated CTA variant — the sink must interpret
16
- * `ChoiceOption.url` when sending platform CTA messages.
17
- */
18
- export declare function renderChoices(options: ChoiceOption[], prompt: string): InteractiveMessage;
3
+ export { renderChoices, BUTTON_TITLE_MAX, LIST_ROW_TITLE_MAX, BUTTON_COUNT_MAX, LIST_ROW_COUNT_MAX, } from '@kuralle-agents/messaging';
19
4
  export declare function interactiveRenderer(policies?: ChannelPolicy[]): OutboundMiddleware;
@@ -1,105 +1,8 @@
1
- /** WhatsApp reply-button title limit (R-11). */
2
- export const BUTTON_TITLE_MAX = 20;
3
- /** WhatsApp list row title limit (R-11). */
4
- export const LIST_ROW_TITLE_MAX = 24;
5
- /** WhatsApp reply buttons per message. */
6
- export const BUTTON_COUNT_MAX = 3;
7
- /** WhatsApp list rows per message. */
8
- export const LIST_ROW_COUNT_MAX = 10;
9
- function assertButtonTitle(label, optionId) {
10
- if (label.length > BUTTON_TITLE_MAX) {
11
- throw new Error(`interactive: button title for "${optionId}" exceeds ${BUTTON_TITLE_MAX} characters (got ${label.length})`);
12
- }
13
- }
14
- function assertListRowTitle(label, optionId) {
15
- if (label.length > LIST_ROW_TITLE_MAX) {
16
- throw new Error(`interactive: list row title for "${optionId}" exceeds ${LIST_ROW_TITLE_MAX} characters (got ${label.length})`);
17
- }
18
- }
19
- /**
20
- * Renders author choices to a channel-neutral `InteractiveMessage`, enforcing
21
- * WhatsApp limits (R-11). URL options map to a single-button reply shape because
22
- * `InteractiveMessage` has no dedicated CTA variant — the sink must interpret
23
- * `ChoiceOption.url` when sending platform CTA messages.
24
- */
25
- export function renderChoices(options, prompt) {
26
- if (options.length === 0) {
27
- throw new Error('interactive: at least one option is required');
28
- }
29
- const flowOption = options.find((o) => o.flow);
30
- if (flowOption) {
31
- if (options.length > 1) {
32
- throw new Error('interactive: flow messages support exactly one option');
33
- }
34
- const { flowId } = flowOption.flow;
35
- if (!flowOption.flow.cta) {
36
- throw new Error('interactive: flow option requires flow.cta');
37
- }
38
- return {
39
- type: 'flow',
40
- body: prompt,
41
- action: {
42
- type: 'flow',
43
- flowId,
44
- flowToken: flowOption.id,
45
- },
46
- };
47
- }
48
- if (options.some((o) => o.url)) {
49
- const urlOptions = options.filter((o) => o.url);
50
- if (urlOptions.length > BUTTON_COUNT_MAX) {
51
- throw new Error(`interactive: too many URL options (max ${BUTTON_COUNT_MAX} reply buttons)`);
52
- }
53
- for (const o of urlOptions) {
54
- assertButtonTitle(o.label, o.id);
55
- }
56
- return {
57
- type: 'buttons',
58
- body: prompt,
59
- action: {
60
- type: 'buttons',
61
- buttons: urlOptions.map((o) => ({ id: o.id, title: o.label })),
62
- },
63
- };
64
- }
65
- if (options.length > LIST_ROW_COUNT_MAX) {
66
- throw new Error(`interactive: too many options (max ${LIST_ROW_COUNT_MAX} list rows)`);
67
- }
68
- if (options.length <= BUTTON_COUNT_MAX) {
69
- for (const o of options) {
70
- assertButtonTitle(o.label, o.id);
71
- }
72
- return {
73
- type: 'buttons',
74
- body: prompt,
75
- action: {
76
- type: 'buttons',
77
- buttons: options.map((o) => ({ id: o.id, title: o.label })),
78
- },
79
- };
80
- }
81
- for (const o of options) {
82
- assertListRowTitle(o.label, o.id);
83
- }
84
- return {
85
- type: 'list',
86
- body: prompt,
87
- action: {
88
- type: 'list',
89
- button: 'Choose',
90
- sections: [
91
- {
92
- title: 'Options',
93
- rows: options.map((o) => ({
94
- id: o.id,
95
- title: o.label,
96
- description: o.description,
97
- })),
98
- },
99
- ],
100
- },
101
- };
102
- }
1
+ // Canonical ChoiceOption -> InteractiveMessage rendering now lives in
2
+ // @kuralle-agents/messaging (the default stream mapper uses it too);
3
+ // re-exported here for existing engagement consumers.
4
+ export { renderChoices, BUTTON_TITLE_MAX, LIST_ROW_TITLE_MAX, BUTTON_COUNT_MAX, LIST_ROW_COUNT_MAX, } from '@kuralle-agents/messaging';
5
+ import { renderChoices } from '@kuralle-agents/messaging';
103
6
  function policyFor(policies, platform) {
104
7
  return policies.find((p) => p.channel === platform);
105
8
  }
@@ -1,27 +1,12 @@
1
- /** A unit of deferred work (broadcast step / drip step). Shape is engagement-internal. */
2
- export interface SendJob {
3
- kind: string;
4
- payload: Record<string, unknown>;
5
- }
6
- export interface Scheduler {
7
- enqueue(job: SendJob, opts?: {
8
- delayMs?: number;
9
- }): Promise<string>;
10
- cancel(jobId: string): Promise<void>;
11
- }
12
- export type InjectableTimer = {
13
- set(fn: () => void, ms: number): unknown;
14
- clear(handle: unknown): void;
15
- };
16
1
  /**
17
- * Default in-process scheduler (timer-based). For single-process/dev.
18
- * Production adapters (interface-compatible, not implemented here):
19
- * - BullMQ (Redis-backed queue)
20
- * - Google Cloud Tasks
21
- * - cron / system scheduler
22
- * Inject a durable adapter for multi-process / serverless.
2
+ * The scheduler contract is owned by `@kuralle-agents/core` — one interface
3
+ * for engagement send-jobs and runtime wake turns (broadcasts, drips, and
4
+ * proactive agent-initiated messages share backends: in-process timers in
5
+ * dev, DO alarms on Cloudflare, any queue in between). Re-exported here so
6
+ * existing engagement consumers keep their import path.
23
7
  */
24
- export declare function createInProcessScheduler(opts: {
25
- run: (job: SendJob) => void | Promise<void>;
26
- timer?: InjectableTimer;
27
- }): Scheduler;
8
+ import type { ScheduledJob } from '@kuralle-agents/core';
9
+ export { createInProcessScheduler } from '@kuralle-agents/core';
10
+ export type { Scheduler, InjectableTimer } from '@kuralle-agents/core';
11
+ /** A unit of deferred engagement work (broadcast step / drip step). */
12
+ export type SendJob = ScheduledJob;
package/dist/scheduler.js CHANGED
@@ -1,36 +1 @@
1
- const defaultTimer = {
2
- set: (fn, ms) => setTimeout(fn, ms),
3
- clear: (handle) => clearTimeout(handle),
4
- };
5
- /**
6
- * Default in-process scheduler (timer-based). For single-process/dev.
7
- * Production adapters (interface-compatible, not implemented here):
8
- * - BullMQ (Redis-backed queue)
9
- * - Google Cloud Tasks
10
- * - cron / system scheduler
11
- * Inject a durable adapter for multi-process / serverless.
12
- */
13
- export function createInProcessScheduler(opts) {
14
- const timer = opts.timer ?? defaultTimer;
15
- let nextJobId = 0;
16
- const handles = new Map();
17
- return {
18
- async enqueue(job, options) {
19
- const jobId = String(++nextJobId);
20
- const delayMs = options?.delayMs ?? 0;
21
- const handle = timer.set(() => {
22
- handles.delete(jobId);
23
- void opts.run(job);
24
- }, delayMs);
25
- handles.set(jobId, handle);
26
- return jobId;
27
- },
28
- async cancel(jobId) {
29
- const handle = handles.get(jobId);
30
- if (handle === undefined)
31
- return;
32
- timer.clear(handle);
33
- handles.delete(jobId);
34
- },
35
- };
36
- }
1
+ export { createInProcessScheduler } from '@kuralle-agents/core';
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/kuralle/kuralle-agents.git",
7
7
  "directory": "packages/kuralle-engagement"
8
8
  },
9
- "version": "0.8.0",
9
+ "version": "0.9.0",
10
10
  "description": "Channel-agnostic engagement layer for Kuralle agents (window-safe outbound, smart-send, interactive fidelity, handoff, consent, proactive).",
11
11
  "type": "module",
12
12
  "main": "dist/index.js",
@@ -27,9 +27,9 @@
27
27
  "dependencies": {
28
28
  "ai": "^6.0.0",
29
29
  "zod": "^4.0.0",
30
- "@kuralle-agents/core": "0.8.0",
31
- "@kuralle-agents/messaging": "0.8.0",
32
- "@kuralle-agents/messaging-meta": "0.8.0"
30
+ "@kuralle-agents/core": "0.9.0",
31
+ "@kuralle-agents/messaging": "0.9.0",
32
+ "@kuralle-agents/messaging-meta": "0.9.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^5.8.2",