/** * Routine notification policies, semantic delta evaluation, * deterministic content hashing, and quiet suppression rules. * * Implements Feature F8 per PROJECT.md and ORIGINAL_REQUEST.md. */ export type RoutineNotifyMode = "always" | "on_change" | "on_failure" | "never"; export interface RoutineDeltaEvaluation { /** True when current output substantively differs from previous output. */ hasChanged: boolean; /** Concise, human-readable summary of what changed (under 120 chars). */ deltaSummary?: string; /** Deterministic content hash of the current normalized output. */ changeHash: string; } export interface NormalizationOptions { /** * Keys to ignore during normalization (e.g. volatile timestamps, ephemeral IDs). * Default includes: ["durationMs", "timestamp", "runId", "attemptId", "executionId", * "messageId", "taskId", "createdAt", "updatedAt", "nonce"] */ ignoreKeys?: readonly string[]; /** * Normalizes whitespace in string values (trims outer whitespace, normalizes CRLF). * Default: true */ normalizeWhitespace?: boolean; /** * Number of decimal places to round floating point numbers to avoid IEEE-754 epsilon noise. * Default: 6 */ numericPrecision?: number; /** * Sorts arrays of primitive values (strings/numbers) when order is non-semantic. * Default: false */ sortArraysOfPrimitives?: boolean; } export interface DeltaEvaluationOptions extends NormalizationOptions { /** Maximum character length of the generated delta summary. Default: 120 */ maxSummaryLength?: number; } export type RoutineSuppressReason = | "unchanged" | "never_mode" | "success_in_failure_mode" | "disabled" | "empty_output"; export type RoutineEarconCue = | "routine_changed" | "routine_failure" | "routine_completed_quiet" | null; export interface RoutineNotificationDecision { /** Whether a user-visible notification (push, toast, badge) should be dispatched. */ shouldNotify: boolean; /** Rationale when notification was suppressed. */ suppressReason?: RoutineSuppressReason; /** Whether substantive output change occurred. */ hasChanged: boolean; /** Normalized hash of the current output. */ changeHash: string; /** Hash of the previous output if available. */ prevHash?: string; /** Human-readable delta summary if changed. */ deltaSummary?: string; /** Associated tactile sound cue from Feature F4. */ earconCue: RoutineEarconCue; /** Whether the chat thread should be marked unread. */ markUnread: boolean; /** True when execution completed in quiet background mode without user-facing interruptions. */ quiet: boolean; /** Pre-built notification payload when shouldNotify is true. */ notificationPayload?: { kind: "completion" | "failure"; title: string; body: string; }; } export interface RoutineNotificationPolicy { /** Configured notification policy mode. */ notifyMode: RoutineNotifyMode; /** * Evaluates delta between two content hashes. * Conforms directly to PROJECT.md line 88 interface contract. */ evaluateDelta(prevOutputHash: string, currentOutputHash: string): boolean; /** * Evaluates substantive delta between previous and current execution outputs. */ evaluateOutputDelta?( prevOutput: unknown, currentOutput: unknown, options?: DeltaEvaluationOptions, ): RoutineDeltaEvaluation; } export const DEFAULT_IGNORE_KEYS: readonly string[] = Object.freeze([ "durationMs", "timestamp", "runId", "attemptId", "executionId", "messageId", "taskId", "createdAt", "updatedAt", "startedAt", "finishedAt", "nonce", "leaseOwner", "leaseFence", ]); /** * 32-bit MurmurHash3 implementation in pure TypeScript. * Deterministic across Node, Browser, Electron, and React Native. */ export function murmurHash3(input: string, seed = 0): string { let h1 = seed >>> 0; const c1 = 0xcc9e2d51; const c2 = 0x1b873593; const len = input.length; for (let i = 0; i < len; i += 1) { let k1 = input.charCodeAt(i) & 0xffff; k1 = Math.imul(k1, c1); k1 = (k1 << 15) | (k1 >>> 17); k1 = Math.imul(k1, c2); h1 ^= k1; h1 = (h1 << 13) | (h1 >>> 19); h1 = (Math.imul(h1, 5) + 0xe6546b64) >>> 0; } h1 ^= len; h1 ^= h1 >>> 16; h1 = Math.imul(h1, 0x85ebca6b); h1 ^= h1 >>> 13; h1 = Math.imul(h1, 0xc2b2ae35); h1 ^= h1 >>> 16; return (h1 >>> 0).toString(16).padStart(8, "0"); } /** * 64-bit MurmurHash3 composite hash returning 16-character hexadecimal string. * High avalanche, near-zero collision rate for structured outputs. */ export function murmurHash64(input: string, seed = 0): string { const h1 = murmurHash3(input, seed); const h2 = murmurHash3(input, (seed ^ 0xe99583c1) >>> 0); return `${h1}${h2}`; } /** * Deterministically serializes any JavaScript value into normalized JSON. * Recursively sorts object keys, filters volatile keys, protects against cycles, * and normalizes whitespace and floating point numbers. */ export function deterministicStringify(value: unknown, options?: NormalizationOptions): string { const ignoreSet = new Set(options?.ignoreKeys ?? DEFAULT_IGNORE_KEYS); const normalizeWs = options?.normalizeWhitespace ?? true; const precision = options?.numericPrecision ?? 6; const sortArrays = options?.sortArraysOfPrimitives ?? false; const ancestors = new WeakSet(); function serialize(current: unknown): string { if (current === null) return "null"; if (current === undefined) return "null"; if (typeof current === "boolean") return current ? "true" : "false"; if (typeof current === "number") { if (!Number.isFinite(current)) return "null"; if (Number.isInteger(current)) return current.toString(); const factor = 10 ** precision; const rounded = Math.round(current * factor) / factor; return rounded.toString(); } if (typeof current === "string") { let str = current; if (normalizeWs) { str = str.replace(/\r\n/g, "\n").trim(); } return JSON.stringify(str); } if (typeof current === "bigint") { return JSON.stringify(`${current.toString()}n`); } if (typeof current !== "object") { return JSON.stringify(String(current)); } if (ancestors.has(current)) { return '"[Circular]"'; } ancestors.add(current); try { if (current instanceof Date) { return JSON.stringify(current.toISOString()); } if (current instanceof Set) { const items = Array.from(current).map((item) => serialize(item)); items.sort(); return `[${items.join(",")}]`; } if (current instanceof Map) { const entries = Array.from(current.entries()).map(([k, v]) => [String(k), v]); entries.sort((a, b) => (a[0] as string).localeCompare(b[0] as string)); const serializedEntries = entries.map(([k, v]) => `${JSON.stringify(k)}:${serialize(v)}`); return `{${serializedEntries.join(",")}}`; } if (Array.isArray(current)) { let items = current.map((item) => serialize(item)); if (sortArrays && current.every((x) => typeof x === "string" || typeof x === "number")) { items = [...items].sort(); } return `[${items.join(",")}]`; } const obj = current as Record; const keys = Object.keys(obj) .filter((k) => !ignoreSet.has(k) && obj[k] !== undefined) .sort(); const pairs = keys.map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`); return `{${pairs.join(",")}}`; } finally { ancestors.delete(current); } } return serialize(value); } /** * Computes deterministic content hash of any normalized value. */ export function computeContentHash(value: unknown, options?: NormalizationOptions): string { const serialized = deterministicStringify(value, options); return murmurHash64(serialized); } export interface DiffChange { path: string; kind: "added" | "removed" | "modified"; oldValue?: unknown; newValue?: unknown; } export interface StructuralDiffResult { hasChanged: boolean; changes: DiffChange[]; summary: string; } /** * Computes structural differences between previous and current values * and generates a concise, human-readable summary. */ export function computeStructuralDiff( prev: unknown, current: unknown, options?: DeltaEvaluationOptions, ): StructuralDiffResult { const maxSummary = options?.maxSummaryLength ?? 120; const changes: DiffChange[] = []; function diffRecursive(p: unknown, c: unknown, path: string): void { if (p === c) return; if ( p === null || c === null || p === undefined || c === undefined || typeof p !== "object" || typeof c !== "object" ) { if (typeof p === "string" && typeof c === "string") { const pNorm = (options?.normalizeWhitespace ?? true) ? p.replace(/\r\n/g, "\n").trim() : p; const cNorm = (options?.normalizeWhitespace ?? true) ? c.replace(/\r\n/g, "\n").trim() : c; if (pNorm !== cNorm) { changes.push({ path: path || "$root", kind: "modified", oldValue: pNorm, newValue: cNorm, }); } return; } changes.push({ path: path || "$root", kind: "modified", oldValue: p, newValue: c }); return; } if (Array.isArray(p) && Array.isArray(c)) { if (p.length !== c.length) { changes.push({ path: `${path}.length`, kind: "modified", oldValue: p.length, newValue: c.length, }); } const maxLen = Math.max(p.length, c.length); for (let i = 0; i < maxLen; i += 1) { const subPath = path ? `${path}[${i}]` : `[${i}]`; if (i >= p.length) { changes.push({ path: subPath, kind: "added", newValue: c[i] }); } else if (i >= c.length) { changes.push({ path: subPath, kind: "removed", oldValue: p[i] }); } else { diffRecursive(p[i], c[i], subPath); } } return; } if (Array.isArray(p) !== Array.isArray(c)) { changes.push({ path: path || "$root", kind: "modified", oldValue: p, newValue: c }); return; } const ignoreSet = new Set(options?.ignoreKeys ?? DEFAULT_IGNORE_KEYS); const pObj = p as Record; const cObj = c as Record; const pKeys = Object.keys(pObj).filter((k) => !ignoreSet.has(k)); const cKeys = Object.keys(cObj).filter((k) => !ignoreSet.has(k)); const allKeys = new Set([...pKeys, ...cKeys]); for (const key of allKeys) { const subPath = path ? `${path}.${key}` : key; const inPrev = key in pObj && pObj[key] !== undefined; const inCurr = key in cObj && cObj[key] !== undefined; if (!inPrev && inCurr) { changes.push({ path: subPath, kind: "added", newValue: cObj[key] }); } else if (inPrev && !inCurr) { changes.push({ path: subPath, kind: "removed", oldValue: pObj[key] }); } else { diffRecursive(pObj[key], cObj[key], subPath); } } } diffRecursive(prev, current, ""); const hasChanged = changes.length > 0; if (!hasChanged) { return { hasChanged: false, changes: [], summary: "No changes" }; } // Synthesize concise summary adhering to AGENTS.md microcopy constraints let summary = ""; if (changes.length === 1) { const single = changes[0]; if ( single && single.kind === "modified" && typeof single.oldValue !== "object" && typeof single.newValue !== "object" ) { summary = `${single.path}: ${String(single.oldValue)} -> ${String(single.newValue)}`; } else if (single?.kind === "added") { summary = `Added ${single.path}`; } else if (single?.kind === "removed") { summary = `Removed ${single.path}`; } else { summary = `Updated ${single?.path ?? "$root"}`; } } else if (changes.length <= 3) { const parts = changes.map((c) => { if ( c.kind === "modified" && typeof c.oldValue !== "object" && typeof c.newValue !== "object" ) { return `${c.path}: ${String(c.oldValue)} -> ${String(c.newValue)}`; } return `${c.kind} ${c.path}`; }); summary = parts.join("; "); } else { const added = changes.filter((c) => c.kind === "added").length; const removed = changes.filter((c) => c.kind === "removed").length; const modified = changes.filter((c) => c.kind === "modified").length; const parts: string[] = []; if (added > 0) parts.push(`${added} added`); if (removed > 0) parts.push(`${removed} removed`); if (modified > 0) parts.push(`${modified} modified`); const keySamples = changes .slice(0, 2) .map((c) => c.path) .join(", "); summary = `${changes.length} changes (${parts.join(", ")}; e.g. ${keySamples})`; } if (summary.length > maxSummary) { summary = `${summary.slice(0, maxSummary - 1)}…`; } return { hasChanged, changes, summary }; } /** * Evaluates delta between two outputs. */ export function evaluateOutputDelta( prevOutput: unknown, currentOutput: unknown, options?: DeltaEvaluationOptions, ): RoutineDeltaEvaluation { const currentHash = computeContentHash(currentOutput, options); if (prevOutput === undefined || prevOutput === null) { return { hasChanged: true, deltaSummary: "Initial routine execution", changeHash: currentHash, }; } const prevHash = computeContentHash(prevOutput, options); if (prevHash === currentHash) { return { hasChanged: false, deltaSummary: "No changes", changeHash: currentHash, }; } const diff = computeStructuralDiff(prevOutput, currentOutput, options); return { hasChanged: diff.hasChanged, deltaSummary: diff.summary, changeHash: currentHash, }; } /** * Pure function evaluating notification decision for routine run completion or failure. */ export function evaluateRoutineNotification(params: { policy: RoutineNotificationPolicy; prevOutput?: unknown; prevHash?: string; currentOutput?: unknown; status: "completed" | "failed"; error?: string; routineName?: string; botName?: string; options?: DeltaEvaluationOptions; }): RoutineNotificationDecision { const { policy, prevOutput, prevHash, currentOutput, status, error, routineName, botName, options, } = params; const currentHash = computeContentHash(currentOutput, options); const titleBase = routineName || botName || "Routine"; // Mode: never if (policy.notifyMode === "never") { return { shouldNotify: false, suppressReason: "never_mode", hasChanged: false, changeHash: currentHash, prevHash, earconCue: null, markUnread: false, quiet: true, }; } // Failure path if (status === "failed") { if ( policy.notifyMode === "on_failure" || policy.notifyMode === "on_change" || policy.notifyMode === "always" ) { return { shouldNotify: true, hasChanged: true, changeHash: currentHash, prevHash, deltaSummary: error || "Routine failed", earconCue: "routine_failure", markUnread: true, quiet: false, notificationPayload: { kind: "failure", title: `${titleBase} failed`, body: error ? error.slice(0, 180) : "Routine failed", }, }; } } // Mode: on_failure (but status is completed) if (policy.notifyMode === "on_failure") { return { shouldNotify: false, suppressReason: "success_in_failure_mode", hasChanged: false, changeHash: currentHash, prevHash, earconCue: "routine_completed_quiet", markUnread: false, quiet: true, }; } // Delta comparison for on_change and always let hasChanged = true; let deltaSummary: string | undefined; if (prevHash) { hasChanged = policy.evaluateDelta(prevHash, currentHash); if (hasChanged && prevOutput !== undefined) { const diff = computeStructuralDiff(prevOutput, currentOutput, options); deltaSummary = diff.summary; } } else if (prevOutput !== undefined) { const delta = evaluateOutputDelta(prevOutput, currentOutput, options); hasChanged = delta.hasChanged; deltaSummary = delta.deltaSummary; } else { // No previous run found - first run hasChanged = true; deltaSummary = "Initial execution"; } // Mode: on_change if (policy.notifyMode === "on_change") { if (!hasChanged) { return { shouldNotify: false, suppressReason: "unchanged", hasChanged: false, changeHash: currentHash, prevHash, deltaSummary: "No changes", earconCue: "routine_completed_quiet", markUnread: false, quiet: true, }; } const bodyText = deltaSummary || "Routine output changed"; return { shouldNotify: true, hasChanged: true, changeHash: currentHash, prevHash, deltaSummary, earconCue: "routine_changed", markUnread: true, quiet: false, notificationPayload: { kind: "completion", title: `${titleBase}: change detected`, body: bodyText.slice(0, 180), }, }; } // Mode: always const alwaysBody = deltaSummary || (typeof currentOutput === "string" ? currentOutput : "Routine completed"); return { shouldNotify: true, hasChanged, changeHash: currentHash, prevHash, deltaSummary, earconCue: hasChanged ? "routine_changed" : "routine_completed_quiet", markUnread: true, quiet: false, notificationPayload: { kind: "completion", title: `${titleBase} finished`, body: alwaysBody.slice(0, 180), }, }; } /** * Resolves notification policy mode with backwards compatibility for legacy boolean notify flags. */ export function resolveRoutineNotifyMode( mode?: RoutineNotifyMode | null, legacyNotify?: boolean, ): RoutineNotifyMode { if (mode) return mode; if (legacyNotify === false) return "never"; return "on_change"; } /** * Reference class implementation of RoutineNotificationPolicy. */ export class RoutinePolicy implements RoutineNotificationPolicy { public readonly notifyMode: RoutineNotifyMode; constructor(notifyMode: RoutineNotifyMode = "on_change") { this.notifyMode = notifyMode; } evaluateDelta(prevOutputHash: string, currentOutputHash: string): boolean { return prevOutputHash !== currentOutputHash; } evaluateOutputDelta( prevOutput: unknown, currentOutput: unknown, options?: DeltaEvaluationOptions, ): RoutineDeltaEvaluation { return evaluateOutputDelta(prevOutput, currentOutput, options); } } export { RoutinePolicy as RoutinePolicyEvaluator };