@agent-assistant/proactive 0.1.0 → 0.1.2

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.
@@ -0,0 +1,3 @@
1
+ export type { RoutingHint, SuppressionReason, FollowUpAction, ReminderPolicy, EvidenceEntry, FollowUpEvaluationContext, FollowUpRule, FollowUpDecision, WatchAction, WatchEvaluationContext, WatchRule, WatchTrigger, WatchRuleStatus, WatchRuleLifecycleStatus, WakeUpContext, SchedulerBinding, FollowUpEvidenceSource, ProactiveEngineConfig, ProactiveEngine, } from './types.js';
2
+ export { ProactiveError, RuleNotFoundError, SchedulerBindingError, } from './types.js';
3
+ export { createProactiveEngine, InMemorySchedulerBinding } from './proactive.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { ProactiveError, RuleNotFoundError, SchedulerBindingError, } from './types.js';
2
+ export { createProactiveEngine, InMemorySchedulerBinding } from './proactive.js';
@@ -0,0 +1,15 @@
1
+ import type { ProactiveEngine, ProactiveEngineConfig, SchedulerBinding, WakeUpContext } from './types.js';
2
+ export declare class InMemorySchedulerBinding implements SchedulerBinding {
3
+ readonly pendingWakeUps: Map<string, {
4
+ at: Date;
5
+ context: WakeUpContext;
6
+ }>;
7
+ requestWakeUp(at: Date, context: WakeUpContext): Promise<string>;
8
+ cancelWakeUp(bindingId: string): Promise<void>;
9
+ /**
10
+ * Test helper: manually fire a pending wake-up.
11
+ * Returns the WakeUpContext and removes it from pending.
12
+ */
13
+ trigger(bindingId: string): Promise<WakeUpContext>;
14
+ }
15
+ export declare function createProactiveEngine(config: ProactiveEngineConfig): ProactiveEngine;
@@ -0,0 +1,285 @@
1
+ import { nanoid } from 'nanoid';
2
+ import { RuleNotFoundError, SchedulerBindingError, } from './types.js';
3
+ // ─── Default policy ───────────────────────────────────────────────────────────
4
+ const POLICY_DEFAULTS = {
5
+ maxReminders: 3,
6
+ cooldownMs: 3_600_000, // 1 hour
7
+ suppressWhenActive: true,
8
+ };
9
+ function mergePolicy(base, override) {
10
+ if (!override)
11
+ return base;
12
+ return {
13
+ maxReminders: override.maxReminders ?? base.maxReminders,
14
+ cooldownMs: override.cooldownMs ?? base.cooldownMs,
15
+ suppressWhenActive: override.suppressWhenActive ?? base.suppressWhenActive,
16
+ };
17
+ }
18
+ // ─── Suppression logic ────────────────────────────────────────────────────────
19
+ function applySuppression(context, policy, state, now) {
20
+ // 1. user_active: user became active after the wake-up was scheduled
21
+ if (policy.suppressWhenActive &&
22
+ new Date(context.lastActivityAt) > new Date(context.scheduledAt)) {
23
+ return { suppressed: true, reason: 'user_active' };
24
+ }
25
+ // 2. max_reminders: reminder count for this (sessionId, ruleId) has reached maxReminders
26
+ if (state.reminderCount >= policy.maxReminders) {
27
+ return { suppressed: true, reason: 'max_reminders' };
28
+ }
29
+ // 3. cooldown: not enough time since last reminder
30
+ if (state.lastReminderSentAt !== null &&
31
+ now.getTime() - new Date(state.lastReminderSentAt).getTime() < policy.cooldownMs) {
32
+ return { suppressed: true, reason: 'cooldown' };
33
+ }
34
+ return { suppressed: false };
35
+ }
36
+ // ─── InMemorySchedulerBinding ─────────────────────────────────────────────────
37
+ export class InMemorySchedulerBinding {
38
+ pendingWakeUps = new Map();
39
+ async requestWakeUp(at, context) {
40
+ const bindingId = nanoid();
41
+ this.pendingWakeUps.set(bindingId, { at, context });
42
+ return bindingId;
43
+ }
44
+ async cancelWakeUp(bindingId) {
45
+ this.pendingWakeUps.delete(bindingId);
46
+ }
47
+ /**
48
+ * Test helper: manually fire a pending wake-up.
49
+ * Returns the WakeUpContext and removes it from pending.
50
+ */
51
+ async trigger(bindingId) {
52
+ const entry = this.pendingWakeUps.get(bindingId);
53
+ if (!entry) {
54
+ throw new Error(`No pending wake-up for bindingId: ${bindingId}`);
55
+ }
56
+ this.pendingWakeUps.delete(bindingId);
57
+ return entry.context;
58
+ }
59
+ }
60
+ // ─── ProactiveEngine factory ──────────────────────────────────────────────────
61
+ export function createProactiveEngine(config) {
62
+ const { schedulerBinding, evidenceSource } = config;
63
+ const defaultPolicy = mergePolicy(POLICY_DEFAULTS, config.defaultReminderPolicy);
64
+ // Follow-up state
65
+ const followUpRules = new Map();
66
+ const reminderStates = new Map();
67
+ // Watch rule state
68
+ const watchRuleRecords = new Map();
69
+ // ── Helpers ──────────────────────────────────────────────────────────────────
70
+ function reminderKey(sessionId, ruleId) {
71
+ return `${sessionId}:${ruleId}`;
72
+ }
73
+ function getReminderState(sessionId, ruleId) {
74
+ return (reminderStates.get(reminderKey(sessionId, ruleId)) ?? {
75
+ reminderCount: 0,
76
+ lastReminderSentAt: null,
77
+ });
78
+ }
79
+ async function scheduleWatchWakeUp(record, metadata) {
80
+ const nextAt = new Date(Date.now() + record.rule.intervalMs);
81
+ try {
82
+ const bindingId = await schedulerBinding.requestWakeUp(nextAt, {
83
+ sessionId: metadata?.['sessionId'] ?? '_watch',
84
+ ruleId: record.rule.id,
85
+ scheduledAt: nextAt.toISOString(),
86
+ metadata,
87
+ });
88
+ return bindingId;
89
+ }
90
+ catch (err) {
91
+ throw new SchedulerBindingError(`Failed to schedule wake-up for watch rule: ${record.rule.id}`, err);
92
+ }
93
+ }
94
+ // ── Follow-up rule methods ────────────────────────────────────────────────────
95
+ function registerFollowUpRule(rule) {
96
+ followUpRules.set(rule.id, rule);
97
+ }
98
+ function removeFollowUpRule(ruleId) {
99
+ if (!followUpRules.has(ruleId)) {
100
+ throw new RuleNotFoundError(ruleId, 'followUp');
101
+ }
102
+ followUpRules.delete(ruleId);
103
+ // Clear all reminder state for this ruleId across all sessions
104
+ for (const key of reminderStates.keys()) {
105
+ if (key.endsWith(`:${ruleId}`)) {
106
+ reminderStates.delete(key);
107
+ }
108
+ }
109
+ }
110
+ function listFollowUpRules() {
111
+ return Array.from(followUpRules.values());
112
+ }
113
+ async function evaluateFollowUp(context) {
114
+ // Fetch evidence once for this entire evaluation pass (Decision 3)
115
+ let evidence;
116
+ if (context.evidence !== undefined) {
117
+ evidence = context.evidence;
118
+ }
119
+ else if (evidenceSource) {
120
+ evidence = await evidenceSource.getRecentEntries(context.sessionId);
121
+ }
122
+ else {
123
+ evidence = [];
124
+ }
125
+ const now = new Date();
126
+ const decisions = [];
127
+ for (const rule of followUpRules.values()) {
128
+ const policy = mergePolicy(defaultPolicy, rule.policy);
129
+ const state = getReminderState(context.sessionId, rule.id);
130
+ const suppression = applySuppression(context, policy, state, now);
131
+ const routingHint = rule.routingHint ?? 'cheap';
132
+ if (suppression.suppressed) {
133
+ decisions.push({
134
+ ruleId: rule.id,
135
+ sessionId: context.sessionId,
136
+ action: 'suppress',
137
+ suppressionReason: suppression.reason,
138
+ routingHint,
139
+ messageTemplate: rule.messageTemplate,
140
+ });
141
+ continue;
142
+ }
143
+ // Evaluate condition
144
+ const conditionResult = await rule.condition(context, evidence);
145
+ if (!conditionResult) {
146
+ decisions.push({
147
+ ruleId: rule.id,
148
+ sessionId: context.sessionId,
149
+ action: 'suppress',
150
+ // No suppressionReason when condition returns false
151
+ routingHint,
152
+ messageTemplate: rule.messageTemplate,
153
+ });
154
+ continue;
155
+ }
156
+ // Fire: update reminder state
157
+ const key = reminderKey(context.sessionId, rule.id);
158
+ reminderStates.set(key, {
159
+ reminderCount: state.reminderCount + 1,
160
+ lastReminderSentAt: now.toISOString(),
161
+ });
162
+ decisions.push({
163
+ ruleId: rule.id,
164
+ sessionId: context.sessionId,
165
+ action: 'fire',
166
+ routingHint,
167
+ messageTemplate: rule.messageTemplate,
168
+ });
169
+ }
170
+ return decisions;
171
+ }
172
+ function resetReminderState(sessionId, ruleId) {
173
+ if (ruleId !== undefined) {
174
+ reminderStates.delete(reminderKey(sessionId, ruleId));
175
+ }
176
+ else {
177
+ // Clear all reminder state for the given session
178
+ const prefix = `${sessionId}:`;
179
+ for (const key of reminderStates.keys()) {
180
+ if (key.startsWith(prefix)) {
181
+ reminderStates.delete(key);
182
+ }
183
+ }
184
+ }
185
+ }
186
+ // ── Watch rule methods ────────────────────────────────────────────────────────
187
+ async function registerWatchRule(rule) {
188
+ const record = {
189
+ rule,
190
+ status: 'active',
191
+ lastEvaluatedAt: null,
192
+ nextWakeUpBindingId: null,
193
+ };
194
+ watchRuleRecords.set(rule.id, record);
195
+ // Engine owns initial watch scheduling (Decision 5)
196
+ const bindingId = await scheduleWatchWakeUp(record);
197
+ record.nextWakeUpBindingId = bindingId;
198
+ }
199
+ function pauseWatchRule(ruleId) {
200
+ const record = watchRuleRecords.get(ruleId);
201
+ if (!record || record.status === 'cancelled') {
202
+ throw new RuleNotFoundError(ruleId, 'watch');
203
+ }
204
+ record.status = 'paused';
205
+ }
206
+ async function resumeWatchRule(ruleId) {
207
+ const record = watchRuleRecords.get(ruleId);
208
+ if (!record || record.status === 'cancelled') {
209
+ throw new RuleNotFoundError(ruleId, 'watch');
210
+ }
211
+ record.status = 'active';
212
+ // Engine schedules wake-up on resume (Decision 5)
213
+ const bindingId = await scheduleWatchWakeUp(record);
214
+ record.nextWakeUpBindingId = bindingId;
215
+ }
216
+ async function cancelWatchRule(ruleId) {
217
+ const record = watchRuleRecords.get(ruleId);
218
+ if (!record || record.status === 'cancelled') {
219
+ throw new RuleNotFoundError(ruleId, 'watch');
220
+ }
221
+ // Cancel any pending wake-up
222
+ if (record.nextWakeUpBindingId) {
223
+ await schedulerBinding.cancelWakeUp(record.nextWakeUpBindingId);
224
+ record.nextWakeUpBindingId = null;
225
+ }
226
+ record.status = 'cancelled';
227
+ }
228
+ function listWatchRules() {
229
+ return Array.from(watchRuleRecords.values()).map((r) => ({
230
+ rule: r.rule,
231
+ status: r.status,
232
+ lastEvaluatedAt: r.lastEvaluatedAt,
233
+ nextWakeUpBindingId: r.nextWakeUpBindingId,
234
+ }));
235
+ }
236
+ async function evaluateWatchRules(context) {
237
+ // Decision 6: evaluates only the rule identified by context.ruleId
238
+ const record = watchRuleRecords.get(context.ruleId);
239
+ if (!record || record.status === 'cancelled') {
240
+ throw new RuleNotFoundError(context.ruleId, 'watch');
241
+ }
242
+ // Paused rules are skipped (no error, just empty result)
243
+ if (record.status === 'paused') {
244
+ return [];
245
+ }
246
+ const now = new Date();
247
+ const triggers = [];
248
+ const ruleContext = {
249
+ ruleId: record.rule.id,
250
+ scheduledAt: context.scheduledAt,
251
+ metadata: context.metadata,
252
+ };
253
+ const conditionResult = await record.rule.condition(ruleContext);
254
+ record.lastEvaluatedAt = now.toISOString();
255
+ if (conditionResult) {
256
+ triggers.push({
257
+ ruleId: record.rule.id,
258
+ triggeredAt: now.toISOString(),
259
+ action: record.rule.action,
260
+ context: ruleContext,
261
+ });
262
+ }
263
+ // Re-schedule only the evaluated rule (Decision 6)
264
+ if (record.nextWakeUpBindingId) {
265
+ await schedulerBinding.cancelWakeUp(record.nextWakeUpBindingId);
266
+ record.nextWakeUpBindingId = null;
267
+ }
268
+ const newBindingId = await scheduleWatchWakeUp(record, context.metadata);
269
+ record.nextWakeUpBindingId = newBindingId;
270
+ return triggers;
271
+ }
272
+ return {
273
+ registerFollowUpRule,
274
+ removeFollowUpRule,
275
+ listFollowUpRules,
276
+ evaluateFollowUp,
277
+ resetReminderState,
278
+ registerWatchRule,
279
+ pauseWatchRule,
280
+ resumeWatchRule,
281
+ cancelWatchRule,
282
+ listWatchRules,
283
+ evaluateWatchRules,
284
+ };
285
+ }
@@ -0,0 +1,167 @@
1
+ export type RoutingHint = 'cheap' | 'fast' | 'deep';
2
+ export type SuppressionReason = 'user_active' | 'cooldown' | 'max_reminders';
3
+ export type FollowUpAction = 'fire' | 'suppress';
4
+ export interface ReminderPolicy {
5
+ /** Maximum number of reminders to send per (sessionId, ruleId). Default: 3. */
6
+ maxReminders?: number;
7
+ /**
8
+ * Minimum milliseconds between reminders for the same (sessionId, ruleId).
9
+ * Default: 3_600_000 (1 hour).
10
+ */
11
+ cooldownMs?: number;
12
+ /**
13
+ * If true, suppress when the user's last activity is after the wake-up's scheduledAt.
14
+ * Default: true.
15
+ */
16
+ suppressWhenActive?: boolean;
17
+ }
18
+ export interface EvidenceEntry {
19
+ id: string;
20
+ content: string;
21
+ tags: string[];
22
+ createdAt: string;
23
+ metadata?: Record<string, unknown>;
24
+ }
25
+ export interface FollowUpEvaluationContext {
26
+ sessionId: string;
27
+ /** ISO-8601 — when the wake-up was originally scheduled. Used for suppression comparison. */
28
+ scheduledAt: string;
29
+ /** ISO-8601 — the session's last user activity timestamp. */
30
+ lastActivityAt: string;
31
+ /** Optional pre-fetched evidence entries from the evidence source. */
32
+ evidence?: EvidenceEntry[];
33
+ }
34
+ export interface FollowUpRule {
35
+ id: string;
36
+ /**
37
+ * Condition function. Receives the evaluation context and optional evidence.
38
+ * Returns true if the assistant should consider following up.
39
+ */
40
+ condition(ctx: FollowUpEvaluationContext, evidence: EvidenceEntry[]): boolean | Promise<boolean>;
41
+ /** Human-readable description for logging. */
42
+ description?: string;
43
+ /** ReminderPolicy governs suppression. If omitted, defaults are used. */
44
+ policy?: ReminderPolicy;
45
+ /** Routing hint passed through to the caller's routing logic. Defaults to 'cheap'. */
46
+ routingHint?: RoutingHint;
47
+ /** Free-form message template. The caller is responsible for final rendering. */
48
+ messageTemplate?: string;
49
+ }
50
+ export interface FollowUpDecision {
51
+ ruleId: string;
52
+ sessionId: string;
53
+ /** What the engine recommends the caller do. */
54
+ action: FollowUpAction;
55
+ /**
56
+ * suppressionReason is present when action === 'suppress' due to a policy check.
57
+ * - 'user_active': user became active after the wake-up was scheduled
58
+ * - 'cooldown': too soon after the previous reminder
59
+ * - 'max_reminders': rule's maxReminders count has been reached
60
+ * Not set when suppression is due to condition returning false.
61
+ */
62
+ suppressionReason?: SuppressionReason;
63
+ /** Routing hint from the rule. Callers should use this to select a model tier. */
64
+ routingHint: RoutingHint;
65
+ /** Message template from the rule, if provided. Caller renders the final message. */
66
+ messageTemplate?: string;
67
+ }
68
+ export interface WatchAction {
69
+ /** Short descriptor used by the caller to identify what to do. */
70
+ type: string;
71
+ /** Optional payload passed through to the caller unchanged. */
72
+ payload?: Record<string, unknown>;
73
+ }
74
+ export interface WatchEvaluationContext {
75
+ /** The specific rule to evaluate. Always required; wake-ups are per-rule. */
76
+ ruleId: string;
77
+ /** ISO-8601 — when this evaluation was scheduled. */
78
+ scheduledAt: string;
79
+ /** Product-supplied metadata from the original WakeUpContext. */
80
+ metadata?: Record<string, unknown>;
81
+ }
82
+ export interface WatchRule {
83
+ id: string;
84
+ /**
85
+ * Condition function evaluated on each scheduled check.
86
+ * Returns true if the watch rule should trigger an action.
87
+ */
88
+ condition(ctx: WatchEvaluationContext): boolean | Promise<boolean>;
89
+ /**
90
+ * Action descriptor. The engine does not execute actions — it returns WatchTrigger
91
+ * objects that the caller handles.
92
+ */
93
+ action: WatchAction;
94
+ /**
95
+ * Interval in milliseconds between condition checks.
96
+ * The engine requests a new wake-up after every evaluation regardless of trigger result.
97
+ */
98
+ intervalMs: number;
99
+ /** Optional description for logging and observability. */
100
+ description?: string;
101
+ }
102
+ export interface WatchTrigger {
103
+ ruleId: string;
104
+ triggeredAt: string;
105
+ action: WatchAction;
106
+ context: WatchEvaluationContext;
107
+ }
108
+ export type WatchRuleLifecycleStatus = 'active' | 'paused' | 'cancelled';
109
+ export interface WatchRuleStatus {
110
+ rule: WatchRule;
111
+ status: WatchRuleLifecycleStatus;
112
+ lastEvaluatedAt: string | null;
113
+ nextWakeUpBindingId: string | null;
114
+ }
115
+ export interface WakeUpContext {
116
+ sessionId: string;
117
+ ruleId?: string;
118
+ scheduledAt: string;
119
+ metadata?: Record<string, unknown>;
120
+ }
121
+ export interface SchedulerBinding {
122
+ /** Request a wake-up at the given time. Returns a bindingId the engine uses to cancel. */
123
+ requestWakeUp(at: Date, context: WakeUpContext): Promise<string>;
124
+ /** Cancel a previously requested wake-up by its bindingId. No-op if already fired or not found. */
125
+ cancelWakeUp(bindingId: string): Promise<void>;
126
+ }
127
+ export interface FollowUpEvidenceSource {
128
+ getRecentEntries(sessionId: string, options?: {
129
+ limit?: number;
130
+ tags?: string[];
131
+ }): Promise<EvidenceEntry[]>;
132
+ }
133
+ export interface ProactiveEngineConfig {
134
+ /** Required scheduler binding. Wire InMemorySchedulerBinding for tests. */
135
+ schedulerBinding: SchedulerBinding;
136
+ /** Optional evidence source wired to a memory store or other evidence backend. */
137
+ evidenceSource?: FollowUpEvidenceSource;
138
+ /** Default reminder policy applied when a rule does not specify its own policy. */
139
+ defaultReminderPolicy?: ReminderPolicy;
140
+ }
141
+ export interface ProactiveEngine {
142
+ registerFollowUpRule(rule: FollowUpRule): void;
143
+ removeFollowUpRule(ruleId: string): void;
144
+ listFollowUpRules(): FollowUpRule[];
145
+ evaluateFollowUp(context: FollowUpEvaluationContext): Promise<FollowUpDecision[]>;
146
+ resetReminderState(sessionId: string, ruleId?: string): void;
147
+ registerWatchRule(rule: WatchRule): Promise<void>;
148
+ pauseWatchRule(ruleId: string): void;
149
+ resumeWatchRule(ruleId: string): Promise<void>;
150
+ cancelWatchRule(ruleId: string): Promise<void>;
151
+ listWatchRules(): WatchRuleStatus[];
152
+ evaluateWatchRules(context: WatchEvaluationContext): Promise<WatchTrigger[]>;
153
+ }
154
+ export declare class ProactiveError extends Error {
155
+ readonly code: string;
156
+ constructor(message: string, code: string);
157
+ }
158
+ export declare class RuleNotFoundError extends ProactiveError {
159
+ readonly ruleId: string;
160
+ readonly ruleType: 'followUp' | 'watch';
161
+ constructor(ruleId: string, ruleType: 'followUp' | 'watch');
162
+ }
163
+ export declare class SchedulerBindingError extends ProactiveError {
164
+ readonly bindingId?: string;
165
+ readonly cause: unknown;
166
+ constructor(message: string, cause: unknown, bindingId?: string);
167
+ }
package/dist/types.js ADDED
@@ -0,0 +1,33 @@
1
+ // ─── Core scalar types ────────────────────────────────────────────────────────
2
+ // ─── Error classes ────────────────────────────────────────────────────────────
3
+ export class ProactiveError extends Error {
4
+ code;
5
+ constructor(message, code) {
6
+ super(message);
7
+ this.name = 'ProactiveError';
8
+ this.code = code;
9
+ Object.setPrototypeOf(this, new.target.prototype);
10
+ }
11
+ }
12
+ export class RuleNotFoundError extends ProactiveError {
13
+ ruleId;
14
+ ruleType;
15
+ constructor(ruleId, ruleType) {
16
+ super(`Rule not found: ${ruleId} (${ruleType})`, 'RULE_NOT_FOUND');
17
+ this.name = 'RuleNotFoundError';
18
+ this.ruleId = ruleId;
19
+ this.ruleType = ruleType;
20
+ Object.setPrototypeOf(this, new.target.prototype);
21
+ }
22
+ }
23
+ export class SchedulerBindingError extends ProactiveError {
24
+ bindingId;
25
+ cause;
26
+ constructor(message, cause, bindingId) {
27
+ super(message, 'SCHEDULER_BINDING_ERROR');
28
+ this.name = 'SchedulerBindingError';
29
+ this.cause = cause;
30
+ this.bindingId = bindingId;
31
+ Object.setPrototypeOf(this, new.target.prototype);
32
+ }
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-assistant/proactive",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Proactive decision engine for Agent Assistant SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",