evOo-local/packages/core/src/routine-policy.ts
cynarAI ca869561ac
Voice-first streaming, non-blocking follow-ups and chat UX (#205)
* media: check ffmpeg filter support before subtitle operations

* sandbox: handle synchronous spawn errors safely in desktop sandbox

* Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX

Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency.

What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens.

Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally.

* Update translation catalogs

Re-extracted catalogs for the new and moved strings; the catalog sync test passes.

* Add project plan and test readiness notes

Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes.

* fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps

* fix(ci): restore space deletion guard, browser toggle, and run-steps contracts

* fix(ci): restore model resolution errors, group stage, and onboarding flow

* fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs

* fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding

* test(web): wait for avatar and rig visibility in oo-avatar motion spec

* test(web): wait for persisted message IDs in message-hover-actions pagination spec

* Onboarding: always drop the local voice when the voice step is skipped

Skipping waited only for the latest voice load and disconnected only
when the client saw the connect succeed. An earlier or client-failed
connect could still persist the local credential, so a user who
skipped voice was left with one. Wait for every load and always
disconnect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Shell: hide the optimistic copy once the transcript shows the send

A message sent while the bot works streamed into the transcript before
its send request resolved, so the same text showed twice until the
optimistic bubble was removed. Drop an optimistic message as soon as a
persisted user message with the same text appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 14:08:31 +02:00

663 lines
19 KiB
TypeScript

/**
* 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<object>();
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<string, unknown>;
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<string, unknown>;
const cObj = c as Record<string, unknown>;
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 };