@driftwatch/sdk 0.2.1 → 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Victory Lucky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -138,12 +138,28 @@ const config = DriftWatchConfigSchema.parse({
138
138
  labelled span + the `agent.tool.calls` / `agent.tool.duration` metrics.
139
139
  - `DriftWatchConfigSchema` / `loadDriftWatchConfigFromEnv` — Zod-validated
140
140
  typed config for telemetry, agent, and drift-detection settings.
141
-
142
- Full docs, architecture, and the reference Fastify server that uses this
143
- SDK live in the [workspace repo](https://github.com/codewithveek/drift-watch)
144
- see [`docs/`](https://github.com/codewithveek/drift-watch/tree/main/docs)
145
- for guides and the [root README](https://github.com/codewithveek/drift-watch#readme)
146
- for the full picture.
141
+ - `ApprovalService` / `AutopilotScheduler` / `executeControlAction` — the full
142
+ perceive→reason→act orchestration engine: runs drift detection on a
143
+ schedule, evaluates policies, dispatches notifications, and manages
144
+ human-in-the-loop approvals for control actions (pause/rollback/throttle/
145
+ switch_model). Built entirely on the `StateStore`/`Notifier` interfaces
146
+ below no concrete I/O, so it costs nothing to import.
147
+ - `MemoryStateStore` — the zero-dependency `StateStore` implementation
148
+ (single-process). For multi-process, `RedisStateStore` lives at the isolated
149
+ subpath `@driftwatch/sdk/redis`, with `ioredis` as an optional peer
150
+ dependency — importing the package root never pulls it in.
151
+ - `evaluatePolicies` — the pure function mapping a `DriftReport` + policy
152
+ config to a list of action intents.
153
+
154
+ Concrete Slack/Telegram/webhook notifiers and inbound-webhook signature
155
+ verification are a separate companion package,
156
+ [`@driftwatch/autopilot`](https://www.npmjs.com/package/@driftwatch/autopilot) —
157
+ install it only if you use those channels.
158
+
159
+ Full docs, architecture, and the reference Fastify server that uses this SDK
160
+ are at **[drift-watch-docs.vercel.app](https://drift-watch-docs.vercel.app)**;
161
+ the [root README](https://github.com/codewithveek/drift-watch#readme) has the
162
+ full picture.
147
163
 
148
164
  ## License
149
165
 
@@ -43,7 +43,7 @@ export async function runAgentTask(options) {
43
43
  tools,
44
44
  stopWhen: stopConditions,
45
45
  prompt,
46
- experimental_telemetry: {
46
+ telemetry: {
47
47
  isEnabled: true,
48
48
  functionId: 'agent-run',
49
49
  },
@@ -0,0 +1,24 @@
1
+ import type { ActionType, AgentRuntimeState, DriftSeverity, StateStore } from './types.js';
2
+ export interface ControlActionContext {
3
+ /** Free-text reason recorded to the audit log. */
4
+ reason: string;
5
+ /** Who triggered it (e.g. a Slack user id, "console", "scheduler"). */
6
+ actor?: string;
7
+ /** Which surface triggered it: 'console' | 'slack' | 'telegram' | 'system'. */
8
+ channel?: string;
9
+ severity?: DriftSeverity;
10
+ /** Target model id for switch_model. */
11
+ targetModel?: string;
12
+ }
13
+ export interface ControlActionResult {
14
+ action: ActionType;
15
+ applied: boolean;
16
+ state: AgentRuntimeState;
17
+ }
18
+ /**
19
+ * Apply a single control action to the shared agent state and append an audit
20
+ * record. Returns the resulting state. Notify actions are rejected here — they
21
+ * do not belong on the control path.
22
+ */
23
+ export declare function executeControlAction(store: StateStore, action: ActionType, context: ControlActionContext): Promise<ControlActionResult>;
24
+ //# sourceMappingURL=actions.d.ts.map
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Control-action executor — the "act" half of Loop 2.
3
+ *
4
+ * These functions mutate the *shared* runtime state (via the StateStore) so
5
+ * that every process and the console converge on the same posture. Control
6
+ * actions are only ever executed here: the scheduler enqueues them behind an
7
+ * approval, and the approval service calls back into this module once a human
8
+ * (from the console, Slack, or Telegram) has said yes.
9
+ *
10
+ * Notify actions are NOT handled here — they are safe side effects dispatched
11
+ * directly through the notifiers by the scheduler/approval service.
12
+ */
13
+ import { trace, metrics } from '@opentelemetry/api';
14
+ import { randomUUID } from 'node:crypto';
15
+ const tracer = trace.getTracer('driftwatch');
16
+ /**
17
+ * Lazily created (see instrument.ts for why module-load creation binds to a
18
+ * no-op meter): counts Autopilot model switches so the drift detector can tell
19
+ * an *intentional* switch from unexplained drift, and so a switch is visible on
20
+ * a dashboard. Paired with the `agent.model.switch` span below, which puts the
21
+ * same event on the trace timeline for correlation and for SigNoz MCP.
22
+ */
23
+ let cachedSwitchCounter;
24
+ function getModelSwitchCounter() {
25
+ if (!cachedSwitchCounter) {
26
+ cachedSwitchCounter = metrics
27
+ .getMeter('driftwatch')
28
+ .createCounter('agent.model.switches', {
29
+ description: 'Count of Autopilot-initiated model switches, by from/to model',
30
+ });
31
+ }
32
+ return cachedSwitchCounter;
33
+ }
34
+ /**
35
+ * Emit the observability marker for an applied model switch: a short span (for
36
+ * trace-timeline correlation — you can see the switch right before the behavior
37
+ * change, and SigNoz MCP can explain it) plus a counter increment (the
38
+ * low-cardinality signal the drift detector queries to avoid flagging the
39
+ * intended change as drift). `from` is the model before the switch, `to` after.
40
+ */
41
+ function recordModelSwitchMarker(from, to, reason) {
42
+ const attributes = {
43
+ 'agent.model.from': from ?? 'default',
44
+ 'agent.model.to': to,
45
+ 'agent.control.reason': reason,
46
+ };
47
+ tracer.startSpan('agent.model.switch', { attributes }).end();
48
+ getModelSwitchCounter().add(1, { from_model: from ?? 'default', to_model: to });
49
+ }
50
+ /**
51
+ * Apply a single control action to the shared agent state and append an audit
52
+ * record. Returns the resulting state. Notify actions are rejected here — they
53
+ * do not belong on the control path.
54
+ */
55
+ export async function executeControlAction(store, action, context) {
56
+ const current = await store.getAgentState();
57
+ const next = applyAction(current, action, context);
58
+ const applied = next !== current;
59
+ if (applied) {
60
+ await store.setAgentState(next);
61
+ if (action === 'switch_model') {
62
+ recordModelSwitchMarker(current.activeModel, next.activeModel ?? '', context.reason);
63
+ }
64
+ }
65
+ await store.recordAction(toLogEntry(action, applied, context));
66
+ return { action, applied, state: next };
67
+ }
68
+ /**
69
+ * Pure state transition for a control action. Returns the SAME object when the
70
+ * action is a no-op (e.g. resuming an already-running agent) so callers can
71
+ * detect whether anything changed.
72
+ */
73
+ function applyAction(current, action, context) {
74
+ const now = Date.now();
75
+ const base = { ...current, updatedAt: now, reason: context.reason };
76
+ switch (action) {
77
+ case 'pause_agent':
78
+ if (current.status === 'paused')
79
+ return current;
80
+ return { ...base, status: 'paused' };
81
+ case 'resume_agent':
82
+ if (current.status === 'running')
83
+ return current;
84
+ return { ...base, status: 'running' };
85
+ case 'throttle':
86
+ if (current.status === 'throttled')
87
+ return current;
88
+ return { ...base, status: 'throttled' };
89
+ case 'rollback': {
90
+ // Roll back to the last-known-good version pointer (floor at 1).
91
+ const target = Math.max(1, current.activeVersion - 1);
92
+ if (target === current.activeVersion)
93
+ return current;
94
+ return { ...base, activeVersion: target };
95
+ }
96
+ case 'switch_model': {
97
+ const target = context.targetModel;
98
+ if (!target || target === current.activeModel)
99
+ return current;
100
+ return { ...base, activeModel: target };
101
+ }
102
+ // Notify actions are not control actions and must not reach this path.
103
+ case 'notify_slack':
104
+ case 'notify_telegram':
105
+ case 'notify_webhook':
106
+ throw new Error(`executeControlAction called with notify action: ${action}`);
107
+ default: {
108
+ const exhaustive = action;
109
+ throw new Error(`Unhandled control action: ${String(exhaustive)}`);
110
+ }
111
+ }
112
+ }
113
+ function toLogEntry(action, applied, context) {
114
+ return {
115
+ id: randomUUID(),
116
+ at: Date.now(),
117
+ action,
118
+ category: 'control',
119
+ outcome: applied ? 'executed' : 'skipped_cooldown',
120
+ reason: applied ? context.reason : `${context.reason} (no-op)`,
121
+ actor: context.actor,
122
+ channel: context.channel,
123
+ };
124
+ }
125
+ //# sourceMappingURL=actions.js.map
@@ -0,0 +1,45 @@
1
+ import type { ActionIntent, Approval, StateStore } from './types.js';
2
+ import { type DispatchLogger, type NotifierRegistry } from './notify-dispatch.js';
3
+ export type ApprovalDecision = 'approved' | 'rejected';
4
+ export interface ApprovalServiceOptions {
5
+ store: StateStore;
6
+ notifiers: NotifierRegistry;
7
+ approvalTimeoutMs: number;
8
+ timeoutDecision: ApprovalDecision;
9
+ logger?: DispatchLogger;
10
+ /**
11
+ * Model id an approved `switch_model` action switches the agent to. The
12
+ * target is deployment config ("when you downshift, go here"), not per-drift
13
+ * data, so it lives on the service rather than being threaded through every
14
+ * intent/approval. Empty = `switch_model` is a no-op (nothing to switch to).
15
+ */
16
+ switchModelTo?: string;
17
+ }
18
+ export declare class ApprovalService {
19
+ private readonly store;
20
+ private readonly notifiers;
21
+ private readonly approvalTimeoutMs;
22
+ private readonly timeoutDecision;
23
+ private readonly logger?;
24
+ private readonly switchModelTo?;
25
+ /** Live timeout handles, so we can cancel on resolve and on shutdown. */
26
+ private readonly timers;
27
+ constructor(options: ApprovalServiceOptions);
28
+ /**
29
+ * Create a pending approval for a control action intent, broadcast it to
30
+ * every notifier (with Approve/Reject affordances), and arm the timeout.
31
+ */
32
+ requestApproval(intent: ActionIntent): Promise<Approval>;
33
+ /**
34
+ * Resolve an approval from any channel. Idempotent: only the first caller to
35
+ * win the store's atomic CAS executes the action; later calls return
36
+ * undefined. Returns the resolved approval, or undefined if it was missing
37
+ * or already resolved.
38
+ */
39
+ resolve(id: string, decision: ApprovalDecision, actor: string, channel: string): Promise<Approval | undefined>;
40
+ /** Cancel all pending timers (called on ordered shutdown). */
41
+ stop(): void;
42
+ private armTimeout;
43
+ private clearTimeout;
44
+ }
45
+ //# sourceMappingURL=approval-service.d.ts.map
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Approval service — creates and resolves human-in-the-loop approvals for
3
+ * control actions, and executes the action once approved.
4
+ *
5
+ * Resolution is channel-agnostic: the console, a Slack button, and a Telegram
6
+ * button all call `resolve(...)`, which delegates to the StateStore's ATOMIC
7
+ * resolveApproval (a Redis Lua CAS, or a guarded map write in memory). That
8
+ * atomicity is what makes approvals safe across processes and idempotent under
9
+ * double-clicks — whoever wins the CAS executes the action exactly once.
10
+ *
11
+ * On creation we arm a timeout so a forgotten approval falls back to a safe
12
+ * default (reject, by default) rather than hanging a paused agent forever.
13
+ */
14
+ import { randomUUID } from 'node:crypto';
15
+ import { executeControlAction } from './actions.js';
16
+ import { notifyAll } from './notify-dispatch.js';
17
+ export class ApprovalService {
18
+ store;
19
+ notifiers;
20
+ approvalTimeoutMs;
21
+ timeoutDecision;
22
+ logger;
23
+ switchModelTo;
24
+ /** Live timeout handles, so we can cancel on resolve and on shutdown. */
25
+ timers = new Map();
26
+ constructor(options) {
27
+ this.store = options.store;
28
+ this.notifiers = options.notifiers;
29
+ this.approvalTimeoutMs = options.approvalTimeoutMs;
30
+ this.timeoutDecision = options.timeoutDecision;
31
+ this.logger = options.logger;
32
+ this.switchModelTo = options.switchModelTo;
33
+ }
34
+ /**
35
+ * Create a pending approval for a control action intent, broadcast it to
36
+ * every notifier (with Approve/Reject affordances), and arm the timeout.
37
+ */
38
+ async requestApproval(intent) {
39
+ const now = Date.now();
40
+ const approval = {
41
+ id: randomUUID(),
42
+ action: intent.type,
43
+ severity: intent.severity,
44
+ reasons: intent.reason ? [intent.reason] : [],
45
+ recommendedAction: `Autopilot proposes: ${intent.type}`,
46
+ status: 'pending',
47
+ createdAt: now,
48
+ expiresAt: now + this.approvalTimeoutMs,
49
+ };
50
+ await this.store.createApproval(approval);
51
+ await notifyAll(this.notifiers, {
52
+ title: `Approval needed: ${intent.type}`,
53
+ severity: intent.severity,
54
+ reasons: approval.reasons,
55
+ recommendedAction: approval.recommendedAction,
56
+ approvalId: approval.id,
57
+ action: intent.type,
58
+ }, this.logger);
59
+ this.armTimeout(approval.id);
60
+ return approval;
61
+ }
62
+ /**
63
+ * Resolve an approval from any channel. Idempotent: only the first caller to
64
+ * win the store's atomic CAS executes the action; later calls return
65
+ * undefined. Returns the resolved approval, or undefined if it was missing
66
+ * or already resolved.
67
+ */
68
+ async resolve(id, decision, actor, channel) {
69
+ const resolved = await this.store.resolveApproval(id, decision, actor, channel);
70
+ if (!resolved)
71
+ return undefined;
72
+ this.clearTimeout(id);
73
+ if (decision === 'approved') {
74
+ await executeControlAction(this.store, resolved.action, {
75
+ reason: `approved via ${channel} by ${actor}`,
76
+ actor,
77
+ channel,
78
+ severity: resolved.severity,
79
+ targetModel: this.switchModelTo,
80
+ });
81
+ }
82
+ return resolved;
83
+ }
84
+ /** Cancel all pending timers (called on ordered shutdown). */
85
+ stop() {
86
+ for (const timer of this.timers.values())
87
+ clearTimeout(timer);
88
+ this.timers.clear();
89
+ }
90
+ armTimeout(id) {
91
+ const timer = setTimeout(() => {
92
+ this.timers.delete(id);
93
+ // Resolve with the configured safe default; 'timeout' marks the channel.
94
+ void this.resolve(id, this.timeoutDecision, 'autopilot', 'timeout').catch((error) => this.logger?.error({ error, id }, 'approval timeout resolve failed'));
95
+ }, this.approvalTimeoutMs);
96
+ // Don't keep the event loop alive solely for a pending approval.
97
+ timer.unref?.();
98
+ this.timers.set(id, timer);
99
+ }
100
+ clearTimeout(id) {
101
+ const timer = this.timers.get(id);
102
+ if (timer) {
103
+ clearTimeout(timer);
104
+ this.timers.delete(id);
105
+ }
106
+ }
107
+ }
108
+ //# sourceMappingURL=approval-service.js.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * In-memory StateStore — the zero-dependency default. Perfect for single-
3
+ * process use (a standalone script, a single container); NOT suitable across
4
+ * multiple processes, since each keeps its own state. For multi-process
5
+ * deployments, use `RedisStateStore` from `@driftwatch/sdk/redis` instead.
6
+ */
7
+ import type { ActionLogEntry, AgentRuntimeState, Approval, ApprovalStatus, DriftHistoryEntry, StateStore } from './types.js';
8
+ export declare class MemoryStateStore implements StateStore {
9
+ private agentState;
10
+ private readonly approvals;
11
+ private readonly driftHistory;
12
+ private readonly actionLog;
13
+ private readonly cooldowns;
14
+ private readonly leaderLocks;
15
+ getAgentState(): Promise<AgentRuntimeState>;
16
+ setAgentState(state: AgentRuntimeState): Promise<void>;
17
+ createApproval(approval: Approval): Promise<void>;
18
+ getApproval(id: string): Promise<Approval | undefined>;
19
+ listPendingApprovals(): Promise<Approval[]>;
20
+ resolveApproval(id: string, status: Exclude<ApprovalStatus, 'pending'>, resolvedBy: string, channel: string): Promise<Approval | undefined>;
21
+ recordDriftVerdict(entry: DriftHistoryEntry): Promise<void>;
22
+ listDriftHistory(limit: number): Promise<DriftHistoryEntry[]>;
23
+ recordAction(entry: ActionLogEntry): Promise<void>;
24
+ listActionLog(limit: number): Promise<ActionLogEntry[]>;
25
+ checkAndSetCooldown(key: string, ttlMs: number): Promise<boolean>;
26
+ acquireLeaderLock(key: string, ttlMs: number): Promise<boolean>;
27
+ close(): Promise<void>;
28
+ }
29
+ //# sourceMappingURL=memory-store.d.ts.map
@@ -0,0 +1,84 @@
1
+ const HISTORY_CAP = 500;
2
+ export class MemoryStateStore {
3
+ agentState = {
4
+ status: 'running',
5
+ activeVersion: 1,
6
+ updatedAt: Date.now(),
7
+ };
8
+ approvals = new Map();
9
+ driftHistory = [];
10
+ actionLog = [];
11
+ cooldowns = new Map();
12
+ leaderLocks = new Map();
13
+ async getAgentState() {
14
+ return { ...this.agentState };
15
+ }
16
+ async setAgentState(state) {
17
+ this.agentState = { ...state };
18
+ }
19
+ async createApproval(approval) {
20
+ this.approvals.set(approval.id, { ...approval });
21
+ }
22
+ async getApproval(id) {
23
+ const approval = this.approvals.get(id);
24
+ return approval ? { ...approval } : undefined;
25
+ }
26
+ async listPendingApprovals() {
27
+ return Array.from(this.approvals.values())
28
+ .filter((approval) => approval.status === 'pending')
29
+ .sort((a, b) => a.createdAt - b.createdAt)
30
+ .map((approval) => ({ ...approval }));
31
+ }
32
+ async resolveApproval(id, status, resolvedBy, channel) {
33
+ const approval = this.approvals.get(id);
34
+ if (!approval || approval.status !== 'pending')
35
+ return undefined;
36
+ const resolved = {
37
+ ...approval,
38
+ status,
39
+ resolvedBy,
40
+ channel,
41
+ resolvedAt: Date.now(),
42
+ };
43
+ this.approvals.set(id, resolved);
44
+ return { ...resolved };
45
+ }
46
+ async recordDriftVerdict(entry) {
47
+ this.driftHistory.unshift({ ...entry });
48
+ if (this.driftHistory.length > HISTORY_CAP) {
49
+ this.driftHistory.length = HISTORY_CAP;
50
+ }
51
+ }
52
+ async listDriftHistory(limit) {
53
+ return this.driftHistory.slice(0, limit).map((entry) => ({ ...entry }));
54
+ }
55
+ async recordAction(entry) {
56
+ this.actionLog.unshift({ ...entry });
57
+ if (this.actionLog.length > HISTORY_CAP) {
58
+ this.actionLog.length = HISTORY_CAP;
59
+ }
60
+ }
61
+ async listActionLog(limit) {
62
+ return this.actionLog.slice(0, limit).map((entry) => ({ ...entry }));
63
+ }
64
+ async checkAndSetCooldown(key, ttlMs) {
65
+ const now = Date.now();
66
+ const existingExpiry = this.cooldowns.get(key);
67
+ if (existingExpiry !== undefined && existingExpiry > now)
68
+ return false;
69
+ this.cooldowns.set(key, now + ttlMs);
70
+ return true;
71
+ }
72
+ async acquireLeaderLock(key, ttlMs) {
73
+ const now = Date.now();
74
+ const existingExpiry = this.leaderLocks.get(key);
75
+ if (existingExpiry !== undefined && existingExpiry > now)
76
+ return false;
77
+ this.leaderLocks.set(key, now + ttlMs);
78
+ return true;
79
+ }
80
+ async close() {
81
+ // nothing to release for the in-memory store
82
+ }
83
+ }
84
+ //# sourceMappingURL=memory-store.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Notifier registry + fire-and-forget dispatch. Pure orchestration over the
3
+ * `Notifier` interface — no concrete channel (Slack/Telegram/webhook) lives
4
+ * here. Concrete notifiers are in the companion `@driftwatch/autopilot`
5
+ * package (or bring your own — anything implementing `Notifier`).
6
+ *
7
+ * Dispatch never throws into the caller: a channel failure is logged and
8
+ * swallowed so one broken webhook can't stall the drift loop.
9
+ */
10
+ import type { ActionType, NotificationMessage, Notifier } from './types.js';
11
+ export interface NotifierRegistry {
12
+ slack?: Notifier;
13
+ telegram?: Notifier;
14
+ webhook?: Notifier;
15
+ /** All configured notifiers, in a stable order. */
16
+ list: Notifier[];
17
+ }
18
+ /** Map a notify_* action to its concrete channel notifier, if configured. */
19
+ export declare function notifierForAction(registry: NotifierRegistry, action: ActionType): Notifier | undefined;
20
+ export interface DispatchLogger {
21
+ error: (obj: unknown, msg?: string) => void;
22
+ }
23
+ /** Fire-and-forget send to a single notifier; failures are logged, not thrown. */
24
+ export declare function safeNotify(notifier: Notifier, message: NotificationMessage, logger?: DispatchLogger): Promise<void>;
25
+ /** Broadcast a message to every configured notifier concurrently. */
26
+ export declare function notifyAll(registry: NotifierRegistry, message: NotificationMessage, logger?: DispatchLogger): Promise<void>;
27
+ //# sourceMappingURL=notify-dispatch.d.ts.map
@@ -0,0 +1,27 @@
1
+ /** Map a notify_* action to its concrete channel notifier, if configured. */
2
+ export function notifierForAction(registry, action) {
3
+ switch (action) {
4
+ case 'notify_slack':
5
+ return registry.slack;
6
+ case 'notify_telegram':
7
+ return registry.telegram;
8
+ case 'notify_webhook':
9
+ return registry.webhook;
10
+ default:
11
+ return undefined;
12
+ }
13
+ }
14
+ /** Fire-and-forget send to a single notifier; failures are logged, not thrown. */
15
+ export async function safeNotify(notifier, message, logger) {
16
+ try {
17
+ await notifier.notify(message);
18
+ }
19
+ catch (error) {
20
+ logger?.error({ error, channel: notifier.channel }, 'notification failed');
21
+ }
22
+ }
23
+ /** Broadcast a message to every configured notifier concurrently. */
24
+ export async function notifyAll(registry, message, logger) {
25
+ await Promise.all(registry.list.map((notifier) => safeNotify(notifier, message, logger)));
26
+ }
27
+ //# sourceMappingURL=notify-dispatch.js.map
@@ -0,0 +1,19 @@
1
+ import type { ActionLogEntry, AgentRuntimeState, Approval, ApprovalStatus, DriftHistoryEntry, StateStore } from './types.js';
2
+ export declare class RedisStateStore implements StateStore {
3
+ private readonly redis;
4
+ constructor(redisUrl: string);
5
+ getAgentState(): Promise<AgentRuntimeState>;
6
+ setAgentState(state: AgentRuntimeState): Promise<void>;
7
+ createApproval(approval: Approval): Promise<void>;
8
+ getApproval(id: string): Promise<Approval | undefined>;
9
+ listPendingApprovals(): Promise<Approval[]>;
10
+ resolveApproval(id: string, status: Exclude<ApprovalStatus, 'pending'>, resolvedBy: string, channel: string): Promise<Approval | undefined>;
11
+ recordDriftVerdict(entry: DriftHistoryEntry): Promise<void>;
12
+ listDriftHistory(limit: number): Promise<DriftHistoryEntry[]>;
13
+ recordAction(entry: ActionLogEntry): Promise<void>;
14
+ listActionLog(limit: number): Promise<ActionLogEntry[]>;
15
+ checkAndSetCooldown(key: string, ttlMs: number): Promise<boolean>;
16
+ acquireLeaderLock(key: string, ttlMs: number): Promise<boolean>;
17
+ close(): Promise<void>;
18
+ }
19
+ //# sourceMappingURL=redis-store.d.ts.map
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Redis-backed StateStore for multi-process operation. When several server
3
+ * processes run behind a load balancer, they share one view of agent state,
4
+ * pending approvals, drift history, and the action log — and a leader lock
5
+ * ensures only one process runs each scheduled drift cycle.
6
+ *
7
+ * Not exported from the package root — import from `@driftwatch/sdk/redis`.
8
+ * `ioredis` is an OPTIONAL peer dependency: installing the core SDK never
9
+ * pulls it in, so this is the one place in the SDK a consumer opts into a
10
+ * concrete I/O dependency, and only if they import this subpath.
11
+ */
12
+ import { Redis } from 'ioredis';
13
+ const HISTORY_CAP = 500;
14
+ const KEY = {
15
+ agentState: 'dw:agent:state',
16
+ pendingApprovals: 'dw:approvals:pending',
17
+ approval: (id) => `dw:approval:${id}`,
18
+ driftHistory: 'dw:drift:history',
19
+ actionLog: 'dw:action:log',
20
+ cooldown: (key) => `dw:cooldown:${key}`,
21
+ leader: (key) => `dw:leader:${key}`,
22
+ };
23
+ const DEFAULT_AGENT_STATE = {
24
+ status: 'running',
25
+ activeVersion: 1,
26
+ updatedAt: 0,
27
+ };
28
+ /**
29
+ * Atomically resolve a still-pending approval. Returns the updated JSON, or an
30
+ * empty string when the approval is missing or already resolved. Running this
31
+ * as a single Lua script guarantees two channels (console + Slack + Telegram)
32
+ * can't both "win" the same approval.
33
+ */
34
+ const RESOLVE_APPROVAL_LUA = `
35
+ local raw = redis.call('GET', KEYS[1])
36
+ if not raw then return '' end
37
+ local approval = cjson.decode(raw)
38
+ if approval.status ~= 'pending' then return '' end
39
+ approval.status = ARGV[1]
40
+ approval.resolvedBy = ARGV[2]
41
+ approval.channel = ARGV[3]
42
+ approval.resolvedAt = tonumber(ARGV[4])
43
+ local updated = cjson.encode(approval)
44
+ redis.call('SET', KEYS[1], updated)
45
+ redis.call('SREM', KEYS[2], ARGV[5])
46
+ return updated
47
+ `;
48
+ export class RedisStateStore {
49
+ redis;
50
+ constructor(redisUrl) {
51
+ this.redis = new Redis(redisUrl, { lazyConnect: false, maxRetriesPerRequest: 3 });
52
+ }
53
+ async getAgentState() {
54
+ const raw = await this.redis.get(KEY.agentState);
55
+ if (!raw)
56
+ return { ...DEFAULT_AGENT_STATE, updatedAt: Date.now() };
57
+ return JSON.parse(raw);
58
+ }
59
+ async setAgentState(state) {
60
+ await this.redis.set(KEY.agentState, JSON.stringify(state));
61
+ }
62
+ async createApproval(approval) {
63
+ await this.redis
64
+ .multi()
65
+ .set(KEY.approval(approval.id), JSON.stringify(approval))
66
+ .sadd(KEY.pendingApprovals, approval.id)
67
+ .exec();
68
+ }
69
+ async getApproval(id) {
70
+ const raw = await this.redis.get(KEY.approval(id));
71
+ return raw ? JSON.parse(raw) : undefined;
72
+ }
73
+ async listPendingApprovals() {
74
+ const ids = await this.redis.smembers(KEY.pendingApprovals);
75
+ if (ids.length === 0)
76
+ return [];
77
+ const raws = await this.redis.mget(ids.map((id) => KEY.approval(id)));
78
+ const approvals = [];
79
+ for (const raw of raws) {
80
+ if (!raw)
81
+ continue;
82
+ const approval = JSON.parse(raw);
83
+ if (approval.status === 'pending')
84
+ approvals.push(approval);
85
+ }
86
+ return approvals.sort((a, b) => a.createdAt - b.createdAt);
87
+ }
88
+ async resolveApproval(id, status, resolvedBy, channel) {
89
+ const result = (await this.redis.eval(RESOLVE_APPROVAL_LUA, 2, KEY.approval(id), KEY.pendingApprovals, status, resolvedBy, channel, String(Date.now()), id));
90
+ return result ? JSON.parse(result) : undefined;
91
+ }
92
+ async recordDriftVerdict(entry) {
93
+ await this.redis
94
+ .multi()
95
+ .lpush(KEY.driftHistory, JSON.stringify(entry))
96
+ .ltrim(KEY.driftHistory, 0, HISTORY_CAP - 1)
97
+ .exec();
98
+ }
99
+ async listDriftHistory(limit) {
100
+ const raws = await this.redis.lrange(KEY.driftHistory, 0, limit - 1);
101
+ return raws.map((raw) => JSON.parse(raw));
102
+ }
103
+ async recordAction(entry) {
104
+ await this.redis
105
+ .multi()
106
+ .lpush(KEY.actionLog, JSON.stringify(entry))
107
+ .ltrim(KEY.actionLog, 0, HISTORY_CAP - 1)
108
+ .exec();
109
+ }
110
+ async listActionLog(limit) {
111
+ const raws = await this.redis.lrange(KEY.actionLog, 0, limit - 1);
112
+ return raws.map((raw) => JSON.parse(raw));
113
+ }
114
+ async checkAndSetCooldown(key, ttlMs) {
115
+ const result = await this.redis.set(KEY.cooldown(key), '1', 'PX', ttlMs, 'NX');
116
+ return result === 'OK';
117
+ }
118
+ async acquireLeaderLock(key, ttlMs) {
119
+ const result = await this.redis.set(KEY.leader(key), '1', 'PX', ttlMs, 'NX');
120
+ return result === 'OK';
121
+ }
122
+ async close() {
123
+ await this.redis.quit();
124
+ }
125
+ }
126
+ //# sourceMappingURL=redis-store.js.map
@@ -0,0 +1,48 @@
1
+ import { type DriftReport } from '../drift/detector.js';
2
+ import type { ModelClient } from '../model-client.js';
3
+ import type { DriftDetectionConfig } from '../config/schema.js';
4
+ import { type PolicyConfig } from './policy.js';
5
+ import type { ActionIntent, StateStore } from './types.js';
6
+ import type { ApprovalService } from './approval-service.js';
7
+ import { type NotifierRegistry } from './notify-dispatch.js';
8
+ export interface SchedulerLogger {
9
+ info: (obj: unknown, msg?: string) => void;
10
+ error: (obj: unknown, msg?: string) => void;
11
+ }
12
+ export interface AutopilotSchedulerOptions {
13
+ store: StateStore;
14
+ notifiers: NotifierRegistry;
15
+ approvalService: ApprovalService;
16
+ modelClient: ModelClient;
17
+ policyConfig: PolicyConfig;
18
+ driftDetectionConfig: DriftDetectionConfig;
19
+ isDryRun: boolean;
20
+ scanIntervalMs: number;
21
+ cooldownMs: number;
22
+ logger: SchedulerLogger;
23
+ }
24
+ export declare class AutopilotScheduler {
25
+ private readonly options;
26
+ private timer?;
27
+ private running;
28
+ constructor(options: AutopilotSchedulerOptions);
29
+ /** Begin the periodic loop. The first cycle runs after one interval. */
30
+ start(): void;
31
+ stop(): void;
32
+ /** One guarded tick: only the elected leader runs a cycle. */
33
+ private tick;
34
+ /**
35
+ * Run a single drift cycle end to end. Exposed so the control-plane's
36
+ * POST /drift/scan can trigger a manual run. Returns the report + intents
37
+ * for the caller to surface.
38
+ */
39
+ runCycle(trigger: string): Promise<{
40
+ report: DriftReport;
41
+ intents: ActionIntent[];
42
+ }>;
43
+ private dispatchIntent;
44
+ private dispatchNotify;
45
+ private dispatchControl;
46
+ private recordOutcome;
47
+ }
48
+ //# sourceMappingURL=scheduler.d.ts.map
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Autopilot scheduler — the autonomous perceive→reason→act loop (Loop 2).
3
+ *
4
+ * Every SCAN_INTERVAL_MS the leader process (elected via the StateStore's
5
+ * leader lock so only ONE process acts per cycle in a multi-process
6
+ * deployment):
7
+ * 1. perceive — detectBehavioralDrift over the SigNoz windows (or fixtures),
8
+ * 2. reason — evaluatePolicies maps the report to ActionIntents,
9
+ * 3. act — notify_* intents fire immediately; control intents open an
10
+ * approval. A per-action cooldown dedups storms.
11
+ *
12
+ * In `shadow` mode nothing is executed: intended actions are logged only, which
13
+ * is the safe default and the CI/demo path.
14
+ */
15
+ import { randomUUID } from 'node:crypto';
16
+ import { detectBehavioralDrift } from '../drift/detector.js';
17
+ import { evaluatePolicies } from './policy.js';
18
+ import { notifierForAction, safeNotify, } from './notify-dispatch.js';
19
+ const LEADER_LOCK_KEY = 'scheduler:leader';
20
+ export class AutopilotScheduler {
21
+ options;
22
+ timer;
23
+ running = false;
24
+ constructor(options) {
25
+ this.options = options;
26
+ }
27
+ /** Begin the periodic loop. The first cycle runs after one interval. */
28
+ start() {
29
+ if (this.timer)
30
+ return;
31
+ this.timer = setInterval(() => {
32
+ void this.tick();
33
+ }, this.options.scanIntervalMs);
34
+ this.timer.unref?.();
35
+ this.options.logger.info({ intervalMs: this.options.scanIntervalMs, mode: this.options.policyConfig.mode }, 'autopilot scheduler started');
36
+ }
37
+ stop() {
38
+ if (this.timer) {
39
+ clearInterval(this.timer);
40
+ this.timer = undefined;
41
+ }
42
+ }
43
+ /** One guarded tick: only the elected leader runs a cycle. */
44
+ async tick() {
45
+ if (this.running)
46
+ return; // never overlap cycles within a process
47
+ const isLeader = await this.options.store.acquireLeaderLock(LEADER_LOCK_KEY, this.options.scanIntervalMs);
48
+ if (!isLeader)
49
+ return;
50
+ this.running = true;
51
+ try {
52
+ await this.runCycle('scheduler');
53
+ }
54
+ catch (error) {
55
+ this.options.logger.error({ error }, 'autopilot cycle failed');
56
+ }
57
+ finally {
58
+ this.running = false;
59
+ }
60
+ }
61
+ /**
62
+ * Run a single drift cycle end to end. Exposed so the control-plane's
63
+ * POST /drift/scan can trigger a manual run. Returns the report + intents
64
+ * for the caller to surface.
65
+ */
66
+ async runCycle(trigger) {
67
+ const { store, modelClient, driftDetectionConfig, isDryRun, policyConfig } = this.options;
68
+ const report = await detectBehavioralDrift({
69
+ modelClient,
70
+ isDryRun,
71
+ driftDetectionConfig,
72
+ });
73
+ await store.recordDriftVerdict({
74
+ id: randomUUID(),
75
+ at: Date.now(),
76
+ drift: report.verdict.drift,
77
+ severity: report.verdict.severity,
78
+ reasons: report.verdict.reasons,
79
+ recommendedAction: report.verdict.recommended_action,
80
+ baselineTokenSpend: report.baselineWindowStats.tokenSpend,
81
+ currentTokenSpend: report.currentWindowStats.tokenSpend,
82
+ });
83
+ const intents = evaluatePolicies(report, policyConfig);
84
+ for (const intent of intents) {
85
+ await this.dispatchIntent(intent, trigger);
86
+ }
87
+ return { report, intents };
88
+ }
89
+ async dispatchIntent(intent, trigger) {
90
+ const { store, policyConfig, cooldownMs, logger } = this.options;
91
+ const isShadow = policyConfig.mode === 'shadow';
92
+ // Cooldown dedup — one entry per action type per window.
93
+ const mayProceed = await store.checkAndSetCooldown(`action:${intent.type}`, cooldownMs);
94
+ if (!mayProceed) {
95
+ await this.recordOutcome(intent, 'skipped_cooldown', trigger);
96
+ return;
97
+ }
98
+ if (isShadow) {
99
+ logger.info({ intent, trigger }, 'autopilot shadow: intended action (not executed)');
100
+ await this.recordOutcome(intent, 'shadowed', trigger);
101
+ return;
102
+ }
103
+ if (intent.category === 'notify') {
104
+ await this.dispatchNotify(intent, trigger);
105
+ }
106
+ else {
107
+ await this.dispatchControl(intent, trigger);
108
+ }
109
+ }
110
+ async dispatchNotify(intent, trigger) {
111
+ const { notifiers, logger } = this.options;
112
+ const notifier = notifierForAction(notifiers, intent.type);
113
+ if (!notifier) {
114
+ logger.info({ intent }, 'notify action skipped: channel not configured');
115
+ await this.recordOutcome(intent, 'failed', trigger);
116
+ return;
117
+ }
118
+ await safeNotify(notifier, {
119
+ title: 'Behavioral drift detected',
120
+ severity: intent.severity,
121
+ reasons: intent.reason ? [intent.reason] : [],
122
+ recommendedAction: intent.reason || 'Review the drift report.',
123
+ action: intent.type,
124
+ }, logger);
125
+ await this.recordOutcome(intent, 'executed', trigger);
126
+ }
127
+ async dispatchControl(intent, trigger) {
128
+ await this.options.approvalService.requestApproval(intent);
129
+ await this.recordOutcome(intent, 'pending_approval', trigger);
130
+ }
131
+ async recordOutcome(intent, outcome, trigger) {
132
+ await this.options.store.recordAction({
133
+ id: randomUUID(),
134
+ at: Date.now(),
135
+ action: intent.type,
136
+ category: intent.category,
137
+ outcome,
138
+ reason: intent.reason,
139
+ actor: 'autopilot',
140
+ channel: trigger,
141
+ });
142
+ }
143
+ }
144
+ //# sourceMappingURL=scheduler.js.map
@@ -1,11 +1,19 @@
1
1
  /**
2
2
  * Shared types for the Autopilot layer — Loop 2 of DriftWatch.
3
3
  *
4
- * These live in the SDK because they are pure data/interfaces with no I/O and
5
- * no provider or env coupling. The *implementations* (Redis state store, Slack
6
- * / Telegram / webhook notifiers, the scheduler) live in @driftwatch/server,
7
- * which is where side effects belong. This keeps the SDK publishable with zero
8
- * provider SDKs and zero process.env reads, exactly like the rest of it.
4
+ * The orchestration built on these types `MemoryStateStore`,
5
+ * `ApprovalService`, `AutopilotScheduler`, `executeControlAction`, and the
6
+ * notify-dispatch helpers lives alongside them in this package, since none
7
+ * of it does concrete I/O: it only calls the `StateStore`/`Notifier`
8
+ * interfaces defined here. Two things that DO require concrete I/O are kept
9
+ * out of the package root:
10
+ * - `RedisStateStore` needs `ioredis` — it's an isolated subpath export at
11
+ * `@driftwatch/sdk/redis` with ioredis as an optional peer dependency, so
12
+ * importing the core SDK never pulls it in.
13
+ * - Concrete Slack/Telegram/webhook `Notifier`s and inbound-webhook
14
+ * signature verification live in the companion `@driftwatch/autopilot`
15
+ * package, since they track those providers' APIs independently of this
16
+ * package's release cadence.
9
17
  */
10
18
  import type { DriftVerdict } from '../drift/detector.js';
11
19
  /** Every remediation action Autopilot knows how to intend. */
@@ -37,11 +37,18 @@ export async function detectBehavioralDrift(options) {
37
37
  const [baselineWindowStats, currentWindowStats] = isDryRun
38
38
  ? loadFixtureWindows()
39
39
  : await queryLiveWindows(driftDetectionConfig);
40
+ // If Autopilot switched the agent's model this window, the resulting behavior
41
+ // change is intentional — tell the judge so it doesn't flag the cure as the
42
+ // disease. Fixtures have no such context. See queryModelSwitchNote.
43
+ const modelSwitchNote = isDryRun
44
+ ? undefined
45
+ : await queryModelSwitchNote(driftDetectionConfig);
40
46
  const modelClientDescriptor = describeModelClient(modelClient);
41
47
  const { verdict, judgeTokenUsage, judgeAttempts } = await judgeDriftVerdict({
42
48
  baselineWindowStats,
43
49
  currentWindowStats,
44
50
  modelClient,
51
+ modelSwitchNote,
45
52
  });
46
53
  return {
47
54
  baselineWindowStats,
@@ -161,6 +168,48 @@ function buildTokenSpendQuery() {
161
168
  expression: 'C',
162
169
  });
163
170
  }
171
+ function buildModelSwitchQuery() {
172
+ return buildMetricBuilderQuery({
173
+ metricKey: 'agent.model.switches',
174
+ instrumentType: 'Sum',
175
+ timeAggregation: 'sum',
176
+ spaceAggregation: 'sum',
177
+ expression: 'A',
178
+ });
179
+ }
180
+ /**
181
+ * Count Autopilot model switches in the current window (the `agent.model.switches`
182
+ * counter emitted by executeControlAction) and, if any, return a note the judge
183
+ * uses to avoid classifying the *intended* behavior change as drift.
184
+ *
185
+ * Fail-safe by design: any query error resolves to "no switch" (undefined), so a
186
+ * hiccup here never breaks drift detection — the worst case is a switch isn't
187
+ * discounted, i.e. exactly today's behavior.
188
+ */
189
+ async function queryModelSwitchNote(driftDetectionConfig) {
190
+ const endTimeMs = Date.now();
191
+ const startTimeMs = endTimeMs - ONE_HOUR_MS;
192
+ try {
193
+ const signozResponseBody = await querySignozRange({
194
+ driftDetectionConfig,
195
+ startTimeMs,
196
+ endTimeMs,
197
+ builderQueries: { A: buildModelSwitchQuery() },
198
+ });
199
+ const switchCount = extractTokenSpend(findBuilderResult(signozResponseBody.data?.result ?? [], 'A'));
200
+ if (switchCount <= 0)
201
+ return undefined;
202
+ const times = switchCount >= 2 ? `${Math.round(switchCount)} times` : 'once';
203
+ return (`Autopilot switched the monitored agent's model ${times} during the CURRENT window ` +
204
+ `(an intentional remediation, not organic behavior). Changes attributable to a model ` +
205
+ `switch — especially token-spend and per-tool latency shifts — are EXPECTED and must ` +
206
+ `NOT be classified as drift on their own. Flag drift only for changes beyond what a ` +
207
+ `model switch explains (e.g. a real error-rate spike or a tool-mix collapse).`);
208
+ }
209
+ catch {
210
+ return undefined;
211
+ }
212
+ }
164
213
  async function querySignozRange(options) {
165
214
  const { driftDetectionConfig, startTimeMs, endTimeMs, builderQueries } = options;
166
215
  // SigNoz v4 requires every builder query to carry a `queryName` that matches
@@ -302,11 +351,15 @@ function buildFixtureWindowStats(windowLabel, driftMultiplier) {
302
351
  */
303
352
  const MAX_JUDGE_ATTEMPTS = 3;
304
353
  async function judgeDriftVerdict(options) {
305
- const { baselineWindowStats, currentWindowStats, modelClient } = options;
354
+ const { baselineWindowStats, currentWindowStats, modelClient, modelSwitchNote } = options;
306
355
  const messages = [
307
356
  {
308
357
  role: 'user',
309
- content: buildDriftJudgePrompt({ baselineWindowStats, currentWindowStats }),
358
+ content: buildDriftJudgePrompt({
359
+ baselineWindowStats,
360
+ currentWindowStats,
361
+ modelSwitchNote,
362
+ }),
310
363
  },
311
364
  ];
312
365
  const usageTotals = {
@@ -433,11 +486,12 @@ The JSON object must have exactly these keys:
433
486
  Example of a correctly formatted reply (structure only — do not reuse these values):
434
487
  {"drift":true,"severity":"high","reasons":["p95 latency 180ms -> 450ms","error rate 2% -> 9%","search_docs share 40% -> 75%"],"recommended_action":"Page the on-call engineer to investigate the search_docs regression."}`;
435
488
  function buildDriftJudgePrompt(options) {
436
- const { baselineWindowStats, currentWindowStats } = options;
489
+ const { baselineWindowStats, currentWindowStats, modelSwitchNote } = options;
490
+ const switchContext = modelSwitchNote ? `\n\nCONTEXT: ${modelSwitchNote}` : '';
437
491
  return `Compare the CURRENT window against the BASELINE and decide whether the agent's behavior has drifted enough to warrant a human alert. Consider shifts in tool-call mix, rising error rate, latency regressions, and token-spend spikes.
438
492
 
439
493
  BASELINE: ${JSON.stringify(baselineWindowStats)}
440
- CURRENT: ${JSON.stringify(currentWindowStats)}
494
+ CURRENT: ${JSON.stringify(currentWindowStats)}${switchContext}
441
495
 
442
496
  Reply with ONLY the JSON object described in your instructions.`;
443
497
  }
package/dist/index.d.ts CHANGED
@@ -9,4 +9,9 @@ export { withSkillExecutionSpan, type WithSkillExecutionSpanOptions, } from './t
9
9
  export { summarizeTokenUsage, recordUsageOnSpan, type TokenUsageSummary, } from './telemetry/usage-tracking.js';
10
10
  export { ACTION_TYPES, CONTROL_ACTIONS, categorizeAction, type ActionType, type ActionCategory, type ActionIntent, type DriftSeverity, type Approval, type ApprovalStatus, type AgentRuntimeState, type AgentStatus, type DriftHistoryEntry, type ActionLogEntry, type ActionOutcome, type NotificationMessage, type StateStore, type Notifier, } from './autopilot/types.js';
11
11
  export { evaluatePolicies, computeWindowDeltas, PolicyConfigSchema, PolicyRuleSchema, PolicyConditionSchema, type PolicyConfig, type PolicyRule, type PolicyCondition, type WindowDeltas, } from './autopilot/policy.js';
12
+ export { MemoryStateStore } from './autopilot/memory-store.js';
13
+ export { notifierForAction, safeNotify, notifyAll, type NotifierRegistry, type DispatchLogger, } from './autopilot/notify-dispatch.js';
14
+ export { executeControlAction, type ControlActionContext, type ControlActionResult, } from './autopilot/actions.js';
15
+ export { ApprovalService, type ApprovalDecision, type ApprovalServiceOptions, } from './autopilot/approval-service.js';
16
+ export { AutopilotScheduler, type SchedulerLogger, type AutopilotSchedulerOptions, } from './autopilot/scheduler.js';
12
17
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -15,4 +15,9 @@ export { summarizeTokenUsage, recordUsageOnSpan, } from './telemetry/usage-track
15
15
  // --- autopilot (Loop 2: drift-triggered remediation, pure decision layer) ---
16
16
  export { ACTION_TYPES, CONTROL_ACTIONS, categorizeAction, } from './autopilot/types.js';
17
17
  export { evaluatePolicies, computeWindowDeltas, PolicyConfigSchema, PolicyRuleSchema, PolicyConditionSchema, } from './autopilot/policy.js';
18
+ export { MemoryStateStore } from './autopilot/memory-store.js';
19
+ export { notifierForAction, safeNotify, notifyAll, } from './autopilot/notify-dispatch.js';
20
+ export { executeControlAction, } from './autopilot/actions.js';
21
+ export { ApprovalService, } from './autopilot/approval-service.js';
22
+ export { AutopilotScheduler, } from './autopilot/scheduler.js';
18
23
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Subpath entry: `import { RedisStateStore } from '@driftwatch/sdk/redis'`.
3
+ * Isolated from the package root so core SDK consumers never pull in
4
+ * `ioredis` — only importers of this subpath need it installed (it's an
5
+ * optional peer dependency; see package.json).
6
+ */
7
+ export { RedisStateStore } from './autopilot/redis-store.js';
8
+ //# sourceMappingURL=redis.d.ts.map
package/dist/redis.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Subpath entry: `import { RedisStateStore } from '@driftwatch/sdk/redis'`.
3
+ * Isolated from the package root so core SDK consumers never pull in
4
+ * `ioredis` — only importers of this subpath need it installed (it's an
5
+ * optional peer dependency; see package.json).
6
+ */
7
+ export { RedisStateStore } from './autopilot/redis-store.js';
8
+ //# sourceMappingURL=redis.js.map
@@ -23,7 +23,7 @@
23
23
  * Logs: the bundled pino auto-instrumentation (enabled by default below) both
24
24
  * injects trace_id/span_id into every Fastify log line for trace<->log
25
25
  * correlation and emits those lines as OTel log records — which the
26
- * LogRecordProcessor registered here ships to SigNoz.
26
+ * LogRecordProcessor registered here sends to SigNoz.
27
27
  */
28
28
  import { NodeSDK } from '@opentelemetry/sdk-node';
29
29
  import type { TelemetryConfig } from '../config/schema.js';
@@ -23,7 +23,7 @@
23
23
  * Logs: the bundled pino auto-instrumentation (enabled by default below) both
24
24
  * injects trace_id/span_id into every Fastify log line for trace<->log
25
25
  * correlation and emits those lines as OTel log records — which the
26
- * LogRecordProcessor registered here ships to SigNoz.
26
+ * LogRecordProcessor registered here sends to SigNoz.
27
27
  */
28
28
  import { NodeSDK } from '@opentelemetry/sdk-node';
29
29
  import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
package/package.json CHANGED
@@ -1,71 +1,84 @@
1
- {
2
- "name": "@driftwatch/sdk",
3
- "version": "0.2.1",
4
- "description": "Self-observing AI agent SDK: OpenTelemetry instrumentation for AI SDK agents plus an LLM-over-traces behavioral drift detector. Bring your own model client — no provider SDKs bundled.",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "Victory Lucky",
8
- "homepage": "https://github.com/codewithveek/drift-watch#readme",
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/codewithveek/drift-watch.git",
12
- "directory": "packages/sdk"
13
- },
14
- "bugs": {
15
- "url": "https://github.com/codewithveek/drift-watch/issues"
16
- },
17
- "keywords": [
18
- "ai-agent",
19
- "opentelemetry",
20
- "observability",
21
- "telemetry",
22
- "drift-detection",
23
- "ai-sdk",
24
- "llm",
25
- "vercel-ai-sdk",
26
- "otel"
27
- ],
28
- "main": "./dist/index.js",
29
- "types": "./dist/index.d.ts",
30
- "exports": {
31
- ".": {
32
- "types": "./dist/index.d.ts",
33
- "import": "./dist/index.js"
34
- }
35
- },
36
- "files": [
37
- "dist",
38
- "!dist/**/*.map"
39
- ],
40
- "publishConfig": {
41
- "access": "public"
42
- },
43
- "engines": {
44
- "node": ">=22"
45
- },
46
- "scripts": {
47
- "build": "tsc",
48
- "typecheck": "tsc --noEmit",
49
- "test": "vitest run",
50
- "prepublishOnly": "pnpm build && pnpm test"
51
- },
52
- "dependencies": {
53
- "@opentelemetry/api": "^1.9.0",
54
- "@opentelemetry/auto-instrumentations-node": "^0.78.0",
55
- "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0",
56
- "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0",
57
- "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0",
58
- "@opentelemetry/resources": "^2.9.0",
59
- "@opentelemetry/sdk-logs": "^0.220.0",
60
- "@opentelemetry/sdk-metrics": "^2.9.0",
61
- "@opentelemetry/sdk-node": "^0.220.0",
62
- "@opentelemetry/semantic-conventions": "^1.43.0",
63
- "ai": "^7.0.31",
64
- "zod": "^3.25.76"
65
- },
66
- "devDependencies": {
67
- "@types/node": "^22.0.0",
68
- "typescript": "^5.6.0",
69
- "vitest": "^4.1.10"
70
- }
71
- }
1
+ {
2
+ "name": "@driftwatch/sdk",
3
+ "version": "0.4.0",
4
+ "description": "Self-observing AI agent SDK: OpenTelemetry instrumentation for AI SDK agents plus an LLM-over-traces behavioral drift detector. Bring your own model client — no provider SDKs bundled.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Victory Lucky",
8
+ "homepage": "https://github.com/codewithveek/drift-watch#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/codewithveek/drift-watch.git",
12
+ "directory": "packages/sdk"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/codewithveek/drift-watch/issues"
16
+ },
17
+ "keywords": [
18
+ "ai-agent",
19
+ "opentelemetry",
20
+ "observability",
21
+ "telemetry",
22
+ "drift-detection",
23
+ "ai-sdk",
24
+ "llm",
25
+ "vercel-ai-sdk",
26
+ "otel"
27
+ ],
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "sideEffects": false,
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "import": "./dist/index.js"
35
+ },
36
+ "./redis": {
37
+ "types": "./dist/redis.d.ts",
38
+ "import": "./dist/redis.js"
39
+ }
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "!dist/**/*.map"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "engines": {
49
+ "node": ">=22"
50
+ },
51
+ "dependencies": {
52
+ "@opentelemetry/api": "^1.9.0",
53
+ "@opentelemetry/auto-instrumentations-node": "^0.78.0",
54
+ "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0",
55
+ "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0",
56
+ "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0",
57
+ "@opentelemetry/resources": "^2.9.0",
58
+ "@opentelemetry/sdk-logs": "^0.220.0",
59
+ "@opentelemetry/sdk-metrics": "^2.9.0",
60
+ "@opentelemetry/sdk-node": "^0.220.0",
61
+ "@opentelemetry/semantic-conventions": "^1.43.0",
62
+ "ai": "^7.0.31",
63
+ "zod": "^3.25.76"
64
+ },
65
+ "peerDependencies": {
66
+ "ioredis": "^5.4.1"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "ioredis": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "devDependencies": {
74
+ "@types/node": "^22.0.0",
75
+ "ioredis": "^5.4.1",
76
+ "typescript": "^5.6.0",
77
+ "vitest": "^4.1.10"
78
+ },
79
+ "scripts": {
80
+ "build": "tsc",
81
+ "typecheck": "tsc --noEmit",
82
+ "test": "vitest run"
83
+ }
84
+ }