@kuralle-agents/engagement 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.
Files changed (51) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +114 -0
  3. package/dist/authoring.d.ts +2 -0
  4. package/dist/authoring.js +3 -0
  5. package/dist/broadcast-ledger.d.ts +10 -0
  6. package/dist/broadcast-ledger.js +25 -0
  7. package/dist/broadcast.d.ts +23 -0
  8. package/dist/broadcast.js +28 -0
  9. package/dist/catalog.d.ts +13 -0
  10. package/dist/catalog.js +109 -0
  11. package/dist/closed-window-recovery.d.ts +3 -0
  12. package/dist/closed-window-recovery.js +31 -0
  13. package/dist/consent.d.ts +11 -0
  14. package/dist/consent.js +75 -0
  15. package/dist/drip.d.ts +26 -0
  16. package/dist/drip.js +94 -0
  17. package/dist/engagement.d.ts +22 -0
  18. package/dist/engagement.js +66 -0
  19. package/dist/index.d.ts +24 -0
  20. package/dist/index.js +23 -0
  21. package/dist/interactive-renderer.d.ts +19 -0
  22. package/dist/interactive-renderer.js +120 -0
  23. package/dist/nodes.d.ts +10 -0
  24. package/dist/nodes.js +15 -0
  25. package/dist/ownership.d.ts +5 -0
  26. package/dist/ownership.js +62 -0
  27. package/dist/policies/instagram.d.ts +7 -0
  28. package/dist/policies/instagram.js +15 -0
  29. package/dist/policies/web.d.ts +3 -0
  30. package/dist/policies/web.js +22 -0
  31. package/dist/policies/whatsapp.d.ts +11 -0
  32. package/dist/policies/whatsapp.js +23 -0
  33. package/dist/policy.d.ts +27 -0
  34. package/dist/policy.js +1 -0
  35. package/dist/render-instagram-interactive.d.ts +14 -0
  36. package/dist/render-instagram-interactive.js +78 -0
  37. package/dist/resolve-inbound-instagram.d.ts +6 -0
  38. package/dist/resolve-inbound-instagram.js +10 -0
  39. package/dist/resolve-inbound-whatsapp.d.ts +7 -0
  40. package/dist/resolve-inbound-whatsapp.js +17 -0
  41. package/dist/scheduler.d.ts +27 -0
  42. package/dist/scheduler.js +36 -0
  43. package/dist/selector.d.ts +3 -0
  44. package/dist/selector.js +45 -0
  45. package/dist/simulator.d.ts +44 -0
  46. package/dist/simulator.js +161 -0
  47. package/dist/strategist-middleware.d.ts +3 -0
  48. package/dist/strategist-middleware.js +19 -0
  49. package/dist/strategist.d.ts +67 -0
  50. package/dist/strategist.js +55 -0
  51. package/package.json +44 -0
package/dist/drip.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { SessionStore } from '@kuralle-agents/core';
2
+ import type { OutboundPipeline, OutboundTemplate, WindowStore } from '@kuralle-agents/messaging';
3
+ import type { Scheduler, SendJob } from './scheduler.js';
4
+ export declare const DRIP_WM_KEY = "__dripCampaign";
5
+ export interface DripStep {
6
+ template: OutboundTemplate;
7
+ delayMs: number;
8
+ }
9
+ export interface DripCampaignState {
10
+ id: string;
11
+ step: number;
12
+ stoppedOnReply?: boolean;
13
+ }
14
+ export interface DripApi {
15
+ scheduleNext(threadId: string, step: DripStep): Promise<string | null>;
16
+ stopOnReply(threadId: string): Promise<void>;
17
+ /** Wire into `createInProcessScheduler({ run: drip.runJob })`. */
18
+ runJob(job: SendJob): Promise<void>;
19
+ }
20
+ export declare function createDrip(opts: {
21
+ scheduler: Scheduler;
22
+ pipeline: OutboundPipeline;
23
+ sessionStore: SessionStore;
24
+ platform: string;
25
+ windowStore?: WindowStore;
26
+ }): DripApi;
package/dist/drip.js ADDED
@@ -0,0 +1,94 @@
1
+ export const DRIP_WM_KEY = '__dripCampaign';
2
+ function loadCampaign(session) {
3
+ const raw = session.workingMemory[DRIP_WM_KEY];
4
+ if (!raw || typeof raw !== 'object')
5
+ return undefined;
6
+ return raw;
7
+ }
8
+ async function loadOrCreateSession(sessionStore, threadId) {
9
+ const existing = await sessionStore.get(threadId);
10
+ if (existing)
11
+ return existing;
12
+ const now = new Date();
13
+ return {
14
+ id: threadId,
15
+ conversationId: threadId,
16
+ channelId: 'api',
17
+ createdAt: now,
18
+ updatedAt: now,
19
+ messages: [],
20
+ workingMemory: {},
21
+ currentAgent: 'main',
22
+ agentStates: {},
23
+ handoffHistory: [],
24
+ metadata: {
25
+ createdAt: now,
26
+ lastActiveAt: now,
27
+ totalTokens: 0,
28
+ totalSteps: 0,
29
+ handoffHistory: [],
30
+ },
31
+ };
32
+ }
33
+ export function createDrip(opts) {
34
+ async function runJob(job) {
35
+ if (job.kind !== 'drip-step')
36
+ return;
37
+ const threadId = job.payload.threadId;
38
+ const template = job.payload.template;
39
+ if (typeof threadId !== 'string' || !template || typeof template !== 'object')
40
+ return;
41
+ const session = await opts.sessionStore.get(threadId);
42
+ if (session) {
43
+ const campaign = loadCampaign(session);
44
+ if (campaign?.stoppedOnReply)
45
+ return;
46
+ }
47
+ const window = opts.windowStore
48
+ ? await opts.windowStore.get(threadId)
49
+ : { open: false, expiresAt: null };
50
+ await opts.pipeline.send({
51
+ threadId,
52
+ platform: opts.platform,
53
+ payload: { kind: 'template', template: template },
54
+ meta: {
55
+ window,
56
+ parts: [],
57
+ sessionId: threadId,
58
+ userId: typeof job.payload.userId === 'string' ? job.payload.userId : undefined,
59
+ },
60
+ });
61
+ }
62
+ return {
63
+ runJob,
64
+ async scheduleNext(threadId, step) {
65
+ const session = await loadOrCreateSession(opts.sessionStore, threadId);
66
+ const campaign = loadCampaign(session);
67
+ if (campaign?.stoppedOnReply)
68
+ return null;
69
+ const nextCampaign = {
70
+ id: campaign?.id ?? threadId,
71
+ step: (campaign?.step ?? 0) + 1,
72
+ stoppedOnReply: campaign?.stoppedOnReply,
73
+ };
74
+ session.workingMemory[DRIP_WM_KEY] = nextCampaign;
75
+ session.updatedAt = new Date();
76
+ await opts.sessionStore.save(session);
77
+ return opts.scheduler.enqueue({
78
+ kind: 'drip-step',
79
+ payload: {
80
+ threadId,
81
+ template: step.template,
82
+ platform: opts.platform,
83
+ },
84
+ }, { delayMs: step.delayMs });
85
+ },
86
+ async stopOnReply(threadId) {
87
+ const session = await loadOrCreateSession(opts.sessionStore, threadId);
88
+ const campaign = loadCampaign(session) ?? { id: threadId, step: 0 };
89
+ session.workingMemory[DRIP_WM_KEY] = { ...campaign, stoppedOnReply: true };
90
+ session.updatedAt = new Date();
91
+ await opts.sessionStore.save(session);
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,22 @@
1
+ import type { ChannelPolicy } from './policy.js';
2
+ import type { ConsentStore, OwnershipStore, WindowStore, InboundResolverPlugin, MessagingRouterConfig, OutboundPipeline } from '@kuralle-agents/messaging';
3
+ import { type BroadcastApi } from './broadcast.js';
4
+ import { type BroadcastLedger } from './broadcast-ledger.js';
5
+ import type { AuditSink } from './strategist.js';
6
+ import type { Scheduler } from './scheduler.js';
7
+ export interface EngagementOptions {
8
+ policies: ChannelPolicy[];
9
+ consent?: ConsentStore;
10
+ ownership?: OwnershipStore;
11
+ audit?: AuditSink;
12
+ scheduler?: Scheduler;
13
+ windowStore?: WindowStore;
14
+ ledger?: BroadcastLedger;
15
+ broadcastPipeline?: OutboundPipeline;
16
+ }
17
+ export type EngagementBridge = Pick<MessagingRouterConfig, 'outbound' | 'inputResolver' | 'windowStore' | 'ownership' | 'consent' | 'onStatus'>;
18
+ export declare function policyInboundResolver(policies: ChannelPolicy[]): InboundResolverPlugin;
19
+ export declare function engagement(opts: EngagementOptions): {
20
+ bridge: EngagementBridge;
21
+ broadcasts: BroadcastApi;
22
+ };
@@ -0,0 +1,66 @@
1
+ import { InMemoryWindowStore } from '@kuralle-agents/messaging';
2
+ import { consentGate } from './consent.js';
3
+ import { ownershipGate } from './ownership.js';
4
+ import { closedWindowRecovery } from './closed-window-recovery.js';
5
+ import { interactiveRenderer } from './interactive-renderer.js';
6
+ import { createBroadcasts } from './broadcast.js';
7
+ import { createInMemoryBroadcastLedger } from './broadcast-ledger.js';
8
+ function alwaysOptedIn() {
9
+ return {
10
+ isOptedIn: async () => true,
11
+ optIn: async () => { },
12
+ optOut: async () => { },
13
+ };
14
+ }
15
+ export function policyInboundResolver(policies) {
16
+ return {
17
+ name: 'policy-inbound',
18
+ async tryResolve(m) {
19
+ const policy = policies.find((p) => p.channel === m.platform);
20
+ if (!policy)
21
+ return undefined;
22
+ return policy.resolveInbound(m);
23
+ },
24
+ };
25
+ }
26
+ function createBroadcastApi(opts) {
27
+ if (!opts.broadcastPipeline) {
28
+ return {
29
+ async send() {
30
+ throw new Error('no broadcast pipeline configured');
31
+ },
32
+ };
33
+ }
34
+ return createBroadcasts({
35
+ pipeline: opts.broadcastPipeline,
36
+ consent: opts.consent,
37
+ ledger: opts.ledger,
38
+ platform: opts.platform,
39
+ });
40
+ }
41
+ export function engagement(opts) {
42
+ const windowStore = opts.windowStore ?? new InMemoryWindowStore();
43
+ const outbound = [];
44
+ if (opts.consent)
45
+ outbound.push(consentGate(opts.consent));
46
+ if (opts.ownership)
47
+ outbound.push(ownershipGate(opts.ownership));
48
+ outbound.push(closedWindowRecovery(opts.policies));
49
+ outbound.push(interactiveRenderer(opts.policies));
50
+ const inputResolver = [policyInboundResolver(opts.policies)];
51
+ const bridge = {
52
+ outbound,
53
+ inputResolver,
54
+ windowStore,
55
+ ownership: opts.ownership,
56
+ consent: opts.consent,
57
+ };
58
+ const ledger = opts.ledger ?? createInMemoryBroadcastLedger();
59
+ const broadcasts = createBroadcastApi({
60
+ broadcastPipeline: opts.broadcastPipeline,
61
+ consent: opts.consent ?? alwaysOptedIn(),
62
+ ledger,
63
+ platform: opts.policies[0]?.channel ?? 'whatsapp',
64
+ });
65
+ return { bridge, broadcasts };
66
+ }
@@ -0,0 +1,24 @@
1
+ export * from './policy.js';
2
+ export { engagement, policyInboundResolver, type EngagementOptions, type EngagementBridge, } from './engagement.js';
3
+ export * from './strategist.js';
4
+ export { strategistMiddleware } from './strategist-middleware.js';
5
+ export { interactiveRenderer, renderChoices } from './interactive-renderer.js';
6
+ export { closedWindowRecovery } from './closed-window-recovery.js';
7
+ export { whatsappPolicy } from './policies/whatsapp.js';
8
+ export { instagramPolicy } from './policies/instagram.js';
9
+ export { resolveInboundWhatsApp } from './resolve-inbound-whatsapp.js';
10
+ export { resolveInboundInstagram } from './resolve-inbound-instagram.js';
11
+ export { renderInstagramInteractive, IG_TITLE_MAX, IG_BUTTON_COUNT_MAX, IG_CAROUSEL_COUNT_MAX, } from './render-instagram-interactive.js';
12
+ export { withChoices } from './authoring.js';
13
+ export { smartSend } from './nodes.js';
14
+ export { whatsappTemplateCatalog, mapTemplateInfoToDescriptor, isApprovedNonPaused, type WhatsAppTemplateCatalogClient, } from './catalog.js';
15
+ export { aiTemplateSelector } from './selector.js';
16
+ export type { OutboundTemplateComponent } from '@kuralle-agents/messaging';
17
+ export { webPolicy } from './policies/web.js';
18
+ export { sessionOwnershipStore, ownershipGate, OWNERSHIP_WM_KEY, } from './ownership.js';
19
+ export { sessionConsentStore, consentGate, CONSENT_WM_KEY } from './consent.js';
20
+ export { createInProcessScheduler, type Scheduler, type SendJob, } from './scheduler.js';
21
+ export { createInMemoryBroadcastLedger, createRedisBroadcastLedger, type BroadcastLedger, } from './broadcast-ledger.js';
22
+ export { createBroadcasts, type Campaign, type BroadcastApi, } from './broadcast.js';
23
+ export { createDrip, DRIP_WM_KEY, type DripStep, type DripCampaignState, type DripApi, } from './drip.js';
24
+ export { createSimulator, type SimChannel, type SimSend, type SimSendKind, type SimInboundInput, type Simulator, type CreateSimulatorOptions, } from './simulator.js';
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ export * from './policy.js';
2
+ export { engagement, policyInboundResolver, } from './engagement.js';
3
+ export * from './strategist.js';
4
+ export { strategistMiddleware } from './strategist-middleware.js';
5
+ export { interactiveRenderer, renderChoices } from './interactive-renderer.js';
6
+ export { closedWindowRecovery } from './closed-window-recovery.js';
7
+ export { whatsappPolicy } from './policies/whatsapp.js';
8
+ export { instagramPolicy } from './policies/instagram.js';
9
+ export { resolveInboundWhatsApp } from './resolve-inbound-whatsapp.js';
10
+ export { resolveInboundInstagram } from './resolve-inbound-instagram.js';
11
+ export { renderInstagramInteractive, IG_TITLE_MAX, IG_BUTTON_COUNT_MAX, IG_CAROUSEL_COUNT_MAX, } from './render-instagram-interactive.js';
12
+ export { withChoices } from './authoring.js';
13
+ export { smartSend } from './nodes.js';
14
+ export { whatsappTemplateCatalog, mapTemplateInfoToDescriptor, isApprovedNonPaused, } from './catalog.js';
15
+ export { aiTemplateSelector } from './selector.js';
16
+ export { webPolicy } from './policies/web.js';
17
+ export { sessionOwnershipStore, ownershipGate, OWNERSHIP_WM_KEY, } from './ownership.js';
18
+ export { sessionConsentStore, consentGate, CONSENT_WM_KEY } from './consent.js';
19
+ export { createInProcessScheduler, } from './scheduler.js';
20
+ export { createInMemoryBroadcastLedger, createRedisBroadcastLedger, } from './broadcast-ledger.js';
21
+ export { createBroadcasts, } from './broadcast.js';
22
+ export { createDrip, DRIP_WM_KEY, } from './drip.js';
23
+ export { createSimulator, } from './simulator.js';
@@ -0,0 +1,19 @@
1
+ import type { ChoiceOption } from '@kuralle-agents/core';
2
+ import type { InteractiveMessage, OutboundMiddleware } from '@kuralle-agents/messaging';
3
+ 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;
19
+ export declare function interactiveRenderer(policies?: ChannelPolicy[]): OutboundMiddleware;
@@ -0,0 +1,120 @@
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
+ }
103
+ function policyFor(policies, platform) {
104
+ return policies.find((p) => p.channel === platform);
105
+ }
106
+ export function interactiveRenderer(policies) {
107
+ return {
108
+ name: 'interactive-renderer',
109
+ async send(req, next) {
110
+ const part = req.meta.parts.find((p) => p.type === 'interactive');
111
+ if (!part)
112
+ return next(req);
113
+ const policy = policies ? policyFor(policies, req.platform) : undefined;
114
+ const interactive = policy
115
+ ? policy.renderInteractive(part.options, part.prompt)
116
+ : renderChoices(part.options, part.prompt);
117
+ return next({ ...req, payload: { kind: 'interactive', interactive } });
118
+ },
119
+ };
120
+ }
@@ -0,0 +1,10 @@
1
+ import type { ActionNode, FlowState, Transition } from '@kuralle-agents/core';
2
+ import type { WindowState } from '@kuralle-agents/messaging';
3
+ import type { SendDecision, SmartSendStrategist } from './strategist.js';
4
+ export declare function smartSend(strategist: SmartSendStrategist, node: {
5
+ id: string;
6
+ message: (s: FlowState) => string;
7
+ intent?: string;
8
+ window?: (s: FlowState) => WindowState;
9
+ next?: (d: SendDecision, s: FlowState) => Transition;
10
+ }): ActionNode;
package/dist/nodes.js ADDED
@@ -0,0 +1,15 @@
1
+ import { action } from '@kuralle-agents/core';
2
+ export function smartSend(strategist, node) {
3
+ return action({
4
+ id: node.id,
5
+ run: async (state) => {
6
+ const text = node.message(state);
7
+ const window = node.window?.(state) ?? {
8
+ open: true,
9
+ expiresAt: new Date(),
10
+ };
11
+ const decision = await strategist.decide({ text, window, intent: node.intent });
12
+ return node.next ? node.next(decision, state) : 'stay';
13
+ },
14
+ });
15
+ }
@@ -0,0 +1,5 @@
1
+ import type { SessionStore } from '@kuralle-agents/core';
2
+ import type { OwnershipStore, OutboundMiddleware } from '@kuralle-agents/messaging';
3
+ export declare const OWNERSHIP_WM_KEY = "__ownership";
4
+ export declare function sessionOwnershipStore(sessionStore: SessionStore): OwnershipStore;
5
+ export declare function ownershipGate(ownership: OwnershipStore): OutboundMiddleware;
@@ -0,0 +1,62 @@
1
+ export const OWNERSHIP_WM_KEY = '__ownership';
2
+ function loadOrCreate(sessionStore, threadId) {
3
+ return sessionStore.get(threadId).then((session) => {
4
+ if (session)
5
+ return session;
6
+ const now = new Date();
7
+ return {
8
+ id: threadId,
9
+ conversationId: threadId,
10
+ channelId: 'api',
11
+ createdAt: now,
12
+ updatedAt: now,
13
+ messages: [],
14
+ workingMemory: {},
15
+ currentAgent: 'main',
16
+ agentStates: {},
17
+ handoffHistory: [],
18
+ metadata: {
19
+ createdAt: now,
20
+ lastActiveAt: now,
21
+ totalTokens: 0,
22
+ totalSteps: 0,
23
+ handoffHistory: [],
24
+ },
25
+ };
26
+ });
27
+ }
28
+ export function sessionOwnershipStore(sessionStore) {
29
+ return {
30
+ async owner(threadId) {
31
+ const session = await sessionStore.get(threadId);
32
+ if (!session)
33
+ return 'bot';
34
+ return session.workingMemory[OWNERSHIP_WM_KEY] === 'human' ? 'human' : 'bot';
35
+ },
36
+ async claim(threadId, by) {
37
+ const session = await loadOrCreate(sessionStore, threadId);
38
+ session.workingMemory[OWNERSHIP_WM_KEY] = by;
39
+ session.updatedAt = new Date();
40
+ await sessionStore.save(session);
41
+ },
42
+ async release(threadId) {
43
+ const session = await sessionStore.get(threadId);
44
+ if (!session)
45
+ return;
46
+ delete session.workingMemory[OWNERSHIP_WM_KEY];
47
+ session.updatedAt = new Date();
48
+ await sessionStore.save(session);
49
+ },
50
+ };
51
+ }
52
+ export function ownershipGate(ownership) {
53
+ return {
54
+ name: 'ownership-gate',
55
+ async send(req, next) {
56
+ if ((await ownership.owner(req.threadId)) === 'human') {
57
+ return { kind: 'suppressed', reason: 'human-owned' };
58
+ }
59
+ return next(req);
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,7 @@
1
+ import type { WindowStore } from '@kuralle-agents/messaging';
2
+ import type { InstagramClient } from '@kuralle-agents/messaging-meta/instagram';
3
+ import type { ChannelPolicy } from '../policy.js';
4
+ export declare function instagramPolicy(opts: {
5
+ client: InstagramClient;
6
+ windowStore: WindowStore;
7
+ }): ChannelPolicy;
@@ -0,0 +1,15 @@
1
+ import { renderInstagramInteractive } from '../render-instagram-interactive.js';
2
+ import { resolveInboundInstagram } from '../resolve-inbound-instagram.js';
3
+ export function instagramPolicy(opts) {
4
+ return {
5
+ channel: 'instagram',
6
+ hasWindow: true,
7
+ async isWindowOpen(threadId) {
8
+ return (await opts.windowStore.get(threadId)).open;
9
+ },
10
+ closedWindow: { kind: 'message-tag', tag: 'HUMAN_AGENT' },
11
+ consentRequired: true,
12
+ renderInteractive: (options, prompt) => renderInstagramInteractive(options, prompt),
13
+ resolveInbound: (m) => resolveInboundInstagram(m),
14
+ };
15
+ }
@@ -0,0 +1,3 @@
1
+ import type { ChannelPolicy } from '../policy.js';
2
+ /** Web/SSE null policy — no window, no consent (RFC §4.12). Proves the abstraction. */
3
+ export declare function webPolicy(): ChannelPolicy;
@@ -0,0 +1,22 @@
1
+ /** Web/SSE null policy — no window, no consent (RFC §4.12). Proves the abstraction. */
2
+ export function webPolicy() {
3
+ return {
4
+ channel: 'web',
5
+ hasWindow: false,
6
+ async isWindowOpen() {
7
+ return true;
8
+ },
9
+ closedWindow: { kind: 'none' },
10
+ consentRequired: false,
11
+ renderInteractive(options, prompt) {
12
+ return {
13
+ type: 'buttons',
14
+ body: prompt,
15
+ action: { type: 'buttons', buttons: options.map((o) => ({ id: o.id, title: o.label })) },
16
+ };
17
+ },
18
+ resolveInbound(m) {
19
+ return { input: m.text ?? '' };
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,11 @@
1
+ import type { WindowStore } from '@kuralle-agents/messaging';
2
+ import type { WhatsAppClient } from '@kuralle-agents/messaging-meta/whatsapp';
3
+ import type { ChannelPolicy } from '../policy.js';
4
+ import { type AuditSink, type TemplateSelector } from '../strategist.js';
5
+ export declare function whatsappPolicy(opts: {
6
+ client: WhatsAppClient;
7
+ selector: TemplateSelector;
8
+ windowStore: WindowStore;
9
+ wabaId: string;
10
+ audit?: AuditSink;
11
+ }): ChannelPolicy;
@@ -0,0 +1,23 @@
1
+ import { whatsappTemplateCatalog } from '../catalog.js';
2
+ import { renderChoices } from '../interactive-renderer.js';
3
+ import { resolveInboundWhatsApp } from '../resolve-inbound-whatsapp.js';
4
+ import { createSmartSendStrategist } from '../strategist.js';
5
+ export function whatsappPolicy(opts) {
6
+ const catalog = whatsappTemplateCatalog({ client: opts.client, wabaId: opts.wabaId });
7
+ const strategist = createSmartSendStrategist({
8
+ catalog,
9
+ selector: opts.selector,
10
+ audit: opts.audit ?? { record() { } },
11
+ });
12
+ return {
13
+ channel: 'whatsapp',
14
+ hasWindow: true,
15
+ async isWindowOpen(threadId) {
16
+ return (await opts.windowStore.get(threadId)).open;
17
+ },
18
+ closedWindow: { kind: 'template', strategist },
19
+ consentRequired: true,
20
+ renderInteractive: (options, prompt) => renderChoices(options, prompt),
21
+ resolveInbound: (m) => resolveInboundWhatsApp(m),
22
+ };
23
+ }
@@ -0,0 +1,27 @@
1
+ import type { InboundMessage, InteractiveMessage } from '@kuralle-agents/messaging';
2
+ import type { ChoiceOption, ResolvedSelection } from '@kuralle-agents/core';
3
+ import type { SmartSendStrategist } from './strategist.js';
4
+ export type { SmartSendStrategist } from './strategist.js';
5
+ export type { ChoiceOption } from '@kuralle-agents/core';
6
+ export type ClosedWindowStrategy = {
7
+ kind: 'template';
8
+ strategist: SmartSendStrategist;
9
+ } | {
10
+ kind: 'message-tag';
11
+ tag: string;
12
+ } | {
13
+ kind: 'none';
14
+ };
15
+ /** The only channel-specific code (RFC §4.12 / REQ-22). */
16
+ export interface ChannelPolicy {
17
+ readonly channel: string;
18
+ readonly hasWindow: boolean;
19
+ isWindowOpen(threadId: string): Promise<boolean>;
20
+ readonly closedWindow: ClosedWindowStrategy;
21
+ readonly consentRequired: boolean;
22
+ renderInteractive(options: ChoiceOption[], prompt: string): InteractiveMessage;
23
+ resolveInbound(m: InboundMessage): {
24
+ input: string;
25
+ selection?: ResolvedSelection;
26
+ };
27
+ }
package/dist/policy.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { ChoiceOption } from '@kuralle-agents/core';
2
+ import type { InteractiveMessage } from '@kuralle-agents/messaging';
3
+ /** Instagram button-template title limit (R-11 / Meta quick-reply parity). */
4
+ export declare const IG_TITLE_MAX = 20;
5
+ /** Instagram button template — max buttons per message. */
6
+ export declare const IG_BUTTON_COUNT_MAX = 3;
7
+ /** Instagram generic-template carousel — max elements. */
8
+ export declare const IG_CAROUSEL_COUNT_MAX = 10;
9
+ /**
10
+ * Renders author choices to a channel-neutral `InteractiveMessage` for Instagram:
11
+ * ≤3 → button template; 4–10 → generic-template carousel via list shape.
12
+ * Quick-replies (≤13) are a future enhancement — not used in this cut.
13
+ */
14
+ export declare function renderInstagramInteractive(options: ChoiceOption[], prompt: string): InteractiveMessage;