From ef7eaa85b7a83b7537f79cc18f46b666b3c9d811 Mon Sep 17 00:00:00 2001
From: Michael McRae
Date: Thu, 13 Aug 2026 10:25:19 +1000
Subject: [PATCH 1/7] PR formatAI new
---
packages/plugins/ai/doc/architecture.md | 126 +++++--
packages/plugins/ai/doc/formatAI.md | 112 ++++++
packages/plugins/ai/doc/index.md | 3 +-
packages/plugins/ai/package.json | 2 +-
packages/plugins/ai/plan/formatAI.plan.md | 10 +-
packages/plugins/ai/plan/v0.3.0-roadmap.md | 10 +-
packages/plugins/ai/src/core/support.ts | 235 ++++++++----
packages/plugins/ai/src/functions/context.ts | 74 ++--
packages/plugins/ai/src/functions/diff.ts | 72 ++--
packages/plugins/ai/src/functions/format.ts | 344 ++++++++++++++----
packages/plugins/ai/src/functions/parse.ts | 6 +-
.../plugins/ai/src/functions/recurrence.ts | 6 +-
packages/plugins/ai/src/functions/schedule.ts | 2 +-
packages/plugins/ai/src/index.ts | 4 +-
packages/plugins/ai/src/types/common.type.ts | 8 +-
packages/plugins/ai/src/types/format.type.ts | 56 +++
packages/plugins/ai/src/types/index.ts | 1 +
packages/plugins/ai/test/format.test.ts | 259 +++++++++++++
packages/plugins/ai/test/recurrence.test.ts | 6 +-
packages/tempo/.vitepress/config.ts | 2 +-
.../tempo/.vitepress/theme/data/catalog.json | 2 +-
.../tempo/src/engine/engine.normalizer.ts | 7 +-
packages/tempo/src/tempo.class.ts | 1 +
packages/tempo/src/tempo.type.ts | 1 +
packages/tempo/test/core/accessors.test.ts | 8 +
packages/tempo/test/core/static.test.ts | 2 +-
26 files changed, 1082 insertions(+), 277 deletions(-)
create mode 100644 packages/plugins/ai/doc/formatAI.md
create mode 100644 packages/plugins/ai/src/types/format.type.ts
create mode 100644 packages/plugins/ai/test/format.test.ts
diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md
index 8f7e7428..7ad9e075 100644
--- a/packages/plugins/ai/doc/architecture.md
+++ b/packages/plugins/ai/doc/architecture.md
@@ -75,43 +75,123 @@ By default, `@magmacomputing/tempo-plugin-ai` lazily fetches provider defaults (
### Frontend Security Warning
> [!CAUTION]
-> **Never** expose a raw LLM API key in a client-side browser bundle (like React or Vue) or store it in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). Any Cross-Site Scripting (XSS) vulnerability, compromised NPM package, or malicious browser extension can easily inspect client-side storage and steal secret keys, leading to quota exhaustion, billing fraud, or permanent provider bans. BYOK keys are *only* safe on backend servers or edge workers.
+> **Never** expose a raw LLM API key in a client-side browser bundle (like React, Vue, or Svelte) or store it in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). Any Cross-Site Scripting (XSS) vulnerability, compromised NPM dependency, or malicious browser extension can inspect client-side memory/storage and extract secret keys, leading to quota drainage, unexpected billing spikes, or account bans. BYOK provider keys are *only* safe on backend servers and edge workers.
-## The Proxy Architecture
+## Browser & Client-Side Proxy Architecture
-If you need to execute AI functions directly on a public frontend application, you must route requests through a secure backend proxy.
+To execute AI functions within client-side browser applications safely, route requests through a secure self-hosted backend proxy or unified AI Gateway (such as a Cloudflare Worker, Next.js API route, Express server, OpenRouter, Portkey, or LiteLLM):
-A standard proxy architecture (e.g. using Cloudflare Workers or a custom Node/Express backend) involves:
-1. **Frontend Request**: The browser sends the prompt or temporal data to your own backend API (e.g., `/api/parse-date`).
-2. **Backend Authentication**: Your API validates the user's session or API token to prevent abuse.
-3. **LLM Inference**: Your backend runs the Tempo AI function (such as `parseAI`) using your securely stored BYOK keys.
-4. **Response**: Your backend returns the resulting ISO 8601 string to the frontend, where it can be instantiated into a native `Tempo` object.
+```mermaid
+flowchart LR
+ subgraph Browser ["Client-Side Browser (SPA)"]
+ Client["Tempo AI Plugin
(initAI / parseAI / diffAI)"]
+ end
-Because LLM API calls typically take ~300-800ms, the ~20ms overhead of routing the request through your own backend proxy is negligible.
+ subgraph Backend ["Self-Hosted Proxy / AI Gateway"]
+ Proxy["Your Backend API / AI Gateway
• User Authentication & Rate Limits
• Secure Secret Management"]
+ end
-## Fallback Loops & Execution Modes
+ subgraph Providers ["Upstream LLM Providers"]
+ LLM["Groq • OpenAI • Gemini • Anthropic"]
+ end
-Because third-party APIs can experience downtime or aggressive rate limiting, the plugin supports flexible multi-provider execution strategies:
-
-### 1. Fallback Mode (Default)
-When configured with multiple providers in `initAI()`, AI functions execute requests sequentially. If the primary provider hits a timeout or a `429 Too Many Requests` limit, the plugin instantly and silently fails over to the next provider in the array. Rate limit headers are updated based on the successful provider response or error resolution.
+ Client -- "1. HTTPS (TLS 1.3)
Session Token / Auth Header" --> Proxy
+ Proxy -- "2. HTTPS (TLS 1.3)
Private Provider API Key" --> LLM
+ LLM -- "3. HTTPS (TLS 1.3)
Raw JSON Completion" --> Proxy
+ Proxy -- "4. HTTPS (TLS 1.3)
Validated Payload" --> Client
+```
-### 2. Race Mode (`mode: 'race'`)
-Dispatches requests to all available providers simultaneously using `Promise.allSettled`. Returns the fastest resolving provider response to minimize user-perceived latency.
+### 1. Browser Configuration Example
+Configure `initAI` in your browser code to target your backend proxy or AI Gateway URL:
```typescript
-const result = await parseAI("Thanksgiving 2026", { mode: 'race' });
+import { initAI, parseAI } from '@magmacomputing/tempo-plugin-ai';
+
+// Safe for browser deployment: No private LLM API keys are bundled
+await initAI({
+ providers: [
+ {
+ id: 'my-gateway',
+ url: 'https://api.mycompany.com/v1/ai/chat/completions', // Your secure proxy endpoint
+ key: userSessionToken, // Short-lived user JWT or session cookie
+ model: 'llama-3.3-70b-instruct'
+ }
+ ]
+});
+
+// All Tempo AI functions now execute securely through your proxy
+const date = await parseAI("Team standup next Wednesday at 9:30am");
```
-### 3. Consensus Mode (`mode: 'consensus'`)
-Executes all providers concurrently. If multiple providers agree on the resolved ISO timestamp, confidence score is boosted (to `1.0`) and the consensus result is returned. Rate limits are applied from the consensus provider.
+### 2. Backend Proxy Handler Example (Next.js / Cloudflare Worker / Express)
+Your backend endpoint receives the request, validates the user's session, attaches your private LLM API key, and forwards the payload to the upstream provider:
```typescript
-const result = await parseAI("The penultimate Tuesday before Thanksgiving", {
- mode: 'consensus',
- minConfidence: 0.85
-});
+// Example: Next.js API Route / Cloudflare Worker
+export async function POST(req: Request) {
+ // 1. Authenticate user session
+ const authHeader = req.headers.get('Authorization');
+ if (!isValidUserSession(authHeader)) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ // 2. Forward request to upstream LLM with private BYOK key
+ const body = await req.json();
+ const upstreamResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${process.env.GROQ_API_KEY}`
+ },
+ body: JSON.stringify(body)
+ });
+
+ // 3. Return provider payload to client
+ const data = await upstreamResponse.json();
+ return new Response(JSON.stringify(data), {
+ status: upstreamResponse.status,
+ headers: { 'Content-Type': 'application/json' }
+ });
+}
```
+---
+
+## 🔒 Security & Privacy Guarantees
+
+Whether running directly on backend servers (Node.js, Deno, Bun, Edge Workers) or through a client-side browser proxy, `@magmacomputing/tempo-plugin-ai` enforces strict security and privacy standards:
+
+### 1. End-to-End Encryption (TLS 1.3)
+All transport communication—both from browser to proxy and from proxy/server to upstream LLM endpoints—is strictly enforced over HTTPS utilizing **TLS 1.3**. Plaintext HTTP endpoints are disallowed in production environments (permitted only on `localhost` during local development).
+
+### 2. Ephemeral Processing & Zero Data Retention
+Temporal processing payloads (dates, times, context snippets, prompts) are processed ephemerally. The plugin does not send telemetry or store user prompt data on external analytics servers. Information is used exclusively during the execution of the requested AI function and discarded immediately after response resolution.
+
+### 3. In-Memory Credential Redaction & Immutability
+* **Automated Key Redaction**: Calling `getAiConfig()` returns a sanitized, deeply read-only snapshot of active configurations with all provider `key` values replaced with `[REDACTED]`, preventing accidental exposure in log files, APM traces, or crash dumps.
+* **Frozen Metadata**: All diagnostic metadata attached to `Tempo` instances via `.ai` is deeply frozen with `Object.freeze()` and guarded via runtime `Proxy` wrappers, eliminating prototype pollution and runtime state mutation.
+
+### 4. Deterministic Schema Guardrails & Hallucination Traps
+All LLM prompts are paired with rigid, machine-verifiable JSON schemas. Responses undergo strict boundary validation, regex parsing, and ISO verification before any native `Tempo` date object is instantiated. If an LLM returns malformed or non-chronological data, the plugin throws a typed `TempoAiError` or triggers automatic fallback rather than silently returning an invalid date.
+
+### 5. Partitioned Caching & Fail-Open Storage Resilience
+* **Strict Cache Key Partitioning**: Caches are namespaced and hashed (`ai:::...`) with timezone, locale, calendar, and anchor date isolation to prevent cross-tenant or cross-regional cache poisoning.
+* **Fail-Open Protection**: If a custom distributed cache adapter (e.g. Redis or Cloudflare KV) encounters network disruption or errors, the plugin automatically fails open to direct LLM resolution, preserving application uptime.
+
+## Multi-Provider Execution Strategies (`AiMode`)
+
+Because third-party APIs can experience downtime, latency spikes, or quota exhaustion, `@magmacomputing/tempo-plugin-ai` provides six dedicated dispatch strategies configured via `AiMode` (or string literals):
+
+| Strategy | Enum (`AiMode`) | Primary Advantage | Typical Use Case |
+| :--- | :--- | :--- | :--- |
+| **Fallback** *(Default)* | `AiMode.Fallback` | Minimum token cost (sequential cascade) | Default production baseline & background tasks |
+| **Hedged** | `AiMode.Hedged` | Ultra-fast latency with low token overhead (~1.15x) | Latency-sensitive interactive search & chatbots |
+| **RoundRobin** | `AiMode.RoundRobin` | Cyclic rotation across multi-key pools | High-throughput batch ingestion across API keys |
+| **Adaptive** | `AiMode.Adaptive` | Telemetry-driven rate-limit avoidance | Multi-tier provider pools with mixed quotas |
+| **Race** | `AiMode.Race` | Absolute minimum response latency | Real-time typeahead & autocomplete |
+| **Consensus** | `AiMode.Consensus` | Cross-LLM verification & hallucination trapping | High-stakes legal, financial, and contract dates |
+
+👉 For detailed architecture breakdowns, Mermaid decision trees, and configuration guides for each mode, see the **[Multi-Provider Execution Modes Guide (`modes.md`)](./modes.md)**.
+
### Provider ID Canonicalization
Provider IDs are normalized case-insensitively during `initAI` lookup (e.g. `'Gemini'`, `'gemini'`, `'OpenAI'`), automatically applying default endpoints and models while preserving the caller's registered identifier for logging and metadata.
diff --git a/packages/plugins/ai/doc/formatAI.md b/packages/plugins/ai/doc/formatAI.md
new file mode 100644
index 00000000..6c265139
--- /dev/null
+++ b/packages/plugins/ai/doc/formatAI.md
@@ -0,0 +1,112 @@
+# `formatAI` — Contextual & Narrative Date Formatting
+
+`formatAI()` formats a `Tempo` instance, TC39 `Temporal` object, Date, or timestamp into human-friendly, contextual narrative text tailored to specific UI tones, relative time frames, or business domains.
+
+While core `Tempo` provides token-based template formatting (`t.format('{yyyy}-{mm}-{dd}')`), `formatAI` bridges the gap to contextual, localized human descriptions that token patterns alone cannot capture (e.g. countdowns, calendar invites, conversational reminders, and domain summaries), backed by mathematical grounding.
+
+---
+
+## Basic Usage
+
+```typescript
+import { Tempo } from '@magmacomputing/tempo';
+import { initAI, formatAI } from '@magmacomputing/tempo-plugin-ai';
+
+// 1. Initialize AI providers
+await initAI({
+ providers: [
+ { id: 'groq', key: process.env.GROQ_API_KEY, model: 'llama-3.3-70b-versatile' }
+ ]
+});
+
+const target = new Tempo('2026-08-07T17:00:00[America/New_York]');
+
+// "this Friday at 5:00 PM EST (in 5 days)"
+const result = await formatAI(target, 'friendly reminder tone with relative countdown');
+
+console.log(result.formatted); // "this Friday at 5:00 PM EST (in 5 days)"
+console.log(result.confidence); // 0.98
+console.log(result.provider); // 'groq'
+```
+
+---
+
+## Configuration Options (`AiFormatOptions`)
+
+| Option | Type | Description |
+| :--- | :--- | :--- |
+| **`anchor`** | `Tempo.DateTime` | Reference anchor date for relative delta calculations (defaults to current time). |
+| **`style`** | `string` | Narrative style or tone hint (e.g. `'casual'`, `'formal'`, `'compact'`, `'countdown'`). |
+| **`region`** | `string` | Regional context (e.g., `'AU-NSW'`, `'US-CA'`) passed to LLM grounding. |
+| **`timeZone`** | `string` | Target IANA timezone for output formatting. |
+| **`locale`** | `string \| string[]` | Target BCP 47 locale or language tag (e.g. `'fr-FR'`, `'en-US'`). |
+| **`force`** | `boolean` | If true, bypasses the cache to initiate a fresh LLM query. |
+| **`cache`** | `boolean` | If false, disables writing to and reading from cache adapters. |
+| **`cacheAdapter`** | `AiCacheAdapter` | Custom cache engine (e.g., Redis, Cloudflare KV) for caching results. |
+| **`ttl`** | `number` | Time-to-live override in milliseconds for cached results (defaults to 24h). |
+| **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required. Throws `TempoAiError(422)` if lower. |
+| **`mode`** | `AiMode` | Concurrency routing strategy (`fallback`, `race`, `consensus`, `hedged`, `roundrobin`, `adaptive`). Refer to the [Multi-Provider Execution Modes Guide](./modes.md). |
+| **`softErrors`** | `boolean` | If true, returns `TempoAiError` into array indices instead of rejecting batch queries. |
+
+---
+
+## Result Schema (`TempoAiFormatResult`)
+
+```typescript
+export interface TempoAiFormatResult {
+ /** Formatted narrative string. */
+ formatted: string;
+
+ /** Confidence score between 0.0 and 1.0. */
+ confidence: number;
+
+ /** ID of the provider that fulfilled the request (or 'cache'). */
+ provider: string;
+
+ /** Optional step-by-step rationale from the LLM. */
+ reasoning?: string | undefined;
+}
+```
+
+---
+
+## Key Architectural Behaviors
+
+### 1. Native Grounding Context
+To eliminate LLM date and day-of-week hallucinations, `formatAI` computes deterministic grounding metrics before constructing the prompt:
+- Exact ISO timestamp and timezone
+- Day of the week name and ordinal (e.g. `Friday`, Day 5)
+- Relative delta in calendar days and elapsed hours compared to anchor
+- Directionality (`'past'`, `'present'`, `'future'`)
+
+These metrics are injected into the system prompt as immutable constraints.
+
+### 2. TC39 Temporal & Universal Interoperability
+`formatAI` seamlessly accepts `Tempo` instances, native JavaScript `Date` objects, ISO strings, timestamps, and TC39 `Temporal` objects (`Temporal.ZonedDateTime`, `Temporal.Instant`, `Temporal.PlainDateTime`, `Temporal.PlainDate`):
+
+```typescript
+import { Temporal } from '@magmacomputing/tempo/library';
+
+const zdt = Temporal.ZonedDateTime.from('2026-08-05T15:00:00+10:00[Australia/Sydney]');
+const result = await formatAI(zdt, 'compact relative format');
+```
+
+### 3. Multi-Tier Distributed Caching
+`formatAI` integrates multi-tier caching (in-memory + optional asynchronous `AiCacheAdapter` such as Redis or Cloudflare KV). Cache keys incorporate input timestamp, anchor timestamp, normalized prompt, timezone, locale, region, and style to ensure complete cache correctness:
+
+```typescript
+const result = await formatAI(target, 'casual invitation', {
+ cacheAdapter: redisCacheAdapter,
+ ttl: 3_600_000, // 1 hour
+});
+```
+
+### 4. Parallel Batch Formatting
+Format multiple dates and prompts concurrently with optional `softErrors` resilience:
+
+```typescript
+const results = await formatAI([
+ { date: '2026-08-03T09:00:00Z', prompt: 'calendar invite' },
+ { date: '2026-08-05T18:00:00Z', prompt: 'flight departure notification' },
+], { softErrors: true });
+```
diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md
index c417d500..12cd1218 100644
--- a/packages/plugins/ai/doc/index.md
+++ b/packages/plugins/ai/doc/index.md
@@ -49,6 +49,7 @@ All AI functions return a standard ES Promise wrapped object.
| **`scheduleAI`** | Booking prompt + busy constraints | `TempoScheduleResult` | **Resolved appointment slot** (`start`, `end`, `slot`, `alternatives`, `ai.conflictBumped`) | |
| **`contextAI`** | Context text string(s) | `TempoContext` \| `TempoContext[]` \| `(TempoContext \| TempoAiError)[]` | **Inferred regional context** (`timeZone`, `locale`, `calendar`, `sphere`) | |
| **`diffAI`** | Start & End dates + prompt | `TempoAiDiffResult` \| `TempoAiDiffResult[]` \| `(TempoAiDiffResult \| TempoAiError)[]` | **Narrative time delta & business days** (`formatted`, `businessDays`, `days`, `hours`, `holidays`) | |
+| **`formatAI`** | Date-time + prompt / style | `TempoAiFormatResult` \| `TempoAiFormatResult[]` \| `(TempoAiFormatResult \| TempoAiError)[]` | **Contextual narrative date formatting** (`formatted`, `confidence`, `provider`, `reasoning`) | |
## Architecture & Infrastructure Guides
@@ -56,7 +57,7 @@ All AI functions return a standard ES Promise wrapped object.
> **Production Recommendation**: Due to the complexities of LLM APIs, including caching gotchas, context injection, rate limits, and calendar math hallucinations, we politely but firmly recommend reading the dedicated guides below before deploying this plugin in a production environment.
- [Multi-Provider Execution Modes](./modes.md) (Hedged, RoundRobin, Adaptive, Race, Consensus, Fallback)
-- [Provider Architecture & Security](./architecture.md) (BYOK vs Proxy patterns, Frontend Security)
+- [Provider Architecture & Security](./architecture.md) (BYOK vs Proxy patterns, Browser Security, TLS 1.3 & Privacy Guarantees)
- [Context & Natural Language Parsing](./context.md) (How Timezone and Locale are injected)
- [Rate Limits & Cache Management](./rate-limits.md) (Tracking API quotas, handling 429 errors, and custom Redis caches)
diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json
index 59d49efd..7c5bca52 100644
--- a/packages/plugins/ai/package.json
+++ b/packages/plugins/ai/package.json
@@ -1,6 +1,6 @@
{
"name": "@magmacomputing/tempo-plugin-ai",
- "version": "0.3.0",
+ "version": "4.0.0",
"description": "Tempo community plugin for LLM-powered natural language parsing.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/packages/plugins/ai/plan/formatAI.plan.md b/packages/plugins/ai/plan/formatAI.plan.md
index dcaa60ee..b6081518 100644
--- a/packages/plugins/ai/plan/formatAI.plan.md
+++ b/packages/plugins/ai/plan/formatAI.plan.md
@@ -11,13 +11,13 @@ By combining deterministic date-time grounding (formatted ISO components, day of
### 2.1 Types (`packages/plugins/ai/src/types/format.type.ts`)
```typescript
-import type { Tempo } from '@magmacomputing/tempo';
+import type { Tempo, DateTime } from '@magmacomputing/tempo';
import type { AiOptions } from './common.type.js';
import type { TempoAiError } from '../core/error.js';
export interface AiFormatOptions extends AiOptions {
/** Reference anchor date for relative calculations (defaults to now). */
- anchor?: Tempo | Date | string | number;
+ anchor?: DateTime;
/** Target IANA timezone (defaults to Tempo instance timezone or global options). */
timeZone?: string;
/** Target BCP 47 locale or language tag (defaults to global options or 'en-US'). */
@@ -29,8 +29,8 @@ export interface AiFormatOptions extends AiOptions {
}
export interface FormatItem {
- /** Date-time instance or string to format. */
- date: Tempo | Date | string | number;
+ /** Date-time instance, Temporal object, or string to format. */
+ date: DateTime;
/** Prompt instructions guiding the output narrative. */
prompt?: string;
}
@@ -50,7 +50,7 @@ export interface TempoAiFormatResult {
### 2.2 Function Signature (`packages/plugins/ai/src/functions/format.ts`)
```typescript
export async function formatAI(items: FormatItem[], options?: AiFormatOptions): Promise<(TempoAiFormatResult | TempoAiError)[]>;
-export async function formatAI(date: any, prompt?: string, options?: AiFormatOptions): Promise;
+export async function formatAI(date: DateTime, prompt?: string, options?: AiFormatOptions): Promise;
```
---
diff --git a/packages/plugins/ai/plan/v0.3.0-roadmap.md b/packages/plugins/ai/plan/v0.3.0-roadmap.md
index 3a9bbfa9..ad1dd460 100644
--- a/packages/plugins/ai/plan/v0.3.0-roadmap.md
+++ b/packages/plugins/ai/plan/v0.3.0-roadmap.md
@@ -18,17 +18,17 @@ This document captures the planned feature set, architectural requirements, and
### 1.4 ✅ `diffAI(start: any, end: any, prompt?: string, options?: AiDiffOptions): Promise`
* Calculates and summarizes the delta between two `Tempo` instances in human, business, or operational terms (e.g., `"5 business days (48 hours)"`), backed by native grounding metrics (calendar days, hours, business days with weekend & holiday exclusion).
+### 1.5 ✅ `formatAI(date: Tempo.DateTime, prompt?: string, options?: AiFormatOptions): Promise`
+* Formats a `Tempo` instance, TC39 `Temporal` object, Date, or timestamp into human-friendly, contextual narrative text tailored to UI tones, relative countdowns, or domain summaries.
+* **Example**: `"this Friday at 5:00 PM EST (in 5 days)"`.
+
---
## 2. Upcoming AI Function Handlers (Post-v0.3.0 Roadmap)
The following functions remain scaffolded for upcoming releases:
-### 2.1 `formatAI(date: any, prompt?: string, options?: AiFormatOptions): Promise`
-* Formats a `Tempo` instance into human-friendly, contextual narrative text tailored to UI tones or relative countdowns.
-* **Example**: `"this Friday at 5:00 PM EST (in 5 days)"`.
-
-### 2.2 `extractAI(text: string, options?: AiExtractOptions): Promise`
+### 2.1 `extractAI(text: string, options?: AiExtractOptions): Promise`
* Scans unstructured text (emails, transcripts, task notes) to extract embedded temporal entities into structured `TempoAiExtractResult` records (`events: TempoExtractedEvent[]`).
diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts
index a43fa1f3..336b08dc 100644
--- a/packages/plugins/ai/src/core/support.ts
+++ b/packages/plugins/ai/src/core/support.ts
@@ -2,100 +2,183 @@ import { Tempo } from '@magmacomputing/tempo';
import { TempoAiError } from './error.js';
import { RESERVED_PROVIDER_IDS } from './config.js';
import { updateRateLimitsFromResponse, _state } from './init.js';
-import type { AiProvider, TempoAiMeta } from '../types/index.js';
+import type { AiCacheAdapter, AiProvider, TempoAiMeta } from '../types/index.js';
export function assertNoReservedProviderId(providers: Partial[]): void {
- for (const p of providers) {
- if (p.id && RESERVED_PROVIDER_IDS.has(p.id.toLowerCase())) {
- throw new TempoAiError(`Provider ID '${p.id}' is a reserved keyword in parseAI.`, 400);
- }
- }
+ for (const p of providers) {
+ if (p.id && RESERVED_PROVIDER_IDS.has(p.id.toLowerCase()))
+ throw new TempoAiError(`Provider ID '${p.id}' is a reserved keyword in parseAI.`, 400);
+ }
}
export function normalizeCacheInput(input: string): string {
- return input.trim().toLowerCase().replace(/\s+/g, ' ');
+ return input.trim().toLowerCase().replace(/\s+/g, ' ');
}
export function getNamespacedCacheKey(namespace: string, key: string): string {
- return `ai:${namespace}::${key}`;
+ return `ai:${namespace}::${key}`;
+}
+
+export function resolveProviderTtl(
+ providerId: string,
+ availableProviders: AiProvider[],
+ callTtl?: number,
+ defaultTtl: number = 86_400_000,
+): number {
+ const providerTtl = providerId === 'consensus'
+ ? availableProviders.reduce((min, p) => p.ttl === undefined ? min : (min === undefined ? p.ttl : Math.min(min, p.ttl)), undefined)
+ : availableProviders.find(p => p.id === providerId)?.ttl;
+ return callTtl ?? providerTtl ?? _state.config.ttl ?? defaultTtl;
+}
+
+export function resolveTzAndLocale(
+ options?: { timeZone?: string | undefined; locale?: string | string[] | undefined } | undefined,
+ fallbackTempo?: Tempo | null,
+): { tz: string; loc: string } {
+ const resolvedOptions = (Tempo as any).options ?? {};
+ const tz = String(options?.timeZone || fallbackTempo?.tz || resolvedOptions.timeZone || _state.config.timeZone || 'UTC');
+ const rawLoc = options?.locale || fallbackTempo?.loc || resolvedOptions.locale || _state.config.locale || 'en-US';
+ const loc = String(Array.isArray(rawLoc) ? rawLoc[0] : rawLoc);
+ return { tz, loc };
+}
+
+export async function readMultiTierCache(
+ cacheKey: string,
+ options: {
+ force?: boolean | undefined;
+ cache?: boolean | undefined;
+ cacheAdapter?: AiCacheAdapter | undefined;
+ debug?: boolean | undefined;
+ tag?: string | undefined;
+ },
+): Promise {
+ if (options.force) return undefined;
+ if (options.cache === false || _state.config.cache === false) return undefined;
+
+ const adapter = options.cacheAdapter || _state.config.cacheAdapter;
+ if (adapter) {
+ try {
+ const val = await adapter.get(cacheKey);
+ if (val !== undefined && val !== null) {
+ if (options.debug) console.log(`[${options.tag ?? 'tempo-plugin-ai'}] Cache hit (adapter): ${cacheKey}`);
+ return val;
+ }
+ } catch (err: any) {
+ if (options.debug) console.warn(`[${options.tag ?? 'tempo-plugin-ai'}] Cache adapter get failed for ${cacheKey}:`, err?.message ?? err);
+ }
+ }
+
+ const localVal = Tempo.cache.get(cacheKey);
+ if (localVal) {
+ if (options.debug) console.log(`[${options.tag ?? 'tempo-plugin-ai'}] Cache hit (local): ${cacheKey}`);
+ return localVal;
+ }
+
+ return undefined;
+}
+
+export async function writeMultiTierCache(
+ cacheKey: string,
+ value: string,
+ ttl: number,
+ options: {
+ cache?: boolean | undefined;
+ cacheAdapter?: AiCacheAdapter | undefined;
+ debug?: boolean | undefined;
+ tag?: string | undefined;
+ },
+): Promise {
+ if (options.cache === false || _state.config.cache === false) return;
+
+ Tempo.cache.set(cacheKey, value);
+
+ const adapter = options.cacheAdapter || _state.config.cacheAdapter;
+ if (adapter) {
+ try {
+ const res = adapter.set(cacheKey, value, ttl);
+ if (res instanceof Promise) await res;
+ } catch (err: any) {
+ if (options.debug) console.warn(`[${options.tag ?? 'tempo-plugin-ai'}] Cache adapter set failed for ${cacheKey}:`, err?.message ?? err);
+ }
+ }
}
export function attachAiMeta(instance: Tempo, meta: TempoAiMeta): Tempo {
- const frozenMeta = Object.freeze(meta);
- const boundMethodCache = new Map();
-
- return new Proxy(instance, {
- get(target, prop, _receiver) {
- if (prop === 'ai') return frozenMeta;
- if (prop === 'isValid') {
- if (meta.confidence === 0.0 || meta.rawIso === 'INVALID' || meta.ambiguous === true || !target.isValid)
- return false;
- }
- if (prop === 'constructor')
- return Reflect.get(target, prop, target);
-
- if (boundMethodCache.has(prop))
- return boundMethodCache.get(prop);
-
- const val = Reflect.get(target, prop, target);
- if (typeof val === 'function') {
- const bound = val.bind(target);
- boundMethodCache.set(prop, bound);
- return bound;
- }
- return val;
- },
- has(target, prop) {
- if (prop === 'ai') return true;
- return Reflect.has(target, prop);
- },
- getOwnPropertyDescriptor(target, prop) {
- if (prop === 'ai') {
- return {
- value: frozenMeta,
- writable: false,
- configurable: true,
- enumerable: true
- };
- }
- return Reflect.getOwnPropertyDescriptor(target, prop);
- },
- ownKeys(target) {
- const keys = Reflect.ownKeys(target);
- if (!keys.includes('ai')) keys.push('ai');
- return keys;
- }
- });
+ const frozenMeta = Object.freeze(meta);
+ const boundMethodCache = new Map();
+
+ return new Proxy(instance, {
+ get(target, prop, _receiver) {
+ if (prop === 'ai') return frozenMeta;
+ if (prop === 'isValid') {
+ if (meta.confidence === 0.0 || meta.rawIso === 'INVALID' || meta.ambiguous === true || !target.isValid)
+ return false;
+ }
+ if (prop === 'constructor')
+ return Reflect.get(target, prop, target);
+
+ if (boundMethodCache.has(prop))
+ return boundMethodCache.get(prop);
+
+ const val = Reflect.get(target, prop, target);
+ if (typeof val === 'function') {
+ const bound = val.bind(target);
+ boundMethodCache.set(prop, bound);
+ return bound;
+ }
+ return val;
+ },
+ has(target, prop) {
+ if (prop === 'ai') return true;
+ return Reflect.has(target, prop);
+ },
+ getOwnPropertyDescriptor(target, prop) {
+ if (prop === 'ai') {
+ return {
+ value: frozenMeta,
+ writable: false,
+ configurable: true,
+ enumerable: true
+ };
+ }
+ return Reflect.getOwnPropertyDescriptor(target, prop);
+ },
+ ownKeys(target) {
+ const keys = Reflect.ownKeys(target);
+ if (!keys.includes('ai')) keys.push('ai');
+ return keys;
+ }
+ });
}
export async function fetchFromProvider(
- provider: AiProvider,
- str: string,
- contextString: string,
- isDebug: boolean,
- parentSignal?: AbortSignal,
- timeoutOverride?: number,
- customSystemPrompt?: string
+ provider: AiProvider,
+ str: string,
+ contextString: string,
+ isDebug: boolean,
+ parentSignal?: AbortSignal,
+ timeoutOverride?: number,
+ customSystemPrompt?: string
): Promise<{ rawContent: string; providerId: string; rateLimits: ReturnType }> {
- const url = provider.url!;
- const model = provider.model!;
+ const url = provider.url!;
+ const model = provider.model!;
- if (!url || typeof url !== 'string')
- throw new TempoAiError(`Provider ${provider.id} missing valid endpoint URL.`, 400);
+ if (!url || typeof url !== 'string')
+ throw new TempoAiError(`Provider ${provider.id} missing valid endpoint URL.`, 400);
- if (!model || typeof model !== 'string')
- throw new TempoAiError(`Provider ${provider.id} missing valid model identifier.`, 400);
+ if (!model || typeof model !== 'string')
+ throw new TempoAiError(`Provider ${provider.id} missing valid model identifier.`, 400);
- try {
- const parsed = new URL(url);
- if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')))
- throw new TempoAiError(`Provider ${provider.id} endpoint URL '${url}' must use secure HTTPS protocol.`, 400);
- } catch (err: any) {
- if (err instanceof TempoAiError) throw err;
- throw new TempoAiError(`Provider ${provider.id} has invalid endpoint URL '${url}'.`, 400);
- }
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')))
+ throw new TempoAiError(`Provider ${provider.id} endpoint URL '${url}' must use secure HTTPS protocol.`, 400);
+ } catch (err: any) {
+ if (err instanceof TempoAiError) throw err;
+ throw new TempoAiError(`Provider ${provider.id} has invalid endpoint URL '${url}'.`, 400);
+ }
- const defaultSystemPrompt = `You are a high-performance date parser. Read the user's string and the provided context. Return ONLY a valid JSON object matching this exact schema:
+ const defaultSystemPrompt = `You are a high-performance date parser. Read the user's string and the provided context. Return ONLY a valid JSON object matching this exact schema:
{
"reasoning": "Step-by-step calendar math from Current Time.",
"iso": "Local ISO 8601 string (YYYY-MM-DDThh:mm:ss) without offset or Z suffix, or 'INVALID' if ambiguous/unparseable.",
@@ -113,7 +196,7 @@ Ambiguity Rules:
Do not include markdown blocks or any text outside the JSON.`;
- const systemPrompt = customSystemPrompt ?? defaultSystemPrompt;
+ const systemPrompt = customSystemPrompt ?? defaultSystemPrompt;
if (isDebug)
console.log(`[tempo-plugin-ai] Querying provider '${provider.id}' (model: ${model})...`);
diff --git a/packages/plugins/ai/src/functions/context.ts b/packages/plugins/ai/src/functions/context.ts
index b9ce90b4..b0a88dc7 100644
--- a/packages/plugins/ai/src/functions/context.ts
+++ b/packages/plugins/ai/src/functions/context.ts
@@ -3,7 +3,14 @@ import { TempoAiError } from '../core/error.js';
import { AiMode } from '../core/config.js';
import { _state } from '../core/init.js';
import { executeWithMode } from '../core/dispatch.js';
-import { normalizeCacheInput, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js';
+import {
+ assertNoReservedProviderId,
+ fetchFromProvider,
+ normalizeCacheInput,
+ readMultiTierCache,
+ resolveProviderTtl,
+ writeMultiTierCache,
+} from '../core/support.js';
import { RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX } from '../core/patterns.js';
import type { TempoContext, AiContextOptions } from '../types/index.js';
@@ -23,21 +30,13 @@ async function contextSingleInput(text: string, options?: AiContextOptions): Pro
const cacheKey = `context::${normalizedStr}::${tz}::${loc}::${cal}::${sph}`;
const adapter = cacheAdapter ?? _state.config.cacheAdapter;
- let cachedVal: string | undefined;
- if (!force && aiCacheOption !== false) {
- if (adapter) {
- try {
- const val = await adapter.get(cacheKey);
- if (val) {
- cachedVal = val;
- }
- } catch (err: any) {
- if (isDebug) console.log('[tempo-plugin-ai:context] Cache adapter read error:', err?.message);
- }
- }
-
- cachedVal ??= Tempo.cache.get(cacheKey);
- }
+ const cachedVal = await readMultiTierCache(cacheKey, {
+ force,
+ cache: aiCacheOption,
+ cacheAdapter: adapter,
+ debug: isDebug,
+ tag: 'tempo-plugin-ai:context',
+ });
if (cachedVal) {
try {
@@ -163,34 +162,25 @@ Do not include markdown blocks or text outside the JSON.`;
confidence,
provider: providerId,
reasoning,
- };
-
- if (aiCacheOption !== false) {
- const providerTtl = providerId === AiMode.Consensus
- ? availableProviders.reduce((min, p) => p.ttl === undefined ? min : (min === undefined ? p.ttl : Math.min(min, p.ttl)), undefined)
- : availableProviders.find(p => p.id === providerId)?.ttl;
- const resolvedTtl = ttl ?? providerTtl ?? _state.config.ttl ?? 86_400_000; // Default to 24 hours for context
-
- const cacheVal = JSON.stringify({
- timeZone: finalResult.timeZone,
- locale: finalResult.locale,
- calendar: finalResult.calendar,
- sphere: finalResult.sphere,
- confidence: finalResult.confidence,
- reasoning: finalResult.reasoning,
- });
-
- if (adapter) {
- try {
- const res = adapter.set(cacheKey, cacheVal, resolvedTtl);
- if (res instanceof Promise) await res;
- } catch (err: any) {
- if (isDebug) console.log('[tempo-plugin-ai:context] Cache adapter write error:', err?.message);
- }
- }
- Tempo.cache.set(cacheKey, cacheVal);
}
+ const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000);
+ const cacheVal = JSON.stringify({
+ timeZone: finalResult.timeZone,
+ locale: finalResult.locale,
+ calendar: finalResult.calendar,
+ sphere: finalResult.sphere,
+ confidence: finalResult.confidence,
+ reasoning: finalResult.reasoning,
+ });
+
+ await writeMultiTierCache(cacheKey, cacheVal, resolvedTtl, {
+ cache: aiCacheOption,
+ cacheAdapter: adapter,
+ debug: isDebug,
+ tag: 'tempo-plugin-ai:context',
+ });
+
return finalResult;
}
diff --git a/packages/plugins/ai/src/functions/diff.ts b/packages/plugins/ai/src/functions/diff.ts
index 686bd33c..6936bd5c 100644
--- a/packages/plugins/ai/src/functions/diff.ts
+++ b/packages/plugins/ai/src/functions/diff.ts
@@ -3,7 +3,14 @@ import { TempoAiError } from '../core/error.js';
import { AiMode } from '../core/config.js';
import { _state } from '../core/init.js';
import { executeWithMode } from '../core/dispatch.js';
-import { normalizeCacheInput, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js';
+import {
+ assertNoReservedProviderId,
+ fetchFromProvider,
+ normalizeCacheInput,
+ readMultiTierCache,
+ resolveProviderTtl,
+ writeMultiTierCache,
+} from '../core/support.js';
import { RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX } from '../core/patterns.js';
import type { TempoAiDiffResult, AiDiffOptions, DiffPair } from '../types/index.js';
@@ -84,19 +91,13 @@ async function diffSingleInput(
const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence;
const effectiveHedgeDelay = hedgeDelay ?? _state.config.hedgeDelay;
- let cachedVal: string | undefined;
- if (!force && aiCacheOption !== false) {
- if (adapter) {
- try {
- const val = await adapter.get(cacheKey);
- if (val) cachedVal = val;
- } catch (err: any) {
- if (isDebug) console.log('[tempo-plugin-ai:diff] Cache adapter read error:', err?.message);
- }
- }
-
- cachedVal ??= Tempo.cache.get(cacheKey);
- }
+ const cachedVal = await readMultiTierCache(cacheKey, {
+ force,
+ cache: aiCacheOption,
+ cacheAdapter: adapter,
+ debug: isDebug,
+ tag: 'tempo-plugin-ai:diff',
+ });
if (cachedVal) {
try {
@@ -228,32 +229,23 @@ Do not include markdown blocks or text outside the JSON.`;
reasoning: parsedData.reasoning,
};
- if (aiCacheOption !== false) {
- const providerTtl = providerId === AiMode.Consensus
- ? availableProviders.reduce((min, p) => p.ttl === undefined ? min : (min === undefined ? p.ttl : Math.min(min, p.ttl)), undefined)
- : availableProviders.find(p => p.id === providerId)?.ttl;
- const resolvedTtl = ttl ?? providerTtl ?? _state.config.ttl ?? 86_400_000;
-
- const cacheVal = JSON.stringify({
- formatted: finalResult.formatted,
- days: finalResult.days,
- hours: finalResult.hours,
- businessDays: finalResult.businessDays,
- holidays: finalResult.holidays,
- confidence: finalResult.confidence,
- reasoning: finalResult.reasoning,
- });
-
- if (adapter) {
- try {
- const res = adapter.set(cacheKey, cacheVal, resolvedTtl);
- if (res instanceof Promise) await res;
- } catch (err: any) {
- if (isDebug) console.log('[tempo-plugin-ai:diff] Cache adapter write error:', err?.message);
- }
- }
- Tempo.cache.set(cacheKey, cacheVal);
- }
+ const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000);
+ const cacheVal = JSON.stringify({
+ formatted: finalResult.formatted,
+ days: finalResult.days,
+ hours: finalResult.hours,
+ businessDays: finalResult.businessDays,
+ holidays: finalResult.holidays,
+ confidence: finalResult.confidence,
+ reasoning: finalResult.reasoning,
+ });
+
+ await writeMultiTierCache(cacheKey, cacheVal, resolvedTtl, {
+ cache: aiCacheOption,
+ cacheAdapter: adapter,
+ debug: isDebug,
+ tag: 'tempo-plugin-ai:diff',
+ });
return finalResult;
}
diff --git a/packages/plugins/ai/src/functions/format.ts b/packages/plugins/ai/src/functions/format.ts
index 1b16a57a..3b835418 100644
--- a/packages/plugins/ai/src/functions/format.ts
+++ b/packages/plugins/ai/src/functions/format.ts
@@ -1,72 +1,266 @@
-import type { Tempo } from '@magmacomputing/tempo';
-import type { TempoAiError } from '../core/error.js';
-import type { AiMode } from '../core/config.js';
-import type { AiCacheAdapter, AiProvider } from '../types/common.type.js';
-
-export interface FormatItem {
- /** Date-time instance or string to format. */
- date: Tempo | Date | string | number;
- /** Prompt instructions guiding the output narrative. */
- prompt?: string | undefined;
+import { Tempo } from '@magmacomputing/tempo';
+import { TempoAiError } from '../core/error.js';
+import { AiMode } from '../core/config.js';
+import { _state } from '../core/init.js';
+import { executeWithMode } from '../core/dispatch.js';
+import {
+ assertNoReservedProviderId,
+ fetchFromProvider,
+ normalizeCacheInput,
+ readMultiTierCache,
+ resolveProviderTtl,
+ resolveTzAndLocale,
+ writeMultiTierCache,
+} from '../core/support.js';
+import type { AiFormatOptions, FormatItem, TempoAiFormatResult } from '../types/format.type.js';
+
+export type { AiFormatOptions, FormatItem, TempoAiFormatResult };
+
+interface FormatGroundingMetrics {
+ iso: string;
+ timeZone: string;
+ dayOfWeek: string;
+ dayOfWeekOrdinal: number;
+ calendarDays: number;
+ elapsedHours: number;
+ direction: 'past' | 'present' | 'future';
}
-export interface TempoAiFormatResult {
- /** Formatted narrative string. */
- formatted: string;
- /** Confidence score between 0.0 and 1.0. */
- confidence: number;
- /** ID of the provider that fulfilled the request (or 'cache'). */
- provider: string;
- /** Optional step-by-step rationale from the LLM. */
- reasoning?: string | undefined;
+function calculateFormatGroundingMetrics(targetTempo: Tempo, anchorTempo: Tempo): FormatGroundingMetrics {
+ const iso = targetTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}');
+ const timeZone = targetTempo.tz || 'UTC';
+ const dayOfWeekOrdinal = targetTempo.dow;
+ const weekdayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
+ const dayOfWeek = weekdayNames[dayOfWeekOrdinal] || targetTempo.format('{www}');
+
+ const calendarDays = Math.round(anchorTempo.until(targetTempo, 'day') * 100) / 100;
+ const elapsedHours = Math.round(anchorTempo.until(targetTempo, 'hour') * 100) / 100;
+
+ let direction: 'past' | 'present' | 'future' = 'future';
+ if (calendarDays < 0 || elapsedHours < 0) {
+ direction = 'past';
+ } else if (calendarDays === 0 && elapsedHours === 0) {
+ direction = 'present';
+ }
+
+ return {
+ iso,
+ timeZone,
+ dayOfWeek,
+ dayOfWeekOrdinal,
+ calendarDays,
+ elapsedHours,
+ direction,
+ };
}
-export interface AiFormatOptions {
- /** Reference anchor date for relative calculations (defaults to now). */
- anchor?: Tempo | Date | string | number | undefined;
- /** Target IANA timezone (defaults to Tempo instance timezone or global options). */
- timeZone?: string | undefined;
- /** Target BCP 47 locale or language tag (defaults to global options or 'en-US'). */
- locale?: string | string[] | undefined;
- /** Desired narrative tone or formatting style hint (e.g. 'casual', 'formal', 'compact', 'countdown'). */
- style?: string | undefined;
- /** Custom regional context (e.g. 'AU-NSW', 'US-CA'). */
- region?: string | undefined;
- /** If true, bypasses cache to force a fresh LLM fetch */
- force?: boolean | undefined;
- /** If false, disables reading and writing to cache */
- cache?: boolean | undefined;
- /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */
- cacheAdapter?: AiCacheAdapter | undefined;
- /** Optional TTL override in milliseconds for cached result */
- ttl?: number | undefined;
- /** If true, logs prompt context and LLM payloads to console */
- debug?: boolean | undefined;
- /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` | `AiMode.Hedged` | `AiMode.RoundRobin` | `AiMode.Adaptive` or string literal) */
- mode?: AiMode | undefined;
- /** Per-request provider configuration overrides */
- providers?: AiProvider[] | undefined;
- /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */
- minConfidence?: number | undefined;
- /** If true, returns TempoAiError into array index position instead of rejecting batch */
- softErrors?: boolean | undefined;
- /** Optional request timeout in milliseconds (overrides provider and global timeout) */
- timeout?: number | undefined;
- /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */
- hedgeDelay?: number | undefined;
- /** Allow extra custom properties */
- [key: string]: any;
+async function formatSingleInput(
+ date: Tempo.DateTime,
+ prompt?: string,
+ options?: AiFormatOptions,
+): Promise {
+ const isDebug = options?.debug ?? _state.config.debug ?? false;
+ const { tz, loc } = resolveTzAndLocale(options, Tempo.isTempo(date) ? date : null);
+
+ let targetTempo: Tempo;
+ try {
+ targetTempo = Tempo.isTempo(date)
+ ? (date.tz === tz ? date : date.set({ timeZone: tz }))
+ : new Tempo(date as any, { timeZone: tz });
+ } catch (err: any) {
+ throw new TempoAiError(`Invalid date provided to formatAI: "${String(date)}"`, 400);
+ }
+
+ if (!targetTempo.isValid) {
+ throw new TempoAiError(`Invalid date provided to formatAI: "${String(date)}"`, 400);
+ }
+
+ const anchor = options?.anchor;
+ let anchorTempo: Tempo;
+ try {
+ anchorTempo = anchor !== undefined
+ ? (Tempo.isTempo(anchor)
+ ? (anchor.tz === tz ? anchor : anchor.set({ timeZone: tz }))
+ : new Tempo(anchor as any, { timeZone: tz }))
+ : new Tempo(undefined, { timeZone: tz });
+ } catch (err: any) {
+ throw new TempoAiError(`Invalid anchor date provided to formatAI: "${String(anchor)}"`, 400);
+ }
+
+ if (!anchorTempo.isValid) {
+ throw new TempoAiError(`Invalid anchor date provided to formatAI: "${String(anchor)}"`, 400);
+ }
+
+ const style = options?.style ? String(options.style).trim() : '';
+ const region = options?.region ? String(options.region).trim() : '';
+ const grounding = calculateFormatGroundingMetrics(targetTempo, anchorTempo);
+
+ const promptText = prompt?.trim() || 'Express this date and time in a clear, human-friendly narrative.';
+ const normalizedPrompt = normalizeCacheInput(promptText);
+
+ const {
+ force,
+ mode: aiMode,
+ providers,
+ minConfidence,
+ cache: aiCacheOption,
+ timeout: callTimeout,
+ ttl,
+ cacheAdapter,
+ hedgeDelay,
+ } = options || {};
+
+ const cacheKey = `format::${targetTempo.epoch.ms}::${anchorTempo.epoch.ms}::${normalizedPrompt}::${tz}::${loc}::${region}::${style}`;
+ const adapter = cacheAdapter ?? _state.config.cacheAdapter;
+
+ const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence;
+ const effectiveHedgeDelay = hedgeDelay ?? _state.config.hedgeDelay;
+
+ const cachedVal = await readMultiTierCache(cacheKey, {
+ force,
+ cache: aiCacheOption,
+ cacheAdapter: adapter,
+ debug: isDebug,
+ tag: 'tempo-plugin-ai:format',
+ });
+
+ if (cachedVal) {
+ try {
+ const parsedCache = JSON.parse(cachedVal);
+ if (typeof parsedCache?.formatted === 'string' && parsedCache.formatted.trim().length > 0) {
+ const cachedConfidence = typeof parsedCache.confidence === 'number' && Number.isFinite(parsedCache.confidence)
+ ? parsedCache.confidence
+ : 1.0;
+ if (effectiveMinConfidence === undefined || cachedConfidence >= effectiveMinConfidence) {
+ if (isDebug) console.log(`[tempo-plugin-ai:format] Cache hit: "${cacheKey}" -> ${cachedVal}`);
+ return {
+ formatted: parsedCache.formatted,
+ confidence: cachedConfidence,
+ provider: 'cache',
+ reasoning: parsedCache.reasoning,
+ };
+ }
+ }
+ } catch {
+ // If cached value is corrupted, proceed to fetch
+ }
+ }
+
+ const availableProviders = providers || _state.config.providers;
+ if (!availableProviders || availableProviders.length === 0) {
+ throw new TempoAiError('No AI providers configured. Please call initAI().', 400);
+ }
+
+ assertNoReservedProviderId(availableProviders);
+
+ const mode = aiMode || _state.config.mode || AiMode.Fallback;
+
+ const contextString = `Grounding Context:
+- Target Date-Time: ${grounding.iso} (${grounding.timeZone})
+- Day of Week: ${grounding.dayOfWeek} (Day ${grounding.dayOfWeekOrdinal})
+- Reference Anchor: ${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')} (${anchorTempo.tz || tz})
+- Relative Delta: ${grounding.calendarDays >= 0 ? '+' : ''}${grounding.calendarDays} calendar days (${grounding.elapsedHours >= 0 ? '+' : ''}${grounding.elapsedHours} hours) in the ${grounding.direction.toUpperCase()}
+- Target Locale: ${loc}
+${style ? `- Desired Style/Tone: ${style}` : ''}
+${region ? `- Regional Context: ${region}` : ''}
+- Formatting Instructions: "${promptText}"`;
+
+ const systemPrompt = `You are a high-performance narrative date formatter. Your task is to format the given Target Date-Time according to the Formatting Instructions, Style, and Target Locale, strictly respecting the mathematical Grounding Context provided. Return ONLY a valid JSON object matching this schema:
+{
+ "formatted": "Contextual narrative string (e.g. 'this Friday at 5:00 PM EST (in 2 days)')",
+ "confidence": 0.98,
+ "reasoning": "Brief explanation of how the narrative reflects the grounding context and prompt."
+}
+
+Rules:
+- Never hallucinate the weekday, date, or relative offset; adhere strictly to the Grounding Context.
+- Apply the requested tone/style and locale conventions.
+- Confidence must be a float between 0.0 and 1.0.
+- Do not include markdown blocks or any text outside the JSON.`;
+
+ const winningCandidate = await executeWithMode(
+ mode,
+ availableProviders,
+ async (provider, signal) => {
+ const { rawContent, providerId, rateLimits } = await fetchFromProvider(
+ provider,
+ promptText,
+ contextString,
+ isDebug,
+ signal,
+ callTimeout,
+ systemPrompt,
+ );
+
+ let parsedData: any;
+ try {
+ parsedData = JSON.parse(rawContent);
+ } catch (err: any) {
+ throw new TempoAiError(`Provider ${providerId} returned invalid JSON: ${err?.message}`, 422);
+ }
+
+ const formatted = typeof parsedData?.formatted === 'string' ? parsedData.formatted.trim() : '';
+ if (!formatted) {
+ throw new TempoAiError(`Provider ${providerId} returned empty formatted string.`, 422);
+ }
+
+ const confidence = typeof parsedData?.confidence === 'number' && Number.isFinite(parsedData.confidence)
+ ? parsedData.confidence
+ : 0.9;
+ const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined;
+
+ return {
+ data: {
+ formatted,
+ reasoning,
+ },
+ providerId,
+ rateLimits,
+ confidence,
+ consensusKey: formatted.toLowerCase(),
+ };
+ },
+ { minConfidence: effectiveMinConfidence, debug: isDebug, tag: 'tempo-plugin-ai:format', hedgeDelay: effectiveHedgeDelay },
+ );
+
+ _state.limits = winningCandidate.rateLimits ?? null;
+
+ const { data: parsedData, providerId } = winningCandidate;
+ const confidence = typeof winningCandidate.confidence === 'number' && Number.isFinite(winningCandidate.confidence)
+ ? winningCandidate.confidence
+ : 0.9;
+
+ if (effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence) {
+ throw new TempoAiError(`formatAI confidence (${confidence}) is below the required threshold of ${effectiveMinConfidence}.`, 422);
+ }
+
+ const finalResult: TempoAiFormatResult = {
+ formatted: parsedData.formatted,
+ confidence,
+ provider: providerId,
+ reasoning: parsedData.reasoning,
+ };
+
+ const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000);
+ const cacheVal = JSON.stringify(finalResult);
+ await writeMultiTierCache(cacheKey, cacheVal, resolvedTtl, {
+ cache: aiCacheOption,
+ cacheAdapter: adapter,
+ debug: isDebug,
+ tag: 'tempo-plugin-ai:format',
+ });
+
+ return finalResult;
}
/**
- * @internal Draft implementation scaffolded for future releases.
- * ## formatAI (Upcoming Export)
- * Formats a `Tempo` instance into human-friendly, contextual narrative text
+ * ## formatAI
+ * Formats a `Tempo` instance, Temporal object, Date, or timestamp into human-friendly, contextual narrative text
* tailored to specific UI tones, relative time frames, or business domains.
*
* ### Why it fits Tempo:
* Expands core `.format('{yyyy}-{mm}-{dd}')` into contextual, localized human
- * descriptions that token patterns alone cannot capture.
+ * descriptions that token patterns alone cannot capture, backed by mathematical grounding.
*
* ### Example Usage:
* ```ts
@@ -78,11 +272,35 @@ export interface AiFormatOptions {
* ```
*/
export async function formatAI(items: FormatItem[], options?: AiFormatOptions): Promise<(TempoAiFormatResult | TempoAiError)[]>;
-export async function formatAI(date: any, prompt?: string, options?: AiFormatOptions): Promise;
+export async function formatAI(date: Tempo.DateTime, prompt?: string, options?: AiFormatOptions): Promise;
export async function formatAI(
- dateOrItems: any,
- _promptOrOptions?: string | AiFormatOptions,
- _options?: AiFormatOptions,
+ dateOrItems: Tempo.DateTime | FormatItem[],
+ promptOrOptions?: string | AiFormatOptions,
+ options?: AiFormatOptions,
): Promise {
- throw new Error('formatAI is not yet implemented in tempo-plugin-ai.');
+ if (Array.isArray(dateOrItems)) {
+ const opts = (typeof promptOrOptions === 'object' && promptOrOptions !== null ? promptOrOptions : options) || {};
+ const softErrors = opts.softErrors ?? false;
+
+ if (softErrors) {
+ const settled = await Promise.allSettled(
+ dateOrItems.map(item => formatSingleInput(item.date, item.prompt, opts)),
+ );
+ return settled.map((res, index) => {
+ if (res.status === 'fulfilled') return res.value;
+ const rawReason = res.reason;
+ if (rawReason instanceof TempoAiError) return rawReason;
+ return new TempoAiError(
+ rawReason?.message || `Failed to format date at index ${index}`,
+ typeof rawReason?.status === 'number' ? rawReason.status : 500,
+ );
+ });
+ }
+
+ return Promise.all(dateOrItems.map(item => formatSingleInput(item.date, item.prompt, opts)));
+ }
+
+ const prompt = typeof promptOrOptions === 'string' ? promptOrOptions : undefined;
+ const opts = typeof promptOrOptions === 'object' && promptOrOptions !== null ? promptOrOptions : options;
+ return formatSingleInput(dateOrItems, prompt, opts);
}
diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts
index b9f7ed33..8fd1cf4d 100644
--- a/packages/plugins/ai/src/functions/parse.ts
+++ b/packages/plugins/ai/src/functions/parse.ts
@@ -15,9 +15,9 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise<
let tz: string, cal: string, loc: string, sph: string, anchorStr: string;
if (Tempo.isTempo(options?.anchor)) {
- tz = String(options!.timeZone || options!.anchor.config.timeZone);
- cal = String(options!.calendar || options!.anchor.config.calendar);
- loc = String(Array.isArray(options!.locale) ? options!.locale[0] : (options!.locale || options!.anchor.config.locale));
+ tz = String(options!.timeZone || options!.anchor.tz);
+ cal = String(options!.calendar || options!.anchor.cal);
+ loc = String(Array.isArray(options!.locale) ? options!.locale[0] : (options!.locale || options!.anchor.loc));
sph = String(options!.sphere || options!.anchor.config.sphere || 'north');
anchorStr = options!.anchor.toString();
} else {
diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts
index 365ec4a9..10380662 100644
--- a/packages/plugins/ai/src/functions/recurrence.ts
+++ b/packages/plugins/ai/src/functions/recurrence.ts
@@ -116,9 +116,9 @@ export async function recurrenceAI(
const isRRule = isRRuleString(input);
// Resolve full Tempo context hierarchy
- const tz = options?.timeZone || (options?.anchor instanceof Tempo ? options.anchor.config.timeZone : undefined) || Tempo.options.timeZone;
- const cal = options?.calendar || (options?.anchor instanceof Tempo ? options.anchor.config.calendar : undefined) || Tempo.options.calendar;
- const loc = options?.locale || (options?.anchor instanceof Tempo ? options.anchor.config.locale : undefined) || Tempo.options.locale;
+ const tz = options?.timeZone || (options?.anchor instanceof Tempo ? options.anchor.tz : undefined) || Tempo.options.timeZone;
+ const cal = options?.calendar || (options?.anchor instanceof Tempo ? options.anchor.cal : undefined) || Tempo.options.calendar;
+ const loc = options?.locale || (options?.anchor instanceof Tempo ? options.anchor.loc : undefined) || Tempo.options.locale;
const sph = options?.sphere || (options?.anchor instanceof Tempo ? options.anchor.config.sphere : undefined) || Tempo.options.sphere;
const contextConfig = { timeZone: tz, calendar: cal, locale: loc, sphere: sph };
diff --git a/packages/plugins/ai/src/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts
index cff5952d..330c9224 100644
--- a/packages/plugins/ai/src/functions/schedule.ts
+++ b/packages/plugins/ai/src/functions/schedule.ts
@@ -183,7 +183,7 @@ export async function scheduleAI(
assertNoReservedProviderId(availableProviders);
const resolvedTz = options?.timeZone
- || (options?.anchor instanceof Tempo ? options.anchor.config?.timeZone || options.anchor.tz : undefined)
+ || (options?.anchor instanceof Tempo ? options.anchor.tz : undefined)
|| Tempo.options?.timeZone
|| 'UTC';
const anchorTempo = new Tempo(options?.anchor, { timeZone: resolvedTz });
diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts
index 30c1ec71..357ac12d 100644
--- a/packages/plugins/ai/src/index.ts
+++ b/packages/plugins/ai/src/index.ts
@@ -15,6 +15,7 @@ export { recurrenceAI } from './functions/recurrence.js';
export { scheduleAI } from './functions/schedule.js';
export { contextAI } from './functions/context.js';
export { diffAI } from './functions/diff.js';
+export { formatAI } from './functions/format.js';
/*
* ============================================================================
@@ -24,8 +25,5 @@ export { diffAI } from './functions/diff.js';
* Uncomment these exports as their implementations are finalized.
*/
-// /** Formats a Tempo instance into human-friendly, contextual narrative text */
-// export { formatAI, type TempoAiFormatResult, type FormatItem, type AiFormatOptions } from './functions/format.js';
-
// /** Scans unstructured text and extracts embedded temporal entities & events */
// export { extractAI, type TempoAiExtractResult, type TempoExtractedEvent, type TempoEvent, type AiExtractOptions } from './functions/extract.js';
diff --git a/packages/plugins/ai/src/types/common.type.ts b/packages/plugins/ai/src/types/common.type.ts
index 6baf84bc..e8d27d0c 100644
--- a/packages/plugins/ai/src/types/common.type.ts
+++ b/packages/plugins/ai/src/types/common.type.ts
@@ -73,10 +73,14 @@ export interface AiConfig {
mode?: AiMode | undefined;
/** Strict minimum confidence threshold (0.0 to 1.0) */
minConfidence?: number | undefined;
- /** Optional custom cache implementation for storing parsed strings */
- cache?: Map | undefined;
+ /** Optional custom cache implementation for storing parsed strings or boolean flag to enable/disable */
+ cache?: Map | boolean | undefined;
/** Optional custom cache storage engine (e.g., Redis, KV store) for storing parsed strings */
cacheAdapter?: AiCacheAdapter | undefined;
+ /** Optional default IANA timezone for AI operations */
+ timeZone?: string | undefined;
+ /** Optional default BCP 47 locale for AI operations */
+ locale?: string | string[] | undefined;
/** Optional global cache TTL in milliseconds for AI parsing entries (default: 3600000ms / 1 hour) */
ttl?: number | undefined;
/** Optional global timeout in milliseconds for AI requests (default: 15000ms) */
diff --git a/packages/plugins/ai/src/types/format.type.ts b/packages/plugins/ai/src/types/format.type.ts
new file mode 100644
index 00000000..0210cffa
--- /dev/null
+++ b/packages/plugins/ai/src/types/format.type.ts
@@ -0,0 +1,56 @@
+import type { Tempo } from '@magmacomputing/tempo';
+import type { AiMode } from '../core/config.js';
+import type { AiCacheAdapter, AiProvider } from './common.type.js';
+
+export interface FormatItem {
+ /** Date-time instance, Temporal object, or string to format. */
+ date: Tempo.DateTime;
+ /** Prompt instructions guiding the output narrative. */
+ prompt?: string | undefined;
+}
+
+export interface TempoAiFormatResult {
+ /** Formatted narrative string. */
+ formatted: string;
+ /** Confidence score between 0.0 and 1.0. */
+ confidence: number;
+ /** ID of the provider that fulfilled the request (or 'cache'). */
+ provider: string;
+ /** Optional step-by-step rationale from the LLM. */
+ reasoning?: string | undefined;
+}
+
+export interface AiFormatOptions {
+ /** Reference anchor date for relative calculations (defaults to now). */
+ anchor?: Tempo.DateTime | undefined;
+ /** Target IANA timezone (defaults to Tempo instance timezone or global options). */
+ timeZone?: string | undefined;
+ /** Target BCP 47 locale or language tag (defaults to global options or 'en-US'). */
+ locale?: string | string[] | undefined;
+ /** Desired narrative tone or formatting style hint (e.g. 'casual', 'formal', 'compact', 'countdown'). */
+ style?: string | undefined;
+ /** Custom regional context (e.g. 'AU-NSW', 'US-CA'). */
+ region?: string | undefined;
+ /** If true, bypasses cache to force a fresh LLM fetch */
+ force?: boolean | undefined;
+ /** If false, disables reading and writing to cache */
+ cache?: boolean | undefined;
+ /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */
+ cacheAdapter?: AiCacheAdapter | undefined;
+ /** Optional TTL override in milliseconds for cached result */
+ ttl?: number | undefined;
+ /** If true, logs prompt context and LLM payloads to console */
+ debug?: boolean | undefined;
+ /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` | `AiMode.Hedged` | `AiMode.RoundRobin` | `AiMode.Adaptive` or string literal) */
+ mode?: AiMode | undefined;
+ /** Per-request provider configuration overrides */
+ providers?: AiProvider[] | undefined;
+ /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */
+ minConfidence?: number | undefined;
+ /** Optional request timeout in milliseconds for this operation */
+ timeout?: number | undefined;
+ /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged */
+ hedgeDelay?: number | undefined;
+ /** If true, returns an array containing both successful results and TempoAiErrors instead of throwing */
+ softErrors?: boolean | undefined;
+}
diff --git a/packages/plugins/ai/src/types/index.ts b/packages/plugins/ai/src/types/index.ts
index 9e85801a..f35d3bc9 100644
--- a/packages/plugins/ai/src/types/index.ts
+++ b/packages/plugins/ai/src/types/index.ts
@@ -4,4 +4,5 @@ export * from './recurrence.type.js';
export * from './schedule.type.js';
export * from './context.type.js';
export * from './diff.type.js';
+export * from './format.type.js';
diff --git a/packages/plugins/ai/test/format.test.ts b/packages/plugins/ai/test/format.test.ts
new file mode 100644
index 00000000..389bf4b4
--- /dev/null
+++ b/packages/plugins/ai/test/format.test.ts
@@ -0,0 +1,259 @@
+import { Tempo } from '@magmacomputing/tempo';
+import { formatAI, initAI, TempoAiError, type TempoAiFormatResult, type AiCacheAdapter } from '../src/index.js';
+
+describe('AI Format Plugin (formatAI)', () => {
+ beforeEach(async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
+ vi.spyOn(console, 'error').mockImplementation(() => { });
+ vi.spyOn(console, 'log').mockImplementation(() => { });
+ await initAI({ remoteConfigUrl: false, providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should calculate native grounding metrics and format natural narrative date', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: 'this Friday at 5:00 PM EST (in 5 days)',
+ confidence: 0.98,
+ reasoning: 'Target date is a Friday, exactly 5 calendar days away.',
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const target = new Tempo('2026-08-07T17:00:00Z');
+ const anchor = new Tempo('2026-08-02T17:00:00Z');
+
+ const result = await formatAI(target, 'friendly reminder tone with countdown', { anchor });
+ expect(result).toBeDefined();
+ expect(result.formatted).toBe('this Friday at 5:00 PM EST (in 5 days)');
+ expect(result.confidence).toBe(0.98);
+ expect(result.provider).toBe('groq');
+ expect(result.reasoning).toContain('Friday');
+
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
+ const systemPrompt = requestBody.messages[0].content;
+ expect(systemPrompt).toContain('Grounding Context:');
+ expect(systemPrompt).toContain('Day of Week: Friday');
+ expect(systemPrompt).toContain('+5 calendar days');
+ expect(systemPrompt).toContain('in the FUTURE');
+ });
+
+ it('should accept TC39 Temporal instances as valid date inputs', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: 'Tomorrow afternoon at 3:00 PM',
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const temporalZdt = new Tempo('2026-08-05T15:00:00+10:00[Australia/Sydney]').toDateTime();
+ const result = await formatAI(temporalZdt, 'compact relative format');
+
+ expect(result.formatted).toBe('Tomorrow afternoon at 3:00 PM');
+ expect(result.confidence).toBe(0.95);
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+
+ const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
+ expect(requestBody.messages[0].content).toContain('(Australia/Sydney)');
+ });
+
+ it('should propagate style, region, and target locale to provider prompt', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: 'Vendredi prochain à 17h00',
+ confidence: 0.96,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const target = '2026-08-07T17:00:00Z';
+ const result = await formatAI(target, 'format for French invite', {
+ style: 'formal',
+ locale: 'fr-FR',
+ region: 'FR-IDF',
+ });
+
+ expect(result.formatted).toBe('Vendredi prochain à 17h00');
+ const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
+ const promptContext = requestBody.messages[0].content;
+ expect(promptContext).toContain('Target Locale: fr-FR');
+ expect(promptContext).toContain('Desired Style/Tone: formal');
+ expect(promptContext).toContain('Regional Context: FR-IDF');
+ });
+
+ it('should check cache and skip network fetch on cache hits', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: 'Pre-cached formatted string',
+ confidence: 0.99,
+ reasoning: 'Generated once',
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const target = new Tempo('2026-08-07T17:00:00Z');
+ const anchor = new Tempo('2026-08-02T17:00:00Z');
+
+ const result1 = await formatAI(target, 'cached prompt', { anchor });
+ expect(result1.provider).toBe('groq');
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+
+ const result2 = await formatAI(target, 'cached prompt', { anchor });
+ expect(result2.formatted).toBe('Pre-cached formatted string');
+ expect(result2.provider).toBe('cache');
+ expect(result2.confidence).toBe(0.99);
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should support custom async AiCacheAdapter storage', async () => {
+ const cacheStore = new Map();
+ const customAdapter: AiCacheAdapter = {
+ get: vi.fn(async (key: string) => cacheStore.get(key)),
+ set: vi.fn(async (key: string, val: string) => { cacheStore.set(key, val); }),
+ };
+
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: 'Distributed adapter cached',
+ confidence: 0.97,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const target = new Tempo('2026-08-07T17:00:00Z');
+ const anchor = new Tempo('2026-08-02T17:00:00Z');
+
+ const result1 = await formatAI(target, 'adapter prompt', { anchor, cacheAdapter: customAdapter });
+ expect(result1.formatted).toBe('Distributed adapter cached');
+ expect(customAdapter.set).toHaveBeenCalledTimes(1);
+
+ // Clear local Tempo memory cache to ensure it reads from custom adapter
+ Tempo.cache.clear();
+
+ const result2 = await formatAI(target, 'adapter prompt', { anchor, cacheAdapter: customAdapter });
+ expect(result2.formatted).toBe('Distributed adapter cached');
+ expect(result2.provider).toBe('cache');
+ expect(customAdapter.get).toHaveBeenCalledTimes(2);
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should throw TempoAiError if confidence is below minConfidence', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: 'Uncertain format',
+ confidence: 0.45,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ await expect(formatAI('2026-08-07', 'test', { minConfidence: 0.8 }))
+ .rejects.toThrow(/formatAI confidence \(0.45\) is below the required threshold of 0.8/i);
+ });
+
+ it('should throw TempoAiError(400) for invalid date or anchor', async () => {
+ await expect(formatAI('invalid-date-string', 'prompt'))
+ .rejects.toThrow(/Invalid date provided to formatAI/i);
+
+ await expect(formatAI('2026-08-07', 'prompt', { anchor: 'invalid-anchor-date' }))
+ .rejects.toThrow(/Invalid anchor date provided to formatAI/i);
+ });
+
+ it('should support multi-provider race execution mode', async () => {
+ let slowWasAborted = false;
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockImplementation(async (_url, init) => {
+ const body = JSON.parse(init?.body as string);
+ const signal = init?.signal as AbortSignal | undefined;
+ if (body.model === 'fast-model') {
+ return new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: 'Fast winner formatted narrative',
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ return new Promise((_resolve, reject) => {
+ if (signal?.aborted) {
+ slowWasAborted = true;
+ reject(new DOMException('Aborted', 'AbortError'));
+ return;
+ }
+ signal?.addEventListener('abort', () => {
+ slowWasAborted = true;
+ reject(new DOMException('Aborted', 'AbortError'));
+ });
+ });
+ });
+
+ const result = await formatAI('2026-08-07', 'quick race format', {
+ mode: 'race',
+ providers: [
+ { id: 'slow-provider', key: 'k1', url: 'https://api.openai.com/v1/chat/completions', model: 'slow-model' },
+ { id: 'fast-provider', key: 'k2', url: 'https://api.groq.com/v1/chat/completions', model: 'fast-model' },
+ ],
+ });
+
+ expect(result.formatted).toBe('Fast winner formatted narrative');
+ expect(result.provider).toBe('fast-provider');
+ expect(slowWasAborted).toBe(true);
+ });
+
+ it('should support batch array processing with softErrors', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy
+ .mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: 'Item 1 formatted',
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
+ .mockResolvedValueOnce(new Response('Server Error', { status: 500 }));
+
+ const items = [
+ { date: '2026-08-03', prompt: 'item 1' },
+ { date: '2026-08-05', prompt: 'item 2' },
+ ];
+
+ const results = await formatAI(items, { softErrors: true });
+ expect(results).toHaveLength(2);
+ expect((results[0] as TempoAiFormatResult).formatted).toBe('Item 1 formatted');
+ expect(results[1]).toBeInstanceOf(TempoAiError);
+ });
+});
diff --git a/packages/plugins/ai/test/recurrence.test.ts b/packages/plugins/ai/test/recurrence.test.ts
index 26106063..aa819722 100644
--- a/packages/plugins/ai/test/recurrence.test.ts
+++ b/packages/plugins/ai/test/recurrence.test.ts
@@ -258,9 +258,9 @@ describe('AI Recurrence Plugin (recurrenceAI)', () => {
const items = result.take(3);
expect(items).toHaveLength(3);
- expect(items[0].config.timeZone).toBe('Australia/Sydney');
- expect(items[0].config.calendar).toBe('iso8601');
- expect(items[0].config.locale).toBe('en-AU');
+ expect(items[0].tz).toBe('Australia/Sydney');
+ expect(items[0].cal).toBe('iso8601');
+ expect(items[0].loc).toBe('en-AU');
expect(items[0].config.sphere).toBe('south');
});
});
diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts
index afa4d5f6..bcb5b197 100644
--- a/packages/tempo/.vitepress/config.ts
+++ b/packages/tempo/.vitepress/config.ts
@@ -200,7 +200,7 @@ export default withMermaid(defineConfig({
ssr: {
// Prevent Vite from externalising these packages during SSR so the aliases
// above are honoured in the server-side rendering pass as well.
- noExternal: ['@magmacomputing/tempo', '@magmacomputing/library']
+ noExternal: ['@magmacomputing/tempo', '@magmacomputing/library', 'vue', '@vue/server-renderer']
}
}
}))
diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json
index f40e7958..14f68039 100644
--- a/packages/tempo/.vitepress/theme/data/catalog.json
+++ b/packages/tempo/.vitepress/theme/data/catalog.json
@@ -51,7 +51,7 @@
"packageName": "@magmacomputing/tempo-plugin-ai",
"plan": "community",
"status": "experimental",
- "version": "0.3.0"
+ "version": "4.0.0"
},
{
"id": "ticker",
diff --git a/packages/tempo/src/engine/engine.normalizer.ts b/packages/tempo/src/engine/engine.normalizer.ts
index 4d2907f1..162bf25a 100644
--- a/packages/tempo/src/engine/engine.normalizer.ts
+++ b/packages/tempo/src/engine/engine.normalizer.ts
@@ -3,7 +3,7 @@ import { getTemporalIds, instant } from '#library/temporal.library.js';
import { ownKeys } from '#library/primitive.library.js';
import type { TypeValue } from '#library/type.library.js';
-import { getRuntime, sym, Match, logError, logDebug, TempoError } from '#tempo/support';
+import { getRuntime, sym, Match, logError, logDebug, TempoError, Default } from '#tempo/support';
import { prefix, parseWeekday, parseDate, parseTime, parseZone } from './engine.lexer.js';
import { resolveTermMutation } from './engine.term.js';
import enums from '#tempo/support/support.enum.js';
@@ -83,6 +83,7 @@ export function getAliasContext(ctx: NormalizerContext, dateTime: Temporal.Zoned
get ss() { return dateTime.second },
get tz() { return tz },
get cal() { return cal },
+ get loc() { return state.config.locale ?? Default.locale },
config: state.config,
[sym.$Identity]: true,
} as t.AliasContext
@@ -205,7 +206,7 @@ export function resolveAliases(
const host = getAliasContext(ctx, dateTime);
const res = aliasEngine?.resolveAlias(key as any, host);
if (!res) continue;
-
+
logDebug(`[Normalizer] Resolved alias '${aliasKey}'`, state.config);
try {
@@ -253,7 +254,7 @@ export function resolveAliases(
if (isDefined(groups["mm"]) && !isNumeric(groups["mm"])) {
const rawMm = String(groups["mm"]).replace(/\.$/, '').toLowerCase();
const mappedMm = state.parse.monthMap?.[rawMm];
-
+
if (isDefined(mappedMm)) {
groups["mm"] = mappedMm.value.toString().padStart(2, '0');
logDebug(`[Normalizer] Normalized localized month string '${groups["mm"]}'`, state.config);
diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts
index c939ae15..e9ca0e07 100644
--- a/packages/tempo/src/tempo.class.ts
+++ b/packages/tempo/src/tempo.class.ts
@@ -1553,6 +1553,7 @@ export class Tempo {
/** Fractional seconds (e.g., 0.123456789) */ get ff() { return +(`0.${pad(this.ms, 3)}${pad(this.us, 3)}${pad(this.ns, 3)}`) }
/** IANA Time Zone ID (e.g., 'Australia/Sydney') */ get tz() { return this.#temporalIds()[0] }
/** Temporal Calendar ID (e.g., 'iso8601' | 'gregory') */ get cal() { return this.#temporalIds()[1] }
+ /** Resolved BCP 47 locale (e.g., 'en-US') */ get loc() { return (this.#local.config.locale ?? (this as any)[$Internal]().config.locale ?? Default.locale) as string | string[] }
/** Unix timestamp (defaults to milliseconds) */ get ts() { return this.epoch[this.#local.config.timeStamp] }
/** Short month name (e.g., 'Jan') */ get mmm() { return Tempo.MONTH.keyOf(this.toDateTime().month as t.Month) }
/** Full month name (e.g., 'January') */ get mon() { return Tempo.MONTHS.keyOf(this.toDateTime().month as t.Month) }
diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts
index 6b6e7976..83a5456d 100644
--- a/packages/tempo/src/tempo.type.ts
+++ b/packages/tempo/src/tempo.type.ts
@@ -71,6 +71,7 @@ export interface AliasContext {
/** Second (0-59) */ readonly ss: IntRange<0, 59>;
/** IANA TimeZone identifier */ readonly tz: string;
/** Calendar identifier */ readonly cal: string;
+ /** Resolved BCP 47 locale */ readonly loc: string | string[];
/** Current configuration state */ readonly config: Internal.Config;
}
diff --git a/packages/tempo/test/core/accessors.test.ts b/packages/tempo/test/core/accessors.test.ts
index 4886b5e2..21ca13a0 100644
--- a/packages/tempo/test/core/accessors.test.ts
+++ b/packages/tempo/test/core/accessors.test.ts
@@ -17,4 +17,12 @@ describe(`${label}`, () => {
test(`${label} get the right day-of-month (${date.getDate()})`, () => {
expect(tempo.dd).toBe(date.getDate())
})
+
+ test(`${label} get instance locale via loc getters`, () => {
+ const tDefault = new Tempo('2024-05-20');
+ expect(tDefault.loc).toBeDefined();
+
+ const tCustom = new Tempo('2024-05-20', { locale: 'fr-FR' });
+ expect(tCustom.loc).toBe('fr-FR');
+ })
})
\ No newline at end of file
diff --git a/packages/tempo/test/core/static.test.ts b/packages/tempo/test/core/static.test.ts
index 2a376ab1..1bfa7586 100644
--- a/packages/tempo/test/core/static.test.ts
+++ b/packages/tempo/test/core/static.test.ts
@@ -9,7 +9,7 @@ describe(`${label}`, () => {
test(`${label} get the properties`, () => {
expect(Tempo.properties.toSorted())
- .toEqual(['yy', 'yw', 'mm', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'fmt', 'ww', 'wy', 'tz', 'cal', 'ts', 'dow', 'mmm', 'mon', 'www', 'wkd', 'day', 'nano', 'term', 'terms', 'config', 'epoch', 'parse', 'ranges', 'isValid', 'iso', 'era', 'eraYear', 'eon'].toSorted())
+ .toEqual(['yy', 'yw', 'mm', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'fmt', 'ww', 'wy', 'tz', 'cal', 'loc', 'ts', 'dow', 'mmm', 'mon', 'www', 'wkd', 'day', 'nano', 'term', 'terms', 'config', 'epoch', 'parse', 'ranges', 'isValid', 'iso', 'era', 'eraYear', 'eon'].toSorted())
})
test(`${label} get the elements`, () => {
From a2f0fd4ef43a69a8351361477ef8ab147938e1cd Mon Sep 17 00:00:00 2001
From: Michael McRae
Date: Thu, 13 Aug 2026 18:01:53 +1000
Subject: [PATCH 2/7] PR formatAI 1st review
---
packages/plugins/ai/doc/architecture.md | 47 ++++---
packages/plugins/ai/doc/formatAI.md | 5 +-
packages/plugins/ai/plan/formatAI.plan.md | 116 ------------------
packages/plugins/ai/src/core/support.ts | 5 +-
packages/plugins/ai/src/functions/format.ts | 75 ++++++-----
packages/plugins/ai/src/functions/parse.ts | 6 +-
.../plugins/ai/src/functions/recurrence.ts | 5 +-
packages/plugins/ai/src/types/format.type.ts | 11 +-
packages/plugins/ai/test/format.test.ts | 45 ++++++-
9 files changed, 140 insertions(+), 175 deletions(-)
delete mode 100644 packages/plugins/ai/plan/formatAI.plan.md
diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md
index 7ad9e075..a65f3c2d 100644
--- a/packages/plugins/ai/doc/architecture.md
+++ b/packages/plugins/ai/doc/architecture.md
@@ -95,10 +95,10 @@ flowchart LR
LLM["Groq • OpenAI • Gemini • Anthropic"]
end
- Client -- "1. HTTPS (TLS 1.3)
Session Token / Auth Header" --> Proxy
- Proxy -- "2. HTTPS (TLS 1.3)
Private Provider API Key" --> LLM
- LLM -- "3. HTTPS (TLS 1.3)
Raw JSON Completion" --> Proxy
- Proxy -- "4. HTTPS (TLS 1.3)
Validated Payload" --> Client
+ Client -- "1. HTTPS (TLS 1.2+)
Bearer Token / Auth Header" --> Proxy
+ Proxy -- "2. HTTPS (TLS 1.2+)
Private Provider API Key" --> LLM
+ LLM -- "3. HTTPS (TLS 1.2+)
Raw JSON Completion" --> Proxy
+ Proxy -- "4. HTTPS (TLS 1.2+)
Validated Payload" --> Client
```
### 1. Browser Configuration Example
@@ -113,7 +113,7 @@ await initAI({
{
id: 'my-gateway',
url: 'https://api.mycompany.com/v1/ai/chat/completions', // Your secure proxy endpoint
- key: userSessionToken, // Short-lived user JWT or session cookie
+ key: userSessionToken, // Short-lived user Bearer JWT token
model: 'llama-3.3-70b-instruct'
}
]
@@ -124,29 +124,42 @@ const date = await parseAI("Team standup next Wednesday at 9:30am");
```
### 2. Backend Proxy Handler Example (Next.js / Cloudflare Worker / Express)
-Your backend endpoint receives the request, validates the user's session, attaches your private LLM API key, and forwards the payload to the upstream provider:
+Your backend endpoint receives the request, validates the user's session, enforces ingress quotas, attaches your private LLM API key, and forwards the validated payload to the upstream provider:
```typescript
// Example: Next.js API Route / Cloudflare Worker
export async function POST(req: Request) {
// 1. Authenticate user session
const authHeader = req.headers.get('Authorization');
- if (!isValidUserSession(authHeader)) {
+ const session = await validateUserSession(authHeader);
+ if (!session) {
return new Response('Unauthorized', { status: 401 });
}
- // 2. Forward request to upstream LLM with private BYOK key
+ // 2. Ingress validation & per-user quota enforcement
const body = await req.json();
+ if (typeof body?.prompt !== 'string' || body.prompt.length > 4096) {
+ return new Response('Invalid prompt or payload exceeds size limit', { status: 400 });
+ }
+ if (!checkUserRateLimit(session.userId)) {
+ return new Response('Too Many Requests', { status: 429 });
+ }
+
+ // 3. Construct sanitized upstream payload with private BYOK key
const upstreamResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.GROQ_API_KEY}`
},
- body: JSON.stringify(body)
+ body: JSON.stringify({
+ model: 'llama-3.3-70b-versatile',
+ messages: body.messages,
+ temperature: 0.1,
+ })
});
- // 3. Return provider payload to client
+ // 4. Return provider payload to client
const data = await upstreamResponse.json();
return new Response(JSON.stringify(data), {
status: upstreamResponse.status,
@@ -161,18 +174,18 @@ export async function POST(req: Request) {
Whether running directly on backend servers (Node.js, Deno, Bun, Edge Workers) or through a client-side browser proxy, `@magmacomputing/tempo-plugin-ai` enforces strict security and privacy standards:
-### 1. End-to-End Encryption (TLS 1.3)
-All transport communication—both from browser to proxy and from proxy/server to upstream LLM endpoints—is strictly enforced over HTTPS utilizing **TLS 1.3**. Plaintext HTTP endpoints are disallowed in production environments (permitted only on `localhost` during local development).
+### 1. Transport Security (HTTPS / TLS)
+All network communication—both from client to proxy and from proxy/server to upstream LLM endpoints—is required over HTTPS. Negotiated TLS versions (such as TLS 1.2 or TLS 1.3) depend on deployment environment and server configuration unless strictly enforced by your reverse proxy. Plaintext HTTP endpoints are disallowed in production environments (permitted only on `localhost` during local development).
-### 2. Ephemeral Processing & Zero Data Retention
-Temporal processing payloads (dates, times, context snippets, prompts) are processed ephemerally. The plugin does not send telemetry or store user prompt data on external analytics servers. Information is used exclusively during the execution of the requested AI function and discarded immediately after response resolution.
+### 2. Ephemeral Processing & Cache Retention Controls
+Temporal processing payloads (dates, times, context snippets, prompts) are processed ephemerally. The plugin does not send telemetry or store user prompt data on external analytics servers. However, functions supporting caching (e.g. `parseAI`, `formatAI`, `diffAI`) may retain prompt-derived cache keys and final results in local memory or configured custom cache adapters according to the resolved TTL. Requests requiring zero cache retention must explicitly pass `cache: false`.
### 3. In-Memory Credential Redaction & Immutability
* **Automated Key Redaction**: Calling `getAiConfig()` returns a sanitized, deeply read-only snapshot of active configurations with all provider `key` values replaced with `[REDACTED]`, preventing accidental exposure in log files, APM traces, or crash dumps.
-* **Frozen Metadata**: All diagnostic metadata attached to `Tempo` instances via `.ai` is deeply frozen with `Object.freeze()` and guarded via runtime `Proxy` wrappers, eliminating prototype pollution and runtime state mutation.
+* **Frozen Metadata**: All diagnostic metadata attached to `Tempo` instances via `.ai` is deeply frozen with `Object.freeze()` and guarded via runtime `Proxy` wrappers, protecting against direct runtime mutation of the `.ai` metadata.
-### 4. Deterministic Schema Guardrails & Hallucination Traps
-All LLM prompts are paired with rigid, machine-verifiable JSON schemas. Responses undergo strict boundary validation, regex parsing, and ISO verification before any native `Tempo` date object is instantiated. If an LLM returns malformed or non-chronological data, the plugin throws a typed `TempoAiError` or triggers automatic fallback rather than silently returning an invalid date.
+### 4. Deterministic Schema Guardrails & Confidence Range Verification
+All LLM prompts are paired with rigid, machine-verifiable JSON schemas. Responses undergo strict boundary validation, regex parsing, confidence range verification (`0.0` to `1.0`), and ISO verification before any native `Tempo` date object or result payload is instantiated. If an LLM returns malformed, out-of-range, or non-chronological data, the plugin throws a typed `TempoAiError` or triggers automatic fallback rather than silently returning an invalid date.
### 5. Partitioned Caching & Fail-Open Storage Resilience
* **Strict Cache Key Partitioning**: Caches are namespaced and hashed (`ai:::...`) with timezone, locale, calendar, and anchor date isolation to prevent cross-tenant or cross-regional cache poisoning.
diff --git a/packages/plugins/ai/doc/formatAI.md b/packages/plugins/ai/doc/formatAI.md
index 6c265139..3fa2cb95 100644
--- a/packages/plugins/ai/doc/formatAI.md
+++ b/packages/plugins/ai/doc/formatAI.md
@@ -20,9 +20,10 @@ await initAI({
});
const target = new Tempo('2026-08-07T17:00:00[America/New_York]');
+const anchor = new Tempo('2026-08-02T17:00:00[America/New_York]');
// "this Friday at 5:00 PM EST (in 5 days)"
-const result = await formatAI(target, 'friendly reminder tone with relative countdown');
+const result = await formatAI(target, 'friendly reminder tone with relative countdown', { anchor });
console.log(result.formatted); // "this Friday at 5:00 PM EST (in 5 days)"
console.log(result.confidence); // 0.98
@@ -35,7 +36,7 @@ console.log(result.provider); // 'groq'
| Option | Type | Description |
| :--- | :--- | :--- |
-| **`anchor`** | `Tempo.DateTime` | Reference anchor date for relative delta calculations (defaults to current time). |
+| **`anchor`** | `TempoDateInput` | Reference anchor date for relative delta calculations (defaults to current time). |
| **`style`** | `string` | Narrative style or tone hint (e.g. `'casual'`, `'formal'`, `'compact'`, `'countdown'`). |
| **`region`** | `string` | Regional context (e.g., `'AU-NSW'`, `'US-CA'`) passed to LLM grounding. |
| **`timeZone`** | `string` | Target IANA timezone for output formatting. |
diff --git a/packages/plugins/ai/plan/formatAI.plan.md b/packages/plugins/ai/plan/formatAI.plan.md
deleted file mode 100644
index b6081518..00000000
--- a/packages/plugins/ai/plan/formatAI.plan.md
+++ /dev/null
@@ -1,116 +0,0 @@
-# Implementation Plan: `formatAI`
-
-## 1. Overview & Goal
-`formatAI` transforms a `Tempo` instance, `Date`, timestamp, or ISO string into human-friendly, contextual narrative text tailored to specific prompts, UI tones, business domains, or relative countdown styles (e.g., *"this Friday at 5:00 PM EST (in 5 days)"*, *"Q3 Fiscal Close — 14 business days remaining"*).
-
-By combining deterministic date-time grounding (formatted ISO components, day of week, relative difference to anchor/now, season, quarter) with LLM prompt execution, `formatAI` eliminates date hallucination while delivering expressive, localized language.
-
----
-
-## 2. Public API & Type Definitions
-
-### 2.1 Types (`packages/plugins/ai/src/types/format.type.ts`)
-```typescript
-import type { Tempo, DateTime } from '@magmacomputing/tempo';
-import type { AiOptions } from './common.type.js';
-import type { TempoAiError } from '../core/error.js';
-
-export interface AiFormatOptions extends AiOptions {
- /** Reference anchor date for relative calculations (defaults to now). */
- anchor?: DateTime;
- /** Target IANA timezone (defaults to Tempo instance timezone or global options). */
- timeZone?: string;
- /** Target BCP 47 locale or language tag (defaults to global options or 'en-US'). */
- locale?: string | string[];
- /** Desired narrative tone or formatting style hint (e.g. 'casual', 'formal', 'compact', 'countdown'). */
- style?: string;
- /** Custom regional context (e.g. 'AU-NSW', 'US-CA'). */
- region?: string;
-}
-
-export interface FormatItem {
- /** Date-time instance, Temporal object, or string to format. */
- date: DateTime;
- /** Prompt instructions guiding the output narrative. */
- prompt?: string;
-}
-
-export interface TempoAiFormatResult {
- /** Formatted narrative string. */
- formatted: string;
- /** Confidence score between 0.0 and 1.0. */
- confidence: number;
- /** ID of the provider that fulfilled the request (or 'cache'). */
- provider: string;
- /** Optional step-by-step rationale from the LLM. */
- reasoning?: string;
-}
-```
-
-### 2.2 Function Signature (`packages/plugins/ai/src/functions/format.ts`)
-```typescript
-export async function formatAI(items: FormatItem[], options?: AiFormatOptions): Promise<(TempoAiFormatResult | TempoAiError)[]>;
-export async function formatAI(date: DateTime, prompt?: string, options?: AiFormatOptions): Promise;
-```
-
----
-
-## 3. Mathematical Grounding & Prompt Strategy
-
-### 3.1 Grounding Calculation
-To guarantee accuracy, pre-compute:
-* **Canonical ISO Representation**: `targetTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')`
-* **Target Timezone & Offset**: `targetTempo.tz`, `targetTempo.offset`
-* **Day of Week & Ordinal**: `targetTempo.dow` (1=Mon, 7=Sun), weekday name
-* **Relative Delta to Anchor**:
- * Calendar days: `Math.round(anchorTempo.until(targetTempo, 'day') * 100) / 100`
- * Elapsed hours: `Math.round(anchorTempo.until(targetTempo, 'hour') * 100) / 100`
- * Relative direction: Past / Present / Future
-
-### 3.2 Context & System Prompt
-```markdown
-Grounding Context:
-- Target Date-Time: 2026-08-14T17:00:00 (America/New_York)
-- Day of Week: Friday (Day 5)
-- Reference Anchor: 2026-08-12T08:00:00 (America/New_York)
-- Relative Delta: +2.38 days (+57.0 hours) in the Future
-- Target Locale: en-US
-- Formatting Style: casual
-- Prompt: "Express as an upcoming meeting reminder with relative countdown"
-```
-
-Schema enforcement:
-```json
-{
- "formatted": "this Friday at 5:00 PM EST (in 2 days)",
- "confidence": 0.98,
- "reasoning": "Target timestamp is in 2 days on Friday afternoon."
-}
-```
-
----
-
-## 4. Caching & Dispatch Pipeline
-
-1. **Cache Key Partition**:
- `format::${targetTempo.epoch.ms}::${anchorTempo.epoch.ms}::${normalizedPrompt}::${tz}::${loc}::${style}::${region}`
-2. **Multi-tier Caching**:
- - Check `AiCacheAdapter` (Redis / Cloudflare KV) then local `Tempo.cache`.
- - Validate non-empty `formatted` and check `effectiveMinConfidence`.
-3. **Execution Modes**:
- - Dispatch via `executeWithMode` supporting all 6 modes (`Fallback`, `Race`, `Consensus`, `Hedged`, `RoundRobin`, `Adaptive`).
-4. **Batch Processing**:
- - Concurrent `Promise.all` / `Promise.allSettled` (with `softErrors: true` normalizing rejections to `TempoAiError`).
-
----
-
-## 5. Verification & Test Plan
-* **Unit Tests (`packages/plugins/ai/test/format.test.ts`)**:
- - Valid date formatting across diverse prompt instructions (business SLA, casual relative, compact countdown).
- - Timezone normalization (preserves target instant in requested timeZone).
- - Cache hit preservation and region/style cache isolation.
- - Multi-provider execution modes (Race, Consensus, Hedged).
- - Batch array processing with `softErrors: true`.
- - Confidence threshold rejection (`minConfidence`).
-* **Documentation (`packages/plugins/ai/doc/formatAI.md`)**:
- - TSDoc, basic usage with `initAI`, options guide, batch examples.
diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts
index 336b08dc..59e25b38 100644
--- a/packages/plugins/ai/src/core/support.ts
+++ b/packages/plugins/ai/src/core/support.ts
@@ -7,7 +7,7 @@ import type { AiCacheAdapter, AiProvider, TempoAiMeta } from '../types/index.js'
export function assertNoReservedProviderId(providers: Partial[]): void {
for (const p of providers) {
if (p.id && RESERVED_PROVIDER_IDS.has(p.id.toLowerCase()))
- throw new TempoAiError(`Provider ID '${p.id}' is a reserved keyword in parseAI.`, 400);
+ throw new TempoAiError(`Provider ID '${p.id}' is a reserved keyword in AI provider configuration.`, 400);
}
}
@@ -95,8 +95,7 @@ export async function writeMultiTierCache(
const adapter = options.cacheAdapter || _state.config.cacheAdapter;
if (adapter) {
try {
- const res = adapter.set(cacheKey, value, ttl);
- if (res instanceof Promise) await res;
+ await adapter.set(cacheKey, value, ttl);
} catch (err: any) {
if (options.debug) console.warn(`[${options.tag ?? 'tempo-plugin-ai'}] Cache adapter set failed for ${cacheKey}:`, err?.message ?? err);
}
diff --git a/packages/plugins/ai/src/functions/format.ts b/packages/plugins/ai/src/functions/format.ts
index 3b835418..4d4ddd28 100644
--- a/packages/plugins/ai/src/functions/format.ts
+++ b/packages/plugins/ai/src/functions/format.ts
@@ -12,9 +12,9 @@ import {
resolveTzAndLocale,
writeMultiTierCache,
} from '../core/support.js';
-import type { AiFormatOptions, FormatItem, TempoAiFormatResult } from '../types/format.type.js';
+import type { AiFormatOptions, FormatItem, TempoAiFormatResult, TempoDateInput } from '../types/format.type.js';
-export type { AiFormatOptions, FormatItem, TempoAiFormatResult };
+export type { AiFormatOptions, FormatItem, TempoAiFormatResult, TempoDateInput };
interface FormatGroundingMetrics {
iso: string;
@@ -55,7 +55,7 @@ function calculateFormatGroundingMetrics(targetTempo: Tempo, anchorTempo: Tempo)
}
async function formatSingleInput(
- date: Tempo.DateTime,
+ date: TempoDateInput,
prompt?: string,
options?: AiFormatOptions,
): Promise {
@@ -82,7 +82,7 @@ async function formatSingleInput(
? (Tempo.isTempo(anchor)
? (anchor.tz === tz ? anchor : anchor.set({ timeZone: tz }))
: new Tempo(anchor as any, { timeZone: tz }))
- : new Tempo(undefined, { timeZone: tz });
+ : new Tempo(Math.floor(Date.now() / 60_000) * 60_000, { timeZone: tz });
} catch (err: any) {
throw new TempoAiError(`Invalid anchor date provided to formatAI: "${String(anchor)}"`, 400);
}
@@ -128,11 +128,13 @@ async function formatSingleInput(
try {
const parsedCache = JSON.parse(cachedVal);
if (typeof parsedCache?.formatted === 'string' && parsedCache.formatted.trim().length > 0) {
- const cachedConfidence = typeof parsedCache.confidence === 'number' && Number.isFinite(parsedCache.confidence)
- ? parsedCache.confidence
+ const cachedConfidence = typeof parsedCache?.confidence === 'number' && Number.isFinite(parsedCache.confidence)
+ ? Math.max(0.0, Math.min(1.0, parsedCache.confidence))
: 1.0;
- if (effectiveMinConfidence === undefined || cachedConfidence >= effectiveMinConfidence) {
- if (isDebug) console.log(`[tempo-plugin-ai:format] Cache hit: "${cacheKey}" -> ${cachedVal}`);
+
+ if (effectiveMinConfidence !== undefined && cachedConfidence < effectiveMinConfidence) {
+ if (isDebug) console.log(`[tempo-plugin-ai:format] Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`);
+ } else {
return {
formatted: parsedCache.formatted,
confidence: cachedConfidence,
@@ -141,8 +143,8 @@ async function formatSingleInput(
};
}
}
- } catch {
- // If cached value is corrupted, proceed to fetch
+ } catch (err: any) {
+ if (isDebug) console.warn(`[tempo-plugin-ai:format] Failed to parse cached payload:`, err?.message ?? err);
}
}
@@ -153,7 +155,29 @@ async function formatSingleInput(
assertNoReservedProviderId(availableProviders);
- const mode = aiMode || _state.config.mode || AiMode.Fallback;
+ const systemPrompt = `You are an expert natural language temporal formatting engine.
+Generate human-friendly, contextual narrative representations of dates and times based on the grounding context.
+
+Grounding Context:
+- Target Date-Time: ${grounding.iso} (${tz})
+- Target Day of Week: ${grounding.dayOfWeek} (Day ${grounding.dayOfWeekOrdinal})
+- Reference Anchor: ${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')} (${tz})
+- Relative Time Delta: ${grounding.calendarDays >= 0 ? '+' : ''}${grounding.calendarDays} calendar days (${grounding.elapsedHours >= 0 ? '+' : ''}${grounding.elapsedHours} hours) in the ${grounding.direction.toUpperCase()}
+- Target Locale: ${loc}${region ? `\n- Regional Context: ${region}` : ''}${style ? `\n- Desired Style/Tone: ${style}` : ''}
+
+Rules:
+1. Always return a single, valid JSON object matching the schema below.
+2. The "formatted" field must contain the contextual narrative string (e.g., "this Friday at 5:00 PM EST (in 5 days)", "Tomorrow afternoon at 3:00 PM").
+3. Respect the target locale, style, and timezone conventions.
+4. "confidence" must be a float between 0.0 and 1.0 representing certainty.
+5. "reasoning" should briefly describe how the formatted output was constructed.
+
+Output JSON Schema:
+{
+ "formatted": "string",
+ "confidence": 0.95,
+ "reasoning": "string"
+}`;
const contextString = `Grounding Context:
- Target Date-Time: ${grounding.iso} (${grounding.timeZone})
@@ -165,18 +189,7 @@ ${style ? `- Desired Style/Tone: ${style}` : ''}
${region ? `- Regional Context: ${region}` : ''}
- Formatting Instructions: "${promptText}"`;
- const systemPrompt = `You are a high-performance narrative date formatter. Your task is to format the given Target Date-Time according to the Formatting Instructions, Style, and Target Locale, strictly respecting the mathematical Grounding Context provided. Return ONLY a valid JSON object matching this schema:
-{
- "formatted": "Contextual narrative string (e.g. 'this Friday at 5:00 PM EST (in 2 days)')",
- "confidence": 0.98,
- "reasoning": "Brief explanation of how the narrative reflects the grounding context and prompt."
-}
-
-Rules:
-- Never hallucinate the weekday, date, or relative offset; adhere strictly to the Grounding Context.
-- Apply the requested tone/style and locale conventions.
-- Confidence must be a float between 0.0 and 1.0.
-- Do not include markdown blocks or any text outside the JSON.`;
+ const mode = aiMode || _state.config.mode || AiMode.Fallback;
const winningCandidate = await executeWithMode(
mode,
@@ -199,14 +212,17 @@ Rules:
throw new TempoAiError(`Provider ${providerId} returned invalid JSON: ${err?.message}`, 422);
}
+ if (typeof parsedData !== 'object' || parsedData === null)
+ throw new TempoAiError(`Provider ${providerId} returned non-object JSON payload.`, 422);
+
const formatted = typeof parsedData?.formatted === 'string' ? parsedData.formatted.trim() : '';
- if (!formatted) {
+ if (!formatted)
throw new TempoAiError(`Provider ${providerId} returned empty formatted string.`, 422);
- }
- const confidence = typeof parsedData?.confidence === 'number' && Number.isFinite(parsedData.confidence)
+ const rawConfidence = typeof parsedData?.confidence === 'number' && Number.isFinite(parsedData.confidence)
? parsedData.confidence
: 0.9;
+ const confidence = Math.max(0.0, Math.min(1.0, rawConfidence));
const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined;
return {
@@ -226,9 +242,10 @@ Rules:
_state.limits = winningCandidate.rateLimits ?? null;
const { data: parsedData, providerId } = winningCandidate;
- const confidence = typeof winningCandidate.confidence === 'number' && Number.isFinite(winningCandidate.confidence)
+ const rawConfidence = typeof winningCandidate.confidence === 'number' && Number.isFinite(winningCandidate.confidence)
? winningCandidate.confidence
: 0.9;
+ const confidence = Math.max(0.0, Math.min(1.0, rawConfidence));
if (effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence) {
throw new TempoAiError(`formatAI confidence (${confidence}) is below the required threshold of ${effectiveMinConfidence}.`, 422);
@@ -272,9 +289,9 @@ Rules:
* ```
*/
export async function formatAI(items: FormatItem[], options?: AiFormatOptions): Promise<(TempoAiFormatResult | TempoAiError)[]>;
-export async function formatAI(date: Tempo.DateTime, prompt?: string, options?: AiFormatOptions): Promise;
+export async function formatAI(date: TempoDateInput, prompt?: string, options?: AiFormatOptions): Promise;
export async function formatAI(
- dateOrItems: Tempo.DateTime | FormatItem[],
+ dateOrItems: TempoDateInput | FormatItem[],
promptOrOptions?: string | AiFormatOptions,
options?: AiFormatOptions,
): Promise {
diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts
index 8fd1cf4d..468a1f24 100644
--- a/packages/plugins/ai/src/functions/parse.ts
+++ b/packages/plugins/ai/src/functions/parse.ts
@@ -17,14 +17,16 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise<
if (Tempo.isTempo(options?.anchor)) {
tz = String(options!.timeZone || options!.anchor.tz);
cal = String(options!.calendar || options!.anchor.cal);
- loc = String(Array.isArray(options!.locale) ? options!.locale[0] : (options!.locale || options!.anchor.loc));
+ const rawLoc = options!.locale || options!.anchor.loc;
+ loc = String(Array.isArray(rawLoc) ? rawLoc[0] : rawLoc);
sph = String(options!.sphere || options!.anchor.config.sphere || 'north');
anchorStr = options!.anchor.toString();
} else {
const resolvedOptions = Tempo.options;
tz = String(options?.timeZone || resolvedOptions.timeZone);
cal = String(options?.calendar || resolvedOptions.calendar);
- loc = String(Array.isArray(options?.locale) ? options?.locale[0] : (options?.locale || resolvedOptions.locale));
+ const rawLoc = options?.locale || resolvedOptions.locale;
+ loc = String(Array.isArray(rawLoc) ? rawLoc[0] : rawLoc);
sph = String(options?.sphere || resolvedOptions.sphere || 'north');
anchorStr = String(options?.anchor || new Tempo().toString());
}
diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts
index 10380662..a27d4af7 100644
--- a/packages/plugins/ai/src/functions/recurrence.ts
+++ b/packages/plugins/ai/src/functions/recurrence.ts
@@ -119,6 +119,7 @@ export async function recurrenceAI(
const tz = options?.timeZone || (options?.anchor instanceof Tempo ? options.anchor.tz : undefined) || Tempo.options.timeZone;
const cal = options?.calendar || (options?.anchor instanceof Tempo ? options.anchor.cal : undefined) || Tempo.options.calendar;
const loc = options?.locale || (options?.anchor instanceof Tempo ? options.anchor.loc : undefined) || Tempo.options.locale;
+ const scalarLoc = String(Array.isArray(loc) ? loc[0] : loc);
const sph = options?.sphere || (options?.anchor instanceof Tempo ? options.anchor.config.sphere : undefined) || Tempo.options.sphere;
const contextConfig = { timeZone: tz, calendar: cal, locale: loc, sphere: sph };
@@ -151,12 +152,12 @@ export async function recurrenceAI(
const mode = options?.mode || _state.config.mode || AiMode.Fallback;
const effectiveMinConfidence = options?.minConfidence ?? _state.config.minConfidence;
const callTimeout = options?.timeout;
- const contextString = `Current Time: ${anchorTempo.format('{wkd}, {yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${loc}, Hemisphere: ${sph}.`;
+ const contextString = `Current Time: ${anchorTempo.format('{wkd}, {yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${scalarLoc}, Hemisphere: ${sph}.`;
const systemPrompt = `You are a calendar recurrence compiler. Read the user's natural language schedule and context. Return ONLY a valid JSON object matching this exact schema:
{
"rrule": "Standard RFC 5545 RRULE string without RRULE: prefix (e.g., 'FREQ=WEEKLY;BYDAY=TU;BYHOUR=15')",
- "summary": "Clear, concise human-friendly description localized to locale '${loc}' (e.g., 'Every Tuesday at 15:00')",
+ "summary": "Clear, concise human-friendly description localized to locale '${scalarLoc}' (e.g., 'Every Tuesday at 15:00')",
"reasoning": "Step-by-step calendar math explanation",
"confidence": 0.95
}
diff --git a/packages/plugins/ai/src/types/format.type.ts b/packages/plugins/ai/src/types/format.type.ts
index 0210cffa..b284c7b2 100644
--- a/packages/plugins/ai/src/types/format.type.ts
+++ b/packages/plugins/ai/src/types/format.type.ts
@@ -2,9 +2,16 @@ import type { Tempo } from '@magmacomputing/tempo';
import type { AiMode } from '../core/config.js';
import type { AiCacheAdapter, AiProvider } from './common.type.js';
+/**
+ * ## TempoDateInput
+ * Flexible date-time input representation accepted by `formatAI`.
+ * Supports `Tempo` instances, `Date`, ISO strings, timestamps, and TC39 `Temporal` objects.
+ */
+export type TempoDateInput = Tempo | Date | string | number | bigint | object;
+
export interface FormatItem {
/** Date-time instance, Temporal object, or string to format. */
- date: Tempo.DateTime;
+ date: TempoDateInput;
/** Prompt instructions guiding the output narrative. */
prompt?: string | undefined;
}
@@ -22,7 +29,7 @@ export interface TempoAiFormatResult {
export interface AiFormatOptions {
/** Reference anchor date for relative calculations (defaults to now). */
- anchor?: Tempo.DateTime | undefined;
+ anchor?: TempoDateInput | undefined;
/** Target IANA timezone (defaults to Tempo instance timezone or global options). */
timeZone?: string | undefined;
/** Target BCP 47 locale or language tag (defaults to global options or 'en-US'). */
diff --git a/packages/plugins/ai/test/format.test.ts b/packages/plugins/ai/test/format.test.ts
index 389bf4b4..d4829355 100644
--- a/packages/plugins/ai/test/format.test.ts
+++ b/packages/plugins/ai/test/format.test.ts
@@ -30,7 +30,7 @@ describe('AI Format Plugin (formatAI)', () => {
const target = new Tempo('2026-08-07T17:00:00Z');
const anchor = new Tempo('2026-08-02T17:00:00Z');
- const result = await formatAI(target, 'friendly reminder tone with countdown', { anchor });
+ const result = await formatAI(target, 'friendly reminder tone with countdown', { anchor, timeZone: 'UTC' });
expect(result).toBeDefined();
expect(result.formatted).toBe('this Friday at 5:00 PM EST (in 5 days)');
expect(result.confidence).toBe(0.98);
@@ -60,7 +60,7 @@ describe('AI Format Plugin (formatAI)', () => {
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
const temporalZdt = new Tempo('2026-08-05T15:00:00+10:00[Australia/Sydney]').toDateTime();
- const result = await formatAI(temporalZdt, 'compact relative format');
+ const result = await formatAI(temporalZdt, 'compact relative format', { timeZone: 'Australia/Sydney' });
expect(result.formatted).toBe('Tomorrow afternoon at 3:00 PM');
expect(result.confidence).toBe(0.95);
@@ -256,4 +256,45 @@ describe('AI Format Plugin (formatAI)', () => {
expect((results[0] as TempoAiFormatResult).formatted).toBe('Item 1 formatted');
expect(results[1]).toBeInstanceOf(TempoAiError);
});
+
+ it('should honor force: true, cache: false, and ttl override options', async () => {
+ const cacheStore = new Map();
+ const customAdapter: AiCacheAdapter = {
+ get: vi.fn(async (key: string) => cacheStore.get(key)),
+ set: vi.fn(async (key: string, val: string) => { cacheStore.set(key, val); }),
+ };
+
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockImplementation(async () => new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: 'Fresh result',
+ confidence: 0.96,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const target = new Tempo('2026-08-07T17:00:00Z');
+ const anchor = new Tempo('2026-08-02T17:00:00Z');
+
+ // 1. Initial fetch with ttl override
+ const res1 = await formatAI(target, 'test prompt', { anchor, ttl: 5000, cacheAdapter: customAdapter });
+ expect(res1.formatted).toBe('Fresh result');
+ expect(customAdapter.set).toHaveBeenCalledWith(expect.any(String), expect.any(String), 5000);
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+
+ // 2. force: true should bypass existing cache and invoke provider again
+ const res2 = await formatAI(target, 'test prompt', { anchor, force: true, cacheAdapter: customAdapter });
+ expect(res2.formatted).toBe('Fresh result');
+ expect(res2.provider).toBe('groq');
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+
+ // 3. cache: false should skip writing to cache
+ customAdapter.set = vi.fn();
+ const res3 = await formatAI('2026-09-01', 'uncached prompt', { anchor, cache: false, cacheAdapter: customAdapter });
+ expect(res3.formatted).toBe('Fresh result');
+ expect(customAdapter.set).not.toHaveBeenCalled();
+ });
});
From 51ffbf82849eef8ac71aec46cd2ef755adf5daa8 Mon Sep 17 00:00:00 2001
From: Michael McRae
Date: Fri, 14 Aug 2026 11:28:17 +1000
Subject: [PATCH 3/7] PR extractAI new
---
.../doc/functions/scheduling/cron.md | 4 +-
packages/plugins/.setup/catalog.json | 2 +-
packages/plugins/ai/README.md | 12 +-
packages/plugins/ai/doc/architecture.md | 67 ++-
packages/plugins/ai/doc/context.md | 144 ++++--
packages/plugins/ai/doc/contextAI.md | 123 -----
.../plugins/ai/doc/{diffAI.md => diff.md} | 0
packages/plugins/ai/doc/extract.md | 145 ++++++
.../plugins/ai/doc/{formatAI.md => format.md} | 4 +-
packages/plugins/ai/doc/grounding.md | 69 +++
packages/plugins/ai/doc/index.md | 29 +-
packages/plugins/ai/doc/modes.md | 42 +-
.../plugins/ai/doc/{parseAI.md => parse.md} | 0
.../ai/doc/{recurrenceAI.md => recurrence.md} | 2 +-
.../ai/doc/{scheduleAI.md => schedule.md} | 16 +-
packages/plugins/ai/package.json | 2 +-
packages/plugins/ai/plan/extractAI.plan.md | 137 ------
packages/plugins/ai/plan/v0.3.0-roadmap.md | 66 ---
packages/plugins/ai/src/core/dispatch.ts | 49 +-
packages/plugins/ai/src/core/error.ts | 12 +-
packages/plugins/ai/src/core/support.ts | 13 +-
packages/plugins/ai/src/functions/context.ts | 10 +-
packages/plugins/ai/src/functions/diff.ts | 12 +-
packages/plugins/ai/src/functions/extract.ts | 430 ++++++++++++++----
packages/plugins/ai/src/functions/format.ts | 119 +++--
packages/plugins/ai/src/functions/parse.ts | 38 +-
.../plugins/ai/src/functions/recurrence.ts | 2 +-
packages/plugins/ai/src/functions/schedule.ts | 3 +-
packages/plugins/ai/src/index.ts | 15 +-
.../types/{common.type.ts => base.type.ts} | 126 +++--
packages/plugins/ai/src/types/context.type.ts | 41 +-
packages/plugins/ai/src/types/diff.type.ts | 35 +-
packages/plugins/ai/src/types/extract.type.ts | 44 ++
packages/plugins/ai/src/types/format.type.ts | 83 ++--
packages/plugins/ai/src/types/index.ts | 4 +-
packages/plugins/ai/src/types/parse.type.ts | 41 +-
.../plugins/ai/src/types/recurrence.type.ts | 17 +-
.../plugins/ai/src/types/schedule.type.ts | 27 +-
packages/plugins/ai/test/context.test.ts | 31 +-
packages/plugins/ai/test/diff.test.ts | 27 ++
packages/plugins/ai/test/dispatch.test.ts | 53 +++
packages/plugins/ai/test/extract.test.ts | 410 +++++++++++++++++
packages/plugins/ai/test/format.test.ts | 127 +++++-
.../tempo/.vitepress/theme/data/catalog.json | 4 +-
44 files changed, 1822 insertions(+), 815 deletions(-)
delete mode 100644 packages/plugins/ai/doc/contextAI.md
rename packages/plugins/ai/doc/{diffAI.md => diff.md} (100%)
create mode 100644 packages/plugins/ai/doc/extract.md
rename packages/plugins/ai/doc/{formatAI.md => format.md} (97%)
create mode 100644 packages/plugins/ai/doc/grounding.md
rename packages/plugins/ai/doc/{parseAI.md => parse.md} (100%)
rename packages/plugins/ai/doc/{recurrenceAI.md => recurrence.md} (99%)
rename packages/plugins/ai/doc/{scheduleAI.md => schedule.md} (85%)
delete mode 100644 packages/plugins/ai/plan/extractAI.plan.md
delete mode 100644 packages/plugins/ai/plan/v0.3.0-roadmap.md
rename packages/plugins/ai/src/types/{common.type.ts => base.type.ts} (51%)
create mode 100644 packages/plugins/ai/src/types/extract.type.ts
create mode 100644 packages/plugins/ai/test/extract.test.ts
diff --git a/packages/functions/doc/functions/scheduling/cron.md b/packages/functions/doc/functions/scheduling/cron.md
index 9eee297e..9078144c 100644
--- a/packages/functions/doc/functions/scheduling/cron.md
+++ b/packages/functions/doc/functions/scheduling/cron.md
@@ -16,7 +16,7 @@ const start = new Tempo('2026-07-01T08:00:00Z');
// Every 5 minutes between 9 AM and 5 PM, Monday-Friday
const next = nextCron(start, '*/5 9-17 * * 1-5');
-console.log(next.format('{hhmiss}')); // '09:00:00'
+console.log(next.format('{hh}:{mi}:{ss}')); // '09:00:00'
```
### `prevCron`
@@ -31,7 +31,7 @@ const start = new Tempo('2026-07-01T18:00:00Z');
// Every 5 minutes between 9 AM and 5 PM, Monday-Friday
const prev = prevCron(start, '*/5 9-17 * * 1-5');
-console.log(prev.format('{hhmiss}')); // '17:55:00'
+console.log(prev.format('{hh}:{mi}:{ss}')); // '17:55:00'
```
### `parseCron`
diff --git a/packages/plugins/.setup/catalog.json b/packages/plugins/.setup/catalog.json
index e31c38e5..1644885b 100644
--- a/packages/plugins/.setup/catalog.json
+++ b/packages/plugins/.setup/catalog.json
@@ -45,7 +45,7 @@
"description": "Tempo community plugin for LLM-powered natural language processing and parsing.",
"packageName": "@magmacomputing/tempo-plugin-ai",
"plan": "community",
- "status": "experimental"
+ "status": "active"
},
{
"id": "ticker",
diff --git a/packages/plugins/ai/README.md b/packages/plugins/ai/README.md
index cd51be0a..adeb6d3c 100644
--- a/packages/plugins/ai/README.md
+++ b/packages/plugins/ai/README.md
@@ -48,11 +48,13 @@ console.log(dt.ai?.confidence); // 0.98
| Endpoint | Description | Doc |
| :--- | :--- | :---: |
-| **`parseAI`** | Parse relative/point-in-time dates (e.g. *"next Friday at 4pm"*) | |
-| **`recurrenceAI`** | Convert repeating patterns (e.g. *"every 2 weeks on Friday"*) to RRULEs | |
-| **`diffAI`** | Calculate natural language difference & business days between dates | |
-| **`scheduleAI`** | Book appointment slots around busy calendar event bounds | |
-| **`contextAI`** | Infer timezone, locale, and calendar from user profiles/bios | |
+| **`parseAI`** | Parse relative/point-in-time dates (e.g. *"next Friday at 4pm"*) | |
+| **`formatAI`** | Format contextual narrative dates & relative countdowns | |
+| **`extractAI`** | Extract embedded temporal entities & calendar events from prose | |
+| **`recurrenceAI`** | Convert repeating patterns (e.g. *"every 2 weeks on Friday"*) to RRULEs | |
+| **`scheduleAI`** | Book appointment slots around busy calendar event bounds | |
+| **`diffAI`** | Calculate natural language difference & business days between dates | |
+| **`contextAI`** | Infer timezone, locale, and calendar from user profiles/bios | |
---
diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md
index a65f3c2d..5f1a7059 100644
--- a/packages/plugins/ai/doc/architecture.md
+++ b/packages/plugins/ai/doc/architecture.md
@@ -127,8 +127,8 @@ const date = await parseAI("Team standup next Wednesday at 9:30am");
Your backend endpoint receives the request, validates the user's session, enforces ingress quotas, attaches your private LLM API key, and forwards the validated payload to the upstream provider:
```typescript
-// Example: Next.js API Route / Cloudflare Worker
-export async function POST(req: Request) {
+// Example: Next.js API Route / Cloudflare Worker / Express Proxy Handler
+export async function POST(req: Request, env?: { GROQ_API_KEY?: string }) {
// 1. Authenticate user session
const authHeader = req.headers.get('Authorization');
const session = await validateUserSession(authHeader);
@@ -145,26 +145,51 @@ export async function POST(req: Request) {
return new Response('Too Many Requests', { status: 429 });
}
- // 3. Construct sanitized upstream payload with private BYOK key
- const upstreamResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${process.env.GROQ_API_KEY}`
- },
- body: JSON.stringify({
- model: 'llama-3.3-70b-versatile',
- messages: body.messages,
- temperature: 0.1,
- })
- });
+ // 3. Resolve API key (Cloudflare Worker env binding or Node/Next.js process.env)
+ const apiKey = env?.GROQ_API_KEY || (typeof process !== 'undefined' ? process.env?.GROQ_API_KEY : undefined);
+ if (!apiKey) {
+ return new Response('Provider key configuration missing', { status: 500 });
+ }
- // 4. Return provider payload to client
- const data = await upstreamResponse.json();
- return new Response(JSON.stringify(data), {
- status: upstreamResponse.status,
- headers: { 'Content-Type': 'application/json' }
- });
+ // 4. Construct upstream fetch with bounded timeout and cleanup
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s upstream limit
+
+ try {
+ const upstreamResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${apiKey}`
+ },
+ body: JSON.stringify({
+ model: 'llama-3.3-70b-versatile',
+ messages: body.messages,
+ temperature: 0.1,
+ }),
+ signal: controller.signal
+ });
+
+ // 5. Return provider payload to client
+ const data = await upstreamResponse.json();
+ return new Response(JSON.stringify(data), {
+ status: upstreamResponse.status,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ } catch (err: any) {
+ if (err.name === 'AbortError' || controller.signal.aborted) {
+ return new Response(JSON.stringify({ error: 'Upstream provider gateway timeout' }), {
+ status: 504,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+ return new Response(JSON.stringify({ error: 'Upstream connection failure' }), {
+ status: 502,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ } finally {
+ clearTimeout(timeoutId);
+ }
}
```
diff --git a/packages/plugins/ai/doc/context.md b/packages/plugins/ai/doc/context.md
index 94b76beb..6d6175e1 100644
--- a/packages/plugins/ai/doc/context.md
+++ b/packages/plugins/ai/doc/context.md
@@ -1,63 +1,123 @@
-# Context & Natural Language Parsing
+# `contextAI` — Context & Regional Inference
-Because natural language dates are entirely relative (e.g., "next Tuesday") and often geographically ambiguous (e.g., "11/12"), an LLM cannot reliably parse them in a vacuum.
+`contextAI()` is designed to analyze unstructured or ambiguous text (e.g. user biographies, locations, or email bodies) and infer their regional configuration settings. It resolves these properties to a standard configuration object containing timezone, locale, calendar system, and hemisphere.
-The Tempo AI plugin solves this by automatically wrapping your input with rich environmental context before sending it to the LLM.
+This is highly useful for user onboarding settings, automatic context mapping for calendar syncs, and geolocating inputs dynamically without maintaining static geographic mapping tables.
-## Geographic Context
+---
-The plugin automatically reads from the global `Tempo.config` to fetch the default TimeZone, Calendar, and Locale, and establishes the "current anchor time" the moment you call it.
+## Basic Usage
-Along with your string, the plugin passes a hidden context payload to the LLM:
-*`Current Time: [Anchor], Timezone: [TZ], Calendar: [Cal], Locale: [Locale], Hemisphere: [Sphere]`*
-
-### Overriding Context
-You can explicitly override any of these global settings on a per-request basis by passing an `options` object as the second argument, identical to how you pass options to a standard `new Tempo()` constructor:
+> [!NOTE]
+> Like all Tempo AI functions, `contextAI` requires prior initialization with at least one active provider via `initAI()`.
```typescript
-// Explicitly evaluate this complex query from the perspective of September 1st
-const dt = await parseAI("The penultimate Tuesday before Thanksgiving", { anchor: '2026-09-01T00:00:00Z' });
-
-// Explicitly parse assuming a Japanese locale and timezone
-const tokyoDt = await parseAI("The second Sunday of May", { locale: 'ja-JP', timeZone: 'Asia/Tokyo' });
+import { initAI, contextAI } from '@magmacomputing/tempo-plugin-ai';
+
+// 1. Configure the AI provider farm
+await initAI({
+ providers: [
+ { id: 'groq', key: process.env.GROQ_API_KEY, model: 'llama-3.3-70b-versatile' }
+ ]
+});
+
+// 2. Infer contextual settings from unstructured text
+const context = await contextAI("I'm a photographer based in Sydney, Australia.");
+
+console.log(context.timeZone); // "Australia/Sydney"
+console.log(context.locale); // "en-AU"
+console.log(context.calendar); // "gregory"
+console.log(context.sphere); // "south"
+console.log(context.confidence); // 0.98
```
-### Why Locale is Critical
-Passing the `Locale` is absolutely critical for the LLM to know whether "11/12" means November 12th (US format) or 11th of December (UK/EU format). The plugin handles this transparently based on your standard Tempo configuration!
+---
-> [!WARNING]
-> **Calendar Math Hallucinations**: LLMs are language predictors, not calculators. While they excel at parsing conversational times (like `"tomorrow at 5pm"`), smaller models are notoriously prone to hallucinations on complex, cross-year calendar math. For example, asking a lightweight model for `"Thanksgiving in 2026"` may result in a hallucinated day of the week because the model doesn't natively compute "the fourth Thursday of November 2026." If your application relies on heavy holiday logic or complex multi-year math, you *must* use a capable frontier model or rely on deterministic plugins instead of AI.
+## Configuration Options (`AiContextOptions`)
-## The Decoupled Output Bridge
+| Option | Type | Description |
+| :--- | :--- | :--- |
+| **`force`** | `boolean` | If true, bypasses the cache to initiate a fresh LLM query. |
+| **`cache`** | `boolean` | If false, disables writing to and reading from cache adapters. |
+| **`cacheAdapter`** | `AiCacheAdapter` | Custom cache engine (e.g., Redis) for caching results on this request. |
+| **`ttl`** | `number` | Time-to-live override in milliseconds for cached results. |
+| **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required to return a valid slot. Throws `TempoAiError(422)` if lower. |
+| **`mode`** | `AiMode` | Concurrency routing strategy (`fallback`, `race`, `consensus`, `hedged`, `roundrobin`, `adaptive`). Refer to the [Multi-Provider Execution Modes Guide](./modes.md). |
+| **`providers`** | `AiProvider[]` | Per-request provider configuration overrides. |
+| **`timeout`** | `number` | Per-request timeout in milliseconds (overrides provider and global timeouts). |
+| **`hedgeDelay`** | `number` | Delay in milliseconds before initiating speculative hedging in `AiMode.Hedged` (default: `800ms`). |
+| **`debug`** | `boolean` | If true, logs prompt context and LLM payloads to console. |
+| **`softErrors`** | `boolean` | If true, returns `TempoAiError` into array index position instead of rejecting the entire batch query. |
-To ensure deterministic behavior, the LLM is instructed to *only* return strict ISO 8601 strings.
+---
-The plugin executes the network request, the LLM returns a local ISO string without a timezone offset or 'Z' suffix (like `"2026-11-26T00:00:00"`), and the plugin immediately passes that string back into the native `new Tempo()` constructor. The provider response must omit timezone suffixes to match the local ISO contract enforced by Tempo AI functions. The developer seamlessly receives a valid, native `Tempo` instance. This eliminates AST-construction ambiguity, creating a decoupled bridge between AI text generation and native Tempo conversion.
+## Result Schema (`TempoContext`)
+
+```typescript
+export interface TempoContext {
+ /** Inferred IANA time zone identifier (e.g. 'America/New_York') */
+ timeZone: string;
+
+ /** Inferred BCP 47 language/region tag (e.g. 'en-US') */
+ locale: string;
+
+ /** Inferred Unicode calendar system type (e.g. 'gregory') */
+ calendar: string;
+
+ /** Inferred hemisphere, or undefined if ambiguous */
+ sphere?: 'north' | 'south' | undefined;
+
+ /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */
+ confidence: number;
+
+ /** Resolution source ('cache' or provider ID like 'groq', 'gemini', 'openai') */
+ provider: string;
+
+ /** Step-by-step reasoning or justification provided by the engine/LLM */
+ reasoning?: string | undefined;
+}
+```
-### Relative Date Ambiguity Tie-Breakers
+---
-To eliminate model variance on idioms like "Next Friday" or "Last Tuesday", the plugin enforces static system prompt ambiguity rules:
-* `"next [weekday/unit]"`: Evaluated as the immediate next chronological occurrence after `Current Time`.
-* `"last [weekday/unit]"` / `"previous [weekday/unit]"`: Evaluated as the most recent past occurrence prior to `Current Time`.
-* `"this [weekday]"`: Evaluated as the occurrence within the current calendar week containing `Current Time`.
+## Key Architectural Behaviors
-### Confidence Thresholds & Metadata (`.ai`)
+### 1. Workspace Baseline Context
+`contextAI` inspects the host runtime or current `Tempo` configuration (`Tempo.options.timeZone`, `Tempo.options.locale`, etc.) as a fallback baseline. If an input like `"at home"` is provided, the LLM will ground its inference in the workstation's baseline defaults.
-When `minConfidence` is supplied in options (e.g. `parseAI("...", { minConfidence: 0.8 })`), any LLM response returning a confidence score below that threshold produces a `Tempo` instance with `isValid === false`.
+### 2. Strict Confidence Thresholds
+Using `minConfidence`, developers can guarantee that low-certainty or completely ambiguous inputs (e.g., `"in the park"`) throw a `TempoAiError(422)` rather than silently returning guessed context parameters:
-Every resolved `Tempo` instance returned by `parseAI` (and other AI functions) has a non-writable, frozen `.ai` metadata descriptor containing execution audit data:
```typescript
-const dt = await parseAI("Christmas 2026", { debug: true });
-console.log(dt.ai);
-// {
-// provider: 'openai',
-// cached: false,
-// confidence: 0.95,
-// ambiguous: false,
-// granularity: 'day',
-// rawIso: '2026-12-25T00:00:00',
-// rawPrompt: 'Christmas 2026', // Present when debug is enabled
-// normalizedPrompt: 'christmas 2026' // Present when debug is enabled
-// }
+const context = await contextAI("meeting somewhere online", { minConfidence: 0.9 });
+// Throws TempoAiError(422): Inferred context confidence (0.4) is below the required threshold of 0.9.
```
+### 3. Timezone Validation
+Before returning, the returned IANA timezone string is dynamically validated against the runtime's native JavaScript `Intl` API. If the LLM returns an unsupported or fake timezone identifier, `contextAI` throws a `TempoAiError(422)` to prevent application runtime failures.
+
+### 4. Parallel Batch Processing
+You can pass an array of strings to process multiple contexts concurrently:
+```typescript
+const [context1, context2] = await contextAI([
+ "Working from Kyoto",
+ "Living in Melbourne"
+]);
+```
+
+### Combining `contextAI` with `parseAI` (The Pivot Flow)
+
+Often, a user will mention their location in one sentence and a relative time in another. You can chain these APIs together to form a seamless date-resolution pipeline:
+
+```typescript
+import { contextAI, parseAI } from '@magmacomputing/tempo-plugin-ai';
+
+// 1. Deduces the context
+const ticketContext = await contextAI("customer issue from our London office");
+// ticketContext = { timeZone: 'Europe/London', locale: 'en-GB', sphere: 'north' }
+
+// 2. Feed the output context directly as options into parseAI
+const resolutionTime = await parseAI("issue occurred on 04/05/2026 at 3 PM", ticketContext);
+// 1. Correctly parses 04/05 to May 4th (UK format) rather than April 5th.
+// 2. Adjusts to BST/GMT (Europe/London).
+```
diff --git a/packages/plugins/ai/doc/contextAI.md b/packages/plugins/ai/doc/contextAI.md
deleted file mode 100644
index 6d6175e1..00000000
--- a/packages/plugins/ai/doc/contextAI.md
+++ /dev/null
@@ -1,123 +0,0 @@
-# `contextAI` — Context & Regional Inference
-
-`contextAI()` is designed to analyze unstructured or ambiguous text (e.g. user biographies, locations, or email bodies) and infer their regional configuration settings. It resolves these properties to a standard configuration object containing timezone, locale, calendar system, and hemisphere.
-
-This is highly useful for user onboarding settings, automatic context mapping for calendar syncs, and geolocating inputs dynamically without maintaining static geographic mapping tables.
-
----
-
-## Basic Usage
-
-> [!NOTE]
-> Like all Tempo AI functions, `contextAI` requires prior initialization with at least one active provider via `initAI()`.
-
-```typescript
-import { initAI, contextAI } from '@magmacomputing/tempo-plugin-ai';
-
-// 1. Configure the AI provider farm
-await initAI({
- providers: [
- { id: 'groq', key: process.env.GROQ_API_KEY, model: 'llama-3.3-70b-versatile' }
- ]
-});
-
-// 2. Infer contextual settings from unstructured text
-const context = await contextAI("I'm a photographer based in Sydney, Australia.");
-
-console.log(context.timeZone); // "Australia/Sydney"
-console.log(context.locale); // "en-AU"
-console.log(context.calendar); // "gregory"
-console.log(context.sphere); // "south"
-console.log(context.confidence); // 0.98
-```
-
----
-
-## Configuration Options (`AiContextOptions`)
-
-| Option | Type | Description |
-| :--- | :--- | :--- |
-| **`force`** | `boolean` | If true, bypasses the cache to initiate a fresh LLM query. |
-| **`cache`** | `boolean` | If false, disables writing to and reading from cache adapters. |
-| **`cacheAdapter`** | `AiCacheAdapter` | Custom cache engine (e.g., Redis) for caching results on this request. |
-| **`ttl`** | `number` | Time-to-live override in milliseconds for cached results. |
-| **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required to return a valid slot. Throws `TempoAiError(422)` if lower. |
-| **`mode`** | `AiMode` | Concurrency routing strategy (`fallback`, `race`, `consensus`, `hedged`, `roundrobin`, `adaptive`). Refer to the [Multi-Provider Execution Modes Guide](./modes.md). |
-| **`providers`** | `AiProvider[]` | Per-request provider configuration overrides. |
-| **`timeout`** | `number` | Per-request timeout in milliseconds (overrides provider and global timeouts). |
-| **`hedgeDelay`** | `number` | Delay in milliseconds before initiating speculative hedging in `AiMode.Hedged` (default: `800ms`). |
-| **`debug`** | `boolean` | If true, logs prompt context and LLM payloads to console. |
-| **`softErrors`** | `boolean` | If true, returns `TempoAiError` into array index position instead of rejecting the entire batch query. |
-
----
-
-## Result Schema (`TempoContext`)
-
-```typescript
-export interface TempoContext {
- /** Inferred IANA time zone identifier (e.g. 'America/New_York') */
- timeZone: string;
-
- /** Inferred BCP 47 language/region tag (e.g. 'en-US') */
- locale: string;
-
- /** Inferred Unicode calendar system type (e.g. 'gregory') */
- calendar: string;
-
- /** Inferred hemisphere, or undefined if ambiguous */
- sphere?: 'north' | 'south' | undefined;
-
- /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */
- confidence: number;
-
- /** Resolution source ('cache' or provider ID like 'groq', 'gemini', 'openai') */
- provider: string;
-
- /** Step-by-step reasoning or justification provided by the engine/LLM */
- reasoning?: string | undefined;
-}
-```
-
----
-
-## Key Architectural Behaviors
-
-### 1. Workspace Baseline Context
-`contextAI` inspects the host runtime or current `Tempo` configuration (`Tempo.options.timeZone`, `Tempo.options.locale`, etc.) as a fallback baseline. If an input like `"at home"` is provided, the LLM will ground its inference in the workstation's baseline defaults.
-
-### 2. Strict Confidence Thresholds
-Using `minConfidence`, developers can guarantee that low-certainty or completely ambiguous inputs (e.g., `"in the park"`) throw a `TempoAiError(422)` rather than silently returning guessed context parameters:
-
-```typescript
-const context = await contextAI("meeting somewhere online", { minConfidence: 0.9 });
-// Throws TempoAiError(422): Inferred context confidence (0.4) is below the required threshold of 0.9.
-```
-
-### 3. Timezone Validation
-Before returning, the returned IANA timezone string is dynamically validated against the runtime's native JavaScript `Intl` API. If the LLM returns an unsupported or fake timezone identifier, `contextAI` throws a `TempoAiError(422)` to prevent application runtime failures.
-
-### 4. Parallel Batch Processing
-You can pass an array of strings to process multiple contexts concurrently:
-```typescript
-const [context1, context2] = await contextAI([
- "Working from Kyoto",
- "Living in Melbourne"
-]);
-```
-
-### Combining `contextAI` with `parseAI` (The Pivot Flow)
-
-Often, a user will mention their location in one sentence and a relative time in another. You can chain these APIs together to form a seamless date-resolution pipeline:
-
-```typescript
-import { contextAI, parseAI } from '@magmacomputing/tempo-plugin-ai';
-
-// 1. Deduces the context
-const ticketContext = await contextAI("customer issue from our London office");
-// ticketContext = { timeZone: 'Europe/London', locale: 'en-GB', sphere: 'north' }
-
-// 2. Feed the output context directly as options into parseAI
-const resolutionTime = await parseAI("issue occurred on 04/05/2026 at 3 PM", ticketContext);
-// 1. Correctly parses 04/05 to May 4th (UK format) rather than April 5th.
-// 2. Adjusts to BST/GMT (Europe/London).
-```
diff --git a/packages/plugins/ai/doc/diffAI.md b/packages/plugins/ai/doc/diff.md
similarity index 100%
rename from packages/plugins/ai/doc/diffAI.md
rename to packages/plugins/ai/doc/diff.md
diff --git a/packages/plugins/ai/doc/extract.md b/packages/plugins/ai/doc/extract.md
new file mode 100644
index 00000000..38abee37
--- /dev/null
+++ b/packages/plugins/ai/doc/extract.md
@@ -0,0 +1,145 @@
+# `extractAI` — Unstructured Text & Calendar Event Extraction
+
+`extractAI()` scans unstructured, multi-paragraph text (emails, meeting transcripts, chat logs, task notes, calendar invitations) to automatically identify, parse, and extract all embedded temporal entities and time-bound events into structured `TempoAiExtractResult` records containing native `Tempo` instances.
+
+Relative expressions (such as *"tomorrow at 10am"*, *"next Tuesday from 1 to 3pm"*, *"final deliverables due Friday EOD"*) are resolved and mathematically grounded against an explicit or current reference `anchor` timestamp, timezone, and calendar system.
+
+---
+
+## Basic Usage
+
+```typescript
+import { Tempo } from '@magmacomputing/tempo';
+import { initAI, extractAI } from '@magmacomputing/tempo-plugin-ai';
+
+// 1. Initialize AI providers
+await initAI({
+ providers: [
+ { id: 'groq', key: process.env.GROQ_API_KEY, model: 'llama-3.3-70b-versatile' }
+ ]
+});
+
+const emailText = `
+Hi team,
+Let's schedule our Sprint Review tomorrow from 10:00 AM to 11:30 AM in Room 4A.
+Also, reminder that all pull requests and documentation are due next Friday by 5:00 PM.
+`;
+
+const anchor = new Tempo('2026-08-10T09:00:00Z'); // Monday morning
+
+const result = await extractAI(emailText, { anchor, timeZone: 'America/New_York' });
+
+for (const event of result.events) {
+ console.log(`[${event.type}] ${event.label}`);
+ console.log(` Start: ${event.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')}`);
+ if (event.end) {
+ console.log(` End: ${event.end.format('{yyyy}-{mm}-{dd} {hh}:{mi}')}`);
+ }
+ console.log(` Source: "${event.rawText}" (Confidence: ${event.confidence})`);
+}
+```
+
+---
+
+## Configuration Options (`AiExtractOptions`)
+
+| Option | Type | Description |
+| :--- | :--- | :--- |
+| **`anchor`** | `TempoDateInput` | Reference anchor date for relative expressions (defaults to current time). |
+| **`timeZone`** | `string` | Target IANA timezone for grounding and output Tempo instances. |
+| **`locale`** | `string \| string[]` | Target BCP 47 locale or language tag (e.g. `'en-US'`, `'fr-FR'`). |
+| **`calendar`** | `string` | Calendar system (e.g. `'gregory'`, `'hebrew'`, `'islamic'`). |
+| **`categories`** | `string[]` | Optional list of categories to filter entities (e.g. `['meeting', 'deadline']`). |
+| **`region`** | `string` | Regional context (e.g. `'AU-NSW'`, `'US-NY'`) passed to LLM grounding. |
+| **`force`** | `boolean` | If true, bypasses cache to force a fresh LLM query. |
+| **`cache`** | `boolean` | If false, disables writing to and reading from cache adapters. |
+| **`cacheAdapter`** | `AiCacheAdapter` | Custom cache engine (e.g., Redis, Cloudflare KV) for caching results. |
+| **`ttl`** | `number` | Time-to-live override in milliseconds for cached results (defaults to 24h). |
+| **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required. Throws `TempoAiError(422)` if lower. |
+| **`mode`** | `AiMode` | Concurrency routing strategy (`fallback`, `race`, `consensus`, `hedged`, `roundrobin`, `adaptive`). Refer to the [Multi-Provider Execution Modes Guide](./modes.md). |
+| **`softErrors`** | `boolean` | If true, returns `TempoAiError` into array indices instead of rejecting batch queries. |
+
+---
+
+## Result Schema (`TempoAiExtractResult`)
+
+```typescript
+export interface TempoAiExtractResult {
+ /** Array of extracted events with instantiated Tempo objects. */
+ events: TempoExtractedEvent[];
+
+ /** Overall extraction confidence score between 0.0 and 1.0. */
+ confidence: number;
+
+ /** ID of the provider that fulfilled the request (or 'cache'). */
+ provider: string;
+
+ /** Optional summary or reasoning from the LLM. */
+ reasoning?: string | undefined;
+}
+
+export interface TempoExtractedEvent {
+ /** Short descriptive label or title of the extracted event/activity. */
+ label: string;
+
+ /** Start date-time point as an instantiated Tempo instance. */
+ start: Tempo;
+
+ /** Optional end date-time point (if an interval or duration was mentioned). */
+ end?: Tempo | undefined;
+
+ /** Classification category ('point' | 'interval' | 'deadline' | 'recurrence' | 'tentative'). */
+ type: TempoEventType;
+
+ /** Raw text snippet extracted from the source document. */
+ rawText?: string | undefined;
+
+ /** Confidence score for this specific entity extraction (0.0 to 1.0). */
+ confidence: number;
+}
+```
+
+---
+
+## Key Architectural Behaviors
+
+### 1. Mathematical Grounding & Hallucination Suppression
+To prevent hallucinated dates, `extractAI` calculates grounding anchor coordinates before dispatching to the LLM:
+- Localized ISO reference timestamp and timezone
+- Day of the week name and ordinal index
+- Target calendar system and regional context
+- Constrained JSON schema ensuring valid ISO dates
+
+### 2. Native `Tempo` Instances
+Extracted start and end points are immediately instantiated as live `Tempo` objects, ready for subsequent date math, interval arithmetic, or timezone shifting:
+
+```typescript
+const result = await extractAI(transcript);
+const meeting = result.events[0];
+
+// Instant date operations with Tempo
+const reminderTime = meeting.start.subtract('15 minutes');
+console.log(`Set alarm for: ${reminderTime.format('{h12}:{mi} {mer}')}`);
+```
+
+### 3. Multi-Tier Distributed Caching
+`extractAI` integrates multi-tier caching (in-memory and optional asynchronous `AiCacheAdapter` such as Redis or Cloudflare KV). Cached ISO timestamps are rehydrated into live `Tempo` objects upon cache hits:
+
+```typescript
+const result = await extractAI(documentText, {
+ cacheAdapter: redisCacheAdapter,
+ ttl: 86_400_000, // 24 hours
+});
+```
+
+### 4. Parallel Batch Extraction
+Process arrays of documents concurrently with optional `softErrors` fault-tolerance:
+
+```typescript
+const documents = [
+ "Team offsite next Thursday from 9am to 5pm.",
+ "Project proposal submission deadline is August 20 at midnight."
+];
+
+const results = await extractAI(documents, { softErrors: true });
+```
diff --git a/packages/plugins/ai/doc/formatAI.md b/packages/plugins/ai/doc/format.md
similarity index 97%
rename from packages/plugins/ai/doc/formatAI.md
rename to packages/plugins/ai/doc/format.md
index 3fa2cb95..c242f510 100644
--- a/packages/plugins/ai/doc/formatAI.md
+++ b/packages/plugins/ai/doc/format.md
@@ -22,10 +22,10 @@ await initAI({
const target = new Tempo('2026-08-07T17:00:00[America/New_York]');
const anchor = new Tempo('2026-08-02T17:00:00[America/New_York]');
-// "this Friday at 5:00 PM EST (in 5 days)"
+// "this Friday at 5:00 PM EDT (in 5 days)"
const result = await formatAI(target, 'friendly reminder tone with relative countdown', { anchor });
-console.log(result.formatted); // "this Friday at 5:00 PM EST (in 5 days)"
+console.log(result.formatted); // "this Friday at 5:00 PM EDT (in 5 days)"
console.log(result.confidence); // 0.98
console.log(result.provider); // 'groq'
```
diff --git a/packages/plugins/ai/doc/grounding.md b/packages/plugins/ai/doc/grounding.md
new file mode 100644
index 00000000..72e44063
--- /dev/null
+++ b/packages/plugins/ai/doc/grounding.md
@@ -0,0 +1,69 @@
+# Grounding & Natural Language Parsing
+
+Because natural language dates are entirely relative (e.g., *"next Tuesday"*) and culturally ambiguous (e.g., *"11/12"*), an LLM cannot reliably parse them in a vacuum.
+
+The Tempo AI plugin solves this by automatically injecting **deterministic temporal and regional grounding coordinates** before dispatching queries to the LLM.
+
+## Temporal & Regional Grounding
+
+The plugin automatically resolves the active `Tempo.config` to establish the exact reference time and regional coordinates:
+- **Anchor Reference Clock**: The exact ISO timestamp at the moment of invocation.
+- **Regional Coordinates**: TimeZone (e.g., `America/New_York`), Calendar system (`iso8601`), Locale (`en-US`), and Hemisphere (`northern`).
+
+Along with your text query, the plugin passes these grounding coordinates directly to the model's system prompt:
+> *`Grounding Anchor: [Anchor], Timezone: [TZ], Calendar: [Cal], Locale: [Loc], Hemisphere: [Sphere]`*
+
+### Custom Grounding Anchors & Options
+You can explicitly override any grounding coordinate on a per-request basis by passing an options object as the second argument, identical to how you pass configuration options to a standard `new Tempo()` constructor:
+
+```typescript
+// Explicitly evaluate this complex relative query from the perspective of September 1st
+const dt = await parseAI("The penultimate Tuesday before Thanksgiving", {
+ anchor: '2026-09-01T00:00:00Z'
+});
+
+// Explicitly parse assuming a Japanese locale and timezone
+const tokyoDt = await parseAI("The second Sunday of May", {
+ locale: 'ja-JP',
+ timeZone: 'Asia/Tokyo'
+});
+```
+
+### Why Cultural & Regional Grounding is Critical
+Passing the `Locale` and `TimeZone` is critical for the LLM to know whether `"11/12"` represents November 12th (US format) or 11th of December (UK/EU format). The plugin grounds these ambiguous tokens transparently based on your standard Tempo configuration!
+
+> [!WARNING]
+> **Calendar Math Hallucinations**: LLMs are language predictors, not calculators. While they excel at parsing conversational times (like `"tomorrow at 5pm"`), smaller models are notoriously prone to hallucinations on complex, cross-year calendar math. For example, asking a lightweight model for `"Thanksgiving in 2026"` may result in a hallucinated day of the week because the model doesn't natively compute "the fourth Thursday of November 2026." If your application relies on heavy holiday logic or complex multi-year math, you *must* use a capable frontier model or rely on deterministic plugins instead of AI.
+
+## The Decoupled Output Bridge
+
+To ensure deterministic behavior, the LLM is instructed to *only* return strict ISO 8601 strings.
+
+The plugin executes the network request, the LLM returns a local ISO string without a timezone offset or 'Z' suffix (like `"2026-11-26T00:00:00"`), and the plugin immediately passes that string back into the native `new Tempo()` constructor. The provider response must omit timezone suffixes to match the local ISO contract enforced by Tempo AI functions. The developer seamlessly receives a valid, native `Tempo` instance. This eliminates AST-construction ambiguity, creating a decoupled bridge between AI text generation and native Tempo conversion.
+
+### Relative Date Ambiguity Tie-Breakers
+
+To eliminate model variance on idioms like "Next Friday" or "Last Tuesday", the plugin enforces static system prompt ambiguity rules:
+* `"next [weekday/unit]"`: Evaluated as the immediate next chronological occurrence after the grounding anchor.
+* `"last [weekday/unit]"` / `"previous [weekday/unit]"`: Evaluated as the most recent past occurrence prior to the grounding anchor.
+* `"this [weekday]"`: Evaluated as the occurrence within the current calendar week containing the grounding anchor.
+
+### Confidence Thresholds & Metadata (`.ai`)
+
+When `minConfidence` is supplied in options (e.g. `parseAI("...", { minConfidence: 0.8 })`), any LLM response returning a confidence score below that threshold produces a `Tempo` instance with `isValid === false`.
+
+Every resolved `Tempo` instance returned by `parseAI` (and other AI functions) has a non-writable, frozen `.ai` metadata descriptor containing execution audit data:
+```typescript
+const dt = await parseAI("Christmas 2026", { debug: true });
+console.log(dt.ai);
+// {
+// provider: 'openai',
+// cached: false,
+// confidence: 0.95,
+// ambiguous: false,
+// granularity: 'day',
+// rawIso: '2026-12-25T00:00:00',
+// rawPrompt: 'Christmas 2026', // Present when debug is enabled
+// normalizedPrompt: 'christmas 2026' // Present when debug is enabled
+// }
+```
diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md
index 12cd1218..9aaf11ff 100644
--- a/packages/plugins/ai/doc/index.md
+++ b/packages/plugins/ai/doc/index.md
@@ -6,18 +6,13 @@
-> [!WARNING]
-> **🧪 EXPERIMENTAL PLUGIN**
-> This plugin relies on Generative AI. While it uses strict JSON schemas and validation to force deterministic outputs, LLMs (especially smaller models) can still hallucinate complex calendar math. We are actively collecting feedback on prompt engineering and model reliability. Please report any strange behavior or unexpected hallucinations on the [Magma GitHub Bug Report Form](https://github.com/magmacomputing/magma/issues/new?template=bug_report_ai.yml)!
->
-> [!CAUTION]
-> **LLM Output Disclaimer**: Magma Computing Solutions and the Tempo core maintainers provide `@magmacomputing/tempo-plugin-ai` "as-is" without warranty of any kind. Large Language Models are probabilistic text generators, not deterministic calculators. Developers and organization operators are solely responsible for validating AI-generated date and time outputs before relying on them in financial, legal, medical, or time-critical production systems.
-
Tempo community plugin for LLM-powered natural language date parsing, schedule compilation, and temporal processing.
This plugin bridges the gap between deterministic date-math and unstructured NLP inputs, utilizing large language models (like Gemini, Groq, or OpenAI) to safely and asynchronously parse, format, and process complex natural language temporal expressions into `Tempo` instances.
-> **CRITICAL SECURITY WARNING**: Raw LLM API keys must **never** be exposed in a client-side browser bundle or stored in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, route requests through a secure backend proxy service.
+::: warning 🔒 Security Notice
+Raw LLM API keys must **never** be exposed in client-side browser bundles or stored in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, route requests through a secure backend proxy service.
+:::
## Installation & Quickstart
@@ -43,13 +38,14 @@ All AI functions return a standard ES Promise wrapped object.
| Function | Input | Returns (`Promise<...>`) | Description | Doc |
| :--- | :--- | :--- | :--- | :---: |
-| **`initAI`** | Provider config & API keys | `void` | Configured AI provider farm | |
| **`parseAI`** | Natural language text string(s) | `Tempo` \| `Tempo[]` \| `(Tempo \| TempoAiError)[]` | Single point-in-time `Tempo` instance (or batch array) | |
+| **`formatAI`** | Date-time + prompt / style | `TempoAiFormatResult` \| `TempoAiFormatResult[]` \| `(TempoAiFormatResult \| TempoAiError)[]` | **Contextual narrative date formatting** (`formatted`, `confidence`, `provider`, `reasoning`) | |
+| **`extractAI`** | Unstructured text string(s) | `TempoAiExtractResult` \| `(TempoAiExtractResult \| TempoAiError)[]` | **Extracted temporal entities & calendar events** (`events: TempoExtractedEvent[]`, `confidence`, `reasoning`) | |
| **`recurrenceAI`** | Natural language pattern or RRULE string | `TempoRecurrenceResult` | **Iterable series of `Tempo` dates** (with `.take(n)`, `[Symbol.iterator]`, & RRULE string) | |
| **`scheduleAI`** | Booking prompt + busy constraints | `TempoScheduleResult` | **Resolved appointment slot** (`start`, `end`, `slot`, `alternatives`, `ai.conflictBumped`) | |
-| **`contextAI`** | Context text string(s) | `TempoContext` \| `TempoContext[]` \| `(TempoContext \| TempoAiError)[]` | **Inferred regional context** (`timeZone`, `locale`, `calendar`, `sphere`) | |
| **`diffAI`** | Start & End dates + prompt | `TempoAiDiffResult` \| `TempoAiDiffResult[]` \| `(TempoAiDiffResult \| TempoAiError)[]` | **Narrative time delta & business days** (`formatted`, `businessDays`, `days`, `hours`, `holidays`) | |
-| **`formatAI`** | Date-time + prompt / style | `TempoAiFormatResult` \| `TempoAiFormatResult[]` \| `(TempoAiFormatResult \| TempoAiError)[]` | **Contextual narrative date formatting** (`formatted`, `confidence`, `provider`, `reasoning`) | |
+| **`contextAI`** | Context text string(s) | `TempoContext` \| `TempoContext[]` \| `(TempoContext \| TempoAiError)[]` | **Inferred regional context** (`timeZone`, `locale`, `calendar`, `sphere`) | |
+| **`initAI`** | Provider config & API keys | `void` | Configured AI provider farm | |
## Architecture & Infrastructure Guides
@@ -58,9 +54,18 @@ All AI functions return a standard ES Promise wrapped object.
- [Multi-Provider Execution Modes](./modes.md) (Hedged, RoundRobin, Adaptive, Race, Consensus, Fallback)
- [Provider Architecture & Security](./architecture.md) (BYOK vs Proxy patterns, Browser Security, TLS 1.3 & Privacy Guarantees)
-- [Context & Natural Language Parsing](./context.md) (How Timezone and Locale are injected)
+- [Grounding & Natural Language Parsing](./grounding.md) (How Timezone and Locale are injected)
- [Rate Limits & Cache Management](./rate-limits.md) (Tracking API quotas, handling 429 errors, and custom Redis caches)
+## Community Feedback & Production Notice
+
+> [!NOTE]
+> **Community Feedback & Prompt Engineering**
+> While `@magmacomputing/tempo-plugin-ai` utilizes deterministic grounding, schema enforcement, and confidence validation, LLM outputs can vary across models and prompt styles. We actively welcome community feedback and prompt optimizations—please report any edge cases or suggestions on the [Magma GitHub Issue Tracker](https://github.com/magmacomputing/magma/issues/new?template=bug_report_ai.yml).
+
+> [!CAUTION]
+> **Production Notice & "As-Is" Disclaimer**: Magma Computing Solutions and the Tempo core maintainers provide `@magmacomputing/tempo-plugin-ai` "as-is" without warranty of any kind. Large Language Models operate probabilistically; developers and system architects are responsible for validating AI-generated temporal outputs before committing them to financial, legal, medical, or life-critical applications.
+
## Licensing
This is a **Community** plugin. It is completely free and open-source for personal and commercial use. No license token is required.
diff --git a/packages/plugins/ai/doc/modes.md b/packages/plugins/ai/doc/modes.md
index 17cd66b3..52a88c94 100644
--- a/packages/plugins/ai/doc/modes.md
+++ b/packages/plugins/ai/doc/modes.md
@@ -28,12 +28,36 @@ const dt = await parseAI('next friday at 3pm', {
| Mode | Dispatch | Token Cost | Latency | Rate-Limit Resilience |
| :--- | :--- | :---: | :---: | :---: |
-| **`Fallback`** *(Default)* | Sequential | 🟢 1 request | 🟡 Moderate | 🟡 Reactive |
-| **`Hedged`** | Staggered (primary + timer) | 🟢 ~1.15 avg | 🟢 Ultra-Fast | 🟡 Reactive |
-| **`RoundRobin`** | Cyclic rotation | 🟢 1 request | 🟡 Moderate | 🟢 High |
-| **`Adaptive`** | Quota-sorted rotation | 🟢 1 request | 🟡 Moderate | 🟢 Maximum |
-| **`Race`** | Full parallel | 🔴 N requests | 🟢 Ultra-Fast | 🟡 Reactive |
-| **`Consensus`** | Full parallel + voting | 🔴 N requests | 🟡 Moderate | 🟡 Reactive |
+| **`Fallback`** *(Default)* | Sequential | 🟢 1 request | 🟡 Moderate | 🟢 Proactive Cooldown Filter |
+| **`Hedged`** | Staggered (primary + timer) | 🟢 ~1.15 avg | 🟢 Ultra-Fast | 🟢 Proactive Cooldown Filter |
+| **`RoundRobin`** | Cyclic rotation | 🟢 1 request | 🟡 Moderate | 🟢 High (Cyclic + Filter) |
+| **`Adaptive`** | Quota-sorted rotation | 🟢 1 request | 🟡 Moderate | 🟢 Maximum (Telemetry-Ranked) |
+| **`Race`** | Full parallel | 🔴 N requests | 🟢 Ultra-Fast | 🟢 Proactive Cooldown Filter |
+| **`Consensus`** | Full parallel + voting | 🔴 N requests | 🟡 Moderate | 🟢 Proactive Cooldown Filter |
+
+---
+
+## Global Telemetry & Cooldown Filtering
+
+Regardless of the execution mode chosen, the AI dispatch engine actively monitors per-provider rate-limiting metadata across all network responses (`x-ratelimit-remaining-requests`, `x-ratelimit-remaining-tokens`, `x-ratelimit-reset-requests`, `retry-after`).
+
+```mermaid
+flowchart LR
+ A["Incoming Request\n(Any AiMode)"] --> B{"Check Provider Farm\nCooldown State"}
+ B -- "Exhausted (remaining === 0\n& resetAt > now)" --> C["🚫 Proactively Filter Out\n(Skip 429 endpoints)"]
+ B -- "Ready / High Quota" --> D["✅ Active Provider Pool"]
+ C -. "If ALL in cooldown" .-> D
+ D --> E["Dispatch via Selected Mode\n(Fallback, Race, Hedged, etc.)"]
+```
+
+### Proactive Cooldown Avoidance
+Before dispatching any request:
+1. **Cooldown Detection**: The orchestrator checks if any configured provider has exhausted its request quota (`remainingRequests === 0`) and is within an active reset window (`resetAt > now`).
+2. **Pre-Dispatch Filtering**: In `Fallback`, `Race`, `Hedged`, and `RoundRobin` modes, exhausted providers are automatically removed from the active candidate pool for that request.
+ - **`Fallback` & `Hedged`**: Avoids stalling on primary providers that are guaranteed to reject with HTTP 429.
+ - **`Race`**: Saves network bandwidth and avoid firing wasted requests to rate-limited models.
+ - **`RoundRobin`**: Skips over cooling-down keys without breaking the cyclic load-balancing progression.
+3. **Fail-Open Resilience**: If *all* providers in the farm are currently in a cooldown window, the orchestrator keeps all providers available rather than failing prematurely, allowing the request to cascade or surface accurate rate-limit errors.
---
@@ -59,7 +83,6 @@ flowchart TD
Audit --> Consensus["🗳️ AiMode.Consensus\nCross-LLM voting • Highest accuracy"]
```
-
---
## Mode Deep-Dives & Code Examples
@@ -93,7 +116,7 @@ const dt = await parseAI('schedule team sync for next wednesday at 2pm', {
```
> [!TIP]
-> `hedgeDelay` can also be set globally in `initAI({ hedgeDelay: 600 })` so it applies to all functions (`parseAI`, `recurrenceAI`, `scheduleAI`).
+> `hedgeDelay` can also be set globally in `initAI({ hedgeDelay: 600 })` so it applies to all functions (`parseAI`, `recurrenceAI`, `scheduleAI`, `extractAI`).
---
@@ -118,7 +141,7 @@ await initAI({
### 4. `AiMode.Adaptive` — Rate-Limit Telemetry Prioritization
-Reads `x-ratelimit-*` HTTP headers after every provider response and stores per-provider quota snapshots. On the next request, providers with `remainingRequests === 0` in an active reset window are automatically deprioritized; remaining providers are sorted by highest available quota.
+Reads `x-ratelimit-*` HTTP headers after every provider response and stores per-provider quota snapshots. On subsequent requests, providers are ranked dynamically by highest remaining quota descending, guaranteeing that providers with ample headroom are prioritized ahead of constrained models.
**Best for:** Multi-tier production gateways — mixed free/paid provider pools where proactively avoiding `429 Too Many Requests` is essential.
@@ -162,3 +185,4 @@ if (dt.ai?.ambiguous) {
console.warn('Providers disagreed — treat this result with caution.');
}
```
+
diff --git a/packages/plugins/ai/doc/parseAI.md b/packages/plugins/ai/doc/parse.md
similarity index 100%
rename from packages/plugins/ai/doc/parseAI.md
rename to packages/plugins/ai/doc/parse.md
diff --git a/packages/plugins/ai/doc/recurrenceAI.md b/packages/plugins/ai/doc/recurrence.md
similarity index 99%
rename from packages/plugins/ai/doc/recurrenceAI.md
rename to packages/plugins/ai/doc/recurrence.md
index e2127cf8..5646f698 100644
--- a/packages/plugins/ai/doc/recurrenceAI.md
+++ b/packages/plugins/ai/doc/recurrence.md
@@ -70,7 +70,7 @@ const schedule = await recurrenceAI("Every Friday");
for (const occurrence of schedule) {
// Always include a termination condition for open-ended schedules
- if (occurrence.year > 2028) break;
+ if (occurrence.yy > 2028) break;
console.log(occurrence.format('{yyyy}-{mm}-{dd}'));
}
diff --git a/packages/plugins/ai/doc/scheduleAI.md b/packages/plugins/ai/doc/schedule.md
similarity index 85%
rename from packages/plugins/ai/doc/scheduleAI.md
rename to packages/plugins/ai/doc/schedule.md
index d0760c6b..3efe8b98 100644
--- a/packages/plugins/ai/doc/scheduleAI.md
+++ b/packages/plugins/ai/doc/schedule.md
@@ -32,22 +32,22 @@ console.log(booking.ai?.conflictBumped); // true (pushed
---
-## Configuration Options (`AiScheduleOptions`)
+## Configuration Options (`TempoScheduleOptions`)
| Option | Type | Description |
| :--- | :--- | :--- |
-| **`anchor`** | `Tempo \| Date \| string \| number` | Anchor reference time to evaluate relative dates from. Defaults to workstation/browser current time. |
-| **`events`** | `TempoEvent[]` | A list of existing busy calendar intervals that the meeting must not overlap with. |
-| **`workingHours`** | `{ start: string; end: string }` | Daily time window constraint (HH:MM formats) inside which slots must fit. |
+| **`anchor`** | `TempoDateInput` | Anchor reference time to evaluate relative dates from. Defaults to workstation/browser current time. |
+| **`events`** | `TempoInterval[]` | A list of existing busy calendar intervals that the meeting must not overlap with. |
+| **`workingHours`** | `TempoWorkingHours` | Daily time window constraint (HH:MM formats) inside which slots must fit. |
| **`timeZone`** | `string` | Target IANA timezone to calculate the booking slot and boundaries within. |
| **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required to return a valid slot. |
| **`mode`** | `AiMode` | Concurrency routing strategy (`fallback`, `race`, `consensus`, `hedged`, `roundrobin`, `adaptive`). Refer to the [Multi-Provider Execution Modes Guide](./modes.md). |
-### `TempoEvent` Interface
+### `TempoInterval` Interface
```typescript
-interface TempoEvent {
- start: Tempo | Date | string | number;
- end: Tempo | Date | string | number;
+interface TempoInterval {
+ start: TempoDateInput;
+ end: TempoDateInput;
title?: string;
}
```
diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json
index 7c5bca52..ce53fb84 100644
--- a/packages/plugins/ai/package.json
+++ b/packages/plugins/ai/package.json
@@ -1,6 +1,6 @@
{
"name": "@magmacomputing/tempo-plugin-ai",
- "version": "4.0.0",
+ "version": "1.0.0",
"description": "Tempo community plugin for LLM-powered natural language parsing.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/packages/plugins/ai/plan/extractAI.plan.md b/packages/plugins/ai/plan/extractAI.plan.md
deleted file mode 100644
index 43e8e1ef..00000000
--- a/packages/plugins/ai/plan/extractAI.plan.md
+++ /dev/null
@@ -1,137 +0,0 @@
-# Implementation Plan: `extractAI`
-
-## 1. Overview & Goal
-`extractAI` scans unstructured, multi-paragraph text (emails, transcripts, chat logs, meeting agendas, task notes) to identify, parse, and extract all embedded temporal entities and time-bound events into structured `TempoAiExtractResult` records containing `TempoExtractedEvent[]` (`label`, `start`, `end`, `type`, `rawText`, `confidence`).
-
-It anchors relative mentions (e.g., *"tomorrow at 2pm"*, *"next Tuesday from 9 to 11am"*, *"the last day of next month"*) against an explicit or current reference `anchor` timestamp and timezone.
-
----
-
-## 2. Public API & Type Definitions
-
-### 2.1 Types (`packages/plugins/ai/src/types/extract.type.ts`)
-```typescript
-import type { Tempo } from '@magmacomputing/tempo';
-import type { AiOptions } from './common.type.js';
-import type { TempoAiError } from '../core/error.js';
-
-export type TempoEventType = 'point' | 'interval' | 'deadline' | 'recurrence' | 'tentative';
-
-export interface TempoExtractedEvent {
- /** Short descriptive label or title of the extracted event/activity. */
- label: string;
- /** Start date-time point as an instantiated Tempo instance. */
- start: Tempo;
- /** Optional end date-time point (if an interval or duration was mentioned). */
- end?: Tempo;
- /** Classification category of the temporal mention. */
- type: TempoEventType;
- /** Raw text snippet extracted from the source document. */
- rawText?: string;
- /** Confidence score for this specific entity extraction (0.0 to 1.0). */
- confidence: number;
-}
-
-/** Backward compatibility alias for TempoExtractedEvent */
-export type TempoEvent = TempoExtractedEvent;
-
-export interface TempoAiExtractResult {
- /** Array of extracted events with instantiated Tempo objects. */
- events: TempoExtractedEvent[];
- /** Overall confidence score. */
- confidence: number;
- /** Provider ID that fulfilled the request (or 'cache'). */
- provider: string;
- /** Optional summary or reasoning from the LLM. */
- reasoning?: string;
-}
-
-export interface AiExtractOptions extends AiOptions {
- /** Reference anchor date-time for relative expressions (defaults to now). */
- anchor?: Tempo | Date | string | number;
- /** Reference IANA timezone (defaults to global options or 'UTC'). */
- timeZone?: string;
- /** Reference BCP 47 locale (defaults to global options or 'en-US'). */
- locale?: string | string[];
- /** Preferred calendar system (e.g. 'gregory', 'islamic', 'hebrew'). */
- calendar?: string;
- /** Optional category filter to restrict extracted entities (e.g. ['meeting', 'deadline']). */
- categories?: string[];
- /** Optional regional context (e.g. 'US-NY', 'GB'). */
- region?: string;
-}
-```
-
-### 2.2 Function Signature (`packages/plugins/ai/src/functions/extract.ts`)
-```typescript
-export async function extractAI(texts: string[], options?: AiExtractOptions): Promise<(TempoAiExtractResult | TempoAiError)[]>;
-export async function extractAI(text: string, options?: AiExtractOptions): Promise;
-```
-
----
-
-## 3. Grounding & Prompt Strategy
-
-### 3.1 Context Construction
-Pass anchor metadata so the LLM has a solid temporal baseline:
-* **Reference Anchor**: `anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')` (`anchorTempo.tz`)
-* **Reference Day of Week**: `anchorTempo.dow` / weekday name
-* **Current Year / Month / Day**: Pre-resolved ISO components
-* **Target Categories / Constraints**: e.g., Filter meetings, deadlines, or flights
-
-### 3.2 System Prompt & Schema
-```markdown
-You are an expert temporal entity extraction engine.
-Scan the user-provided text for all temporal expressions, deadlines, meetings, and intervals.
-Resolve relative references ("tomorrow", "next Monday", "in 2 hours") strictly against the Reference Anchor date and timezone.
-
-Return ONLY a valid JSON object matching this schema:
-{
- "events": [
- {
- "label": "Brief descriptive title",
- "start": "ISO 8601 string (e.g. 2026-08-13T14:00:00)",
- "end": "ISO 8601 string or null",
- "type": "point | interval | deadline | recurrence | tentative",
- "rawText": "Exact text fragment from the input",
- "confidence": 0.95
- }
- ],
- "confidence": 0.95,
- "reasoning": "Identified 2 scheduled meetings and 1 project deadline."
-}
-```
-
-### 3.3 Post-Processing & Validation
-1. For each item in `events`, validate `start` using `new Tempo(item.start, { timeZone: tz, locale: loc, calendar: cal })`.
-2. If `item.end` is present, construct `new Tempo(item.end, { timeZone: tz, locale: loc, calendar: cal })`.
-3. Filter out invalid date results gracefully.
-4. Ensure returned `start` and `end` are native `Tempo` instances for immediate date arithmetic.
-
----
-
-## 4. Caching & Dispatch Pipeline
-
-1. **Cache Key Partition**:
- `extract::${normalizedTextHash}::${anchorTempo.format('{yyyy}-{mm}-{dd}')}::${tz}::${loc}::${cal}::${region}`
-2. **Multi-tier Caching**:
- - Check `AiCacheAdapter` then `Tempo.cache`.
- - Reconstitute cached ISO strings into `Tempo` instances upon cache hit.
-3. **Execution Modes**:
- - Dispatch via `executeWithMode` supporting all 6 modes.
-4. **Batch Processing**:
- - `Promise.all` / `Promise.allSettled` (with `softErrors` normalization).
-
----
-
-## 5. Verification & Test Plan
-* **Unit Tests (`packages/plugins/ai/test/extract.test.ts`)**:
- - Extract multiple events from email text (e.g., meeting + follow-up deadline).
- - Resolve relative dates against custom `anchor` timestamps.
- - Interval extraction with start and end times.
- - Handle inputs containing no temporal entities (returns `events: []`).
- - Cache hit rehydration into `Tempo` instances.
- - Multi-provider execution modes (Fallback, Race, Adaptive).
- - Batch processing with `softErrors: true`.
-* **Documentation (`packages/plugins/ai/doc/extractAI.md`)**:
- - TSDoc, usage examples with email text, calendar creation, and options guide.
diff --git a/packages/plugins/ai/plan/v0.3.0-roadmap.md b/packages/plugins/ai/plan/v0.3.0-roadmap.md
deleted file mode 100644
index ad1dd460..00000000
--- a/packages/plugins/ai/plan/v0.3.0-roadmap.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# @magmacomputing/tempo-plugin-ai: v0.3.0 Release Roadmap & Requirements
-
-This document captures the planned feature set, architectural requirements, and design specifications for the **v0.3.0** release of `@magmacomputing/tempo-plugin-ai`.
-
----
-
-## 1. AI Function Handler Implementations (v0.3.0 Status)
-
-### 1.1 ✅ `scheduleAI(prompt: string, options?: TempoScheduleOptions): Promise`
-* Resolves natural language scheduling prompts against working hours, existing calendar events, and timezones into an optimal start/end `TempoScheduleResult` interval (`slot`, `alternatives`, `ai.conflictBumped`).
-
-### 1.2 ✅ `recurrenceAI(prompt: string, options?: TempoRecurrenceOptions): Promise`
-* Translates complex natural language repeating schedule descriptions into standard RFC 5545 RRULE strings and stateful `Tempo` date batches (`rule.take(count)`).
-
-### 1.3 ✅ `contextAI(text: string, options?: AiContextOptions): Promise`
-* Infers `timeZone`, `locale`, and preferred `calendar` system from ambiguous location descriptions or user bios.
-
-### 1.4 ✅ `diffAI(start: any, end: any, prompt?: string, options?: AiDiffOptions): Promise`
-* Calculates and summarizes the delta between two `Tempo` instances in human, business, or operational terms (e.g., `"5 business days (48 hours)"`), backed by native grounding metrics (calendar days, hours, business days with weekend & holiday exclusion).
-
-### 1.5 ✅ `formatAI(date: Tempo.DateTime, prompt?: string, options?: AiFormatOptions): Promise`
-* Formats a `Tempo` instance, TC39 `Temporal` object, Date, or timestamp into human-friendly, contextual narrative text tailored to UI tones, relative countdowns, or domain summaries.
-* **Example**: `"this Friday at 5:00 PM EST (in 5 days)"`.
-
----
-
-## 2. Upcoming AI Function Handlers (Post-v0.3.0 Roadmap)
-
-The following functions remain scaffolded for upcoming releases:
-
-### 2.1 `extractAI(text: string, options?: AiExtractOptions): Promise`
-* Scans unstructured text (emails, transcripts, task notes) to extract embedded temporal entities into structured `TempoAiExtractResult` records (`events: TempoExtractedEvent[]`).
-
-
----
-Conceptually, the execution modes actually govern two different, orthogonal concerns:
-
-Concurrency Strategy (How do we invoke providers?):
-
-Sequential: Call one-by-one (e.g., Fallback, RoundRobin, Adaptive).
-Speculative: Call with a staggered delay (e.g., Hedged).
-Parallel: Call all at once (e.g., Race, Consensus).
-Provider Prioritization Strategy (Which providers do we call, and in what order?):
-
-Static: Configured order (e.g., Fallback, Hedged, Race, Consensus).
-Cyclic: Round-robin rotation (e.g., RoundRobin).
-Telemetry-Aware: Sorted dynamically by remaining rate-limit quota (e.g., Adaptive).
-Should Telemetry (Adaptive) be "on" for all modes?
-Yes! Conceptually, Telemetry-Awareness (filtering out exhausted providers in an active cooldown window) should ideally be active globally, regardless of the concurrency strategy:
-
-In Hedged / Fallback: Instead of starting with a hardcoded primary provider (which might have 0 remaining requests), we should start with the provider that telemetry reports has the highest quota, and hedge to the one with the second-highest.
-In Race / Consensus: If we know a provider is currently rate-limited (in a 429 cooldown reset window), launching a request to it is a waste of network resources and will immediately fail. We should filter it out of the race/consensus pool before firing.
-Why it isn't "on" everywhere by default in practice
-Stale Telemetry Risks: Telemetry depends on header ingestion from previous calls. If the reset window is short (e.g., resets in 2 seconds), but our cache reports "exhausted" for another 3 seconds, we might unnecessarily skip a provider that has recovered. Sequential fallback acts as a natural check.
-Simple/Deterministic expectation: Users choosing Race or Fallback often expect absolute determinism based strictly on their provider configuration array order.
-Single-Provider setups: For single-provider setups, telemetry-aware prioritization has no effect.
-Future Design Direction
-If we wanted to support combining them in a future version of Tempo, we could decouple the selection strategy from the concurrency mode:
-
-```typescript
-await initAI({
- mode: AiMode.Hedged, // How we invoke
- prioritization: 'telemetry' // How we order/filter (static | cyclic | telemetry)
-});
-```
-For the current codebase, keeping them as separate named presets (RoundRobin vs. Adaptive) keeps the setup simple and easy to reason about, but we could certainly update the other strategies to check for and skip active cooldown limits in a future update!
\ No newline at end of file
diff --git a/packages/plugins/ai/src/core/dispatch.ts b/packages/plugins/ai/src/core/dispatch.ts
index d6235dbe..17122473 100644
--- a/packages/plugins/ai/src/core/dispatch.ts
+++ b/packages/plugins/ai/src/core/dispatch.ts
@@ -387,6 +387,42 @@ async function executeAdaptiveMode(
return executeFallbackMode(sortedProviders, task, options);
}
+/**
+ * Checks if a provider has exhausted its request quota and is currently within an active cooldown window.
+ *
+ * @internal
+ */
+export function isProviderInCooldown(provider: AiProvider, now = Date.now()): boolean {
+ const limits = _state.providerLimits.get(provider.id);
+ if (!limits) return false;
+ const resetMs = limits.resetAt?.epoch?.ms ?? now;
+ return limits.remainingRequests === 0 && resetMs > now;
+}
+
+/**
+ * Filters out providers currently in an active rate-limit cooldown window,
+ * provided there is at least one non-exhausted provider available.
+ * If all providers are in cooldown, returns all providers so execution can attempt or fail naturally.
+ *
+ * @internal
+ */
+export function filterCooldownProviders(
+ providers: AiProvider[],
+ options?: ExecuteModeOptions,
+): AiProvider[] {
+ if (providers.length <= 1) return providers;
+ const now = Date.now();
+ const available = providers.filter(p => !isProviderInCooldown(p, now));
+ if (available.length > 0 && available.length < providers.length) {
+ if (options?.debug) {
+ const skipped = providers.filter(p => isProviderInCooldown(p, now)).map(p => p.id);
+ console.log(`[${options?.tag || 'tempo-plugin-ai'}] Proactively filtered ${skipped.length} provider(s) in active 429 cooldown: ${skipped.join(', ')}`);
+ }
+ return available;
+ }
+ return providers;
+}
+
/**
* ## executeWithMode
* Central multi-provider execution orchestrator for Tempo AI plugins.
@@ -413,21 +449,23 @@ export async function executeWithMode(
task: ProviderTask,
options?: ExecuteModeOptions,
): Promise> {
+ const effectiveProviders = filterCooldownProviders(providers, options);
+
switch (mode) {
case AiMode.Fallback:
- return executeFallbackMode(providers, task, options);
+ return executeFallbackMode(effectiveProviders, task, options);
case AiMode.Race:
- return executeRaceMode(providers, task, options);
+ return executeRaceMode(effectiveProviders, task, options);
case AiMode.Consensus:
- return executeConsensusMode(providers, task);
+ return executeConsensusMode(effectiveProviders, task);
case AiMode.Hedged:
- return executeHedgedMode(providers, task, options);
+ return executeHedgedMode(effectiveProviders, task, options);
case AiMode.RoundRobin:
- return executeRoundRobinMode(providers, task, options);
+ return executeRoundRobinMode(effectiveProviders, task, options);
case AiMode.Adaptive:
return executeAdaptiveMode(providers, task, options);
@@ -436,3 +474,4 @@ export async function executeWithMode(
throw new TempoAiError(`Invalid execution mode: '${mode}'. Supported modes: ${Object.values(AiMode).map(m => `'${m}'`).join(', ')}.`, 400);
}
}
+
diff --git a/packages/plugins/ai/src/core/error.ts b/packages/plugins/ai/src/core/error.ts
index 4dce5b55..bcadddc1 100644
--- a/packages/plugins/ai/src/core/error.ts
+++ b/packages/plugins/ai/src/core/error.ts
@@ -11,12 +11,12 @@ export class TempoAiError extends Error {
/** A Tempo instance representing the rate limit reset time (extracted from Headers) */
#retryAt?: Tempo | undefined;
- constructor(message: string, code: number, retryAt?: Tempo) {
- super(message);
- this.name = 'TempoAiError';
- this.#code = code;
- this.#retryAt = retryAt;
- }
+ constructor(message: string, code: number, retryAt?: Tempo, options?: ErrorOptions) {
+ super(message, options);
+ this.name = 'TempoAiError';
+ this.#code = code;
+ this.#retryAt = retryAt;
+ }
get code(): number {
return this.#code;
diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts
index 59e25b38..7d24d5f7 100644
--- a/packages/plugins/ai/src/core/support.ts
+++ b/packages/plugins/ai/src/core/support.ts
@@ -2,7 +2,7 @@ import { Tempo } from '@magmacomputing/tempo';
import { TempoAiError } from './error.js';
import { RESERVED_PROVIDER_IDS } from './config.js';
import { updateRateLimitsFromResponse, _state } from './init.js';
-import type { AiCacheAdapter, AiProvider, TempoAiMeta } from '../types/index.js';
+import type { AiCacheAdapter, AiProvider, TempoParseAiMeta } from '../types/index.js';
export function assertNoReservedProviderId(providers: Partial[]): void {
for (const p of providers) {
@@ -37,8 +37,13 @@ export function resolveTzAndLocale(
): { tz: string; loc: string } {
const resolvedOptions = (Tempo as any).options ?? {};
const tz = String(options?.timeZone || fallbackTempo?.tz || resolvedOptions.timeZone || _state.config.timeZone || 'UTC');
- const rawLoc = options?.locale || fallbackTempo?.loc || resolvedOptions.locale || _state.config.locale || 'en-US';
- const loc = String(Array.isArray(rawLoc) ? rawLoc[0] : rawLoc);
+ const rawLoc = (options?.locale !== undefined && (Array.isArray(options.locale) ? options.locale.length > 0 : Boolean(options.locale)))
+ ? options.locale
+ : (fallbackTempo?.loc !== undefined && (Array.isArray(fallbackTempo.loc) ? fallbackTempo.loc.length > 0 : Boolean(fallbackTempo.loc)))
+ ? fallbackTempo.loc
+ : resolvedOptions.locale || _state.config.locale || 'en-US';
+ const firstLoc = Array.isArray(rawLoc) ? rawLoc[0] : rawLoc;
+ const loc = typeof firstLoc === 'string' && firstLoc.trim().length > 0 ? firstLoc.trim() : 'en-US';
return { tz, loc };
}
@@ -102,7 +107,7 @@ export async function writeMultiTierCache(
}
}
-export function attachAiMeta(instance: Tempo, meta: TempoAiMeta): Tempo {
+export function attachAiMeta(instance: Tempo, meta: TempoParseAiMeta): Tempo {
const frozenMeta = Object.freeze(meta);
const boundMethodCache = new Map();
diff --git a/packages/plugins/ai/src/functions/context.ts b/packages/plugins/ai/src/functions/context.ts
index b0a88dc7..ac76cd29 100644
--- a/packages/plugins/ai/src/functions/context.ts
+++ b/packages/plugins/ai/src/functions/context.ts
@@ -1,4 +1,5 @@
import { Tempo } from '@magmacomputing/tempo';
+import { secure } from '@magmacomputing/tempo/library';
import { TempoAiError } from '../core/error.js';
import { AiMode } from '../core/config.js';
import { _state } from '../core/init.js';
@@ -6,6 +7,7 @@ import { executeWithMode } from '../core/dispatch.js';
import {
assertNoReservedProviderId,
fetchFromProvider,
+ getNamespacedCacheKey,
normalizeCacheInput,
readMultiTierCache,
resolveProviderTtl,
@@ -27,7 +29,7 @@ async function contextSingleInput(text: string, options?: AiContextOptions): Pro
const loc = String(Array.isArray(options?.locale) ? options?.locale[0] : (options?.locale || resolvedOptions.locale));
const sph = String(options?.sphere || resolvedOptions.sphere || 'north');
- const cacheKey = `context::${normalizedStr}::${tz}::${loc}::${cal}::${sph}`;
+ const cacheKey = getNamespacedCacheKey('context', `${normalizedStr}::${tz}::${loc}::${cal}::${sph}`);
const adapter = cacheAdapter ?? _state.config.cacheAdapter;
const cachedVal = await readMultiTierCache(cacheKey, {
@@ -48,7 +50,7 @@ async function contextSingleInput(text: string, options?: AiContextOptions): Pro
: 1.0;
if (effectiveMinConfidence === undefined || cachedConfidence >= effectiveMinConfidence) {
if (isDebug) console.log(`[tempo-plugin-ai:context] Cache hit: "${text}" -> ${cachedVal}`);
- return {
+ return secure({
timeZone: parsedCache.timeZone,
locale: parsedCache.locale,
calendar: parsedCache.calendar,
@@ -56,7 +58,7 @@ async function contextSingleInput(text: string, options?: AiContextOptions): Pro
confidence: cachedConfidence,
provider: 'cache',
reasoning: parsedCache.reasoning,
- }
+ });
}
}
} catch {
@@ -181,7 +183,7 @@ Do not include markdown blocks or text outside the JSON.`;
tag: 'tempo-plugin-ai:context',
});
- return finalResult;
+ return secure(finalResult);
}
/**
diff --git a/packages/plugins/ai/src/functions/diff.ts b/packages/plugins/ai/src/functions/diff.ts
index 6936bd5c..0cc0b880 100644
--- a/packages/plugins/ai/src/functions/diff.ts
+++ b/packages/plugins/ai/src/functions/diff.ts
@@ -1,4 +1,5 @@
import { Tempo } from '@magmacomputing/tempo';
+import { secure } from '@magmacomputing/tempo/library';
import { TempoAiError } from '../core/error.js';
import { AiMode } from '../core/config.js';
import { _state } from '../core/init.js';
@@ -6,6 +7,7 @@ import { executeWithMode } from '../core/dispatch.js';
import {
assertNoReservedProviderId,
fetchFromProvider,
+ getNamespacedCacheKey,
normalizeCacheInput,
readMultiTierCache,
resolveProviderTtl,
@@ -85,7 +87,7 @@ async function diffSingleInput(
const { force, mode: aiMode, providers, minConfidence, cache: aiCacheOption, timeout: callTimeout, ttl, cacheAdapter, hedgeDelay } = options || {};
const sortedHolidays = holidays ? [...holidays].sort().join(',') : '';
- const cacheKey = `diff::${startTempo.epoch.ms}::${endTempo.epoch.ms}::${normalizedPrompt}::${tz}::${loc}::${region}::${sortedHolidays}`;
+ const cacheKey = getNamespacedCacheKey('diff', `${startTempo.epoch.ms}::${endTempo.epoch.ms}::${normalizedPrompt}::${tz}::${loc}::${region}::${sortedHolidays}`);
const adapter = cacheAdapter ?? _state.config.cacheAdapter;
const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence;
@@ -108,7 +110,7 @@ async function diffSingleInput(
: 1.0;
if (effectiveMinConfidence === undefined || cachedConfidence >= effectiveMinConfidence) {
if (isDebug) console.log(`[tempo-plugin-ai:diff] Cache hit: "${cacheKey}" -> ${cachedVal}`);
- return {
+ return secure({
formatted: parsedCache.formatted,
days: parsedCache.days ?? grounding.calendarDays,
hours: parsedCache.hours ?? grounding.elapsedHours,
@@ -117,7 +119,7 @@ async function diffSingleInput(
confidence: cachedConfidence,
provider: 'cache',
reasoning: parsedCache.reasoning,
- };
+ });
}
}
} catch {
@@ -227,7 +229,7 @@ Do not include markdown blocks or text outside the JSON.`;
confidence,
provider: providerId,
reasoning: parsedData.reasoning,
- };
+ }
const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000);
const cacheVal = JSON.stringify({
@@ -247,7 +249,7 @@ Do not include markdown blocks or text outside the JSON.`;
tag: 'tempo-plugin-ai:diff',
});
- return finalResult;
+ return secure(finalResult);
}
/**
diff --git a/packages/plugins/ai/src/functions/extract.ts b/packages/plugins/ai/src/functions/extract.ts
index 64579084..83011298 100644
--- a/packages/plugins/ai/src/functions/extract.ts
+++ b/packages/plugins/ai/src/functions/extract.ts
@@ -1,102 +1,366 @@
-import type { Tempo } from '@magmacomputing/tempo';
-import type { TempoAiError } from '../core/error.js';
-import type { AiMode } from '../core/config.js';
-import type { AiCacheAdapter, AiProvider } from '../types/common.type.js';
-
-export type TempoEventType = 'point' | 'interval' | 'deadline' | 'recurrence' | 'tentative';
-
-export interface TempoExtractedEvent {
- /** Short descriptive label or title of the extracted event/activity. */
- label: string;
- /** Start date-time point as an instantiated Tempo instance. */
- start: Tempo;
- /** Optional end date-time point (if an interval or duration was mentioned). */
- end?: Tempo | undefined;
- /** Classification category of the temporal mention. */
- type: TempoEventType;
- /** Raw text snippet extracted from the source document. */
- rawText?: string | undefined;
- /** Confidence score for this specific entity extraction (0.0 to 1.0). */
- confidence: number;
-}
+import { Tempo } from '@magmacomputing/tempo';
+import { secure } from '@magmacomputing/tempo/library';
+import { TempoAiError } from '../core/error.js';
+import { AiMode } from '../core/config.js';
+import { _state } from '../core/init.js';
+import { executeWithMode } from '../core/dispatch.js';
+import {
+ assertNoReservedProviderId,
+ fetchFromProvider,
+ normalizeCacheInput,
+ readMultiTierCache,
+ resolveProviderTtl,
+ resolveTzAndLocale,
+ writeMultiTierCache,
+} from '../core/support.js';
+import type {
+ AiExtractOptions,
+ TempoAiExtractResult,
+ TempoExtractedEvent,
+ TempoEventType,
+} from '../types/extract.type.js';
-/**
- * Backward compatibility alias for TempoExtractedEvent.
- */
-export type TempoEvent = TempoExtractedEvent;
-
-export interface TempoAiExtractResult {
- /** Array of extracted events with instantiated Tempo objects. */
- events: TempoExtractedEvent[];
- /** Overall confidence score. */
- confidence: number;
- /** Provider ID that fulfilled the request (or 'cache'). */
- provider: string;
- /** Optional summary or reasoning from the LLM. */
- reasoning?: string | undefined;
+export type {
+ AiExtractOptions,
+ TempoAiExtractResult,
+ TempoExtractedEvent,
+ TempoEventType,
+};
+
+async function extractSingleInput(
+ text: string,
+ options?: AiExtractOptions,
+): Promise {
+ if (typeof text !== 'string' || !text.trim()) {
+ throw new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400);
+ }
+
+ const isDebug = options?.debug ?? _state.config.debug ?? false;
+ const anchor = options?.anchor;
+ const { tz, loc } = resolveTzAndLocale(options, Tempo.isTempo(anchor) ? anchor : null);
+
+ let anchorTempo: Tempo;
+ try {
+ anchorTempo = anchor !== undefined
+ ? (Tempo.isTempo(anchor)
+ ? (anchor.tz === tz ? anchor : anchor.set({ timeZone: tz }))
+ : new Tempo(anchor as any, { timeZone: tz }))
+ : new Tempo(Math.floor(Date.now() / 60_000) * 60_000, { timeZone: tz });
+ } catch (err: any) {
+ throw new TempoAiError(`Invalid anchor date provided to extractAI: "${String(anchor)}"`, 400);
+ }
+
+ if (!anchorTempo.isValid) {
+ throw new TempoAiError(`Invalid anchor date provided to extractAI: "${String(anchor)}"`, 400);
+ }
+
+ const cal = options?.calendar || 'gregory';
+ const region = options?.region ? String(options.region).trim() : '';
+ const categories = options?.categories ? options.categories.map(c => String(c).trim()).filter(Boolean) : [];
+ const categoriesStr = categories.sort().join(',');
+
+ const {
+ force,
+ mode: aiMode,
+ providers,
+ minConfidence,
+ cache: aiCacheOption,
+ timeout: callTimeout,
+ ttl,
+ cacheAdapter,
+ hedgeDelay,
+ } = options || {};
+
+ const normalizedText = normalizeCacheInput(text);
+ const cacheKey = `extract::${normalizedText}::${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}')}::${tz}::${loc}::${cal}::${region}::${categoriesStr}`;
+ const adapter = cacheAdapter ?? _state.config.cacheAdapter;
+
+ const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence;
+ if (
+ effectiveMinConfidence !== undefined &&
+ (typeof effectiveMinConfidence !== 'number' ||
+ !Number.isFinite(effectiveMinConfidence) ||
+ effectiveMinConfidence < 0.0 ||
+ effectiveMinConfidence > 1.0)
+ ) {
+ throw new TempoAiError(`Invalid minConfidence provided to extractAI: "${String(effectiveMinConfidence)}"`, 400);
+ }
+
+ const effectiveHedgeDelay = hedgeDelay ?? _state.config.hedgeDelay;
+
+ const cachedVal = await readMultiTierCache(cacheKey, {
+ force,
+ cache: aiCacheOption,
+ cacheAdapter: adapter,
+ debug: isDebug,
+ tag: 'tempo-plugin-ai:extract',
+ });
+
+ if (cachedVal) {
+ try {
+ const parsedCache = JSON.parse(cachedVal);
+ if (Array.isArray(parsedCache?.events)) {
+ const cachedConfidence = typeof parsedCache.confidence === 'number' && Number.isFinite(parsedCache.confidence)
+ ? Math.max(0.0, Math.min(1.0, parsedCache.confidence))
+ : 1.0;
+
+ if (effectiveMinConfidence !== undefined && cachedConfidence < effectiveMinConfidence) {
+ if (isDebug) console.log(`[tempo-plugin-ai:extract] Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`);
+ } else {
+ const rehydratedEvents: TempoExtractedEvent[] = [];
+ for (const ev of parsedCache.events) {
+ try {
+ const start = new Tempo(ev.start, { timeZone: tz, locale: loc, calendar: cal });
+ if (!start.isValid) continue;
+ const end = ev.end ? new Tempo(ev.end, { timeZone: tz, locale: loc, calendar: cal }) : undefined;
+ if (end && !end.isValid) continue;
+ rehydratedEvents.push({
+ label: String(ev.label || 'Event'),
+ start,
+ end,
+ type: ev.type || 'point',
+ rawText: ev.rawText ? String(ev.rawText) : undefined,
+ confidence: typeof ev.confidence === 'number' && Number.isFinite(ev.confidence)
+ ? Math.max(0.0, Math.min(1.0, ev.confidence))
+ : 1.0,
+ });
+ } catch { }
+ }
+
+ return secure({
+ events: rehydratedEvents,
+ confidence: cachedConfidence,
+ provider: 'cache',
+ reasoning: parsedCache.reasoning,
+ });
+ }
+ }
+ } catch (err: any) {
+ if (isDebug) console.warn(`[tempo-plugin-ai:extract] Failed to parse cached payload:`, err?.message ?? err);
+ }
+ }
+
+ const availableProviders = providers || _state.config.providers;
+ if (!availableProviders || availableProviders.length === 0) {
+ throw new TempoAiError('No AI providers configured. Please call initAI().', 400);
+ }
+
+ assertNoReservedProviderId(availableProviders);
+
+ const weekdayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
+ const anchorWeekday = weekdayNames[anchorTempo.dow] || anchorTempo.format('{www}');
+ const anchorIso = anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}');
+
+ const systemPrompt = `You are an expert temporal entity and calendar event extraction engine.
+Scan the user-provided text for all temporal expressions, deadlines, appointments, meetings, intervals, and time-bound events.
+Resolve all relative references ("tomorrow", "next Tuesday", "in 2 hours", "at 5pm") strictly against the Reference Anchor date and timezone.
+
+Return ONLY a valid JSON object matching this exact schema:
+{
+ "events": [
+ {
+ "label": "Brief descriptive title of the event or task",
+ "start": "ISO 8601 string without offset or Z (e.g. 2026-08-14T10:00:00)",
+ "end": "ISO 8601 string without offset or Z or null if point in time",
+ "type": "point | interval | deadline | recurrence | tentative",
+ "rawText": "Exact text snippet from the input mentioning this event",
+ "confidence": 0.95
+ }
+ ],
+ "confidence": 0.95,
+ "reasoning": "Summary of temporal entities identified"
}
-export interface AiExtractOptions {
- /** Reference anchor date-time for relative expressions (defaults to now). */
- anchor?: Tempo | Date | string | number | undefined;
- /** Reference IANA timezone (defaults to global options or 'UTC'). */
- timeZone?: string | undefined;
- /** Reference BCP 47 locale (defaults to global options or 'en-US'). */
- locale?: string | string[] | undefined;
- /** Preferred calendar system (e.g. 'gregory', 'islamic', 'hebrew'). */
- calendar?: string | undefined;
- /** Optional category filter to restrict extracted entities (e.g. ['meeting', 'deadline']). */
- categories?: string[] | undefined;
- /** Optional regional context (e.g. 'US-NY', 'GB'). */
- region?: string | undefined;
- /** If true, bypasses cache to force a fresh LLM fetch */
- force?: boolean | undefined;
- /** If false, disables reading and writing to cache */
- cache?: boolean | undefined;
- /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */
- cacheAdapter?: AiCacheAdapter | undefined;
- /** Optional TTL override in milliseconds for cached result */
- ttl?: number | undefined;
- /** If true, logs prompt context and LLM payloads to console */
- debug?: boolean | undefined;
- /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` | `AiMode.Hedged` | `AiMode.RoundRobin` | `AiMode.Adaptive` or string literal) */
- mode?: AiMode | undefined;
- /** Per-request provider configuration overrides */
- providers?: AiProvider[] | undefined;
- /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */
- minConfidence?: number | undefined;
- /** If true, returns TempoAiError into array index position instead of rejecting batch */
- softErrors?: boolean | undefined;
- /** Optional request timeout in milliseconds (overrides provider and global timeout) */
- timeout?: number | undefined;
- /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */
- hedgeDelay?: number | undefined;
- /** Allow extra custom properties */
- [key: string]: any;
+Rules:
+1. "events": Array of extracted event objects. If no temporal entities are mentioned, return an empty array [].
+2. "start": Local ISO 8601 representation (YYYY-MM-DDThh:mm:ss) anchored to the reference date and timezone.
+3. "end": Local ISO 8601 string for interval end / duration, or null.
+4. "type": Must be one of 'point', 'interval', 'deadline', 'recurrence', 'tentative'.
+5. "confidence": Float score between 0.0 and 1.0 representing extraction certainty.
+${categories.length > 0 ? `6. Only extract events matching one of these categories: ${categories.join(', ')}.` : ''}`;
+
+ const contextString = `Grounding Context:
+- Reference Anchor Date-Time: ${anchorIso} (${tz})
+- Reference Day of Week: ${anchorWeekday} (Day ${anchorTempo.dow})
+- Target TimeZone: ${tz}
+- Target Locale: ${loc}
+- Calendar System: ${cal}
+${region ? `- Region Context: ${region}\n` : ''}${categories.length > 0 ? `- Filter Categories: ${categories.join(', ')}\n` : ''}`;
+
+ const mode = aiMode || _state.config.mode || AiMode.Fallback;
+
+ const winningCandidate = await executeWithMode(
+ mode,
+ availableProviders,
+ async (provider, signal) => {
+ const { rawContent, providerId, rateLimits } = await fetchFromProvider(
+ provider,
+ text,
+ contextString,
+ isDebug,
+ signal,
+ callTimeout,
+ systemPrompt,
+ );
+
+ let parsedData: any;
+ try {
+ parsedData = JSON.parse(rawContent);
+ } catch (err: any) {
+ throw new TempoAiError(`Provider ${providerId} returned invalid JSON: ${err?.message}`, 422);
+ }
+
+ if (typeof parsedData !== 'object' || parsedData === null)
+ throw new TempoAiError(`Provider ${providerId} returned non-object JSON payload.`, 422);
+
+ if (!Array.isArray(parsedData?.events))
+ throw new TempoAiError(`Provider ${providerId} returned invalid response: 'events' array missing.`, 422);
+
+ const rawConfidence = typeof parsedData?.confidence === 'number' && Number.isFinite(parsedData.confidence)
+ ? parsedData.confidence
+ : 0.9;
+ const confidence = Math.max(0.0, Math.min(1.0, rawConfidence));
+ const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined;
+
+ const validEvents: TempoExtractedEvent[] = [];
+ const rawEventItems: any[] = [];
+ for (const item of parsedData.events) {
+ if (!item || typeof item !== 'object') continue;
+ try {
+ const start = new Tempo(item.start, { timeZone: tz, locale: loc, calendar: cal });
+ if (!start.isValid) continue;
+
+ let end: Tempo | undefined;
+ if (item.end && typeof item.end === 'string') {
+ const parsedEnd = new Tempo(item.end, { timeZone: tz, locale: loc, calendar: cal });
+ if (parsedEnd.isValid) end = parsedEnd;
+ }
+
+ const allowedTypes: TempoEventType[] = ['point', 'interval', 'deadline', 'recurrence', 'tentative'];
+ const type: TempoEventType = allowedTypes.includes(item.type) ? item.type : 'point';
+ const label = typeof item.label === 'string' && item.label.trim() ? item.label.trim() : 'Event';
+ const rawText = typeof item.rawText === 'string' ? item.rawText : undefined;
+ const itemConf = typeof item.confidence === 'number' && Number.isFinite(item.confidence)
+ ? Math.max(0.0, Math.min(1.0, item.confidence))
+ : confidence;
+
+ validEvents.push({
+ label,
+ start,
+ end,
+ type,
+ rawText,
+ confidence: itemConf,
+ });
+
+ rawEventItems.push({
+ label,
+ start: start.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}'),
+ end: end ? end.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}') : null,
+ type,
+ rawText,
+ confidence: itemConf,
+ });
+ } catch { }
+ }
+
+ return {
+ data: {
+ events: validEvents,
+ rawEvents: rawEventItems,
+ reasoning,
+ },
+ providerId,
+ rateLimits,
+ confidence,
+ consensusKey: JSON.stringify(rawEventItems),
+ };
+ },
+ { minConfidence: effectiveMinConfidence, debug: isDebug, tag: 'tempo-plugin-ai:extract', hedgeDelay: effectiveHedgeDelay },
+ );
+
+ _state.limits = winningCandidate.rateLimits ?? null;
+
+ const { data: parsedData, providerId } = winningCandidate;
+ const rawConfidence = typeof winningCandidate.confidence === 'number' && Number.isFinite(winningCandidate.confidence)
+ ? winningCandidate.confidence
+ : 0.9;
+ const confidence = Math.max(0.0, Math.min(1.0, rawConfidence));
+
+ if (effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence) {
+ throw new TempoAiError(`extractAI confidence (${confidence}) is below the required threshold of ${effectiveMinConfidence}.`, 422);
+ }
+
+ const finalResult: TempoAiExtractResult = {
+ events: parsedData.events,
+ confidence,
+ provider: providerId,
+ reasoning: parsedData.reasoning,
+ }
+
+ const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000);
+ const cacheVal = JSON.stringify({
+ events: parsedData.rawEvents,
+ confidence,
+ provider: providerId,
+ reasoning: parsedData.reasoning,
+ });
+
+ await writeMultiTierCache(cacheKey, cacheVal, resolvedTtl, {
+ cache: aiCacheOption,
+ cacheAdapter: adapter,
+ debug: isDebug,
+ tag: 'tempo-plugin-ai:extract',
+ });
+
+ return secure(finalResult);
}
/**
- * @internal Draft implementation scaffolded for future releases.
- * ## extractAI (Upcoming Export)
- * Scans unstructured text (emails, transcripts, task notes) and extracts all
- * embedded temporal entities, deadlines, and events into structured `TempoAiExtractResult` records.
+ * ## extractAI
+ * Scans unstructured multi-paragraph text (emails, meeting transcripts, chat logs, task notes)
+ * and extracts all embedded temporal entities, deadlines, appointments, and intervals into structured `TempoAiExtractResult` records.
*
* ### Why it fits Tempo:
- * Essential for calendar apps and document processing workflows where temporal references
- * are buried inside unstructured text.
+ * Translates messy unstructured prose into typed, validated `Tempo` instances anchored to reference timezones and calendar contexts.
*
* ### Example Usage:
* ```ts
- * const text = "Let's meet tomorrow at 10am. Final deliverables due next Friday EOD.";
- * const result = await extractAI(text, { anchor: new Tempo() });
- * // returns TempoAiExtractResult with parsed Tempo instances in result.events
+ * const email = "Let's meet tomorrow at 10am for sprint planning. Deliverables due next Friday by 5pm.";
+ * const result = await extractAI(email, { anchor: new Tempo('2026-08-10T09:00:00Z') });
+ *
+ * for (const event of result.events) {
+ * console.log(event.label, event.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}'));
+ * }
* ```
*/
export async function extractAI(texts: string[], options?: AiExtractOptions): Promise<(TempoAiExtractResult | TempoAiError)[]>;
export async function extractAI(text: string, options?: AiExtractOptions): Promise;
export async function extractAI(
textOrTexts: string | string[],
- _options?: AiExtractOptions,
+ options?: AiExtractOptions,
): Promise {
- throw new Error('extractAI is not yet implemented in tempo-plugin-ai.');
+ if (Array.isArray(textOrTexts)) {
+ const opts = options || {};
+ const softErrors = opts.softErrors ?? false;
+
+ if (softErrors) {
+ const settled = await Promise.allSettled(
+ textOrTexts.map(t => extractSingleInput(t, opts)),
+ );
+ return settled.map((res, index) => {
+ if (res.status === 'fulfilled') return res.value;
+ const rawReason = res.reason;
+ if (rawReason instanceof TempoAiError) return rawReason;
+ return new TempoAiError(
+ rawReason?.message || `Failed to extract events at index ${index}`,
+ typeof rawReason?.status === 'number' ? rawReason.status : 500,
+ );
+ });
+ }
+
+ return Promise.all(textOrTexts.map(t => extractSingleInput(t, opts)));
+ }
+
+ return extractSingleInput(textOrTexts, options);
}
diff --git a/packages/plugins/ai/src/functions/format.ts b/packages/plugins/ai/src/functions/format.ts
index 4d4ddd28..c4505441 100644
--- a/packages/plugins/ai/src/functions/format.ts
+++ b/packages/plugins/ai/src/functions/format.ts
@@ -1,4 +1,5 @@
import { Tempo } from '@magmacomputing/tempo';
+import { secure } from '@magmacomputing/tempo/library';
import { TempoAiError } from '../core/error.js';
import { AiMode } from '../core/config.js';
import { _state } from '../core/init.js';
@@ -68,11 +69,13 @@ async function formatSingleInput(
? (date.tz === tz ? date : date.set({ timeZone: tz }))
: new Tempo(date as any, { timeZone: tz });
} catch (err: any) {
- throw new TempoAiError(`Invalid date provided to formatAI: "${String(date)}"`, 400);
+ const safeDateRep = typeof date === 'object' && date !== null ? JSON.stringify(date) : String(date);
+ throw new TempoAiError(`Invalid date provided to formatAI: "${safeDateRep}"`, 400, undefined, { cause: err });
}
if (!targetTempo.isValid) {
- throw new TempoAiError(`Invalid date provided to formatAI: "${String(date)}"`, 400);
+ const safeDateRep = typeof date === 'object' && date !== null ? JSON.stringify(date) : String(date);
+ throw new TempoAiError(`Invalid date provided to formatAI: "${safeDateRep}"`, 400);
}
const anchor = options?.anchor;
@@ -84,11 +87,13 @@ async function formatSingleInput(
: new Tempo(anchor as any, { timeZone: tz }))
: new Tempo(Math.floor(Date.now() / 60_000) * 60_000, { timeZone: tz });
} catch (err: any) {
- throw new TempoAiError(`Invalid anchor date provided to formatAI: "${String(anchor)}"`, 400);
+ const safeAnchorRep = typeof anchor === 'object' && anchor !== null ? JSON.stringify(anchor) : String(anchor);
+ throw new TempoAiError(`Invalid anchor date provided to formatAI: "${safeAnchorRep}"`, 400, undefined, { cause: err });
}
if (!anchorTempo.isValid) {
- throw new TempoAiError(`Invalid anchor date provided to formatAI: "${String(anchor)}"`, 400);
+ const safeAnchorRep = typeof anchor === 'object' && anchor !== null ? JSON.stringify(anchor) : String(anchor);
+ throw new TempoAiError(`Invalid anchor date provided to formatAI: "${safeAnchorRep}"`, 400);
}
const style = options?.style ? String(options.style).trim() : '';
@@ -114,6 +119,16 @@ async function formatSingleInput(
const adapter = cacheAdapter ?? _state.config.cacheAdapter;
const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence;
+ if (
+ effectiveMinConfidence !== undefined &&
+ (typeof effectiveMinConfidence !== 'number' ||
+ !Number.isFinite(effectiveMinConfidence) ||
+ effectiveMinConfidence < 0.0 ||
+ effectiveMinConfidence > 1.0)
+ ) {
+ throw new TempoAiError(`Invalid minConfidence provided to formatAI: "${String(effectiveMinConfidence)}"`, 400);
+ }
+
const effectiveHedgeDelay = hedgeDelay ?? _state.config.hedgeDelay;
const cachedVal = await readMultiTierCache(cacheKey, {
@@ -135,12 +150,13 @@ async function formatSingleInput(
if (effectiveMinConfidence !== undefined && cachedConfidence < effectiveMinConfidence) {
if (isDebug) console.log(`[tempo-plugin-ai:format] Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`);
} else {
- return {
+ const reasoning = typeof parsedCache?.reasoning === 'string' ? parsedCache.reasoning : undefined;
+ return secure({
formatted: parsedCache.formatted,
confidence: cachedConfidence,
provider: 'cache',
- reasoning: parsedCache.reasoning,
- };
+ reasoning,
+ });
}
}
} catch (err: any) {
@@ -158,16 +174,9 @@ async function formatSingleInput(
const systemPrompt = `You are an expert natural language temporal formatting engine.
Generate human-friendly, contextual narrative representations of dates and times based on the grounding context.
-Grounding Context:
-- Target Date-Time: ${grounding.iso} (${tz})
-- Target Day of Week: ${grounding.dayOfWeek} (Day ${grounding.dayOfWeekOrdinal})
-- Reference Anchor: ${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')} (${tz})
-- Relative Time Delta: ${grounding.calendarDays >= 0 ? '+' : ''}${grounding.calendarDays} calendar days (${grounding.elapsedHours >= 0 ? '+' : ''}${grounding.elapsedHours} hours) in the ${grounding.direction.toUpperCase()}
-- Target Locale: ${loc}${region ? `\n- Regional Context: ${region}` : ''}${style ? `\n- Desired Style/Tone: ${style}` : ''}
-
Rules:
1. Always return a single, valid JSON object matching the schema below.
-2. The "formatted" field must contain the contextual narrative string (e.g., "this Friday at 5:00 PM EST (in 5 days)", "Tomorrow afternoon at 3:00 PM").
+2. The "formatted" field must contain the contextual narrative string (e.g., "this Friday at 5:00 PM EDT (in 5 days)", "Tomorrow afternoon at 3:00 PM").
3. Respect the target locale, style, and timezone conventions.
4. "confidence" must be a float between 0.0 and 1.0 representing certainty.
5. "reasoning" should briefly describe how the formatted output was constructed.
@@ -179,15 +188,18 @@ Output JSON Schema:
"reasoning": "string"
}`;
- const contextString = `Grounding Context:
-- Target Date-Time: ${grounding.iso} (${grounding.timeZone})
-- Day of Week: ${grounding.dayOfWeek} (Day ${grounding.dayOfWeekOrdinal})
-- Reference Anchor: ${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')} (${anchorTempo.tz || tz})
-- Relative Delta: ${grounding.calendarDays >= 0 ? '+' : ''}${grounding.calendarDays} calendar days (${grounding.elapsedHours >= 0 ? '+' : ''}${grounding.elapsedHours} hours) in the ${grounding.direction.toUpperCase()}
-- Target Locale: ${loc}
-${style ? `- Desired Style/Tone: ${style}` : ''}
-${region ? `- Regional Context: ${region}` : ''}
-- Formatting Instructions: "${promptText}"`;
+ const contextParts = [
+ 'Grounding Context:',
+ `- Target Date-Time: ${grounding.iso} (${grounding.timeZone})`,
+ `- Day of Week: ${grounding.dayOfWeek} (Day ${grounding.dayOfWeekOrdinal})`,
+ `- Reference Anchor: ${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')} (${anchorTempo.tz || tz})`,
+ `- Relative Delta: ${grounding.calendarDays >= 0 ? '+' : ''}${grounding.calendarDays} calendar days (${grounding.elapsedHours >= 0 ? '+' : ''}${grounding.elapsedHours} hours) in the ${grounding.direction.toUpperCase()}`,
+ `- Target Locale: ${loc}`,
+ ];
+ if (style) contextParts.push(`- Desired Style/Tone: ${style}`);
+ if (region) contextParts.push(`- Regional Context: ${region}`);
+ contextParts.push(`- Formatting Instructions: "${promptText}"`);
+ const contextString = contextParts.join('\n');
const mode = aiMode || _state.config.mode || AiMode.Fallback;
@@ -234,7 +246,7 @@ ${region ? `- Regional Context: ${region}` : ''}
rateLimits,
confidence,
consensusKey: formatted.toLowerCase(),
- };
+ }
},
{ minConfidence: effectiveMinConfidence, debug: isDebug, tag: 'tempo-plugin-ai:format', hedgeDelay: effectiveHedgeDelay },
);
@@ -256,7 +268,7 @@ ${region ? `- Regional Context: ${region}` : ''}
confidence,
provider: providerId,
reasoning: parsedData.reasoning,
- };
+ }
const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000);
const cacheVal = JSON.stringify(finalResult);
@@ -267,7 +279,7 @@ ${region ? `- Regional Context: ${region}` : ''}
tag: 'tempo-plugin-ai:format',
});
- return finalResult;
+ return secure(finalResult);
}
/**
@@ -283,7 +295,7 @@ ${region ? `- Regional Context: ${region}` : ''}
* ```ts
* const t = new Tempo('2026-08-07T17:00:00[America/New_York]');
*
- * // "this Friday at 5:00 PM EST (in 5 days)"
+ * // "this Friday at 5:00 PM EDT (in 5 days)"
* const result = await formatAI(t, 'friendly reminder tone with relative countdown');
* console.log(result.formatted);
* ```
@@ -296,25 +308,48 @@ export async function formatAI(
options?: AiFormatOptions,
): Promise {
if (Array.isArray(dateOrItems)) {
+ if (dateOrItems.length === 0) return [];
const opts = (typeof promptOrOptions === 'object' && promptOrOptions !== null ? promptOrOptions : options) || {};
const softErrors = opts.softErrors ?? false;
+ const concurrencyLimit = Math.max(1, Math.min(opts.concurrency ?? 4, dateOrItems.length));
+
+ const results: (TempoAiFormatResult | TempoAiError)[] = new Array(dateOrItems.length);
+ let nextIdx = 0;
+ let firstError: any = null;
+
+ const worker = async () => {
+ while (nextIdx < dateOrItems.length) {
+ if (!softErrors && firstError) break;
+ const currentIndex = nextIdx++;
+ const item = dateOrItems[currentIndex];
+ const itemOpts = item.options ? { ...opts, ...item.options } : opts;
+ try {
+ const res = await formatSingleInput(item.date, item.prompt, itemOpts);
+ results[currentIndex] = res;
+ } catch (err: any) {
+ if (softErrors) {
+ results[currentIndex] = err instanceof TempoAiError
+ ? err
+ : new TempoAiError(
+ err?.message || `Failed to format date at index ${currentIndex}`,
+ typeof err?.status === 'number' ? err.status : 500,
+ );
+ } else {
+ if (!firstError) firstError = err;
+ break;
+ }
+ }
+ }
+ };
- if (softErrors) {
- const settled = await Promise.allSettled(
- dateOrItems.map(item => formatSingleInput(item.date, item.prompt, opts)),
- );
- return settled.map((res, index) => {
- if (res.status === 'fulfilled') return res.value;
- const rawReason = res.reason;
- if (rawReason instanceof TempoAiError) return rawReason;
- return new TempoAiError(
- rawReason?.message || `Failed to format date at index ${index}`,
- typeof rawReason?.status === 'number' ? rawReason.status : 500,
- );
- });
+ const workers = Array.from({ length: concurrencyLimit }, () => worker());
+ await Promise.all(workers);
+
+ if (!softErrors && firstError) {
+ throw firstError;
}
- return Promise.all(dateOrItems.map(item => formatSingleInput(item.date, item.prompt, opts)));
+ return results;
}
const prompt = typeof promptOrOptions === 'string' ? promptOrOptions : undefined;
diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts
index 468a1f24..b0473ea8 100644
--- a/packages/plugins/ai/src/functions/parse.ts
+++ b/packages/plugins/ai/src/functions/parse.ts
@@ -11,7 +11,26 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise<
const isDebug = options?.debug ?? _state.config.debug ?? false;
const normalizedStr = normalizeCacheInput(str);
- const { force, debug, mode: aiMode, providers, minConfidence, softErrors, cache: aiCacheOption, timeout: callTimeout, ttl, cacheAdapter, anchor, hedgeDelay, ...coreOptions } = options || {};
+ const {
+ force,
+ debug,
+ mode: aiMode,
+ providers,
+ minConfidence,
+ softErrors,
+ cache: aiCacheOption,
+ timeout: callTimeout,
+ ttl,
+ cacheAdapter,
+ anchor,
+ hedgeDelay,
+ timeZone: _tz,
+ locale: _loc,
+ calendar: _cal,
+ region: _reg,
+ sphere: _sph,
+ ...coreOptions
+ } = options || {};
let tz: string, cal: string, loc: string, sph: string, anchorStr: string;
if (Tempo.isTempo(options?.anchor)) {
@@ -31,7 +50,8 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise<
anchorStr = String(options?.anchor || new Tempo().toString());
}
- const anchorTempo = new Tempo(anchorStr, { ...coreOptions, timeZone: tz, calendar: cal, locale: loc, sphere: sph as any });
+ const tempoConfig = { ...coreOptions, timeZone: tz, calendar: cal, locale: loc, sphere: sph as any };
+ const anchorTempo = new Tempo(anchorStr, tempoConfig);
const cacheSalt = anchorTempo.format('{yyyy}-{mm}-{dd}');
const cacheKey = `${normalizedStr}::${cacheSalt}::${tz}::${cal}::${loc}::${sph}`;
const adapter = cacheAdapter ?? _state.config.cacheAdapter;
@@ -54,7 +74,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise<
if (cachedIso) {
if (isDebug) console.log(`[tempo-plugin-ai] Cache hit: "${str}" -> ${cachedIso}`);
- const cachedInstance = new Tempo(cachedIso, coreOptions);
+ const cachedInstance = new Tempo(cachedIso, tempoConfig);
return attachAiMeta(cachedInstance, {
provider: 'cache',
cached: true,
@@ -69,7 +89,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise<
if (!force) {
try {
- const native = new Tempo(str, { ...coreOptions, silent: true });
+ const native = new Tempo(str, { ...tempoConfig, silent: true });
const hasNativeMatches = Tempo.cache.has(str)
|| Tempo.cache.has(normalizedStr)
|| RE_ISO_DATE_PREFIX.test(str.trim())
@@ -142,7 +162,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise<
const isBelowMinConfidence = effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence;
if (rawIso === 'INVALID' || isBelowMinConfidence) {
- const invalidInstance = new Tempo('INVALID', { ...coreOptions, catch: true });
+ const invalidInstance = new Tempo('INVALID', { ...tempoConfig, catch: true });
return attachAiMeta(invalidInstance, {
provider: providerId,
cached: false,
@@ -163,8 +183,10 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise<
// In Consensus mode, providerId is the synthetic sentinel 'consensus' (not a real provider id),
// so use the minimum TTL across all participating providers as the conservative policy.
const providerTtl = providerId === AiMode.Consensus
- ? availableProviders.reduce((min, p) => p.ttl === undefined ? min : (min === undefined ? p.ttl : Math.min(min, p.ttl)), undefined)
- : availableProviders.find(p => p.id === providerId)?.ttl;
+ ? (availableProviders)
+ .reduce((min: number | undefined, p: any) =>
+ p.ttl === undefined ? min : (min === undefined ? p.ttl : Math.min(min, p.ttl)), undefined)
+ : (availableProviders).find((p: any) => p.id === providerId)?.ttl;
const resolvedTtl = ttl ?? providerTtl ?? _state.config.ttl ?? 3_600_000;
if (aiCacheOption !== false) {
@@ -179,7 +201,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise<
Tempo.cache.set(cacheKey, parsedIso);
}
- const finalInstance = new Tempo(parsedIso, coreOptions);
+ const finalInstance = new Tempo(parsedIso, tempoConfig);
return attachAiMeta(finalInstance, {
provider: providerId,
diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts
index a27d4af7..1bfb84a8 100644
--- a/packages/plugins/ai/src/functions/recurrence.ts
+++ b/packages/plugins/ai/src/functions/recurrence.ts
@@ -123,7 +123,7 @@ export async function recurrenceAI(
const sph = options?.sphere || (options?.anchor instanceof Tempo ? options.anchor.config.sphere : undefined) || Tempo.options.sphere;
const contextConfig = { timeZone: tz, calendar: cal, locale: loc, sphere: sph };
- const anchorTempo = new Tempo(options?.anchor, contextConfig);
+ const anchorTempo = new Tempo(options?.anchor as any, contextConfig);
const defaultBatchSize = options?.count ?? 5;
if (isRRule) {
diff --git a/packages/plugins/ai/src/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts
index 330c9224..aed395c0 100644
--- a/packages/plugins/ai/src/functions/schedule.ts
+++ b/packages/plugins/ai/src/functions/schedule.ts
@@ -186,7 +186,7 @@ export async function scheduleAI(
|| (options?.anchor instanceof Tempo ? options.anchor.tz : undefined)
|| Tempo.options?.timeZone
|| 'UTC';
- const anchorTempo = new Tempo(options?.anchor, { timeZone: resolvedTz });
+ const anchorTempo = new Tempo(options?.anchor as any, { timeZone: resolvedTz });
const timeZone = options?.timeZone || anchorTempo.tz || 'UTC';
const workingHours: TempoWorkingHours = {
start: options?.workingHours?.start ?? '09:00',
@@ -401,6 +401,7 @@ export async function scheduleAI(
ai: {
provider: providerId,
confidence,
+ cached: false,
conflictBumped,
originalSlot,
reasoning,
diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts
index 357ac12d..1a42b67a 100644
--- a/packages/plugins/ai/src/index.ts
+++ b/packages/plugins/ai/src/index.ts
@@ -11,19 +11,10 @@ export { initAI, resetAI, clearAiCache, getAiRateLimits, getAiProviderRateLimits
// AI Function Handlers
export { parseAI } from './functions/parse.js';
+export { formatAI } from './functions/format.js';
+export { extractAI } from './functions/extract.js';
export { recurrenceAI } from './functions/recurrence.js';
export { scheduleAI } from './functions/schedule.js';
-export { contextAI } from './functions/context.js';
export { diffAI } from './functions/diff.js';
-export { formatAI } from './functions/format.js';
-
-/*
- * ============================================================================
- * Upcoming AI Function Exports (Scaffolded for Future Releases)
- * ============================================================================
- * The following exports lay the groundwork for expanding tempo-plugin-ai.
- * Uncomment these exports as their implementations are finalized.
- */
+export { contextAI } from './functions/context.js';
-// /** Scans unstructured text and extracts embedded temporal entities & events */
-// export { extractAI, type TempoAiExtractResult, type TempoExtractedEvent, type TempoEvent, type AiExtractOptions } from './functions/extract.js';
diff --git a/packages/plugins/ai/src/types/common.type.ts b/packages/plugins/ai/src/types/base.type.ts
similarity index 51%
rename from packages/plugins/ai/src/types/common.type.ts
rename to packages/plugins/ai/src/types/base.type.ts
index e8d27d0c..bce9747a 100644
--- a/packages/plugins/ai/src/types/common.type.ts
+++ b/packages/plugins/ai/src/types/base.type.ts
@@ -1,24 +1,90 @@
import type { Tempo } from '@magmacomputing/tempo';
import type { AiMode } from '../core/config.js';
+/**
+ * Universal date-time input representation accepted across AI operations.
+ * Accepts any native Tempo instance, Temporal object, ISO string, Date, timestamp, or Tempo.DateTime.
+ */
+export type TempoDateInput = Tempo | Tempo.DateTime | (Record & { readonly isValid?: boolean });
+
+/**
+ * ## AiBaseOptions
+ * Fundamental execution, caching, timeout, and dispatch routing options
+ * accepted by all AI plugin functions.
+ */
+export interface AiBaseOptions {
+ /** If true, bypasses cache to force a fresh LLM fetch */
+ force?: boolean | undefined;
+ /** If false, disables reading and writing to cache */
+ cache?: boolean | undefined;
+ /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */
+ cacheAdapter?: AiCacheAdapter | undefined;
+ /** Optional TTL override in milliseconds for cached result */
+ ttl?: number | undefined;
+ /** If true, logs prompt context and LLM payloads to console */
+ debug?: boolean | undefined;
+ /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` | `AiMode.Hedged` | `AiMode.RoundRobin` | `AiMode.Adaptive` or string literal) */
+ mode?: AiMode | undefined;
+ /** Per-request provider configuration overrides */
+ providers?: AiProvider[] | undefined;
+ /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */
+ minConfidence?: number | undefined;
+ /** Optional request timeout in milliseconds (overrides provider and global timeout) */
+ timeout?: number | undefined;
+ /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */
+ hedgeDelay?: number | undefined;
+ /** If true, returns an array containing both successful results and TempoAiErrors instead of throwing */
+ softErrors?: boolean | undefined;
+}
+
+/**
+ * ## AiDateContextOptions
+ * Base options for operations requiring relative anchor dates, timezone, and calendar grounding.
+ */
+export interface AiDateContextOptions extends AiBaseOptions {
+ /** Reference anchor date for relative calculations (defaults to current time). */
+ anchor?: TempoDateInput | undefined;
+ /** Target IANA timezone. */
+ timeZone?: string | undefined;
+ /** Target BCP 47 locale or language tag. */
+ locale?: string | string[] | undefined;
+ /** Preferred calendar system (e.g. 'gregory', 'islamic', 'hebrew'). */
+ calendar?: string | undefined;
+ /** Custom regional context (e.g. 'AU-NSW', 'US-CA'). */
+ region?: string | undefined;
+}
+
+/**
+ * ## TempoBaseAiResult
+ * Standard base result structure shared across all AI operations returning structured metadata.
+ */
+export interface TempoBaseAiResult {
+ /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */
+ confidence: number;
+ /** Provider ID responsible for processing (e.g., 'groq', 'gemini', 'openai', 'cache', 'native') */
+ provider: string;
+ /** Optional step-by-step reasoning or justification provided by the engine/LLM */
+ reasoning?: string | undefined;
+}
+
/**
* ## TempoBaseAiMeta
- * Fundamental AI resolution telemetry and metadata shared across all AI functions.
+ * Telemetry and provenance metadata attached to parsed Tempo instances via `.ai`.
*/
export interface TempoBaseAiMeta {
- /** Resolution source ('native', 'cache', or provider ID like 'groq', 'openai', 'ollama') */
+ /** Provider identifier that produced the result */
readonly provider: string;
/** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */
readonly confidence: number;
- /** Whether the result was retrieved from cache */
+ /** Indicates if the result was served from cache */
readonly cached?: boolean | undefined;
- /** Step-by-step reasoning or justification provided by the engine/LLM */
+ /** Optional step-by-step reasoning or justification from LLM */
readonly reasoning?: string | undefined;
- /** Rate limit snapshot returned by the provider HTTP headers for this request */
+ /** Upstream rate-limit diagnostic telemetry (if provided by response headers) */
readonly limits?: AiRateLimits | undefined;
- /** Raw prompt input (only included when debug: true) */
+ /** Raw prompt passed by caller (available in debug mode) */
readonly rawPrompt?: string | undefined;
- /** Normalized prompt input (only included when debug: true) */
+ /** Normalized prompt used for caching and token counting (available in debug mode) */
readonly normalizedPrompt?: string | undefined;
/** Arbitrary provider-specific extra metadata */
readonly [key: string]: any;
@@ -34,32 +100,52 @@ export interface AiCacheAdapter {
/** Store a value by key with optional TTL in milliseconds */
set(key: string, value: string, ttlMs?: number): Promise | void;
/** Delete a specific entry by key */
- delete?(key: string): Promise | void;
+ delete?(key: string): Promise | boolean | void;
/** Clear entries, optionally matching a key prefix */
clear?(prefix?: string): Promise | void;
}
+/**
+ * ## AiRateLimits
+ * Exposes the rate limit and billing statistics returned in the HTTP headers
+ * of the most recent LLM proxy request.
+ */
+export interface AiRateLimits {
+ /** Number of remaining requests allowed in the current time window, or null if unknown */
+ remainingRequests: number | null;
+ /** Number of remaining tokens allowed in the current time window, or null if unknown */
+ remainingTokens: number | null;
+ /** A Tempo instance representing the exact time the limits reset, or null if unknown */
+ resetAt: Tempo | null;
+}
+
/**
* ## AiProvider
- * Represents an LLM provider and its respective BYOK API key.
+ * Represents an LLM provider and its respective BYOK API key and configuration options.
*/
export interface AiProvider {
/** The provider identifier (e.g., 'groq', 'gemini', 'openai', 'mistral', 'custom') */
id: string;
/** The raw API key for the respective provider */
- key: string;
+ key?: string | undefined;
/** Optional custom API endpoint URL (e.g., for local Ollama or Azure OpenAI) */
- url?: string;
+ url?: string | undefined;
/** Optional custom model identifier (e.g., to override the provider's default model) */
- model?: string;
+ model?: string | undefined;
/** Optional parameter name for max token limit (e.g. 'max_tokens' or 'max_completion_tokens') */
tokenParam?: string | undefined;
/** Optional cache TTL override in milliseconds for entries produced by this provider */
ttl?: number | undefined;
/** Optional HTTP request timeout override in milliseconds for requests to this provider */
timeout?: number | undefined;
+ /** Optional provider weight for probabilistic routing */
+ weight?: number | undefined;
+ /** Requests-per-minute quota limit for client-side Adaptive dispatch throttling */
+ rpm?: number | undefined;
+ /** Tokens-per-minute quota limit for client-side Adaptive dispatch throttling */
+ tpm?: number | undefined;
/** Optional LLM parameters (e.g. temperature, max_tokens, top_p) */
- options?: Record;
+ options?: Record | undefined;
}
/**
@@ -94,17 +180,3 @@ export interface AiConfig {
/** If true, logs the spoon-fed LLM context prompt and raw LLM response to the console */
debug?: boolean | undefined;
}
-
-/**
- * ## AiRateLimits
- * Exposes the rate limit and billing statistics returned in the HTTP headers
- * of the most recent LLM proxy request.
- */
-export interface AiRateLimits {
- /** Number of remaining requests allowed in the current time window, or null if unknown */
- remainingRequests: number | null;
- /** Number of remaining tokens allowed in the current time window, or null if unknown */
- remainingTokens: number | null;
- /** A Tempo instance representing the exact time the limits reset, or null if unknown */
- resetAt: Tempo | null;
-}
diff --git a/packages/plugins/ai/src/types/context.type.ts b/packages/plugins/ai/src/types/context.type.ts
index ef4f00eb..8e4aa633 100644
--- a/packages/plugins/ai/src/types/context.type.ts
+++ b/packages/plugins/ai/src/types/context.type.ts
@@ -1,11 +1,10 @@
-import type { AiMode } from '../core/config.js';
-import type { AiCacheAdapter, AiProvider } from './common.type.js';
+import type { AiBaseOptions, TempoBaseAiResult } from './base.type.js';
/**
* ## TempoContext
* The inferred regional and calendar settings resolved by `contextAI`.
*/
-export interface TempoContext {
+export interface TempoContext extends TempoBaseAiResult {
/** Inferred IANA time zone identifier (e.g. 'America/New_York') */
timeZone: string;
/** Inferred BCP 47 language/region tag (e.g. 'en-US') */
@@ -14,39 +13,21 @@ export interface TempoContext {
calendar: string;
/** Inferred hemisphere, constrained strictly to 'north' or 'south' (omitted if unknowable) */
sphere?: 'north' | 'south' | undefined;
- /** Confidence score between 0.0 (highly ambiguous) and 1.0 (certain) */
- confidence: number;
- /** The identifier of the AI provider that successfully produced this context */
- provider: string;
- /** Step-by-step reasoning explaining the inference */
- reasoning?: string | undefined;
}
/**
* ## AiContextOptions
* Configuration options passed to `contextAI(text, options)`.
*/
-export interface AiContextOptions {
- /** If true, bypasses cache to force a fresh LLM fetch */
- force?: boolean | undefined;
- /** If false, disables reading and writing to cache */
- cache?: boolean | undefined;
- /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */
- cacheAdapter?: AiCacheAdapter | undefined;
- /** Optional TTL override in milliseconds for cached result */
- ttl?: number | undefined;
- /** If true, logs prompt context and LLM payloads to console */
- debug?: boolean | undefined;
- /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` or string literal) */
- mode?: AiMode | undefined;
- /** Per-request provider configuration overrides */
- providers?: AiProvider[] | undefined;
- /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */
- minConfidence?: number | undefined;
- /** Optional request timeout in milliseconds (overrides provider and global timeout) */
- timeout?: number | undefined;
- /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */
- hedgeDelay?: number | undefined;
+export interface AiContextOptions extends AiBaseOptions {
+ /** Target timeZone override if evaluating against a specific baseline */
+ timeZone?: string | undefined;
+ /** Target locale override if evaluating against a specific baseline */
+ locale?: string | string[] | undefined;
+ /** Target calendar override if evaluating against a specific baseline */
+ calendar?: string | undefined;
+ /** Target sphere override if evaluating against a specific baseline */
+ sphere?: string | undefined;
/** Allow extra custom properties */
[key: string]: any;
}
diff --git a/packages/plugins/ai/src/types/diff.type.ts b/packages/plugins/ai/src/types/diff.type.ts
index 7d3c1e7e..9fc3d019 100644
--- a/packages/plugins/ai/src/types/diff.type.ts
+++ b/packages/plugins/ai/src/types/diff.type.ts
@@ -1,11 +1,10 @@
-import type { AiMode } from '../core/config.js';
-import type { AiCacheAdapter, AiProvider } from './common.type.js';
+import type { AiBaseOptions, TempoBaseAiResult } from './base.type.js';
/**
* ## TempoAiDiffResult
* The calculated and AI-formatted natural difference between two date-time points.
*/
-export interface TempoAiDiffResult {
+export interface TempoAiDiffResult extends TempoBaseAiResult {
/** Human-friendly, contextual narrative text summarizing the difference */
formatted: string;
/** Total calendar days between start and end */
@@ -16,12 +15,6 @@ export interface TempoAiDiffResult {
businessDays?: number | undefined;
/** List of holiday dates (YYYY-MM-DD) encountered within the interval */
holidays?: string[] | undefined;
- /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */
- confidence: number;
- /** Resolution source ('cache' or provider ID like 'groq', 'gemini', 'openai') */
- provider: string;
- /** Step-by-step reasoning or justification provided by the engine/LLM */
- reasoning?: string | undefined;
}
/**
@@ -38,7 +31,7 @@ export interface DiffPair {
* ## AiDiffOptions
* Configuration options passed to `diffAI(start, end, prompt, options)`.
*/
-export interface AiDiffOptions {
+export interface AiDiffOptions extends AiBaseOptions {
/** Optional target timeZone for relative calculation and business day boundaries */
timeZone?: string | undefined;
/** Optional target locale override for language/formatting specific output */
@@ -47,28 +40,6 @@ export interface AiDiffOptions {
holidays?: string[] | undefined;
/** Expected country/region code (e.g. 'AU', 'US') */
region?: string | undefined;
- /** If true, bypasses cache to force a fresh LLM fetch */
- force?: boolean | undefined;
- /** If false, disables reading and writing to cache */
- cache?: boolean | undefined;
- /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */
- cacheAdapter?: AiCacheAdapter | undefined;
- /** Optional TTL override in milliseconds for cached result */
- ttl?: number | undefined;
- /** If true, logs prompt context and LLM payloads to console */
- debug?: boolean | undefined;
- /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` | `AiMode.Hedged` | `AiMode.RoundRobin` | `AiMode.Adaptive` or string literal) */
- mode?: AiMode | undefined;
- /** Per-request provider configuration overrides */
- providers?: AiProvider[] | undefined;
- /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */
- minConfidence?: number | undefined;
- /** If true, returns TempoAiError into array index position instead of rejecting batch */
- softErrors?: boolean | undefined;
- /** Optional request timeout in milliseconds (overrides provider and global timeout) */
- timeout?: number | undefined;
- /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */
- hedgeDelay?: number | undefined;
/** Allow extra custom properties */
[key: string]: any;
}
diff --git a/packages/plugins/ai/src/types/extract.type.ts b/packages/plugins/ai/src/types/extract.type.ts
new file mode 100644
index 00000000..0547c22f
--- /dev/null
+++ b/packages/plugins/ai/src/types/extract.type.ts
@@ -0,0 +1,44 @@
+import type { Tempo } from '@magmacomputing/tempo';
+import type { AiDateContextOptions, TempoBaseAiResult } from './base.type.js';
+
+/**
+ * Categorical classifications for extracted calendar events and temporal entities.
+ */
+export type TempoEventType = 'event' | 'deadline' | 'reminder' | 'point' | 'interval' | string;
+
+/**
+ * ## TempoExtractedEvent
+ * A single temporal entity or calendar event extracted from unstructured text.
+ */
+export interface TempoExtractedEvent {
+ /** Human-readable event title or description */
+ label: string;
+ /** Start date-time of the event as a native Tempo instance */
+ start: Tempo;
+ /** Optional end date-time of the event as a native Tempo instance */
+ end?: Tempo | undefined;
+ /** Entity category classification */
+ type: TempoEventType;
+ /** The exact text snippet/substring extracted from the source input */
+ rawText?: string | undefined;
+ /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */
+ confidence: number;
+}
+
+/**
+ * ## TempoAiExtractResult
+ * The structured result returned by `extractAI` containing all extracted calendar events.
+ */
+export interface TempoAiExtractResult extends TempoBaseAiResult {
+ /** Array of extracted events and temporal entities */
+ events: TempoExtractedEvent[];
+}
+
+/**
+ * ## AiExtractOptions
+ * Configuration options passed to `extractAI(text, options)`.
+ */
+export interface AiExtractOptions extends AiDateContextOptions {
+ /** Optional category filters to guide event identification (e.g. ['meeting', 'deadline']) */
+ categories?: string[] | undefined;
+}
diff --git a/packages/plugins/ai/src/types/format.type.ts b/packages/plugins/ai/src/types/format.type.ts
index b284c7b2..92be2c0f 100644
--- a/packages/plugins/ai/src/types/format.type.ts
+++ b/packages/plugins/ai/src/types/format.type.ts
@@ -1,63 +1,36 @@
-import type { Tempo } from '@magmacomputing/tempo';
-import type { AiMode } from '../core/config.js';
-import type { AiCacheAdapter, AiProvider } from './common.type.js';
+import type { AiDateContextOptions, TempoBaseAiResult, TempoDateInput } from './base.type.js';
+
+export type { TempoDateInput };
/**
- * ## TempoDateInput
- * Flexible date-time input representation accepted by `formatAI`.
- * Supports `Tempo` instances, `Date`, ISO strings, timestamps, and TC39 `Temporal` objects.
+ * ## TempoAiFormatResult
+ * Structured contextual narrative formatting result returned by `formatAI`.
*/
-export type TempoDateInput = Tempo | Date | string | number | bigint | object;
-
-export interface FormatItem {
- /** Date-time instance, Temporal object, or string to format. */
- date: TempoDateInput;
- /** Prompt instructions guiding the output narrative. */
- prompt?: string | undefined;
-}
-
-export interface TempoAiFormatResult {
- /** Formatted narrative string. */
+export interface TempoAiFormatResult extends TempoBaseAiResult {
+ /** Human-friendly, contextual narrative text summarizing the date-time */
formatted: string;
- /** Confidence score between 0.0 and 1.0. */
- confidence: number;
- /** ID of the provider that fulfilled the request (or 'cache'). */
- provider: string;
- /** Optional step-by-step rationale from the LLM. */
- reasoning?: string | undefined;
}
-export interface AiFormatOptions {
- /** Reference anchor date for relative calculations (defaults to now). */
- anchor?: TempoDateInput | undefined;
- /** Target IANA timezone (defaults to Tempo instance timezone or global options). */
- timeZone?: string | undefined;
- /** Target BCP 47 locale or language tag (defaults to global options or 'en-US'). */
- locale?: string | string[] | undefined;
- /** Desired narrative tone or formatting style hint (e.g. 'casual', 'formal', 'compact', 'countdown'). */
+/**
+ * ## AiFormatOptions
+ * Configuration options passed to `formatAI(date, prompt, options)`.
+ */
+export interface AiFormatOptions extends AiDateContextOptions {
+ /** Desired formatting style or tone (e.g., 'casual', 'formal', 'concise', 'relative') */
style?: string | undefined;
- /** Custom regional context (e.g. 'AU-NSW', 'US-CA'). */
- region?: string | undefined;
- /** If true, bypasses cache to force a fresh LLM fetch */
- force?: boolean | undefined;
- /** If false, disables reading and writing to cache */
- cache?: boolean | undefined;
- /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */
- cacheAdapter?: AiCacheAdapter | undefined;
- /** Optional TTL override in milliseconds for cached result */
- ttl?: number | undefined;
- /** If true, logs prompt context and LLM payloads to console */
- debug?: boolean | undefined;
- /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` | `AiMode.Hedged` | `AiMode.RoundRobin` | `AiMode.Adaptive` or string literal) */
- mode?: AiMode | undefined;
- /** Per-request provider configuration overrides */
- providers?: AiProvider[] | undefined;
- /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */
- minConfidence?: number | undefined;
- /** Optional request timeout in milliseconds for this operation */
- timeout?: number | undefined;
- /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged */
- hedgeDelay?: number | undefined;
- /** If true, returns an array containing both successful results and TempoAiErrors instead of throwing */
- softErrors?: boolean | undefined;
+ /** Optional max concurrent provider requests for batch formatting (defaults to 4) */
+ concurrency?: number | undefined;
+}
+
+/**
+ * ## FormatItem
+ * Input item for batch date formatting requests.
+ */
+export interface FormatItem {
+ /** Target date-time input to format */
+ date: TempoDateInput;
+ /** Optional specific prompt/question for this item */
+ prompt?: string | undefined;
+ /** Per-item option overrides */
+ options?: AiFormatOptions | undefined;
}
diff --git a/packages/plugins/ai/src/types/index.ts b/packages/plugins/ai/src/types/index.ts
index f35d3bc9..8e7c2b27 100644
--- a/packages/plugins/ai/src/types/index.ts
+++ b/packages/plugins/ai/src/types/index.ts
@@ -1,8 +1,8 @@
-export * from './common.type.js';
+export * from './base.type.js';
export * from './parse.type.js';
export * from './recurrence.type.js';
export * from './schedule.type.js';
export * from './context.type.js';
export * from './diff.type.js';
export * from './format.type.js';
-
+export * from './extract.type.js';
diff --git a/packages/plugins/ai/src/types/parse.type.ts b/packages/plugins/ai/src/types/parse.type.ts
index 045e3ebe..81cb84d1 100644
--- a/packages/plugins/ai/src/types/parse.type.ts
+++ b/packages/plugins/ai/src/types/parse.type.ts
@@ -1,6 +1,4 @@
-import type { Tempo } from '@magmacomputing/tempo';
-import type { AiMode } from '../core/config.js';
-import type { AiCacheAdapter, AiProvider, TempoBaseAiMeta } from './common.type.js';
+import type { AiDateContextOptions, TempoBaseAiMeta } from './base.type.js';
declare module '@magmacomputing/tempo' {
interface Tempo {
@@ -24,46 +22,13 @@ export interface TempoParseAiMeta extends TempoBaseAiMeta {
readonly rawIso?: string | undefined;
}
-/** Backward-compatible alias for TempoParseAiMeta */
-export type TempoAiMeta = TempoParseAiMeta;
-
/**
* ## AiParseOptions
* Options passed to `parseAI(input, options)`.
*/
-export interface AiParseOptions {
- /** Reference anchor date/time instance or string */
- anchor?: Tempo | Date | string | number | undefined;
- /** Target timeZone override */
- timeZone?: string;
- /** Target calendar override */
- calendar?: string;
- /** Target locale override */
- locale?: string | string[];
+export interface AiParseOptions extends AiDateContextOptions {
/** Target sphere override */
- sphere?: string;
- /** If true, bypasses cache and native parsing to force an LLM fetch */
- force?: boolean;
- /** If false, disables reading and writing to cache */
- cache?: boolean;
- /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */
- cacheAdapter?: AiCacheAdapter;
- /** Optional TTL override in milliseconds for cached result */
- ttl?: number;
- /** If true, logs prompt context and LLM payloads to console */
- debug?: boolean;
- /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` or string literal) */
- mode?: AiMode;
- /** Per-request provider configuration overrides */
- providers?: AiProvider[];
- /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */
- minConfidence?: number;
- /** If true, places TempoAiError into array index position instead of rejecting batch */
- softErrors?: boolean;
- /** Optional request timeout in milliseconds (overrides provider and global timeout) */
- timeout?: number;
- /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */
- hedgeDelay?: number;
+ sphere?: string | undefined;
/** Allow extra options */
[key: string]: any;
}
diff --git a/packages/plugins/ai/src/types/recurrence.type.ts b/packages/plugins/ai/src/types/recurrence.type.ts
index cb9dc5c5..e53ad281 100644
--- a/packages/plugins/ai/src/types/recurrence.type.ts
+++ b/packages/plugins/ai/src/types/recurrence.type.ts
@@ -1,4 +1,5 @@
import type { Tempo } from '@magmacomputing/tempo';
+import type { TempoBaseAiResult, TempoDateInput } from './base.type.js';
import type { AiParseOptions } from './parse.type.js';
/**
@@ -7,20 +8,20 @@ import type { AiParseOptions } from './parse.type.js';
*/
export interface TempoRecurrenceOptions extends AiParseOptions {
/** Start date/time window for occurrence expansion */
- after?: Tempo | Date | string | number | undefined;
+ after?: TempoDateInput | undefined;
/** End date/time window for occurrence expansion */
- before?: Tempo | Date | string | number | undefined;
+ before?: TempoDateInput | undefined;
/** Number of occurrences to pull per batch (default: 5) */
- count?: number;
+ count?: number | undefined;
/** Preferred BCP 47 locale tag for summary output (e.g. 'en-US', 'fr-FR', 'es-ES') */
- locale?: string;
+ locale?: string | undefined;
}
/**
* ## TempoRecurrenceResult
* Structured multi-directional recurrence result returned by `recurrenceAI`.
*/
-export interface TempoRecurrenceResult {
+export interface TempoRecurrenceResult extends TempoBaseAiResult {
/** Standard RFC 5545 RRULE string (e.g. 'FREQ=WEEKLY;BYDAY=TU') */
rrule: string;
/** Localized human-friendly summary of the schedule (e.g. 'Every Tuesday at 15:00') */
@@ -33,10 +34,4 @@ export interface TempoRecurrenceResult {
take(count?: number): Tempo[];
/** Lazy generator yielding Tempo instances on demand */
[Symbol.iterator](): Generator;
- /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */
- confidence: number;
- /** Provider ID responsible for processing or 'rrule-parser' for native RRULE inputs */
- provider: string;
- /** Reasoning / explanation of the recurrence pattern */
- reasoning?: string | undefined;
}
diff --git a/packages/plugins/ai/src/types/schedule.type.ts b/packages/plugins/ai/src/types/schedule.type.ts
index c45c730f..727289a5 100644
--- a/packages/plugins/ai/src/types/schedule.type.ts
+++ b/packages/plugins/ai/src/types/schedule.type.ts
@@ -1,6 +1,6 @@
import type { Tempo, Interval } from '@magmacomputing/tempo';
import type { DayKey } from '@magmacomputing/tempo/library';
-import type { TempoBaseAiMeta } from './common.type.js';
+import type { TempoBaseAiMeta, TempoDateInput } from './base.type.js';
import type { AiParseOptions } from './parse.type.js';
/**
@@ -9,13 +9,13 @@ import type { AiParseOptions } from './parse.type.js';
*/
export interface TempoWorkingHours {
/** Start time of working day in HH:mm format (default: '09:00') */
- start?: string;
+ start?: string | undefined;
/** End time of working day in HH:mm format (default: '17:00') */
- end?: string;
+ end?: string | undefined;
/** Active working weekdays (1 = Monday, ... 7 = Sunday; or tokens like 'MO', 'MON'; default: [1, 2, 3, 4, 5]) */
- days?: Array;
+ days?: Array | undefined;
/** Target timeZone for working hours (defaults to anchor or options timeZone) */
- timeZone?: string;
+ timeZone?: string | undefined;
}
/**
@@ -35,21 +35,21 @@ export interface TempoInterval {
*/
export interface TempoScheduleOptions extends AiParseOptions {
/** Target slot duration in minutes (if not explicitly specified in prompt) */
- durationMinutes?: number;
+ durationMinutes?: number | undefined;
/** Working hours configuration for slot resolution */
- workingHours?: TempoWorkingHours;
+ workingHours?: TempoWorkingHours | undefined;
/** Existing booked events or busy intervals to avoid */
- events?: Array<{ start: any; end: any; title?: string }> | Array>;
+ events?: Array<{ start: any; end: any; title?: string }> | Array> | undefined;
/** Alias for events */
- intervals?: Array<{ start: any; end: any; title?: string }> | Array>;
+ intervals?: Array<{ start: any; end: any; title?: string }> | Array> | undefined;
/** Search window start constraint */
- after?: any;
+ after?: TempoDateInput | undefined;
/** Search window end constraint */
- before?: any;
+ before?: TempoDateInput | undefined;
/** Preferred slot positioning ('earliest' | 'latest' | 'morning' | 'afternoon' | string) */
- preference?: string;
+ preference?: string | undefined;
/** Number of alternative slots to return if requesting multiple options */
- count?: number;
+ count?: number | undefined;
}
/**
@@ -94,4 +94,3 @@ export interface TempoScheduleResult extends Interval, TempoScheduleMeta
/** Resolved end boundary as a Tempo instance */
end: Tempo;
}
-
diff --git a/packages/plugins/ai/test/context.test.ts b/packages/plugins/ai/test/context.test.ts
index da4a438f..4b44151d 100644
--- a/packages/plugins/ai/test/context.test.ts
+++ b/packages/plugins/ai/test/context.test.ts
@@ -76,7 +76,7 @@ describe('AI Context Plugin (contextAI)', () => {
const cal = String(Tempo.options.calendar);
const loc = String(Array.isArray(Tempo.options.locale) ? Tempo.options.locale[0] : Tempo.options.locale);
const sph = String(Tempo.options.sphere || 'north');
- Tempo.cache.set(`context::cached prompt::${tz}::${loc}::${cal}::${sph}`, JSON.stringify({
+ Tempo.cache.set(`ai:context::cached prompt::${tz}::${loc}::${cal}::${sph}`, JSON.stringify({
timeZone: 'Europe/London',
locale: 'en-GB',
calendar: 'gregory',
@@ -230,7 +230,7 @@ describe('AI Context Plugin (contextAI)', () => {
const loc = String(Array.isArray(Tempo.options.locale) ? Tempo.options.locale[0] : Tempo.options.locale);
const sph = String(Tempo.options.sphere || 'north');
- Tempo.cache.set(`context::low confidence::${tz}::${loc}::${cal}::${sph}`, JSON.stringify({
+ Tempo.cache.set(`ai:context::low confidence::${tz}::${loc}::${cal}::${sph}`, JSON.stringify({
timeZone: 'Europe/Berlin',
locale: 'de-DE',
calendar: 'gregory',
@@ -293,4 +293,31 @@ describe('AI Context Plugin (contextAI)', () => {
await expect(contextAI('Berlin tech hub')).rejects.toThrow(/invalid confidence score/i);
});
+
+ it('should return a secure() protected immutable object supporting .toJSON() clone', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ timeZone: 'America/Chicago',
+ locale: 'en-US',
+ calendar: 'gregory',
+ sphere: 'north',
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const result = await contextAI('Chicago downtown');
+ expect(() => {
+ (result as any).timeZone = 'America/New_York';
+ }).toThrow(TypeError);
+
+ const clone = (result as any).toJSON();
+ expect(clone.timeZone).toBe('America/Chicago');
+ clone.timeZone = 'America/New_York';
+ expect(clone.timeZone).toBe('America/New_York');
+ });
});
diff --git a/packages/plugins/ai/test/diff.test.ts b/packages/plugins/ai/test/diff.test.ts
index f71cad33..4471fedd 100644
--- a/packages/plugins/ai/test/diff.test.ts
+++ b/packages/plugins/ai/test/diff.test.ts
@@ -302,4 +302,31 @@ describe('AI Diff Plugin (diffAI)', () => {
const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(requestBody.messages[0].content).toContain('(Europe/London)');
});
+
+ it('should return a secure() protected immutable object supporting .toJSON() clone', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: '3 business days difference',
+ days: 3,
+ hours: 72,
+ businessDays: 3,
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const result = await diffAI('2026-08-03', '2026-08-06', 'summarize');
+ expect(() => {
+ (result as any).formatted = 'hacked';
+ }).toThrow(TypeError);
+
+ const clone = (result as any).toJSON();
+ expect(clone.formatted).toBe('3 business days difference');
+ clone.formatted = 'modified';
+ expect(clone.formatted).toBe('modified');
+ });
});
diff --git a/packages/plugins/ai/test/dispatch.test.ts b/packages/plugins/ai/test/dispatch.test.ts
index 5df9f94b..f73d4a34 100644
--- a/packages/plugins/ai/test/dispatch.test.ts
+++ b/packages/plugins/ai/test/dispatch.test.ts
@@ -369,6 +369,59 @@ describe('AI Dispatch Helper (executeWithMode)', () => {
});
});
+ describe('Global Telemetry Cooldown Filtering', () => {
+ it('should skip exhausted cooldown providers in Fallback mode', async () => {
+ const resetFuture = new Tempo().add('2 minutes');
+ _state.providerLimits.set('provider-a', {
+ remainingRequests: 0,
+ remainingTokens: 0,
+ resetAt: resetFuture,
+ });
+
+ const task = vi.fn().mockImplementation(async (provider: AiProvider) => {
+ return { data: { id: provider.id }, providerId: provider.id, confidence: 0.95 };
+ });
+
+ const winner = await executeWithMode(AiMode.Fallback, mockProviders, task);
+ expect(winner.providerId).toBe('provider-b');
+ expect(task).not.toHaveBeenCalledWith(mockProviders[0]);
+ });
+
+ it('should skip exhausted cooldown providers in Race mode', async () => {
+ const resetFuture = new Tempo().add('2 minutes');
+ _state.providerLimits.set('provider-a', {
+ remainingRequests: 0,
+ remainingTokens: 0,
+ resetAt: resetFuture,
+ });
+
+ const task = vi.fn().mockImplementation(async (provider: AiProvider) => {
+ return { data: { id: provider.id }, providerId: provider.id, confidence: 0.95 };
+ });
+
+ const winner = await executeWithMode(AiMode.Race, mockProviders, task);
+ expect(['provider-b', 'provider-c']).toContain(winner.providerId);
+ expect(task).not.toHaveBeenCalledWith(mockProviders[0], expect.anything());
+ });
+
+ it('should skip exhausted cooldown providers in Hedged mode', async () => {
+ const resetFuture = new Tempo().add('2 minutes');
+ _state.providerLimits.set('provider-a', {
+ remainingRequests: 0,
+ remainingTokens: 0,
+ resetAt: resetFuture,
+ });
+
+ const task = vi.fn().mockImplementation(async (provider: AiProvider) => {
+ return { data: { id: provider.id }, providerId: provider.id, confidence: 0.95 };
+ });
+
+ const winner = await executeWithMode(AiMode.Hedged, mockProviders, task, { hedgeDelay: 100 });
+ expect(winner.providerId).toBe('provider-b');
+ expect(task).not.toHaveBeenCalledWith(mockProviders[0], expect.anything());
+ });
+ });
+
describe('Invalid Modes', () => {
it('should throw TempoAiError with status 400 for invalid mode', async () => {
const task = vi.fn();
diff --git a/packages/plugins/ai/test/extract.test.ts b/packages/plugins/ai/test/extract.test.ts
new file mode 100644
index 00000000..cfa326bf
--- /dev/null
+++ b/packages/plugins/ai/test/extract.test.ts
@@ -0,0 +1,410 @@
+import { Tempo } from '@magmacomputing/tempo';
+import {
+ extractAI,
+ initAI,
+ resetAI,
+ TempoAiError,
+ AiMode,
+ type TempoAiExtractResult,
+ type AiCacheAdapter,
+} from '../src/index.js';
+
+describe('AI Extract Plugin (extractAI)', () => {
+ beforeEach(async () => {
+ resetAI();
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
+ vi.spyOn(console, 'error').mockImplementation(() => { });
+ vi.spyOn(console, 'log').mockImplementation(() => { });
+ await initAI({ remoteConfigUrl: false, providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] });
+ });
+
+ afterEach(() => {
+ resetAI();
+ vi.restoreAllMocks();
+ });
+
+ it('should scan unstructured text and extract multiple temporal events with native Tempo instances', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ events: [
+ {
+ label: 'Sprint Planning',
+ start: '2026-08-11T10:00:00',
+ end: '2026-08-11T11:30:00',
+ type: 'interval',
+ rawText: 'tomorrow from 10:00 AM to 11:30 AM',
+ confidence: 0.98,
+ },
+ {
+ label: 'Q3 Deliverables Deadline',
+ start: '2026-08-14T17:00:00',
+ end: null,
+ type: 'deadline',
+ rawText: 'due next Friday by 5:00 PM',
+ confidence: 0.95,
+ },
+ ],
+ confidence: 0.96,
+ reasoning: 'Extracted 1 scheduled meeting interval and 1 project deadline.',
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const text = "Hi team, let's meet tomorrow from 10:00 AM to 11:30 AM for Sprint Planning. Also, all Q3 deliverables are due next Friday by 5:00 PM.";
+ const anchor = new Tempo('2026-08-10T09:00:00Z');
+
+ const result = await extractAI(text, { anchor, timeZone: 'UTC' });
+
+ expect(result).toBeDefined();
+ expect(result.events).toHaveLength(2);
+ expect(result.confidence).toBe(0.96);
+ expect(result.provider).toBe('groq');
+ expect(result.reasoning).toContain('scheduled meeting');
+
+ const event1 = result.events[0];
+ expect(event1.label).toBe('Sprint Planning');
+ expect(Tempo.isTempo(event1.start)).toBe(true);
+ expect(event1.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 10:00');
+ expect(Tempo.isTempo(event1.end)).toBe(true);
+ expect(event1.end?.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 11:30');
+ expect(event1.type).toBe('interval');
+ expect(event1.rawText).toBe('tomorrow from 10:00 AM to 11:30 AM');
+
+ const event2 = result.events[1];
+ expect(event2.label).toBe('Q3 Deliverables Deadline');
+ expect(Tempo.isTempo(event2.start)).toBe(true);
+ expect(event2.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-14 17:00');
+ expect(event2.end).toBeUndefined();
+ expect(event2.type).toBe('deadline');
+
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
+ const systemPrompt = requestBody.messages[0].content;
+ expect(systemPrompt).toContain('Grounding Context:');
+ expect(systemPrompt).toContain('Reference Anchor Date-Time: 2026-08-10T09:00:00 (UTC)');
+ expect(systemPrompt).toContain('Reference Day of Week: Monday');
+ });
+
+ it('should return empty events array when text contains no temporal entities', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ events: [],
+ confidence: 1.0,
+ reasoning: 'No temporal expressions or events were detected in the input text.',
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const text = 'The quick brown fox jumps over the lazy dog. Just some generic prose without any dates.';
+ const result = await extractAI(text);
+
+ expect(result).toBeDefined();
+ expect(result.events).toHaveLength(0);
+ expect(result.confidence).toBe(1.0);
+ expect(result.provider).toBe('groq');
+ });
+
+ it('should include category filter in grounding context when specified', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ events: [
+ {
+ label: 'Product Demo',
+ start: '2026-08-12T14:00:00',
+ type: 'point',
+ confidence: 0.95,
+ },
+ ],
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const text = 'Product demo on Wednesday at 2pm. Flights booked for Thursday at 6am.';
+ const result = await extractAI(text, {
+ categories: ['meeting', 'demo'],
+ region: 'US-NY',
+ });
+
+ expect(result.events).toHaveLength(1);
+ expect(result.events[0].label).toBe('Product Demo');
+
+ const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
+ const systemPrompt = requestBody.messages[0].content;
+ expect(systemPrompt).toContain('Filter Categories: demo, meeting');
+ expect(systemPrompt).toContain('Region Context: US-NY');
+ });
+
+ it('should write to and read from multi-tier cache with Tempo instance rehydration', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ events: [
+ {
+ label: 'Dentist Appointment',
+ start: '2026-08-15T09:00:00',
+ end: '2026-08-15T10:00:00',
+ type: 'interval',
+ confidence: 0.99,
+ },
+ ],
+ confidence: 0.99,
+ reasoning: 'Extracted dentist appointment.',
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const cacheStore = new Map();
+ const customAdapter: AiCacheAdapter = {
+ get: vi.fn(async (key: string) => cacheStore.get(key)),
+ set: vi.fn(async (key: string, val: string) => {
+ cacheStore.set(key, val);
+ }),
+ };
+
+ const text = 'Dentist appointment on August 15 from 9am to 10am.';
+ const anchor = new Tempo('2026-08-01T00:00:00Z');
+
+ // First call - should query provider and populate cache
+ const result1 = await extractAI(text, {
+ anchor,
+ cacheAdapter: customAdapter,
+ timeZone: 'UTC',
+ });
+ expect(result1.provider).toBe('groq');
+ expect(result1.events).toHaveLength(1);
+ expect(Tempo.isTempo(result1.events[0].start)).toBe(true);
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(customAdapter.set).toHaveBeenCalledTimes(1);
+
+ // Second call - should return rehydrated cache
+ const result2 = await extractAI(text, {
+ anchor,
+ cacheAdapter: customAdapter,
+ timeZone: 'UTC',
+ });
+ expect(result2.provider).toBe('cache');
+ expect(result2.events).toHaveLength(1);
+ expect(Tempo.isTempo(result2.events[0].start)).toBe(true);
+ expect(result2.events[0].start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-15 09:00');
+ expect(Tempo.isTempo(result2.events[0].end)).toBe(true);
+ expect(result2.events[0].end?.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-15 10:00');
+ expect(fetchSpy).toHaveBeenCalledTimes(1); // No new network call
+ });
+
+ it('should support force: true and cache: false bypass options', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ const mockResponse = () => new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ events: [
+ {
+ label: 'One-on-One',
+ start: '2026-08-12T15:00:00',
+ type: 'point',
+ confidence: 0.95,
+ },
+ ],
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+
+ fetchSpy.mockResolvedValueOnce(mockResponse()).mockResolvedValueOnce(mockResponse());
+
+ const text = '1-on-1 catchup on Wednesday at 3pm.';
+ const anchor = new Tempo('2026-08-10T09:00:00Z');
+
+ await extractAI(text, { anchor, timeZone: 'UTC' });
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+
+ // force: true should make a new fetch
+ const forcedResult = await extractAI(text, { anchor, timeZone: 'UTC', force: true });
+ expect(forcedResult.provider).toBe('groq');
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should reject invalid text and anchor inputs with TempoAiError(400)', async () => {
+ await expect(extractAI(''))
+ .rejects.toThrow(new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400));
+
+ await expect(extractAI(' '))
+ .rejects.toThrow(new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400));
+
+ await expect(extractAI(null as any))
+ .rejects.toThrow(new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400));
+
+ await expect(extractAI('some text', { anchor: 'invalid-anchor-date' }))
+ .rejects.toThrow(/Invalid anchor date provided to extractAI/i);
+ });
+
+ it('should validate minConfidence and reject non-finite and out-of-range thresholds before cache read or provider calls', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ const customAdapter: AiCacheAdapter = {
+ get: vi.fn(async () => undefined),
+ set: vi.fn(async () => {}),
+ };
+
+ // Non-finite
+ await expect(extractAI('some text', { minConfidence: NaN, cacheAdapter: customAdapter }))
+ .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "NaN"', 400));
+
+ await expect(extractAI('some text', { minConfidence: Infinity, cacheAdapter: customAdapter }))
+ .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "Infinity"', 400));
+
+ // Out-of-bounds
+ await expect(extractAI('some text', { minConfidence: -0.5, cacheAdapter: customAdapter }))
+ .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "-0.5"', 400));
+
+ await expect(extractAI('some text', { minConfidence: 1.2, cacheAdapter: customAdapter }))
+ .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "1.2"', 400));
+
+ expect(customAdapter.get).not.toHaveBeenCalled();
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('should throw TempoAiError(422) when extracted confidence is below minConfidence', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ events: [{ label: 'Vague Meeting', start: '2026-08-11T10:00:00', type: 'point', confidence: 0.5 }],
+ confidence: 0.5,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ await expect(extractAI('maybe meet sometime next week', { minConfidence: 0.8 }))
+ .rejects.toThrow(/extractAI confidence \(0.5\) is below the required threshold of 0.8/i);
+ });
+
+ it('should throw TempoAiError(400) when no providers are configured', async () => {
+ resetAI();
+ await expect(extractAI('Meeting tomorrow at 10am'))
+ .rejects.toThrow(new TempoAiError('No AI providers configured. Please call initAI().', 400));
+ });
+
+ it('should support multi-provider race execution mode', async () => {
+ let slowWasAborted = false;
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockImplementation(async (_url, init) => {
+ const body = JSON.parse(init?.body as string);
+ const signal = init?.signal as AbortSignal | undefined;
+ if (body.model === 'fast-model') {
+ return new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ events: [{ label: 'Fast Event', start: '2026-08-12T10:00:00', type: 'point', confidence: 0.95 }],
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ return new Promise((_resolve, reject) => {
+ if (signal?.aborted) {
+ slowWasAborted = true;
+ reject(new DOMException('Aborted', 'AbortError'));
+ return;
+ }
+ signal?.addEventListener('abort', () => {
+ slowWasAborted = true;
+ reject(new DOMException('Aborted', 'AbortError'));
+ });
+ });
+ });
+
+ const result = await extractAI('Team sync on Wednesday at 10am', {
+ mode: 'race',
+ anchor: new Tempo('2026-08-10T00:00:00Z'),
+ timeZone: 'UTC',
+ providers: [
+ { id: 'slow-provider', key: 'key-slow', url: 'https://api.openai.com/v1/chat/completions', model: 'slow-model' },
+ { id: 'fast-provider', key: 'key-fast', url: 'https://api.groq.com/v1/chat/completions', model: 'fast-model' },
+ ],
+ });
+
+ expect(result.provider).toBe('fast-provider');
+ expect(result.events[0].label).toBe('Fast Event');
+ expect(slowWasAborted).toBe(true);
+ });
+
+ it('should support batch array processing with softErrors', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy
+ .mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ events: [{ label: 'Event 1', start: '2026-08-11T09:00:00', type: 'point', confidence: 0.95 }],
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
+ .mockResolvedValueOnce(new Response('Internal Error', { status: 500 }));
+
+ const inputs = ['Meeting tomorrow at 9am', 'Another event'];
+ const results = await extractAI(inputs, {
+ softErrors: true,
+ anchor: new Tempo('2026-08-10T00:00:00Z'),
+ timeZone: 'UTC',
+ });
+
+ expect(Array.isArray(results)).toBe(true);
+ expect(results).toHaveLength(2);
+
+ const successResult = results[0] as TempoAiExtractResult;
+ expect(successResult.events).toHaveLength(1);
+ expect(successResult.events[0].label).toBe('Event 1');
+
+ const errorResult = results[1] as TempoAiError;
+ expect(errorResult).toBeInstanceOf(TempoAiError);
+ expect(errorResult.status).toBe(500);
+ });
+
+ it('should return a secure() protected immutable object supporting .toJSON() clone', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ events: [{ label: 'Conference', start: '2026-08-15T09:00:00', type: 'point', confidence: 0.95 }],
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const result = await extractAI('Conference on August 15 at 9am');
+ expect(() => {
+ (result as any).confidence = 0.5;
+ }).toThrow(TypeError);
+
+ const clone = (result as any).toJSON();
+ expect(clone.confidence).toBe(0.95);
+ clone.confidence = 0.5;
+ expect(clone.confidence).toBe(0.5);
+ });
+});
diff --git a/packages/plugins/ai/test/format.test.ts b/packages/plugins/ai/test/format.test.ts
index d4829355..87d2a1ca 100644
--- a/packages/plugins/ai/test/format.test.ts
+++ b/packages/plugins/ai/test/format.test.ts
@@ -1,8 +1,10 @@
import { Tempo } from '@magmacomputing/tempo';
-import { formatAI, initAI, TempoAiError, type TempoAiFormatResult, type AiCacheAdapter } from '../src/index.js';
+import { formatAI, initAI, resetAI, TempoAiError, type TempoAiFormatResult, type AiCacheAdapter } from '../src/index.js';
describe('AI Format Plugin (formatAI)', () => {
beforeEach(async () => {
+ resetAI();
+ Tempo.cache.clear();
vi.spyOn(console, 'warn').mockImplementation(() => { });
vi.spyOn(console, 'error').mockImplementation(() => { });
vi.spyOn(console, 'log').mockImplementation(() => { });
@@ -10,6 +12,8 @@ describe('AI Format Plugin (formatAI)', () => {
});
afterEach(() => {
+ resetAI();
+ Tempo.cache.clear();
vi.restoreAllMocks();
});
@@ -98,6 +102,31 @@ describe('AI Format Plugin (formatAI)', () => {
expect(promptContext).toContain('Regional Context: FR-IDF');
});
+ it('should normalize empty array locale to system default or en-US without stringifying undefined', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: 'Formatted with default locale',
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const target = '2026-08-07T17:00:00Z';
+ const result = await formatAI(target, 'test prompt', {
+ locale: [],
+ });
+
+ expect(result.formatted).toBe('Formatted with default locale');
+ const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
+ const promptContext = requestBody.messages[0].content;
+ expect(promptContext).toMatch(/Target Locale: [a-zA-Z-]+/);
+ expect(promptContext).not.toContain('undefined');
+ });
+
it('should check cache and skip network fetch on cache hits', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
@@ -187,6 +216,46 @@ describe('AI Format Plugin (formatAI)', () => {
.rejects.toThrow(/Invalid anchor date provided to formatAI/i);
});
+ it('should reject non-finite and out-of-range minConfidence values before cache read or provider calls', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ const customAdapter: AiCacheAdapter = {
+ get: vi.fn(async () => undefined),
+ set: vi.fn(async () => {}),
+ };
+
+ // Non-finite values
+ await expect(formatAI('2026-08-07', 'test', { minConfidence: NaN, cacheAdapter: customAdapter }))
+ .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "NaN"', 400));
+
+ await expect(formatAI('2026-08-07', 'test', { minConfidence: Infinity, cacheAdapter: customAdapter }))
+ .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "Infinity"', 400));
+
+ await expect(formatAI('2026-08-07', 'test', { minConfidence: -Infinity, cacheAdapter: customAdapter }))
+ .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "-Infinity"', 400));
+
+ // Out-of-range values
+ await expect(formatAI('2026-08-07', 'test', { minConfidence: -0.1, cacheAdapter: customAdapter }))
+ .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "-0.1"', 400));
+
+ await expect(formatAI('2026-08-07', 'test', { minConfidence: 1.05, cacheAdapter: customAdapter }))
+ .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "1.05"', 400));
+
+ // Verify neither cache nor provider fetch was called
+ expect(customAdapter.get).not.toHaveBeenCalled();
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('should reject invalid configured default minConfidence from initAI', async () => {
+ await initAI({
+ remoteConfigUrl: false,
+ minConfidence: 1.5,
+ providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }],
+ });
+
+ await expect(formatAI('2026-08-07', 'test'))
+ .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "1.5"', 400));
+ });
+
it('should support multi-provider race execution mode', async () => {
let slowWasAborted = false;
const fetchSpy = vi.spyOn(globalThis, 'fetch');
@@ -257,6 +326,37 @@ describe('AI Format Plugin (formatAI)', () => {
expect(results[1]).toBeInstanceOf(TempoAiError);
});
+ it('should reject with TempoAiError on batch failure when softErrors is false', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy
+ .mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: 'Item 1 formatted',
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
+ .mockResolvedValueOnce(new Response('Server Error', { status: 500 }));
+
+ const items = [
+ { date: '2026-08-03', prompt: 'item 1' },
+ { date: '2026-08-05', prompt: 'item 2' },
+ ];
+
+ await expect(formatAI(items, { softErrors: false }))
+ .rejects.toThrow(TempoAiError);
+ });
+
+ it('should return an empty array for an empty batch input without provider requests', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ const results = await formatAI([]);
+ expect(results).toEqual([]);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
it('should honor force: true, cache: false, and ttl override options', async () => {
const cacheStore = new Map();
const customAdapter: AiCacheAdapter = {
@@ -297,4 +397,29 @@ describe('AI Format Plugin (formatAI)', () => {
expect(res3.formatted).toBe('Fresh result');
expect(customAdapter.set).not.toHaveBeenCalled();
});
+
+ it('should return a secure() protected immutable object supporting .toJSON() clone', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: 'Tomorrow at 5pm',
+ confidence: 0.95,
+ reasoning: 'Target is tomorrow.',
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const result = await formatAI('2026-08-03T17:00:00Z', undefined, { anchor: '2026-08-02T17:00:00Z' });
+ expect(() => {
+ (result as any).formatted = 'hacked';
+ }).toThrow(TypeError);
+
+ const clone = (result as any).toJSON();
+ expect(clone.formatted).toBe('Tomorrow at 5pm');
+ clone.formatted = 'modified';
+ expect(clone.formatted).toBe('modified');
+ });
});
diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json
index 14f68039..f5d89227 100644
--- a/packages/tempo/.vitepress/theme/data/catalog.json
+++ b/packages/tempo/.vitepress/theme/data/catalog.json
@@ -50,8 +50,8 @@
"description": "Tempo community plugin for LLM-powered natural language processing and parsing.",
"packageName": "@magmacomputing/tempo-plugin-ai",
"plan": "community",
- "status": "experimental",
- "version": "4.0.0"
+ "status": "active",
+ "version": "1.0.0"
},
{
"id": "ticker",
From 10b8207109488d91ebc521dc3350b88cb527029c Mon Sep 17 00:00:00 2001
From: Michael McRae
Date: Sat, 15 Aug 2026 11:58:59 +1000
Subject: [PATCH 4/7] PR extractAI 1st review
---
packages/plugins/ai/CHANGELOG.md | 6 +-
packages/plugins/ai/doc/architecture.md | 7 +-
packages/plugins/ai/doc/context.md | 7 +-
packages/plugins/ai/doc/grounding.md | 18 +-
packages/plugins/ai/doc/index.md | 18 +-
packages/plugins/ai/doc/init.md | 8 +-
packages/plugins/ai/doc/modes.md | 14 +-
packages/plugins/ai/doc/parse.md | 2 +-
packages/plugins/ai/doc/rate-limits.md | 23 +-
packages/plugins/ai/doc/schedule.md | 15 +-
packages/plugins/ai/doc/security.md | 150 ++++++++
packages/plugins/ai/src/core/cache.ts | 219 ++++++++++++
packages/plugins/ai/src/core/dispatch.ts | 33 +-
packages/plugins/ai/src/core/init.ts | 113 ++----
packages/plugins/ai/src/core/logger.ts | 182 ++++++++++
packages/plugins/ai/src/core/support.ts | 90 +----
packages/plugins/ai/src/functions/context.ts | 35 +-
packages/plugins/ai/src/functions/diff.ts | 39 +-
packages/plugins/ai/src/functions/extract.ts | 114 ++++--
packages/plugins/ai/src/functions/format.ts | 31 +-
packages/plugins/ai/src/functions/parse.ts | 12 +-
.../plugins/ai/src/functions/recurrence.ts | 20 +-
packages/plugins/ai/src/functions/schedule.ts | 56 ++-
packages/plugins/ai/src/index.ts | 5 +-
packages/plugins/ai/src/types/extract.type.ts | 2 +
.../plugins/ai/src/types/recurrence.type.ts | 2 +-
packages/plugins/ai/test/benchmark.spec.ts | 2 +-
packages/plugins/ai/test/cache.test.ts | 64 +++-
packages/plugins/ai/test/debug.test.ts | 334 ++++++++++++++++++
packages/plugins/ai/test/extract.test.ts | 125 +++++--
packages/plugins/ai/test/format.test.ts | 38 +-
packages/plugins/ai/test/parse.test.ts | 8 +-
32 files changed, 1483 insertions(+), 309 deletions(-)
create mode 100644 packages/plugins/ai/doc/security.md
create mode 100644 packages/plugins/ai/src/core/cache.ts
create mode 100644 packages/plugins/ai/src/core/logger.ts
create mode 100644 packages/plugins/ai/test/debug.test.ts
diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md
index 1f1864ca..2bdb3c44 100644
--- a/packages/plugins/ai/CHANGELOG.md
+++ b/packages/plugins/ai/CHANGELOG.md
@@ -35,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Cascading Cache TTL Hierarchy**: Implemented strict 4-tier TTL resolution (`options.ttl` > `provider.ttl` > `global config.ttl` > default 1 hour / 3,600,000 ms for `parseAI` or 24 hours / 86,400,000 ms for context/difference handlers) for fine-grained cache entry expiration control on stores enforcing TTL.
- **Fail-Open Storage Resilience**: Custom cache adapter errors during `get()` or `set()` operations fail open silently to direct LLM resolution, preserving application request uptime.
- **Dynamic Remote Provider Manifest (`loadRemoteManifest`)**: Lazily fetches remote provider defaults (`providers.v1.json`) on initialization with a 1500ms timeout and automatic air-gapped fallback to compiled `DEFAULT_PROVIDERS`.
-- **Cache Eviction Synchronization**: Enhanced `clearAiCache()` to flush matching keys and prefixes across both `Tempo.cache` and custom `AiCacheAdapter` storage engines, returning `Promise` to await async adapter eviction.
+- **Cache Eviction Synchronization**: Enhanced `aiCache.clear()` to flush matching keys and prefixes across both `Tempo.cache` and custom `AiCacheAdapter` storage engines, returning `Promise` to await async adapter eviction.
### Changed & Hardened
- **Consensus Mode TTL Resolution**: Fixed a runtime bug where standard provider TTL lookups failed in Consensus mode due to the synthetic sentinel provider ID (`'consensus'`), which caused lookups on the winning provider array to return undefined. Now reduces over all participating provider configs to select the minimum (most conservative) TTL.
@@ -65,11 +65,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Non-Destructive Glossary Appending**: Custom glossaries provided via `initAI({ cache })` are safely appended to `Tempo.cache` as static immortal terms without destructive overrides.
- **Silent Native Pre-Parsing & Cache Controls**: `parseAI` attempts fast, zero-latency native `Tempo` resolution and checks `Tempo.cache` before initiating LLM network calls. Supports `cache: false` to bypass cache lookups and `force: true` to force a fresh LLM API request.
- **Anchor Instance Reuse & Cache Salting**: Reuses anchor `Tempo` instances to minimize memory allocations and salts cache keys with the anchor's date and system context (`timeZone`, `calendar`, `locale`, `sphere`), preventing stale cache hits across midnight boundaries or context shifts.
-- **Resilient Cache Invalidation**: Normalized cache key input (whitespace trimming and case insensitivity) for `clearAiCache` and internal lookups.
+- **Resilient Cache Invalidation**: Normalized cache key input (whitespace trimming and case insensitivity) for `aiCache.clear()` and internal lookups.
## [0.1.0] - 2026-07-26
### Added
- Initial scaffolding of the AI natural language parsing plugin.
-- Functional exports for `parseAI`, `initAI`, and `clearAiCache`.
+- Functional exports for `parseAI`, `initAI`, and `aiCache`.
- Initial provider fallback-routing engine supporting HTTP requests to configured LLM provider endpoints.
diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md
index 5f1a7059..9e7ed782 100644
--- a/packages/plugins/ai/doc/architecture.md
+++ b/packages/plugins/ai/doc/architecture.md
@@ -197,6 +197,9 @@ export async function POST(req: Request, env?: { GROQ_API_KEY?: string }) {
## 🔒 Security & Privacy Guarantees
+> [!TIP]
+> For an in-depth breakdown of our automated PII redaction, Smart Debug infrastructure, and tamper-resistant Proxy introspection, see the dedicated **[Security & Privacy Architecture Guide (`security.md`)](./security.md)**.
+
Whether running directly on backend servers (Node.js, Deno, Bun, Edge Workers) or through a client-side browser proxy, `@magmacomputing/tempo-plugin-ai` enforces strict security and privacy standards:
### 1. Transport Security (HTTPS / TLS)
@@ -207,10 +210,10 @@ Temporal processing payloads (dates, times, context snippets, prompts) are proce
### 3. In-Memory Credential Redaction & Immutability
* **Automated Key Redaction**: Calling `getAiConfig()` returns a sanitized, deeply read-only snapshot of active configurations with all provider `key` values replaced with `[REDACTED]`, preventing accidental exposure in log files, APM traces, or crash dumps.
-* **Frozen Metadata**: All diagnostic metadata attached to `Tempo` instances via `.ai` is deeply frozen with `Object.freeze()` and guarded via runtime `Proxy` wrappers, protecting against direct runtime mutation of the `.ai` metadata.
+* **Frozen Metadata**: All diagnostic metadata attached to `Tempo` instances via `.ai` and all structured AI result objects (`TempoAiFormatResult`, `TempoAiExtractResult`, `TempoAiDiffResult`, `TempoScheduleResult`, `TempoRecurrenceResult`, `TempoContext`) are deeply frozen with `Object.freeze()` and guarded via runtime `Proxy` wrappers, protecting against direct runtime mutation.
### 4. Deterministic Schema Guardrails & Confidence Range Verification
-All LLM prompts are paired with rigid, machine-verifiable JSON schemas. Responses undergo strict boundary validation, regex parsing, confidence range verification (`0.0` to `1.0`), and ISO verification before any native `Tempo` date object or result payload is instantiated. If an LLM returns malformed, out-of-range, or non-chronological data, the plugin throws a typed `TempoAiError` or triggers automatic fallback rather than silently returning an invalid date.
+All LLM prompts are paired with rigid, machine-verifiable JSON schemas. Responses undergo strict boundary validation, regex parsing, confidence range verification (`0.0` to `1.0`), and schema verification before any native `Tempo` date object or structured result payload is instantiated. If an LLM returns malformed, out-of-range, or unparseable data, the plugin throws a typed `TempoAiError` or triggers automatic provider fallback rather than silently propagating corrupt data.
### 5. Partitioned Caching & Fail-Open Storage Resilience
* **Strict Cache Key Partitioning**: Caches are namespaced and hashed (`ai:::...`) with timezone, locale, calendar, and anchor date isolation to prevent cross-tenant or cross-regional cache poisoning.
diff --git a/packages/plugins/ai/doc/context.md b/packages/plugins/ai/doc/context.md
index 6d6175e1..f80773e2 100644
--- a/packages/plugins/ai/doc/context.md
+++ b/packages/plugins/ai/doc/context.md
@@ -2,6 +2,9 @@
`contextAI()` is designed to analyze unstructured or ambiguous text (e.g. user biographies, locations, or email bodies) and infer their regional configuration settings. It resolves these properties to a standard configuration object containing timezone, locale, calendar system, and hemisphere.
+> [!TIP]
+> **Smart Debug Telemetry**: Enabling `debug: true` activates operational logs. In production environments (`NODE_ENV === 'production'`), PII (emails, phone numbers, auth tokens) is automatically sanitized and masked in console output and terminal inspections. See the [Security & Privacy Architecture Guide](./security.md).
+
This is highly useful for user onboarding settings, automatic context mapping for calendar syncs, and geolocating inputs dynamically without maintaining static geographic mapping tables.
---
@@ -28,7 +31,7 @@ console.log(context.timeZone); // "Australia/Sydney"
console.log(context.locale); // "en-AU"
console.log(context.calendar); // "gregory"
console.log(context.sphere); // "south"
-console.log(context.confidence); // 0.98
+console.log(context.confidence);// 0.98
```
---
@@ -46,7 +49,7 @@ console.log(context.confidence); // 0.98
| **`providers`** | `AiProvider[]` | Per-request provider configuration overrides. |
| **`timeout`** | `number` | Per-request timeout in milliseconds (overrides provider and global timeouts). |
| **`hedgeDelay`** | `number` | Delay in milliseconds before initiating speculative hedging in `AiMode.Hedged` (default: `800ms`). |
-| **`debug`** | `boolean` | If true, logs prompt context and LLM payloads to console. |
+| **`debug`** | `boolean` | If true, logs prompt context and cache operations to console (automatically PII-sanitized in production). |
| **`softErrors`** | `boolean` | If true, returns `TempoAiError` into array index position instead of rejecting the entire batch query. |
---
diff --git a/packages/plugins/ai/doc/grounding.md b/packages/plugins/ai/doc/grounding.md
index 72e44063..87cb554a 100644
--- a/packages/plugins/ai/doc/grounding.md
+++ b/packages/plugins/ai/doc/grounding.md
@@ -37,9 +37,13 @@ Passing the `Locale` and `TimeZone` is critical for the LLM to know whether `"11
## The Decoupled Output Bridge
-To ensure deterministic behavior, the LLM is instructed to *only* return strict ISO 8601 strings.
+To ensure deterministic, type-safe behavior, the plugin enforces a strict decoupled bridge between AI text generation and JavaScript object hydration:
-The plugin executes the network request, the LLM returns a local ISO string without a timezone offset or 'Z' suffix (like `"2026-11-26T00:00:00"`), and the plugin immediately passes that string back into the native `new Tempo()` constructor. The provider response must omit timezone suffixes to match the local ISO contract enforced by Tempo AI functions. The developer seamlessly receives a valid, native `Tempo` instance. This eliminates AST-construction ambiguity, creating a decoupled bridge between AI text generation and native Tempo conversion.
+* **For Point-in-Time Parsing (`parseAI`)**: The LLM is instructed to return a strict local ISO 8601 string without a timezone offset or 'Z' suffix (e.g. `"2026-11-26T00:00:00"`). The plugin immediately constructs a native `new Tempo()` instance with caller-defined timezone and calendar context.
+* **For Structured Functions (`formatAI`, `extractAI`, `diffAI`, `contextAI`)**: The LLM completes rigid JSON schemas validated against strict boundary rules, instantiating typed result objects (`TempoAiFormatResult`, `TempoAiExtractResult`, `TempoAiDiffResult`, `TempoContext`).
+* **For Intervals & Generators (`scheduleAI`, `recurrenceAI`)**: The plugin hydrates interval boundaries into a proxied `Interval` or exposes an iterable generator yielding sequential `Tempo` instances.
+
+This eliminates AST-construction ambiguity and provides clean runtime contracts for every operation.
### Relative Date Ambiguity Tie-Breakers
@@ -48,11 +52,13 @@ To eliminate model variance on idioms like "Next Friday" or "Last Tuesday", the
* `"last [weekday/unit]"` / `"previous [weekday/unit]"`: Evaluated as the most recent past occurrence prior to the grounding anchor.
* `"this [weekday]"`: Evaluated as the occurrence within the current calendar week containing the grounding anchor.
-### Confidence Thresholds & Metadata (`.ai`)
+### Confidence Thresholds & Metadata Handling
-When `minConfidence` is supplied in options (e.g. `parseAI("...", { minConfidence: 0.8 })`), any LLM response returning a confidence score below that threshold produces a `Tempo` instance with `isValid === false`.
+When `minConfidence` is supplied in options (e.g. `{ minConfidence: 0.85 }`):
+* **`parseAI`**: Any LLM response returning a confidence score below the threshold produces a `Tempo` instance with `isValid === false` (when using `softErrors: true`) or throws a `TempoAiError(422)`.
+* **Structured Functions (`formatAI`, `extractAI`, `diffAI`, `contextAI`, `scheduleAI`, `recurrenceAI`)**: Low-confidence completions immediately throw a `TempoAiError(422)` (or return a `TempoAiError` in batch arrays when `softErrors: true` is enabled).
-Every resolved `Tempo` instance returned by `parseAI` (and other AI functions) has a non-writable, frozen `.ai` metadata descriptor containing execution audit data:
+Every resolved `Tempo` instance returned by `parseAI` has a non-writable, frozen `.ai` metadata descriptor containing execution audit data:
```typescript
const dt = await parseAI("Christmas 2026", { debug: true });
console.log(dt.ai);
@@ -67,3 +73,5 @@ console.log(dt.ai);
// normalizedPrompt: 'christmas 2026' // Present when debug is enabled
// }
```
+
+*(For other AI functions like `extractAI` or `diffAI`, diagnostic metadata including `confidence`, `reasoning`, and `provider` is attached directly to the returned result object.)*
diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md
index 9aaf11ff..f25cba95 100644
--- a/packages/plugins/ai/doc/index.md
+++ b/packages/plugins/ai/doc/index.md
@@ -40,18 +40,29 @@ All AI functions return a standard ES Promise wrapped object.
| :--- | :--- | :--- | :--- | :---: |
| **`parseAI`** | Natural language text string(s) | `Tempo` \| `Tempo[]` \| `(Tempo \| TempoAiError)[]` | Single point-in-time `Tempo` instance (or batch array) | |
| **`formatAI`** | Date-time + prompt / style | `TempoAiFormatResult` \| `TempoAiFormatResult[]` \| `(TempoAiFormatResult \| TempoAiError)[]` | **Contextual narrative date formatting** (`formatted`, `confidence`, `provider`, `reasoning`) | |
-| **`extractAI`** | Unstructured text string(s) | `TempoAiExtractResult` \| `(TempoAiExtractResult \| TempoAiError)[]` | **Extracted temporal entities & calendar events** (`events: TempoExtractedEvent[]`, `confidence`, `reasoning`) | |
+| **`extractAI`** | Unstructured text string(s) | `TempoAiExtractResult` \| `TempoAiExtractResult[]` \| `(TempoAiExtractResult \| TempoAiError)[]` | **Extracted temporal entities & calendar events** (`events: TempoExtractedEvent[]`, `confidence`, `reasoning`) | |
| **`recurrenceAI`** | Natural language pattern or RRULE string | `TempoRecurrenceResult` | **Iterable series of `Tempo` dates** (with `.take(n)`, `[Symbol.iterator]`, & RRULE string) | |
| **`scheduleAI`** | Booking prompt + busy constraints | `TempoScheduleResult` | **Resolved appointment slot** (`start`, `end`, `slot`, `alternatives`, `ai.conflictBumped`) | |
| **`diffAI`** | Start & End dates + prompt | `TempoAiDiffResult` \| `TempoAiDiffResult[]` \| `(TempoAiDiffResult \| TempoAiError)[]` | **Narrative time delta & business days** (`formatted`, `businessDays`, `days`, `hours`, `holidays`) | |
| **`contextAI`** | Context text string(s) | `TempoContext` \| `TempoContext[]` \| `(TempoContext \| TempoAiError)[]` | **Inferred regional context** (`timeZone`, `locale`, `calendar`, `sphere`) | |
| **`initAI`** | Provider config & API keys | `void` | Configured AI provider farm | |
+### Summary of Distinct Return Contracts
+
+To streamline error handling and data consumption, return shapes across the AI plugin follow three distinct contracts:
+
+| Category | Functions | Return Type | Single Query Low-Confidence / Failure | Batch Array `softErrors: true` Contract |
+| :--- | :--- | :--- | :--- | :--- |
+| **Point-in-Time Date** | `parseAI` | `Tempo` (with `.ai`) | Throws `TempoAiError` (or returns invalid `Tempo` if `minConfidence` threshold unmet) | Returns invalid `Tempo` (`isValid === false`) in array position |
+| **Structured AI Objects** | `formatAI`
`extractAI`
`diffAI`
`contextAI` | `TempoAiFormatResult`
`TempoAiExtractResult`
`TempoAiDiffResult`
`TempoContext` | Throws `TempoAiError` (422 for low confidence, 429 for quota, 500 for network) | Returns typed `TempoAiError` object directly in array position |
+| **Intervals & Generators** | `scheduleAI`
`recurrenceAI` | `TempoScheduleResult` (Proxied `Interval`)
`TempoRecurrenceResult` (`.take(n)`) | Throws `TempoAiError` (Single item query only) | N/A (Single query operations) |
+
## Architecture & Infrastructure Guides
> [!IMPORTANT]
-> **Production Recommendation**: Due to the complexities of LLM APIs, including caching gotchas, context injection, rate limits, and calendar math hallucinations, we politely but firmly recommend reading the dedicated guides below before deploying this plugin in a production environment.
+> **Production Recommendation**: Due to the complexities of LLM APIs, including caching gotchas, context injection, rate limits, and calendar math hallucinations, we politely but strongly recommend reading the dedicated guides below before deploying this plugin in a production environment.
+- [Security & Privacy Architecture](./security.md) (Smart Debug Telemetry, PII Masking, HTTPS & Proxy Introspection)
- [Multi-Provider Execution Modes](./modes.md) (Hedged, RoundRobin, Adaptive, Race, Consensus, Fallback)
- [Provider Architecture & Security](./architecture.md) (BYOK vs Proxy patterns, Browser Security, TLS 1.3 & Privacy Guarantees)
- [Grounding & Natural Language Parsing](./grounding.md) (How Timezone and Locale are injected)
@@ -62,8 +73,7 @@ All AI functions return a standard ES Promise wrapped object.
> [!NOTE]
> **Community Feedback & Prompt Engineering**
> While `@magmacomputing/tempo-plugin-ai` utilizes deterministic grounding, schema enforcement, and confidence validation, LLM outputs can vary across models and prompt styles. We actively welcome community feedback and prompt optimizations—please report any edge cases or suggestions on the [Magma GitHub Issue Tracker](https://github.com/magmacomputing/magma/issues/new?template=bug_report_ai.yml).
-
-> [!CAUTION]
+>
> **Production Notice & "As-Is" Disclaimer**: Magma Computing Solutions and the Tempo core maintainers provide `@magmacomputing/tempo-plugin-ai` "as-is" without warranty of any kind. Large Language Models operate probabilistically; developers and system architects are responsible for validating AI-generated temporal outputs before committing them to financial, legal, medical, or life-critical applications.
## Licensing
diff --git a/packages/plugins/ai/doc/init.md b/packages/plugins/ai/doc/init.md
index 49c93810..3e518eaf 100644
--- a/packages/plugins/ai/doc/init.md
+++ b/packages/plugins/ai/doc/init.md
@@ -14,7 +14,7 @@ await initAI({
{ id: 'openai', key: process.env.OPENAI_API_KEY, model: 'gpt-4o' }
],
timeout: 5000, // 5-second global SLA default
- debug: true // Enable operational trace logging (development-only)
+ debug: true // Enable operational trace logging (automatically PII-sanitized in production)
});
```
@@ -80,10 +80,10 @@ const dt = await parseAI("Next Tuesday at 4pm", { timeout: 3000 });
**Operational Trace Logging**
Passing `debug: true` into `initAI` (or setting `{ debug: true }` on a per-request options object) outputs concise operational trace logs (prefixed with `[tempo-plugin-ai]`) to the developer console for monitoring provider fallback decisions and execution timing.
-Detailed diagnostic context—including `rawPrompt`, `normalizedPrompt`, `reasoning`, confidence scores, and rate limit snapshots—is attached directly to the returned `Tempo` instance via the `.ai` property when `debug: true` is enabled.
+Detailed diagnostic context—including `rawPrompt`, `normalizedPrompt`, `reasoning`, confidence scores, and rate limit snapshots—is attached directly to the returned `Tempo` instance via the `.ai` property for `parseAI`, or surfaced directly on the typed result object (`res.reasoning`, `res.confidence`, `res.ai`) for other AI functions when `debug: true` is enabled.
-> [!WARNING]
-> **Diagnostic Security Notice**: Inspecting or exposing the `.ai` metadata property (such as `rawPrompt` or `reasoning`) in public UI components or client-side telemetry may expose raw user inputs. Ensure sensitive diagnostic fields on `Tempo.ai` are sanitized before forwarding instances to external monitoring tools.
+> [!TIP]
+> **Smart Debug & Proxy Introspection**: In production environments (`NODE_ENV === 'production'`), terminal logging via `console.log(date.ai)` or `console.log(result)` automatically sanitizes and masks PII (emails, phones, bearer tokens) while preserving 100% in-memory data integrity for application code. Refer to the [Security & Privacy Architecture Guide](./security.md).
## Configuration Options Reference
diff --git a/packages/plugins/ai/doc/modes.md b/packages/plugins/ai/doc/modes.md
index 52a88c94..b1a599af 100644
--- a/packages/plugins/ai/doc/modes.md
+++ b/packages/plugins/ai/doc/modes.md
@@ -52,10 +52,10 @@ flowchart LR
### Proactive Cooldown Avoidance
Before dispatching any request:
-1. **Cooldown Detection**: The orchestrator checks if any configured provider has exhausted its request quota (`remainingRequests === 0`) and is within an active reset window (`resetAt > now`).
+1. **Cooldown Detection**: The orchestrator checks if any configured provider has exhausted its request or token quota (`remainingRequests === 0` or `remainingTokens === 0`) and is within an active reset window (`resetAt > now`, derived from response reset timestamps or `retry-after` metadata).
2. **Pre-Dispatch Filtering**: In `Fallback`, `Race`, `Hedged`, and `RoundRobin` modes, exhausted providers are automatically removed from the active candidate pool for that request.
- **`Fallback` & `Hedged`**: Avoids stalling on primary providers that are guaranteed to reject with HTTP 429.
- - **`Race`**: Saves network bandwidth and avoid firing wasted requests to rate-limited models.
+ - **`Race`**: Saves network bandwidth and avoids firing wasted requests to rate-limited models.
- **`RoundRobin`**: Skips over cooling-down keys without breaking the cyclic load-balancing progression.
3. **Fail-Open Resilience**: If *all* providers in the farm are currently in a cooldown window, the orchestrator keeps all providers available rather than failing prematurely, allowing the request to cascade or surface accurate rate-limit errors.
@@ -172,17 +172,23 @@ const dt = await parseAI('tomorrow at noon', {
### 6. `AiMode.Consensus` — Multi-LLM Cross-Validation
-Dispatches requests concurrently across all providers and compares the normalized ISO timestamps or RRULE strings. If all responding providers agree, confidence is elevated to `1.0` (unanimous). If providers disagree, the highest-confidence candidate is returned and flagged with `dt.ai.ambiguous = true`.
+Dispatches requests concurrently across all providers and compares the normalized outputs (e.g. ISO timestamps for `parseAI`, RRULE strings for `recurrenceAI`, formatted strings for `diffAI`/`formatAI`, or structured entity counts for `extractAI`). If all responding providers agree, confidence is elevated to `1.0` (unanimous). If providers disagree, the highest-confidence candidate is returned and flagged with `ai.ambiguous = true` (attached to `Tempo.ai` on `parseAI` or returned on structured result objects).
**Best for:** High-stakes legal, financial, and scheduling — contract dates, event conflict resolution, or auditing where hallucination prevention requires unanimous LLM agreement.
```typescript
+// 1. Point-in-time cross validation
const dt = await parseAI('contract renewal date', {
mode: AiMode.Consensus
});
if (dt.ai?.ambiguous) {
- console.warn('Providers disagreed — treat this result with caution.');
+ console.warn('Providers disagreed — treat this date with caution.');
}
+
+// 2. High-precision duration calculation across multiple providers
+const diff = await diffAI(startDate, endDate, 'in business days excluding UK bank holidays', {
+ mode: AiMode.Consensus
+});
```
diff --git a/packages/plugins/ai/doc/parse.md b/packages/plugins/ai/doc/parse.md
index 5189c5bb..537ddd6b 100644
--- a/packages/plugins/ai/doc/parse.md
+++ b/packages/plugins/ai/doc/parse.md
@@ -31,7 +31,7 @@ const dt = await parseAI("Third Friday of October", {
timeZone: 'Australia/Sydney', // Context timezone
locale: 'en-AU', // Context locale
minConfidence: 0.85, // Require at least 0.85 confidence score
- timeout: 3000, // 3-second SLA call-site timeout
+ timeout: 3000, // 3-second request timeout (throws TempoAiError(504) if exceeded)
force: true, // Skip native pre-parsing & cache lookup
debug: true // Enable operational trace logging & .ai metadata
});
diff --git a/packages/plugins/ai/doc/rate-limits.md b/packages/plugins/ai/doc/rate-limits.md
index d8d10562..187017e0 100644
--- a/packages/plugins/ai/doc/rate-limits.md
+++ b/packages/plugins/ai/doc/rate-limits.md
@@ -9,7 +9,7 @@ The plugin automatically tracks these limits by reading the standard `x-ratelimi
Quota and rate-limit metadata can be inspected in two convenient ways:
### 1. Request-Locked Instance Metadata (`dt.ai.limits`)
-Every `Tempo` instance returned by `parseAI` includes a frozen `.ai.limits` snapshot representing the rate limit state returned by provider HTTP headers for *that specific request*. Note that `.ai.limits` is guaranteed only for provider-backed network requests where rate-limit headers are returned by the selected provider; results resolved via native parsing (`provider: 'native'`) or cache hits (`provider: 'cache'`) may omit `.ai.limits` (`undefined`).
+For `parseAI`, every resolved `Tempo` instance includes a frozen `.ai.limits` snapshot representing the rate limit state returned by provider HTTP headers for *that specific request*. Note that `.ai.limits` is guaranteed only for provider-backed network requests where rate-limit headers are returned by the selected provider; results resolved via native parsing (`provider: 'native'`) or cache hits (`provider: 'cache'`) may omit `.ai.limits` (`undefined`).
```typescript
const dt = await parseAI("The third Friday of next month");
@@ -74,12 +74,24 @@ This is by design for three critical reasons:
### Soft Errors in Array Batches
-When processing arrays of inputs, an unparseable input or provider failure on one item will throw an error by default, stopping execution. Passing `softErrors: true` allows AI functions to return invalid `Tempo` instances (`isValid === false`) for failing items while completing the rest of the array:
+When processing arrays of inputs, an unparseable input or provider failure on one item will throw an error by default, halting execution of the entire batch. Passing `softErrors: true` allows batch operations to gracefully complete the rest of the array:
+
+* **For `parseAI`**: Failed array items return an invalid `Tempo` instance (`isValid === false`).
+* **For Structured Functions (`formatAI`, `extractAI`, `diffAI`, `contextAI`)**: Failed array items return the typed `TempoAiError` object directly in that array position.
```typescript
+// 1. parseAI with softErrors returns invalid Tempo instances
const dates = await parseAI(["Thanksgiving 2026", "INVALID_PROMPT_STRING"], { softErrors: true });
console.log(dates[0].isValid); // true
console.log(dates[1].isValid); // false
+
+// 2. Structured functions return TempoAiError objects into the array
+import { formatAI, TempoAiError } from '@magmacomputing/tempo-plugin-ai';
+
+const formatted = await formatAI([validDate, invalidDate], 'casual tone', { softErrors: true });
+if (formatted[1] instanceof TempoAiError) {
+ console.warn(`Format failed with code: ${formatted[1].code}`);
+}
```
### Static Glossary Seeding
@@ -112,10 +124,13 @@ const dt = await parseAI("The last Friday before Christmas", { force: true, cach
If the LLM hallucinates or returns an incorrect absolute date, you can explicitly purge the string from the cache:
```typescript
-import { clearAiCache } from '@magmacomputing/tempo-plugin-ai';
+import { aiCache } from '@magmacomputing/tempo-plugin-ai';
// Evict a single string
-clearAiCache("2nd tuesday in nov");
+await aiCache.clear("2nd tuesday in nov");
+
+// Or purge all AI cached entries
+await aiCache.clear();
```
### Forcing a Refresh
diff --git a/packages/plugins/ai/doc/schedule.md b/packages/plugins/ai/doc/schedule.md
index 3efe8b98..98e3b401 100644
--- a/packages/plugins/ai/doc/schedule.md
+++ b/packages/plugins/ai/doc/schedule.md
@@ -37,7 +37,7 @@ console.log(booking.ai?.conflictBumped); // true (pushed
| Option | Type | Description |
| :--- | :--- | :--- |
| **`anchor`** | `TempoDateInput` | Anchor reference time to evaluate relative dates from. Defaults to workstation/browser current time. |
-| **`events`** | `TempoInterval[]` | A list of existing busy calendar intervals that the meeting must not overlap with. |
+| **`events`** | `Array<{ start: any; end: any; title?: string } \| TempoInterval \| Interval>` | A list of existing busy calendar intervals that the meeting must not overlap with. |
| **`workingHours`** | `TempoWorkingHours` | Daily time window constraint (HH:MM formats) inside which slots must fit. |
| **`timeZone`** | `string` | Target IANA timezone to calculate the booking slot and boundaries within. |
| **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required to return a valid slot. |
@@ -45,11 +45,20 @@ console.log(booking.ai?.conflictBumped); // true (pushed
### `TempoInterval` Interface
```typescript
-interface TempoInterval {
+export interface TempoInterval {
+ start: Tempo;
+ end: Tempo;
+}
+```
+
+### Event Input Shape (`TempoScheduleOptions.events`)
+The `events` option accepts raw event objects, continuous `TempoInterval` pairs, or native `Interval` instances:
+```typescript
+type ScheduleEventInput = {
start: TempoDateInput;
end: TempoDateInput;
title?: string;
-}
+} | TempoInterval | Interval;
```
---
diff --git a/packages/plugins/ai/doc/security.md b/packages/plugins/ai/doc/security.md
new file mode 100644
index 00000000..fd4c11e0
--- /dev/null
+++ b/packages/plugins/ai/doc/security.md
@@ -0,0 +1,150 @@
+# Security & Privacy Architecture
+
+The `@magmacomputing/tempo-plugin-ai` plugin is engineered with a **"Privacy and Security by Default"** philosophy. Because date parsing and calendar scheduling frequently interact with Personally Identifiable Information (PII)—such as meeting attendees, emails, phone numbers, and sensitive notes—the plugin incorporates multi-layered security controls to protect user data across transit, runtime memory, log output, and caching tiers.
+
+```mermaid
+flowchart TD
+ subgraph Input ["1. Ingress & Transport"]
+ User["User Prompt / Event Data"] -->|"HTTPS / TLS 1.3 Enforcement"| Transport["Secure Transport Layer"]
+ end
+
+ subgraph Memory ["2. In-Memory Processing & Storage"]
+ Transport --> Schema["Rigid Schema Validation & Grounding"]
+ Schema --> Runtime["In-Memory Execution
(Full Fidelity Access)"]
+ Runtime --> Cache["Partitioned Multi-Tier Cache
(Namespaced by Tenant/TZ/Locale)"]
+ end
+
+ subgraph Egress ["3. Egress & Smart Debugging"]
+ Runtime --> ProxyMeta["Proxy-Wrapped Result Objects
(attachCustomInspect)"]
+ ProxyMeta --> Logic["Application Business Logic
(100% Raw Data Access)"]
+ ProxyMeta -->|"console.log() / util.inspect"| Logger["Smart Logger (logDebug)
• NODE_ENV=production: Auto-Masked PII
• NODE_ENV=development: Full Diagnostic Logs"]
+ end
+```
+
+---
+
+## 1. Smart Debug Telemetry & PII Hardening
+
+Debugging LLM integrations traditionally presents a major security dilemma: enabling debug logs often inadvertently dumps raw prompts containing sensitive user emails, phone numbers, and auth tokens into centralized log aggregators (e.g. Datadog, CloudWatch, Sentry).
+
+`@magmacomputing/tempo-plugin-ai` eliminates this risk through **Smart Debug Infrastructure**:
+
+### Universal Environment Detection & Zero-Config Safety
+* **Single Flag Experience**: Developers simply pass `{ debug: true }` (or configure `initAI({ debug: true })`). There are no confusing secondary flags to memorize.
+* **Environment-Aware Sanitization**: The runtime automatically inspects `NODE_ENV`. In production environments (`NODE_ENV === 'production'`), all debug logs and terminal outputs automatically sanitize sensitive data before printing to `console.log` or `console.warn`.
+* **Development Fidelity**: In non-production environments (local development, testing), full diagnostic strings are preserved for seamless prompt debugging.
+
+### Automatic PII Redaction
+In production mode, all debug telemetry is scrubbed through automated regex sanitizers:
+* **Email Addresses**: Masked to initial and domain (e.g., `john.doe@enterprise.com` $\rightarrow$ `j***@enterprise.com`).
+* **Phone Numbers**: Masked to last four digits (e.g., `+1-555-867-5309` $\rightarrow$ `***-***-5309`).
+* **Bearer & API Tokens**: Redacted with prefix/suffix preservation (e.g., `Bearer sk-proj-1234...` $\rightarrow$ `Bearer sk-p...1234`).
+* **Length Bounds**: Exceptionally long strings (> 256 characters) are safely truncated with character count annotations to prevent log bloat and denial-of-service attacks.
+
+```typescript
+import { parseAI, initAI } from '@magmacomputing/tempo-plugin-ai';
+
+await initAI({
+ providers: [{ id: 'groq', key: process.env.GROQ_API_KEY }],
+ debug: true // Safe in all environments
+});
+
+// Input containing sensitive attendee data
+const date = await parseAI("Meeting with john.smith@company.org (call 555-123-4567) next Friday");
+
+// In Production, console.log(date.ai) outputs:
+// {
+// provider: 'groq',
+// confidence: 0.98,
+// rawPrompt: 'Meeting with j***@company.org (call ***-***-4567) next Friday',
+// reasoning: 'Parsed meeting for next Friday with j***@company.org'
+// }
+```
+
+---
+
+## 2. Tamper-Resistant Proxy Introspection
+
+All AI return objects (`Tempo.ai`, `TempoAiFormatResult`, `TempoAiExtractResult`, `TempoAiDiffResult`, `TempoScheduleResult`, `TempoRecurrenceResult`) utilize JavaScript `Proxy` wrappers and Node.js custom inspection hooks (`Symbol.for('nodejs.util.inspect.custom')` and `.toJSON()`):
+
+1. **Terminal & Log Safety**: When an AI result object is logged via `console.log()`, `util.inspect()`, or serialized for telemetry, the custom inspection hook intercepts the call and outputs the PII-masked view.
+2. **100% In-Memory Code Integrity**: In-memory property access within your application code (`date.ai?.rawPrompt`, `result.events[0].rawText`, `res.reasoning`) retains full, unmodified data fidelity.
+3. **Deep Immutability**: Metadata properties attached to `Tempo` instances are frozen using `Object.freeze()`, preventing runtime tampering or prototype pollution by downstream code or dependencies.
+
+```typescript
+const result = await formatAI(targetDate, 'Notify alice.cooper@domain.com');
+
+// 1. Terminal / Log Aggregators see sanitized PII in production:
+console.log(result);
+// => { formatted: '...', reasoning: '... client a***@domain.com ...' }
+
+// 2. Your application code receives full raw fidelity:
+const rawReasoning = result.reasoning;
+// => "Formatted for client alice.cooper@domain.com"
+```
+
+---
+
+## 3. Transport Security & Network Hardening
+
+### Enforced HTTPS / TLS
+* **Strict HTTPS Requirement**: All network communication with upstream LLM APIs and remote configuration servers must use HTTPS with modern TLS (TLS 1.2 or TLS 1.3).
+* **Plaintext HTTP Disallowed**: Unencrypted HTTP endpoints are rejected at runtime, with an exception allowed exclusively for `localhost` origins during local development or unit testing with mock servers.
+
+### Dynamic Manifest Host Verification
+* **Trusted Remote Endpoints**: When `loadRemoteManifest` resolves provider manifests, it enforces trusted origin allowlists.
+* **Provider URL Sanitization**: Any dynamic endpoint received via remote manifests or the `fetchDefaults` hook is verified before runtime merging. Disallowed hosts are rejected and stripped to prevent server-side request forgery (SSRF).
+
+---
+
+## 4. Credential Isolation & BYOK Architecture
+
+### Automated In-Memory Key Redaction
+* Calling `getAiConfig()` returns a sanitized, read-only configuration snapshot.
+* All provider `key` values, authorization tokens, and shared secrets are permanently replaced with `[REDACTED]`, ensuring secrets cannot be leaked via diagnostic endpoints or error monitors.
+
+### Frontend Zero-Storage Principle
+* **No Client-Side Secrets**: LLM API keys must **never** be bundled into client-side single-page applications (React, Vue, Svelte) or stored in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`).
+* **Proxy Architecture**: Public frontend web applications must route requests through a self-hosted backend proxy or secure AI Gateway (Cloudflare Worker, Next.js API Route) where private API keys are kept server-side.
+
+---
+
+## 5. Ephemeral Processing & Partitioned Caching
+
+### Zero Data Retention Policy
+* The plugin does not transmit telemetry, analytics, or prompt logs to external tracking servers.
+* Prompts and temporal calculations exist ephemerally in memory during execution.
+
+### Tenant-Isolated Partitioned Caching
+* **Namespaced Cache Keys**: Cache keys are generated with multi-factor hashing (`ai:::`) incorporating the user prompt, target timezone, locale, calendar system, and anchor date.
+* **Zero Cross-Contamination**: Isolated cache keys prevent cross-tenant and cross-regional data leakage.
+* **Granular Bypass Controls**: Operations requiring zero cache persistence can supply `cache: false` or `force: true` on any individual request.
+
+---
+
+## 6. Schema Enforcement & Hallucination Defense
+
+Large Language Models can occasionally hallucinate dates or output non-deterministic formats. `@magmacomputing/tempo-plugin-ai` prevents invalid data propagation through strict input/output boundaries:
+
+1. **Rigid Schema Validation**: All provider completions are validated against deterministic schemas and regex patterns prior to object construction.
+2. **Confidence Threshold Gating**: The plugin enforces configurable `minConfidence` thresholds (e.g. `minConfidence: 0.85`). Results falling below the threshold throw a typed `TempoAiError` or trigger automatic fallback.
+3. **Deterministic Grounding Fallbacks**: Grounding metrics (such as business days, calendar day offsets, and duration calculations) are verified using deterministic `Tempo` calculations rather than unverified LLM assumptions.
+
+---
+
+## 7. Residual Risks & Threat Model Matrix
+
+> [!IMPORTANT]
+> **Primary Production Strategy**: The primary recommendation for production environments is to keep **`debug: false`** (the default). Smart Debug is designed as an automated safety net to prevent catastrophic PII leaks when developers troubleshoot live issues, but no automated sanitization layer can eliminate 100% of risk when raw diagnostic telemetry is captured.
+
+The following matrix documents residual threat vectors and recommended mitigations:
+
+| Threat Vector | Source | Risk Level | Architectural Behavior | Recommended Mitigation |
+| :--- | :--- | :---: | :--- | :--- |
+| **Direct Primitive Logging** | Developer `console.log(res.reasoning)` | Medium | Evaluates to the raw in-memory string and bypasses object inspection hooks. | Log entire result objects (`console.log(res)`) or leave `debug: false`. |
+| **Object Spread Logging** | `console.log({ ...res })` | Low | Spreading copies raw enumerable keys into a plain object without non-enumerable inspect symbols. | Log the object directly (`console.log(res)`) rather than shallow spreading. |
+| **Network-Layer APM Tracing** | Datadog, OpenTelemetry, Sentry HTTP capture | High | APM agents monkey-patching `fetch` capture raw outbound HTTP payloads in transit. | Disable full HTTP body capture on LLM routes in your APM configuration. |
+| **Semantic PII** | Unstructured names, physical addresses, health info | Medium | Regexes catch structured PII (emails, phones, tokens) but not unstructured names/addresses. | Rely on payload truncation limits (< 256 chars) and avoid `debug: true` on sensitive workflows. |
+| **Environment Variable Drift** | `NODE_ENV` not set or misconfigured | Low | Logger checks `NODE_ENV` (`production`, `prod`, `live`) and `PROD=true`. If unset, defaults to dev mode. | Verify deployment manifests explicitly export `NODE_ENV=production`. |
+| **External Cache Driver Logs** | Third-party Redis/DB client debug logs | Low | Distributed cache adapters store raw JSON required to rehydrate `Tempo` instances. | Ensure production Redis/database clients have debug logging disabled. |
+
diff --git a/packages/plugins/ai/src/core/cache.ts b/packages/plugins/ai/src/core/cache.ts
new file mode 100644
index 00000000..38a293f8
--- /dev/null
+++ b/packages/plugins/ai/src/core/cache.ts
@@ -0,0 +1,219 @@
+import { Tempo } from '@magmacomputing/tempo';
+import { secure } from '@magmacomputing/tempo/library';
+import { _state } from './init.js';
+import { logDebug, warnDebug } from './logger.js';
+import type { AiCacheAdapter } from '../types/index.js';
+
+export const AI_CACHE_NAMESPACE_PREFIX = 'ai:';
+
+/**
+ * Normalizes input string for deterministic cache lookups by trimming excess whitespace and lowercasing.
+ */
+export function normalizeCacheInput(input: string): string {
+ return input.trim().toLowerCase().replace(/\s+/g, ' ');
+}
+
+/**
+ * Generates a namespaced cache key for domain-specific AI functions.
+ */
+export function getNamespacedCacheKey(namespace: string, key: string): string {
+ return `${AI_CACHE_NAMESPACE_PREFIX}${namespace}::${key}`;
+}
+
+/**
+ * Reads from multi-tier cache (Tier 2 external async adapter first, Tier 1 local in-memory Tempo.cache fallback).
+ */
+export async function readMultiTierCache(
+ cacheKey: string,
+ options: {
+ force?: boolean | undefined;
+ cache?: boolean | undefined;
+ cacheAdapter?: AiCacheAdapter | undefined;
+ debug?: boolean | undefined;
+ tag?: string | undefined;
+ },
+): Promise {
+ if (options.force) return undefined;
+ if (options.cache === false || _state.config.cache === false) return undefined;
+
+ const tag = options.tag ?? 'tempo-plugin-ai';
+ const adapter = options.cacheAdapter || _state.config.cacheAdapter;
+ if (adapter) {
+ try {
+ const val = await adapter.get(cacheKey);
+ if (val !== undefined && val !== null) {
+ logDebug(tag, `Cache hit (adapter): ${cacheKey}`, undefined, { debug: options.debug });
+ return val;
+ }
+ } catch (err: any) {
+ warnDebug(tag, `Cache adapter get failed for ${cacheKey}`, err, { debug: options.debug });
+ }
+ }
+
+ const localVal = Tempo.cache.get(cacheKey);
+ if (localVal) {
+ logDebug(tag, `Cache hit (local): ${cacheKey}`, undefined, { debug: options.debug });
+ return localVal;
+ }
+
+ return undefined;
+}
+
+/**
+ * Writes to multi-tier cache (Tier 1 local in-memory Tempo.cache and Tier 2 external async adapter).
+ */
+export async function writeMultiTierCache(
+ cacheKey: string,
+ value: string,
+ ttl: number,
+ options: {
+ cache?: boolean | undefined;
+ cacheAdapter?: AiCacheAdapter | undefined;
+ debug?: boolean | undefined;
+ tag?: string | undefined;
+ },
+): Promise {
+ if (options.cache === false || _state.config.cache === false) return;
+
+ const tag = options.tag ?? 'tempo-plugin-ai';
+ Tempo.cache.set(cacheKey, value);
+
+ const adapter = options.cacheAdapter || _state.config.cacheAdapter;
+ if (adapter) {
+ try {
+ await adapter.set(cacheKey, value, ttl);
+ } catch (err: any) {
+ warnDebug(tag, `Cache adapter set failed for ${cacheKey}`, err, { debug: options.debug });
+ }
+ }
+}
+
+/**
+ * ## aiCache
+ * Unified, secure multi-tier cache manager for the Tempo AI plugin suite.
+ * Manages both local in-memory `Tempo.cache` (Tier 1) and external distributed storage adapters (Tier 2).
+ *
+ * Protected with `secure()` proxy to prevent direct external manipulation while providing a full store interface.
+ */
+export const aiCache = secure({
+ /**
+ * Clears AI entries from the in-memory cache and any external storage adapters.
+ * If specific input strings or keys are provided, selectively purges only those entries and prefix trees.
+ *
+ * @param input - Optional string key, input prompt, or array of keys to purge
+ * @returns Promise that resolves once cache eviction is complete
+ */
+ async clear(input?: string | string[]): Promise {
+ const adapter = _state.config.cacheAdapter;
+
+ if (!input) {
+ Tempo.cache.clear();
+ if (adapter?.clear) {
+ try {
+ await Promise.resolve(adapter.clear()).catch(() => { });
+ } catch { }
+ }
+ return;
+ }
+
+ const inputs = Array.isArray(input) ? input : [input];
+ for (const i of inputs) {
+ const normalized = normalizeCacheInput(i);
+ const prefix = `${normalized}::`;
+ Tempo.cache.delete(normalized);
+ Tempo.cache.delete(i);
+ Tempo.cache.deletePrefix(prefix);
+
+ if (adapter) {
+ try {
+ if (adapter.delete) {
+ await Promise.resolve(adapter.delete(normalized)).catch(() => { });
+ await Promise.resolve(adapter.delete(i)).catch(() => { });
+ }
+ if (adapter.clear) {
+ await Promise.resolve(adapter.clear(prefix)).catch(() => { });
+ }
+ } catch { }
+ }
+ }
+ },
+
+ /**
+ * Deletes a specific key from both Tier 1 in-memory cache and Tier 2 storage adapter.
+ *
+ * @param key - The cache key to delete
+ * @returns True if the key was present in the in-memory cache, false otherwise
+ */
+ async delete(key: string): Promise {
+ const normalized = normalizeCacheInput(key);
+ const deletedLocal = Tempo.cache.delete(normalized) || Tempo.cache.delete(key);
+ const adapter = _state.config.cacheAdapter;
+ if (adapter?.delete) {
+ try {
+ await Promise.resolve(adapter.delete(normalized)).catch(() => { });
+ await Promise.resolve(adapter.delete(key)).catch(() => { });
+ } catch { }
+ }
+ return deletedLocal;
+ },
+
+ /**
+ * Retrieves a cached string by key across multi-tier storage.
+ *
+ * @param key - The cache key to fetch
+ * @returns The cached string value, or undefined if not found
+ */
+ async get(key: string): Promise {
+ const adapter = _state.config.cacheAdapter;
+ if (adapter?.get) {
+ try {
+ const val = await adapter.get(key);
+ if (val !== undefined && val !== null) return val;
+ } catch { }
+ }
+ return Tempo.cache.get(key);
+ },
+
+ /**
+ * Checks if a key exists in either Tier 1 in-memory cache or Tier 2 storage adapter.
+ *
+ * @param key - The cache key to check
+ * @returns True if the key exists, false otherwise
+ */
+ async has(key: string): Promise {
+ const val = await this.get(key);
+ return val !== undefined;
+ },
+
+ /**
+ * Sets a string value into multi-tier cache with an optional TTL.
+ *
+ * @param key - The cache key
+ * @param value - The serialized string value to cache
+ * @param ttl - Optional TTL in milliseconds
+ */
+ async set(key: string, value: string, ttl?: number): Promise {
+ Tempo.cache.set(key, value);
+ const adapter = _state.config.cacheAdapter;
+ if (adapter?.set) {
+ try {
+ await adapter.set(key, value, ttl);
+ } catch { }
+ }
+ },
+
+ /**
+ * Returns an iterator over active in-memory cache entries.
+ */
+ entries(): IterableIterator<[string, string]> {
+ return Tempo.cache.entries();
+ },
+
+ /**
+ * Returns a plain object snapshot of active in-memory cache entries.
+ */
+ toJSON(): Record {
+ return Tempo.cache.toJSON();
+ },
+});
+
diff --git a/packages/plugins/ai/src/core/dispatch.ts b/packages/plugins/ai/src/core/dispatch.ts
index 17122473..1f0808da 100644
--- a/packages/plugins/ai/src/core/dispatch.ts
+++ b/packages/plugins/ai/src/core/dispatch.ts
@@ -1,6 +1,7 @@
import { TempoAiError } from './error.js';
import { AiMode } from './config.js';
import { _state } from './init.js';
+import { logDebug, warnDebug } from './logger.js';
import type { AiProvider } from '../types/index.js';
/**
@@ -102,14 +103,22 @@ async function executeFallbackMode(
if (options?.minConfidence === undefined || confidence >= options.minConfidence)
return candidate;
- if (options?.debug)
- console.log(`[${options.tag || 'tempo-plugin-ai'}] Provider '${candidate.providerId}' confidence (${confidence}) below minConfidence (${options.minConfidence}). Cascading to next provider...`);
+ logDebug(
+ options?.tag || 'tempo-plugin-ai',
+ `Provider '${candidate.providerId}' confidence (${confidence}) below minConfidence (${options.minConfidence}). Cascading to next provider...`,
+ undefined,
+ { debug: options?.debug },
+ );
} catch (err: any) {
lastError = err;
if (err instanceof TempoAiError && err.code === 422 && options?.minConfidence === undefined) break;
- if (options?.debug)
- console.warn(`[${options.tag || 'tempo-plugin-ai'}] Provider '${provider.id}' failed:`, err);
+ warnDebug(
+ options?.tag || 'tempo-plugin-ai',
+ `Provider '${provider.id}' failed`,
+ err,
+ { debug: options?.debug },
+ );
}
}
@@ -363,7 +372,7 @@ async function executeAdaptiveMode(
if (limits) {
const resetMs = limits.resetAt?.epoch?.ms ?? now;
- isExhausted = limits.remainingRequests === 0 && resetMs > now;
+ isExhausted = (limits.remainingRequests === 0 || limits.remainingTokens === 0) && resetMs > now;
}
return {
@@ -396,7 +405,8 @@ export function isProviderInCooldown(provider: AiProvider, now = Date.now()): bo
const limits = _state.providerLimits.get(provider.id);
if (!limits) return false;
const resetMs = limits.resetAt?.epoch?.ms ?? now;
- return limits.remainingRequests === 0 && resetMs > now;
+ const isExhausted = limits.remainingRequests === 0 || limits.remainingTokens === 0;
+ return isExhausted && resetMs > now;
}
/**
@@ -414,10 +424,13 @@ export function filterCooldownProviders(
const now = Date.now();
const available = providers.filter(p => !isProviderInCooldown(p, now));
if (available.length > 0 && available.length < providers.length) {
- if (options?.debug) {
- const skipped = providers.filter(p => isProviderInCooldown(p, now)).map(p => p.id);
- console.log(`[${options?.tag || 'tempo-plugin-ai'}] Proactively filtered ${skipped.length} provider(s) in active 429 cooldown: ${skipped.join(', ')}`);
- }
+ const skipped = providers.filter(p => isProviderInCooldown(p, now)).map(p => p.id);
+ logDebug(
+ options?.tag || 'tempo-plugin-ai',
+ `Proactively filtered ${skipped.length} provider(s) in active 429 cooldown: ${skipped.join(', ')}`,
+ undefined,
+ { debug: options?.debug },
+ );
return available;
}
return providers;
diff --git a/packages/plugins/ai/src/core/init.ts b/packages/plugins/ai/src/core/init.ts
index 1ee7a9d1..ef4cf38a 100644
--- a/packages/plugins/ai/src/core/init.ts
+++ b/packages/plugins/ai/src/core/init.ts
@@ -1,7 +1,7 @@
import { Tempo } from '@magmacomputing/tempo';
import { getResolvedProviderDefaults, loadRemoteManifest, resetManifestCache } from './manifest.js';
-import { normalizeCacheInput, assertNoReservedProviderId } from './support.js';
+import { assertNoReservedProviderId } from './support.js';
import type { AiConfig, AiRateLimits, AiProvider } from '../types/index.js';
/**
@@ -71,39 +71,39 @@ export function initAI(config: AiConfig): Promise {
Tempo.init({ cache: config.cache, silent: true });
}
- return (async () => {
- if (remoteUrl !== false) {
- try {
- await loadRemoteManifest(remoteUrl, undefined, config.debug ?? _state.config.debug);
- } catch { }
- }
-
- if (_state.revision !== currentRevision) return;
-
- const fetchDefaults = config.fetchDefaults ?? _state.config.fetchDefaults;
- const currentProviders = callerProviders;
+ return (async () => {
+ if (remoteUrl !== false) {
+ try {
+ await loadRemoteManifest(remoteUrl, undefined, config.debug ?? _state.config.debug);
+ } catch { }
+ }
- if (fetchDefaults && currentProviders) {
- const asyncProviders = await Promise.all(currentProviders.map(async p => {
- const normalizedId = p.id?.toLowerCase() ?? '';
- const defaults = getResolvedProviderDefaults(normalizedId, remoteUrl, config.debug ?? _state.config.debug);
- let hookOptions: Partial | null = null;
- try {
- hookOptions = await fetchDefaults(normalizedId);
- } catch { }
- return {
- ...defaults,
- ...(hookOptions ?? {}),
- ...p,
- } as AiProvider;
- }));
- if (_state.revision === currentRevision)
- _state.config.providers = asyncProviders;
- } else if (currentProviders) {
- if (_state.revision === currentRevision)
- _state.config.providers = resolveSyncProviders(currentProviders);
- }
- })();
+ if (_state.revision !== currentRevision) return;
+
+ const fetchDefaults = config.fetchDefaults ?? _state.config.fetchDefaults;
+ const currentProviders = callerProviders;
+
+ if (fetchDefaults && currentProviders) {
+ const asyncProviders = await Promise.all(currentProviders.map(async p => {
+ const normalizedId = p.id?.toLowerCase() ?? '';
+ const defaults = getResolvedProviderDefaults(normalizedId, remoteUrl, config.debug ?? _state.config.debug);
+ let hookOptions: Partial | null = null;
+ try {
+ hookOptions = await fetchDefaults(normalizedId);
+ } catch { }
+ return {
+ ...defaults,
+ ...(hookOptions ?? {}),
+ ...p,
+ } as AiProvider;
+ }));
+ if (_state.revision === currentRevision)
+ _state.config.providers = asyncProviders;
+ } else if (currentProviders) {
+ if (_state.revision === currentRevision)
+ _state.config.providers = resolveSyncProviders(currentProviders);
+ }
+ })();
}
/**
@@ -119,53 +119,6 @@ export function resetAI(): void {
resetManifestCache();
}
-/**
- * Clears AI parsing results from the in-memory cache and any external storage adapters.
- * If specific input strings or keys are provided, selectively purges only those entries.
- *
- * @param input - Optional string key, date string, or array of strings to purge from the cache
- * @returns A Promise that resolves once cache eviction is completed
- * @example
- * ```ts
- * await clearAiCache('next tuesday');
- * await clearAiCache(); // Clears all cached AI entries
- * ```
- */
-export async function clearAiCache(input?: string | string[]): Promise {
- const adapter = _state.config.cacheAdapter;
-
- if (!input) {
- Tempo.cache.clear();
- if (adapter?.clear) {
- try {
- await Promise.resolve(adapter.clear()).catch(() => { });
- } catch { }
- }
- return;
- }
-
- const inputs = Array.isArray(input) ? input : [input];
- for (const i of inputs) {
- const normalized = normalizeCacheInput(i);
- const prefix = `${normalized}::`;
- Tempo.cache.delete(normalized);
- Tempo.cache.delete(i);
- Tempo.cache.deletePrefix(prefix);
-
- if (adapter) {
- try {
- if (adapter.delete) {
- await Promise.resolve(adapter.delete(normalized)).catch(() => { });
- await Promise.resolve(adapter.delete(i)).catch(() => { });
- }
- if (adapter.clear) {
- await Promise.resolve(adapter.clear(prefix)).catch(() => { });
- }
- } catch { }
- }
- }
-}
-
/**
* Retrieves the latest observed rate limits across all provider responses.
*
diff --git a/packages/plugins/ai/src/core/logger.ts b/packages/plugins/ai/src/core/logger.ts
new file mode 100644
index 00000000..342b7f0d
--- /dev/null
+++ b/packages/plugins/ai/src/core/logger.ts
@@ -0,0 +1,182 @@
+import { _state } from './init.js';
+
+export const CUSTOM_INSPECT_SYMBOL = Symbol.for('nodejs.util.inspect.custom');
+
+const RE_EMAIL = /[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g;
+const RE_PHONE = /(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?(\d{4})/g;
+const RE_BEARER = /Bearer\s+[A-Za-z0-9_\-\.]+/gi;
+const RE_API_KEY = /\b(?:sk-[a-zA-Z0-9_\-]{6,}|gsk_[a-zA-Z0-9_\-]{6,}|key-[a-zA-Z0-9_\-]{6,})\b/gi;
+
+/**
+ * Universal runtime environment detector.
+ * Safely checks if the execution context is in production mode.
+ */
+export function isProductionEnvironment(): boolean {
+ try {
+ if (typeof process === 'undefined' || !process?.env) return false;
+ const nodeEnv = (process.env.NODE_ENV ?? '').toLowerCase();
+ if (nodeEnv === 'production' || nodeEnv === 'prod' || nodeEnv === 'live') return true;
+ if (process.env.PROD === 'true' || process.env.PRODUCTION === 'true') return true;
+ return false;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Sanitizes and masks potential PII from strings for terminal/log output.
+ *
+ * In production mode:
+ * - Emails are masked (e.g. `j***@example.com`)
+ * - Phone numbers are masked (e.g. `***-***-5309`)
+ * - Bearer tokens and API keys are redacted
+ *
+ * In development mode:
+ * - Full fidelity text is preserved for local debugging.
+ */
+export function maskPii(input: string, isProd: boolean = isProductionEnvironment()): string {
+ if (typeof input !== 'string') return String(input);
+ if (!isProd) return input;
+
+ return input
+ .replace(RE_BEARER, match => {
+ const token = match.replace(/Bearer\s+/i, '');
+ if (token.length <= 8) return 'Bearer [REDACTED]';
+ return `Bearer ${token.slice(0, 4)}...${token.slice(-4)}`;
+ })
+ .replace(RE_API_KEY, match => {
+ if (match.length <= 8) return '[REDACTED_KEY]';
+ return `${match.slice(0, 5)}...${match.slice(-4)}`;
+ })
+ .replace(RE_EMAIL, match => {
+ const parts = match.split('@');
+ const name = parts[0] || '';
+ const domain = parts[1] || '';
+ return `${name.slice(0, 1)}***@${domain}`;
+ })
+ .replace(RE_PHONE, '***-***-$1');
+}
+
+/**
+ * Sanitizes arbitrary objects, arrays, or primitives for safe log printing.
+ */
+export function sanitizeForLog(data: any, isProd: boolean = isProductionEnvironment()): any {
+ if (data === null || data === undefined) return data;
+ if (!isProd) return data;
+
+ if (typeof data === 'string') {
+ const masked = maskPii(data, true);
+ if (masked.length > 256) {
+ return `${masked.slice(0, 200)}... [truncated, length: ${data.length}]`;
+ }
+ return masked;
+ }
+ if (typeof data === 'number' || typeof data === 'boolean') return data;
+
+ if (Array.isArray(data))
+ return data.map(item => sanitizeForLog(item, isProd));
+
+ if (typeof data === 'object') {
+ const result: Record = {};
+ for (const [key, val] of Object.entries(data)) {
+ const lowerKey = key.toLowerCase();
+ if (lowerKey.includes('key') || lowerKey.includes('secret') || lowerKey.includes('token') || lowerKey.includes('password') || lowerKey.includes('auth')) {
+ result[key] = '[REDACTED]';
+ } else if (key === 'rawPrompt' || key === 'normalizedPrompt' || key === 'prompt' || key === 'reasoning') {
+ result[key] = typeof val === 'string' ? maskPii(val, isProd) : val;
+ } else {
+ result[key] = sanitizeForLog(val, isProd);
+ }
+ }
+ return result;
+ }
+
+ return String(data);
+}
+
+/**
+ * Emits a sanitized debug log line if debugging is active.
+ *
+ * @param tag - Logging namespace / tag (e.g. 'tempo-plugin-ai:parse')
+ * @param message - Descriptive log message (automatically PII-masked)
+ * @param payload - Optional diagnostic metadata or payload
+ * @param options - Explicit debug override
+ */
+export function logDebug(
+ tag: string,
+ message: string,
+ payload?: any,
+ options?: { debug?: boolean | undefined },
+): void {
+ const shouldLog = options?.debug ?? _state.config.debug ?? false;
+ if (!shouldLog) return;
+
+ const isProd = isProductionEnvironment();
+ const sanitizedMsg = maskPii(message, isProd);
+ const prefix = tag.startsWith('[') ? tag : `[${tag}]`;
+
+ if (payload !== undefined) {
+ const sanitizedPayload = sanitizeForLog(payload, isProd);
+ console.log(`${prefix} ${sanitizedMsg}`, sanitizedPayload);
+ } else {
+ console.log(`${prefix} ${sanitizedMsg}`);
+ }
+}
+
+/**
+ * Emits a sanitized debug warning if debugging is active.
+ */
+export function warnDebug(
+ tag: string,
+ message: string,
+ error?: any,
+ options?: { debug?: boolean | undefined },
+): void {
+ const shouldLog = options?.debug ?? _state.config.debug ?? false;
+ if (!shouldLog) return;
+
+ const isProd = isProductionEnvironment();
+ const sanitizedMsg = maskPii(message, isProd);
+ const prefix = tag.startsWith('[') ? tag : `[${tag}]`;
+
+ if (error !== undefined) {
+ const sanitizedError = error instanceof Error ? error : (typeof error === 'string' ? maskPii(error, isProd) : sanitizeForLog(error, isProd));
+ console.warn(`${prefix} ${sanitizedMsg}:`, sanitizedError);
+ } else {
+ console.warn(`${prefix} ${sanitizedMsg}`);
+ }
+}
+
+/**
+ * Attaches custom inspection (`util.inspect.custom` and `toJSON`) hooks to an object
+ * to ensure that `console.log()` outputs a PII-sanitized summary in terminal/log aggregators
+ * without altering in-memory property access.
+ */
+export function attachCustomInspect(
+ target: T,
+ getInspectView: (obj: T, isProd: boolean) => Record,
+): T {
+ try {
+ Object.defineProperty(target, CUSTOM_INSPECT_SYMBOL, {
+ value: function () {
+ const isProd = isProductionEnvironment();
+ return getInspectView(target, isProd);
+ },
+ configurable: true,
+ enumerable: false,
+ writable: true,
+ });
+
+ Object.defineProperty(target, 'toJSON', {
+ value: function () {
+ const isProd = isProductionEnvironment();
+ return getInspectView(target, isProd);
+ },
+ configurable: true,
+ enumerable: false,
+ writable: true,
+ });
+ } catch { }
+
+ return target;
+}
diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts
index 7d24d5f7..a513689e 100644
--- a/packages/plugins/ai/src/core/support.ts
+++ b/packages/plugins/ai/src/core/support.ts
@@ -2,7 +2,8 @@ import { Tempo } from '@magmacomputing/tempo';
import { TempoAiError } from './error.js';
import { RESERVED_PROVIDER_IDS } from './config.js';
import { updateRateLimitsFromResponse, _state } from './init.js';
-import type { AiCacheAdapter, AiProvider, TempoParseAiMeta } from '../types/index.js';
+import { logDebug, attachCustomInspect, maskPii } from './logger.js';
+import type { AiProvider, TempoParseAiMeta } from '../types/index.js';
export function assertNoReservedProviderId(providers: Partial[]): void {
for (const p of providers) {
@@ -11,14 +12,6 @@ export function assertNoReservedProviderId(providers: Partial[]): vo
}
}
-export function normalizeCacheInput(input: string): string {
- return input.trim().toLowerCase().replace(/\s+/g, ' ');
-}
-
-export function getNamespacedCacheKey(namespace: string, key: string): string {
- return `ai:${namespace}::${key}`;
-}
-
export function resolveProviderTtl(
providerId: string,
availableProviders: AiProvider[],
@@ -47,68 +40,20 @@ export function resolveTzAndLocale(
return { tz, loc };
}
-export async function readMultiTierCache(
- cacheKey: string,
- options: {
- force?: boolean | undefined;
- cache?: boolean | undefined;
- cacheAdapter?: AiCacheAdapter | undefined;
- debug?: boolean | undefined;
- tag?: string | undefined;
- },
-): Promise {
- if (options.force) return undefined;
- if (options.cache === false || _state.config.cache === false) return undefined;
-
- const adapter = options.cacheAdapter || _state.config.cacheAdapter;
- if (adapter) {
- try {
- const val = await adapter.get(cacheKey);
- if (val !== undefined && val !== null) {
- if (options.debug) console.log(`[${options.tag ?? 'tempo-plugin-ai'}] Cache hit (adapter): ${cacheKey}`);
- return val;
- }
- } catch (err: any) {
- if (options.debug) console.warn(`[${options.tag ?? 'tempo-plugin-ai'}] Cache adapter get failed for ${cacheKey}:`, err?.message ?? err);
- }
- }
-
- const localVal = Tempo.cache.get(cacheKey);
- if (localVal) {
- if (options.debug) console.log(`[${options.tag ?? 'tempo-plugin-ai'}] Cache hit (local): ${cacheKey}`);
- return localVal;
- }
-
- return undefined;
-}
-
-export async function writeMultiTierCache(
- cacheKey: string,
- value: string,
- ttl: number,
- options: {
- cache?: boolean | undefined;
- cacheAdapter?: AiCacheAdapter | undefined;
- debug?: boolean | undefined;
- tag?: string | undefined;
- },
-): Promise {
- if (options.cache === false || _state.config.cache === false) return;
-
- Tempo.cache.set(cacheKey, value);
-
- const adapter = options.cacheAdapter || _state.config.cacheAdapter;
- if (adapter) {
- try {
- await adapter.set(cacheKey, value, ttl);
- } catch (err: any) {
- if (options.debug) console.warn(`[${options.tag ?? 'tempo-plugin-ai'}] Cache adapter set failed for ${cacheKey}:`, err?.message ?? err);
- }
- }
-}
-
export function attachAiMeta(instance: Tempo, meta: TempoParseAiMeta): Tempo {
- const frozenMeta = Object.freeze(meta);
+ const inspectableMeta = attachCustomInspect({ ...meta }, (obj, isProd) => ({
+ provider: obj.provider,
+ cached: obj.cached,
+ confidence: obj.confidence,
+ ambiguous: obj.ambiguous,
+ granularity: obj.granularity,
+ rawIso: obj.rawIso,
+ ...(obj.rawPrompt !== undefined ? { rawPrompt: maskPii(obj.rawPrompt, isProd) } : {}),
+ ...(obj.normalizedPrompt !== undefined ? { normalizedPrompt: maskPii(obj.normalizedPrompt, isProd) } : {}),
+ ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}),
+ ...(obj.limits ? { limits: obj.limits } : {}),
+ }));
+ const frozenMeta = Object.freeze(inspectableMeta);
const boundMethodCache = new Map();
return new Proxy(instance, {
@@ -202,8 +147,7 @@ Do not include markdown blocks or any text outside the JSON.`;
const systemPrompt = customSystemPrompt ?? defaultSystemPrompt;
- if (isDebug)
- console.log(`[tempo-plugin-ai] Querying provider '${provider.id}' (model: ${model})...`);
+ logDebug('tempo-plugin-ai', `Querying provider '${provider.id}' (model: ${model})...`, undefined, { debug: isDebug });
const tokenParam = provider.tokenParam
|| (provider.options?.max_completion_tokens !== undefined ? 'max_completion_tokens' : undefined)
@@ -265,7 +209,7 @@ Do not include markdown blocks or any text outside the JSON.`;
if (isDebug) {
const elapsed = Math.round(performance.now() - startTime);
- console.log(`[tempo-plugin-ai] Received response from '${provider.id}' in ${elapsed}ms`);
+ logDebug('tempo-plugin-ai', `Received response from '${provider.id}' in ${elapsed}ms`, undefined, { debug: isDebug });
}
return { rawContent: rawContent.trim(), providerId: provider.id, rateLimits: limits };
diff --git a/packages/plugins/ai/src/functions/context.ts b/packages/plugins/ai/src/functions/context.ts
index ac76cd29..1053fbfe 100644
--- a/packages/plugins/ai/src/functions/context.ts
+++ b/packages/plugins/ai/src/functions/context.ts
@@ -5,14 +5,17 @@ import { AiMode } from '../core/config.js';
import { _state } from '../core/init.js';
import { executeWithMode } from '../core/dispatch.js';
import {
- assertNoReservedProviderId,
- fetchFromProvider,
getNamespacedCacheKey,
normalizeCacheInput,
readMultiTierCache,
- resolveProviderTtl,
writeMultiTierCache,
+} from '../core/cache.js';
+import {
+ assertNoReservedProviderId,
+ fetchFromProvider,
+ resolveProviderTtl,
} from '../core/support.js';
+import { logDebug, warnDebug, attachCustomInspect, maskPii } from '../core/logger.js';
import { RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX } from '../core/patterns.js';
import type { TempoContext, AiContextOptions } from '../types/index.js';
@@ -49,8 +52,8 @@ async function contextSingleInput(text: string, options?: AiContextOptions): Pro
? parsedCache.confidence
: 1.0;
if (effectiveMinConfidence === undefined || cachedConfidence >= effectiveMinConfidence) {
- if (isDebug) console.log(`[tempo-plugin-ai:context] Cache hit: "${text}" -> ${cachedVal}`);
- return secure({
+ logDebug('tempo-plugin-ai:context', `Cache hit: "${text}" -> ${cachedVal}`, undefined, { debug: isDebug });
+ const cachedResult: TempoContext = {
timeZone: parsedCache.timeZone,
locale: parsedCache.locale,
calendar: parsedCache.calendar,
@@ -58,7 +61,17 @@ async function contextSingleInput(text: string, options?: AiContextOptions): Pro
confidence: cachedConfidence,
provider: 'cache',
reasoning: parsedCache.reasoning,
- });
+ };
+ attachCustomInspect(cachedResult, (obj, isProd) => ({
+ timeZone: obj.timeZone,
+ locale: obj.locale,
+ calendar: obj.calendar,
+ sphere: obj.sphere,
+ confidence: obj.confidence,
+ provider: obj.provider,
+ ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}),
+ }));
+ return secure(cachedResult);
}
}
} catch {
@@ -183,6 +196,16 @@ Do not include markdown blocks or text outside the JSON.`;
tag: 'tempo-plugin-ai:context',
});
+ attachCustomInspect(finalResult, (obj, isProd) => ({
+ timeZone: obj.timeZone,
+ locale: obj.locale,
+ calendar: obj.calendar,
+ sphere: obj.sphere,
+ confidence: obj.confidence,
+ provider: obj.provider,
+ ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}),
+ }));
+
return secure(finalResult);
}
diff --git a/packages/plugins/ai/src/functions/diff.ts b/packages/plugins/ai/src/functions/diff.ts
index 0cc0b880..080be3c3 100644
--- a/packages/plugins/ai/src/functions/diff.ts
+++ b/packages/plugins/ai/src/functions/diff.ts
@@ -5,14 +5,17 @@ import { AiMode } from '../core/config.js';
import { _state } from '../core/init.js';
import { executeWithMode } from '../core/dispatch.js';
import {
- assertNoReservedProviderId,
- fetchFromProvider,
getNamespacedCacheKey,
normalizeCacheInput,
readMultiTierCache,
- resolveProviderTtl,
writeMultiTierCache,
+} from '../core/cache.js';
+import {
+ assertNoReservedProviderId,
+ fetchFromProvider,
+ resolveProviderTtl,
} from '../core/support.js';
+import { logDebug, warnDebug, attachCustomInspect, maskPii } from '../core/logger.js';
import { RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX } from '../core/patterns.js';
import type { TempoAiDiffResult, AiDiffOptions, DiffPair } from '../types/index.js';
@@ -109,8 +112,8 @@ async function diffSingleInput(
? parsedCache.confidence
: 1.0;
if (effectiveMinConfidence === undefined || cachedConfidence >= effectiveMinConfidence) {
- if (isDebug) console.log(`[tempo-plugin-ai:diff] Cache hit: "${cacheKey}" -> ${cachedVal}`);
- return secure({
+ logDebug('tempo-plugin-ai:diff', `Cache hit: "${cacheKey}" -> ${cachedVal}`, undefined, { debug: isDebug });
+ const cachedResult: TempoAiDiffResult = {
formatted: parsedCache.formatted,
days: parsedCache.days ?? grounding.calendarDays,
hours: parsedCache.hours ?? grounding.elapsedHours,
@@ -119,7 +122,18 @@ async function diffSingleInput(
confidence: cachedConfidence,
provider: 'cache',
reasoning: parsedCache.reasoning,
- });
+ };
+ attachCustomInspect(cachedResult, (obj, isProd) => ({
+ formatted: obj.formatted,
+ days: obj.days,
+ hours: obj.hours,
+ businessDays: obj.businessDays,
+ ...(obj.holidays ? { holidays: obj.holidays } : {}),
+ confidence: obj.confidence,
+ provider: obj.provider,
+ ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}),
+ }));
+ return secure(cachedResult);
}
}
} catch {
@@ -207,7 +221,7 @@ Do not include markdown blocks or text outside the JSON.`;
rateLimits,
confidence,
consensusKey: `${formatted}::${businessDays}`,
- };
+ }
},
{ minConfidence: effectiveMinConfidence, debug: isDebug, tag: 'tempo-plugin-ai:diff', hedgeDelay: effectiveHedgeDelay },
);
@@ -249,6 +263,17 @@ Do not include markdown blocks or text outside the JSON.`;
tag: 'tempo-plugin-ai:diff',
});
+ attachCustomInspect(finalResult, (obj, isProd) => ({
+ formatted: obj.formatted,
+ days: obj.days,
+ hours: obj.hours,
+ businessDays: obj.businessDays,
+ ...(obj.holidays ? { holidays: obj.holidays } : {}),
+ confidence: obj.confidence,
+ provider: obj.provider,
+ ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}),
+ }));
+
return secure(finalResult);
}
diff --git a/packages/plugins/ai/src/functions/extract.ts b/packages/plugins/ai/src/functions/extract.ts
index 83011298..26b705ec 100644
--- a/packages/plugins/ai/src/functions/extract.ts
+++ b/packages/plugins/ai/src/functions/extract.ts
@@ -5,14 +5,17 @@ import { AiMode } from '../core/config.js';
import { _state } from '../core/init.js';
import { executeWithMode } from '../core/dispatch.js';
import {
- assertNoReservedProviderId,
- fetchFromProvider,
normalizeCacheInput,
readMultiTierCache,
+ writeMultiTierCache,
+} from '../core/cache.js';
+import {
+ assertNoReservedProviderId,
+ fetchFromProvider,
resolveProviderTtl,
resolveTzAndLocale,
- writeMultiTierCache,
} from '../core/support.js';
+import { logDebug, warnDebug, attachCustomInspect, maskPii } from '../core/logger.js';
import type {
AiExtractOptions,
TempoAiExtractResult,
@@ -47,7 +50,7 @@ async function extractSingleInput(
: new Tempo(anchor as any, { timeZone: tz }))
: new Tempo(Math.floor(Date.now() / 60_000) * 60_000, { timeZone: tz });
} catch (err: any) {
- throw new TempoAiError(`Invalid anchor date provided to extractAI: "${String(anchor)}"`, 400);
+ throw new TempoAiError(`Invalid anchor date provided to extractAI: "${String(anchor)}"`, 400, undefined, { cause: err });
}
if (!anchorTempo.isValid) {
@@ -105,38 +108,59 @@ async function extractSingleInput(
: 1.0;
if (effectiveMinConfidence !== undefined && cachedConfidence < effectiveMinConfidence) {
- if (isDebug) console.log(`[tempo-plugin-ai:extract] Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`);
+ logDebug('tempo-plugin-ai:extract', `Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`, undefined, { debug: isDebug });
} else {
const rehydratedEvents: TempoExtractedEvent[] = [];
+ const allowedTypes: TempoEventType[] = ['point', 'interval', 'deadline', 'recurrence', 'tentative'];
for (const ev of parsedCache.events) {
try {
const start = new Tempo(ev.start, { timeZone: tz, locale: loc, calendar: cal });
if (!start.isValid) continue;
const end = ev.end ? new Tempo(ev.end, { timeZone: tz, locale: loc, calendar: cal }) : undefined;
if (end && !end.isValid) continue;
+ const type: TempoEventType = allowedTypes.includes(ev.type) ? ev.type : 'point';
rehydratedEvents.push({
label: String(ev.label || 'Event'),
start,
end,
- type: ev.type || 'point',
+ type,
rawText: ev.rawText ? String(ev.rawText) : undefined,
confidence: typeof ev.confidence === 'number' && Number.isFinite(ev.confidence)
? Math.max(0.0, Math.min(1.0, ev.confidence))
: 1.0,
});
- } catch { }
+ } catch (err: any) {
+ warnDebug('tempo-plugin-ai:extract', 'Failed to rehydrate cached event', err, { debug: isDebug });
+ }
}
- return secure({
+ const reasoning = typeof parsedCache.reasoning === 'string' ? parsedCache.reasoning : undefined;
+ const cachedResult: TempoAiExtractResult = {
events: rehydratedEvents,
confidence: cachedConfidence,
provider: 'cache',
- reasoning: parsedCache.reasoning,
- });
+ reasoning,
+ }
+
+ attachCustomInspect(cachedResult, (obj, isProd) => ({
+ events: obj.events.map(e => ({
+ label: maskPii(e.label, isProd),
+ start: e.start?.toString(),
+ ...(e.end ? { end: e.end?.toString() } : {}),
+ type: e.type,
+ ...(e.rawText ? { rawText: maskPii(e.rawText, isProd) } : {}),
+ confidence: e.confidence,
+ })),
+ confidence: obj.confidence,
+ provider: obj.provider,
+ ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}),
+ }));
+
+ return secure(cachedResult);
}
}
} catch (err: any) {
- if (isDebug) console.warn(`[tempo-plugin-ai:extract] Failed to parse cached payload:`, err?.message ?? err);
+ warnDebug('tempo-plugin-ai:extract', 'Failed to parse cached payload', err, { debug: isDebug });
}
}
@@ -261,7 +285,9 @@ ${region ? `- Region Context: ${region}\n` : ''}${categories.length > 0 ? `- Fil
rawText,
confidence: itemConf,
});
- } catch { }
+ } catch (err: any) {
+ warnDebug('tempo-plugin-ai:extract', `Failed to parse event from provider '${providerId}'`, err, { debug: isDebug });
+ }
}
return {
@@ -313,6 +339,20 @@ ${region ? `- Region Context: ${region}\n` : ''}${categories.length > 0 ? `- Fil
tag: 'tempo-plugin-ai:extract',
});
+ attachCustomInspect(finalResult, (obj, isProd) => ({
+ events: obj.events.map(e => ({
+ label: maskPii(e.label, isProd),
+ start: e.start?.toString(),
+ ...(e.end ? { end: e.end?.toString() } : {}),
+ type: e.type,
+ ...(e.rawText ? { rawText: maskPii(e.rawText, isProd) } : {}),
+ confidence: e.confidence,
+ })),
+ confidence: obj.confidence,
+ provider: obj.provider,
+ ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}),
+ }));
+
return secure(finalResult);
}
@@ -341,25 +381,47 @@ export async function extractAI(
options?: AiExtractOptions,
): Promise {
if (Array.isArray(textOrTexts)) {
+ if (textOrTexts.length === 0) return [];
const opts = options || {};
const softErrors = opts.softErrors ?? false;
+ const concurrencyLimit = Math.max(1, Math.min(opts.concurrency ?? 4, textOrTexts.length));
- if (softErrors) {
- const settled = await Promise.allSettled(
- textOrTexts.map(t => extractSingleInput(t, opts)),
- );
- return settled.map((res, index) => {
- if (res.status === 'fulfilled') return res.value;
- const rawReason = res.reason;
- if (rawReason instanceof TempoAiError) return rawReason;
- return new TempoAiError(
- rawReason?.message || `Failed to extract events at index ${index}`,
- typeof rawReason?.status === 'number' ? rawReason.status : 500,
- );
- });
+ const results: (TempoAiExtractResult | TempoAiError)[] = new Array(textOrTexts.length);
+ let nextIdx = 0;
+ let firstError: any = null;
+
+ const worker = async () => {
+ while (nextIdx < textOrTexts.length) {
+ if (!softErrors && firstError) break;
+ const currentIndex = nextIdx++;
+ const item = textOrTexts[currentIndex];
+ try {
+ const res = await extractSingleInput(item, opts);
+ results[currentIndex] = res;
+ } catch (err: any) {
+ if (softErrors) {
+ results[currentIndex] = err instanceof TempoAiError
+ ? err
+ : new TempoAiError(
+ err?.message || `Failed to extract events at index ${currentIndex}`,
+ typeof err?.status === 'number' ? err.status : 500,
+ );
+ } else {
+ if (!firstError) firstError = err;
+ break;
+ }
+ }
+ }
+ };
+
+ const workers = Array.from({ length: concurrencyLimit }, () => worker());
+ await Promise.all(workers);
+
+ if (!softErrors && firstError) {
+ throw firstError;
}
- return Promise.all(textOrTexts.map(t => extractSingleInput(t, opts)));
+ return results;
}
return extractSingleInput(textOrTexts, options);
diff --git a/packages/plugins/ai/src/functions/format.ts b/packages/plugins/ai/src/functions/format.ts
index c4505441..24bb1096 100644
--- a/packages/plugins/ai/src/functions/format.ts
+++ b/packages/plugins/ai/src/functions/format.ts
@@ -5,14 +5,17 @@ import { AiMode } from '../core/config.js';
import { _state } from '../core/init.js';
import { executeWithMode } from '../core/dispatch.js';
import {
- assertNoReservedProviderId,
- fetchFromProvider,
normalizeCacheInput,
readMultiTierCache,
+ writeMultiTierCache,
+} from '../core/cache.js';
+import {
+ assertNoReservedProviderId,
+ fetchFromProvider,
resolveProviderTtl,
resolveTzAndLocale,
- writeMultiTierCache,
} from '../core/support.js';
+import { logDebug, warnDebug, attachCustomInspect, maskPii } from '../core/logger.js';
import type { AiFormatOptions, FormatItem, TempoAiFormatResult, TempoDateInput } from '../types/format.type.js';
export type { AiFormatOptions, FormatItem, TempoAiFormatResult, TempoDateInput };
@@ -148,19 +151,26 @@ async function formatSingleInput(
: 1.0;
if (effectiveMinConfidence !== undefined && cachedConfidence < effectiveMinConfidence) {
- if (isDebug) console.log(`[tempo-plugin-ai:format] Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`);
+ logDebug('tempo-plugin-ai:format', `Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`, undefined, { debug: isDebug });
} else {
const reasoning = typeof parsedCache?.reasoning === 'string' ? parsedCache.reasoning : undefined;
- return secure({
+ const cachedResult: TempoAiFormatResult = {
formatted: parsedCache.formatted,
confidence: cachedConfidence,
provider: 'cache',
reasoning,
- });
+ }
+ attachCustomInspect(cachedResult, (obj, isProd) => ({
+ formatted: obj.formatted,
+ confidence: obj.confidence,
+ provider: obj.provider,
+ ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}),
+ }));
+ return secure(cachedResult);
}
}
} catch (err: any) {
- if (isDebug) console.warn(`[tempo-plugin-ai:format] Failed to parse cached payload:`, err?.message ?? err);
+ warnDebug('tempo-plugin-ai:format', 'Failed to parse cached payload', err, { debug: isDebug });
}
}
@@ -279,6 +289,13 @@ Output JSON Schema:
tag: 'tempo-plugin-ai:format',
});
+ attachCustomInspect(finalResult, (obj, isProd) => ({
+ formatted: obj.formatted,
+ confidence: obj.confidence,
+ provider: obj.provider,
+ ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}),
+ }));
+
return secure(finalResult);
}
diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts
index b0473ea8..4cff2b60 100644
--- a/packages/plugins/ai/src/functions/parse.ts
+++ b/packages/plugins/ai/src/functions/parse.ts
@@ -3,7 +3,9 @@ import { TempoAiError } from '../core/error.js';
import { AiMode } from '../core/config.js';
import { _state } from '../core/init.js';
import { executeWithMode } from '../core/dispatch.js';
-import { normalizeCacheInput, attachAiMeta, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js';
+import { normalizeCacheInput } from '../core/cache.js';
+import { attachAiMeta, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js';
+import { logDebug, warnDebug } from '../core/logger.js';
import { RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX, RE_ISO_DATE_PREFIX, RE_ISO_Z_SUFFIX } from '../core/patterns.js';
import type { AiParseOptions } from '../types/index.js';
@@ -65,7 +67,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise<
cachedIso = val;
}
} catch (err: any) {
- if (isDebug) console.log('[tempo-plugin-ai] Cache adapter read error:', err?.message);
+ warnDebug('tempo-plugin-ai:parse', 'Cache adapter read error', err?.message, { debug: isDebug });
}
}
@@ -73,7 +75,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise<
}
if (cachedIso) {
- if (isDebug) console.log(`[tempo-plugin-ai] Cache hit: "${str}" -> ${cachedIso}`);
+ logDebug('tempo-plugin-ai:parse', `Cache hit: "${str}" -> ${cachedIso}`, undefined, { debug: isDebug });
const cachedInstance = new Tempo(cachedIso, tempoConfig);
return attachAiMeta(cachedInstance, {
provider: 'cache',
@@ -96,7 +98,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise<
|| native.isValid;
if (native.isValid && hasNativeMatches) {
- if (isDebug) console.log(`[tempo-plugin-ai] Resolved natively: "${str}"`);
+ logDebug('tempo-plugin-ai:parse', `Resolved natively: "${str}"`, undefined, { debug: isDebug });
return attachAiMeta(native, {
provider: 'native',
cached: false,
@@ -195,7 +197,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise<
const res = adapter.set(cacheKey, parsedIso, resolvedTtl);
if (res instanceof Promise) await res;
} catch (err: any) {
- if (isDebug) console.log('[tempo-plugin-ai] Cache adapter write error:', err?.message);
+ warnDebug('tempo-plugin-ai:parse', 'Cache adapter write error', err?.message, { debug: isDebug });
}
}
Tempo.cache.set(cacheKey, parsedIso);
diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts
index 1bfb84a8..4664c62d 100644
--- a/packages/plugins/ai/src/functions/recurrence.ts
+++ b/packages/plugins/ai/src/functions/recurrence.ts
@@ -5,6 +5,7 @@ import { AiMode } from '../core/config.js';
import { _state } from '../core/init.js';
import { executeWithMode } from '../core/dispatch.js';
import { fetchFromProvider, assertNoReservedProviderId } from '../core/support.js';
+import { logDebug, attachCustomInspect, maskPii } from '../core/logger.js';
import { RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX, RE_RRULE_PREFIX } from '../core/patterns.js';
import type { TempoRecurrenceOptions, TempoRecurrenceResult } from '../types/index.js';
@@ -89,7 +90,7 @@ function createRecurrenceResult(
}
}
- return {
+ const result: TempoRecurrenceResult = {
rrule: rruleStr,
summary: summaryText,
isFinite,
@@ -99,7 +100,19 @@ function createRecurrenceResult(
confidence,
provider: providerId,
reasoning
- };
+ }
+
+ attachCustomInspect(result, (obj, isProd) => ({
+ rrule: obj.rrule,
+ summary: maskPii(obj.summary, isProd),
+ isFinite: obj.isFinite,
+ size: obj.size,
+ confidence: obj.confidence,
+ provider: obj.provider,
+ ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}),
+ }));
+
+ return result;
}
/**
@@ -128,8 +141,7 @@ export async function recurrenceAI(
if (isRRule) {
const cleanRRule = input.trim().replace(RE_RRULE_PREFIX, '');
- if (isDebug)
- console.log(`[tempo-plugin-ai:recurrence] Detected raw RRULE string: "${cleanRRule}"`);
+ logDebug('tempo-plugin-ai:recurrence', `Detected raw RRULE string: "${cleanRRule}"`, undefined, { debug: isDebug });
return createRecurrenceResult(
cleanRRule,
diff --git a/packages/plugins/ai/src/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts
index aed395c0..9a3ee76d 100644
--- a/packages/plugins/ai/src/functions/schedule.ts
+++ b/packages/plugins/ai/src/functions/schedule.ts
@@ -5,6 +5,7 @@ import { AiMode } from '../core/config.js';
import { _state } from '../core/init.js';
import { executeWithMode } from '../core/dispatch.js';
import { fetchFromProvider, assertNoReservedProviderId } from '../core/support.js';
+import { CUSTOM_INSPECT_SYMBOL, isProductionEnvironment, maskPii, attachCustomInspect } from '../core/logger.js';
import { RE_DURATION_MINUTES, RE_DURATION_HOURS, RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX, RE_ISO_WEEKDAY_DIGIT } from '../core/patterns.js';
import type { TempoScheduleOptions, TempoScheduleResult, TempoWorkingHours, TempoInterval, TempoScheduleMeta, AiProvider } from '../types/index.js';
@@ -110,36 +111,67 @@ Instructions:
"alternatives": array of secondary { "start": "...", "end": "..." } options if available`;
function wrapScheduleInterval(interval: Interval, meta: TempoScheduleMeta): TempoScheduleResult {
- const frozenMeta = Object.freeze(meta);
+ const inspectableMeta = attachCustomInspect({ ...meta }, (obj, isProd) => ({
+ start: interval.start?.toString(),
+ end: interval.end?.toString(),
+ durationMinutes: obj.durationMinutes,
+ summary: maskPii(obj.summary, isProd),
+ confidence: obj.confidence,
+ provider: obj.provider,
+ ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}),
+ ...(obj.ai ? {
+ ai: {
+ provider: obj.ai.provider,
+ confidence: obj.ai.confidence,
+ cached: obj.ai.cached,
+ conflictBumped: obj.ai.conflictBumped,
+ ...(obj.ai.reasoning !== undefined ? { reasoning: maskPii(obj.ai.reasoning, isProd) } : {}),
+ },
+ } : {}),
+ }));
+
const boundMethodCache = new Map();
+ const carrier = Object.create(interval);
+ Object.assign(carrier, inspectableMeta);
+ attachCustomInspect(carrier, (_obj, isProd) => {
+ const inspectFn = (inspectableMeta as any)[CUSTOM_INSPECT_SYMBOL];
+ return typeof inspectFn === 'function' ? inspectFn() : inspectableMeta;
+ });
- return new Proxy(interval, {
+ return new Proxy(carrier, {
get(target, prop) {
- if (Object.hasOwn(frozenMeta, prop))
- return (frozenMeta as any)[prop];
+ if (prop === CUSTOM_INSPECT_SYMBOL)
+ return (inspectableMeta as any)[CUSTOM_INSPECT_SYMBOL];
+
+ if (prop === 'toJSON')
+ return (inspectableMeta as any).toJSON;
+
+ if (Object.hasOwn(inspectableMeta, prop))
+ return (inspectableMeta as any)[prop];
if (prop === 'constructor')
- return Reflect.get(target, prop, target);
+ return Interval;
if (boundMethodCache.has(prop))
return boundMethodCache.get(prop);
- const val = Reflect.get(target, prop, target);
+ const val = Reflect.get(interval, prop, interval);
if (isFunction(val)) {
- const bound = val.bind(target);
+ const bound = val.bind(interval);
boundMethodCache.set(prop, bound);
return bound;
}
return val;
},
has(target, prop) {
- if (Object.hasOwn(frozenMeta, prop)) return true;
- return Reflect.has(target, prop);
+ if (prop === CUSTOM_INSPECT_SYMBOL || prop === 'toJSON') return true;
+ if (Object.hasOwn(inspectableMeta, prop)) return true;
+ return Reflect.has(interval, prop);
},
getOwnPropertyDescriptor(target, prop) {
- if (Object.hasOwn(frozenMeta, prop)) {
+ if (Object.hasOwn(inspectableMeta, prop)) {
return {
- value: (frozenMeta as any)[prop],
+ value: (inspectableMeta as any)[prop],
writable: false,
configurable: true,
enumerable: true,
@@ -149,7 +181,7 @@ function wrapScheduleInterval(interval: Interval, meta: TempoScheduleMeta
},
ownKeys(target) {
const keys = Reflect.ownKeys(target);
- for (const k of Object.keys(frozenMeta)) {
+ for (const k of Object.keys(inspectableMeta)) {
if (!keys.includes(k)) keys.push(k);
}
return keys;
diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts
index 1a42b67a..b8d79ed8 100644
--- a/packages/plugins/ai/src/index.ts
+++ b/packages/plugins/ai/src/index.ts
@@ -6,8 +6,11 @@ export * from './core/config.js';
// AI Manifest Support
export { loadRemoteManifest, resetManifestCache, DEFAULT_REMOTE_MANIFEST_URL } from './core/manifest.js';
+// AI Cache Manager
+export { aiCache } from './core/cache.js';
+
// AI Core Functions
-export { initAI, resetAI, clearAiCache, getAiRateLimits, getAiProviderRateLimits, getAiConfig } from './core/init.js';
+export { initAI, resetAI, getAiRateLimits, getAiProviderRateLimits, getAiConfig } from './core/init.js';
// AI Function Handlers
export { parseAI } from './functions/parse.js';
diff --git a/packages/plugins/ai/src/types/extract.type.ts b/packages/plugins/ai/src/types/extract.type.ts
index 0547c22f..5d0df35b 100644
--- a/packages/plugins/ai/src/types/extract.type.ts
+++ b/packages/plugins/ai/src/types/extract.type.ts
@@ -41,4 +41,6 @@ export interface TempoAiExtractResult extends TempoBaseAiResult {
export interface AiExtractOptions extends AiDateContextOptions {
/** Optional category filters to guide event identification (e.g. ['meeting', 'deadline']) */
categories?: string[] | undefined;
+ /** Optional maximum number of concurrent extraction requests when processing arrays (default: 4) */
+ concurrency?: number | undefined;
}
diff --git a/packages/plugins/ai/src/types/recurrence.type.ts b/packages/plugins/ai/src/types/recurrence.type.ts
index e53ad281..d6c0d428 100644
--- a/packages/plugins/ai/src/types/recurrence.type.ts
+++ b/packages/plugins/ai/src/types/recurrence.type.ts
@@ -14,7 +14,7 @@ export interface TempoRecurrenceOptions extends AiParseOptions {
/** Number of occurrences to pull per batch (default: 5) */
count?: number | undefined;
/** Preferred BCP 47 locale tag for summary output (e.g. 'en-US', 'fr-FR', 'es-ES') */
- locale?: string | undefined;
+ locale?: string | string[] | undefined;
}
/**
diff --git a/packages/plugins/ai/test/benchmark.spec.ts b/packages/plugins/ai/test/benchmark.spec.ts
index 4f1d1e40..75a8273c 100644
--- a/packages/plugins/ai/test/benchmark.spec.ts
+++ b/packages/plugins/ai/test/benchmark.spec.ts
@@ -1,4 +1,4 @@
-import { normalizeCacheInput, getNamespacedCacheKey } from '../src/core/support.js';
+import { normalizeCacheInput, getNamespacedCacheKey } from '../src/core/cache.js';
describe('AI Support Helpers Benchmark & Integrity', () => {
it('should normalize cache input string whitespace and case', () => {
diff --git a/packages/plugins/ai/test/cache.test.ts b/packages/plugins/ai/test/cache.test.ts
index 0790fdb1..ab9daa7f 100644
--- a/packages/plugins/ai/test/cache.test.ts
+++ b/packages/plugins/ai/test/cache.test.ts
@@ -1,4 +1,4 @@
-import { parseAI, initAI, clearAiCache, type AiCacheAdapter } from '../src/index.js';
+import { parseAI, initAI, aiCache, type AiCacheAdapter } from '../src/index.js';
import { Tempo } from '@magmacomputing/tempo';
describe('Advanced Cache TTL & Async Storage Adapters', () => {
@@ -122,7 +122,7 @@ describe('Advanced Cache TTL & Async Storage Adapters', () => {
expect(result.ai?.provider).toBe('groq');
});
- it('should clear custom cacheAdapter entries when clearAiCache is invoked', async () => {
+ it('should clear custom cacheAdapter entries when aiCache.clear is invoked', async () => {
const mockAdapter: AiCacheAdapter = {
get: vi.fn(),
set: vi.fn(),
@@ -135,12 +135,68 @@ describe('Advanced Cache TTL & Async Storage Adapters', () => {
cacheAdapter: mockAdapter,
});
- await clearAiCache('Easter 2026');
+ await aiCache.clear('Easter 2026');
expect(mockAdapter.delete).toHaveBeenCalledWith('easter 2026');
expect(mockAdapter.delete).toHaveBeenCalledWith('Easter 2026');
expect(mockAdapter.clear).toHaveBeenCalledWith('easter 2026::');
- await clearAiCache();
+ await aiCache.clear();
expect(mockAdapter.clear).toHaveBeenCalledTimes(2);
});
+
+ it('should protect aiCache object from direct property mutation via secure()', () => {
+ expect(() => {
+ (aiCache as any).clear = null;
+ }).toThrow();
+
+ expect(() => {
+ (aiCache as any).newProp = 'tampered';
+ }).toThrow();
+
+ expect(() => {
+ delete (aiCache as any).clear;
+ }).toThrow();
+ });
+
+ it('should support store methods on aiCache (set, get, has, delete, clear, entries, toJSON)', async () => {
+ const store = new Map();
+ const mockAdapter: AiCacheAdapter = {
+ get: vi.fn(async (key: string) => store.get(key)),
+ set: vi.fn(async (key: string, val: string) => { store.set(key, val); }),
+ delete: vi.fn(async (key: string) => { store.delete(key); }),
+ clear: vi.fn(async () => { store.clear(); }),
+ };
+
+ await initAI({
+ remoteConfigUrl: false,
+ cacheAdapter: mockAdapter,
+ });
+
+ await aiCache.set('custom-key', 'custom-value', 5000);
+ expect(mockAdapter.set).toHaveBeenCalledWith('custom-key', 'custom-value', 5000);
+
+ const hasKey = await aiCache.has('custom-key');
+ expect(hasKey).toBe(true);
+
+ const val = await aiCache.get('custom-key');
+ expect(val).toBe('custom-value');
+
+ const deleted = await aiCache.delete('custom-key');
+ expect(deleted).toBe(true);
+ expect(mockAdapter.delete).toHaveBeenCalledWith('custom-key');
+
+ const hasAfterDelete = await aiCache.has('custom-key');
+ expect(hasAfterDelete).toBe(false);
+
+ await aiCache.set('key-a', 'val-a');
+ const json = aiCache.toJSON();
+ expect(json['key-a']).toBe('val-a');
+
+ const entries = Array.from(aiCache.entries());
+ expect(entries.some(([k, v]) => k === 'key-a' && v === 'val-a')).toBe(true);
+
+ await aiCache.clear();
+ expect(mockAdapter.clear).toHaveBeenCalled();
+ expect(await aiCache.has('key-a')).toBe(false);
+ });
});
diff --git a/packages/plugins/ai/test/debug.test.ts b/packages/plugins/ai/test/debug.test.ts
new file mode 100644
index 00000000..ca39417c
--- /dev/null
+++ b/packages/plugins/ai/test/debug.test.ts
@@ -0,0 +1,334 @@
+import util from 'node:util';
+import { Tempo } from '@magmacomputing/tempo';
+import {
+ initAI,
+ resetAI,
+ parseAI,
+ formatAI,
+ extractAI,
+ diffAI,
+ contextAI,
+ scheduleAI,
+ recurrenceAI,
+} from '../src/index.js';
+import { maskPii, sanitizeForLog, logDebug, warnDebug, attachCustomInspect } from '../src/core/logger.js';
+
+describe('Smart Debug & PII Protection Infrastructure', () => {
+ const originalEnv = process.env.NODE_ENV;
+
+ beforeEach(async () => {
+ resetAI();
+ Tempo.cache.clear();
+ process.env.NODE_ENV = 'test';
+ await initAI({
+ remoteConfigUrl: false,
+ providers: [{ id: 'groq', key: 'gsk-1234567890abcdef1234567890' }],
+ });
+ });
+
+ afterEach(() => {
+ process.env.NODE_ENV = originalEnv;
+ resetAI();
+ Tempo.cache.clear();
+ vi.restoreAllMocks();
+ });
+
+ describe('maskPii utility', () => {
+ it('should preserve full text when isProd is false (development mode)', () => {
+ const raw = 'Contact user john.smith@company.org or call +1-555-867-5309 with Bearer sk-ant-secret12345';
+ const masked = maskPii(raw, false);
+ expect(masked).toBe(raw);
+ });
+
+ it('should mask emails, phone numbers, and bearer tokens when isProd is true', () => {
+ const raw = 'Reach out to support@magma.com or sales.desk@domain.co.uk';
+ const masked = maskPii(raw, true);
+ expect(masked).not.toContain('support@magma.com');
+ expect(masked).not.toContain('sales.desk@domain.co.uk');
+ expect(masked).toContain('s***@magma.com');
+ expect(masked).toContain('s***@domain.co.uk');
+ });
+
+ it('should mask phone numbers in production', () => {
+ const raw = 'Direct line: +1-555-867-5309 or 555-123-4567';
+ const masked = maskPii(raw, true);
+ expect(masked).toContain('***-***-5309');
+ expect(masked).toContain('***-***-4567');
+ });
+
+ it('should mask API keys and bearer tokens in production', () => {
+ const raw = 'Authorization: Bearer gsk_99887766554433221100 and key sk-proj-1234567890abcdef1234';
+ const masked = maskPii(raw, true);
+ expect(masked).toContain('Bearer gsk_...1100');
+ expect(masked).toContain('sk-pr...1234');
+ });
+
+ it('should recognize production environment aliases (prod, live, PROD=true)', () => {
+ process.env.NODE_ENV = 'prod';
+ expect(maskPii('email test@corp.com')).toContain('t***@corp.com');
+
+ process.env.NODE_ENV = 'live';
+ expect(maskPii('email test@corp.com')).toContain('t***@corp.com');
+
+ process.env.NODE_ENV = 'development';
+ process.env.PROD = 'true';
+ expect(maskPii('email test@corp.com')).toContain('t***@corp.com');
+ delete process.env.PROD;
+ });
+ });
+
+ describe('sanitizeForLog utility', () => {
+ it('should truncate strings exceeding 256 characters in production', () => {
+ const longStr = 'A'.repeat(400);
+ const sanitizedProd = sanitizeForLog(longStr, true);
+ expect(typeof sanitizedProd).toBe('string');
+ expect((sanitizedProd as string).length).toBeLessThan(400);
+ expect((sanitizedProd as string)).toContain('... [truncated');
+
+ const sanitizedDev = sanitizeForLog(longStr, false);
+ expect(sanitizedDev).toBe(longStr);
+ });
+
+ it('should recursively sanitize objects and mask sensitive values in production', () => {
+ const payload = {
+ user: 'alice@example.com',
+ details: {
+ phone: '555-987-6543',
+ notes: 'Regular note',
+ },
+ tags: ['confidential: Bearer secret-token-123456'],
+ };
+
+ const sanitized = sanitizeForLog(payload, true) as any;
+ expect(sanitized.user).toBe('a***@example.com');
+ expect(sanitized.details.phone).toBe('***-***-6543');
+ expect(sanitized.tags[0]).toContain('Bearer secr...3456');
+ });
+ });
+
+ describe('attachCustomInspect & Proxy Introspection', () => {
+ it('should redact inspect/JSON output while maintaining 100% in-memory data integrity', () => {
+ const rawMeta = {
+ rawPrompt: 'Meeting with ceo@acme.com on next Friday',
+ reasoning: 'Parsed meeting request for next Friday',
+ confidence: 0.95,
+ };
+
+ const inspectable = attachCustomInspect(rawMeta, (obj, isProd) => ({
+ confidence: obj.confidence,
+ rawPrompt: maskPii(obj.rawPrompt, isProd),
+ reasoning: maskPii(obj.reasoning, isProd),
+ }));
+
+ // In-memory data is completely unredacted
+ expect(inspectable.rawPrompt).toBe('Meeting with ceo@acme.com on next Friday');
+ expect(inspectable.reasoning).toBe('Parsed meeting request for next Friday');
+
+ // Custom inspect in production
+ process.env.NODE_ENV = 'production';
+ const inspectCustomSymbol = Symbol.for('nodejs.util.inspect.custom');
+ const inspectFn = (inspectable as any)[inspectCustomSymbol];
+ expect(typeof inspectFn).toBe('function');
+
+ const inspectedProd = inspectFn();
+ expect(inspectedProd.rawPrompt).toContain('c***@acme.com');
+ expect(inspectedProd.rawPrompt).not.toContain('ceo@acme.com');
+
+ const jsonProd = (inspectable as any).toJSON();
+ expect(jsonProd.rawPrompt).toContain('c***@acme.com');
+
+ // In-memory data still untouched
+ expect(inspectable.rawPrompt).toBe('Meeting with ceo@acme.com on next Friday');
+
+ // Node.js util.inspect integration
+ const terminalOutput = util.inspect(inspectable);
+ expect(terminalOutput).toContain('c***@acme.com');
+ expect(terminalOutput).not.toContain('ceo@acme.com');
+ });
+ });
+
+ describe('Smart Logger (logDebug / warnDebug)', () => {
+ it('should only log when debug flag is active', () => {
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ logDebug('test-tag', 'Message 1', undefined, { debug: false });
+ expect(logSpy).not.toHaveBeenCalled();
+
+ logDebug('test-tag', 'Message 2', undefined, { debug: true });
+ expect(logSpy).toHaveBeenCalledTimes(1);
+ expect(logSpy.mock.calls[0][0]).toContain('[test-tag] Message 2');
+
+ warnDebug('test-tag', 'Warning 1', new Error('Err'), { debug: false });
+ expect(warnSpy).not.toHaveBeenCalled();
+
+ warnDebug('test-tag', 'Warning 2', new Error('Err'), { debug: true });
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should sanitize PII in console.log when in production environment', () => {
+ process.env.NODE_ENV = 'production';
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+
+ logDebug('test-tag', 'User email is sensitive@corp.com with token Bearer sk-1234567890', undefined, { debug: true });
+ expect(logSpy).toHaveBeenCalledTimes(1);
+ const loggedMsg = logSpy.mock.calls[0][0];
+ expect(loggedMsg).not.toContain('sensitive@corp.com');
+ expect(loggedMsg).toContain('s***@corp.com');
+ expect(loggedMsg).toContain('Bearer sk-1...7890');
+ });
+ });
+
+ describe('End-to-End AI Function Inspect Hardening', () => {
+ it('should protect parseAI returned Tempo instance metadata', async () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ iso: '2026-08-15T10:00:00Z',
+ confidence: 0.99,
+ reasoning: 'User john.doe@example.com requested next Saturday',
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const target = await parseAI('Meeting with john.doe@example.com next Saturday', { debug: true });
+ expect(target.isValid).toBe(true);
+
+ // In-memory access is 100% full fidelity
+ expect(target.ai?.rawPrompt).toBe('Meeting with john.doe@example.com next Saturday');
+ expect(target.ai?.reasoning).toBe('User john.doe@example.com requested next Saturday');
+
+ // In production, util.inspect output masks PII
+ process.env.NODE_ENV = 'production';
+ const terminalLog = util.inspect(target.ai);
+ expect(terminalLog).toContain('j***@example.com');
+ expect(terminalLog).not.toContain('john.doe@example.com');
+ });
+
+ it('should protect formatAI result object inspection', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: 'Saturday morning at 10:00 AM',
+ confidence: 0.95,
+ reasoning: 'Formatted for client alice.smith@partner.org with note call 555-123-4567',
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const res = await formatAI(new Tempo('2026-08-15T10:00:00Z'), 'friendly tone');
+ expect(res.formatted).toBe('Saturday morning at 10:00 AM');
+ expect(res.reasoning).toContain('alice.smith@partner.org');
+
+ // Under production inspect
+ process.env.NODE_ENV = 'production';
+ const inspected = util.inspect(res);
+ expect(inspected).toContain('a***@partner.org');
+ expect(inspected).not.toContain('alice.smith@partner.org');
+ expect(inspected).toContain('***-***-4567');
+ });
+
+ it('should protect extractAI result object inspection', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ events: [{
+ label: 'Interview with candidate bob.ross@art.com',
+ start: '2026-08-15T14:00:00Z',
+ type: 'point',
+ rawText: 'Interview bob.ross@art.com (555-888-9999) at 2pm',
+ }],
+ confidence: 0.98,
+ reasoning: 'Extracted single candidate event',
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const res = await extractAI('Interview bob.ross@art.com (555-888-9999) at 2pm');
+ expect(res.events[0].label).toBe('Interview with candidate bob.ross@art.com');
+ expect(res.events[0].rawText).toContain('555-888-9999');
+
+ // Under production inspect
+ process.env.NODE_ENV = 'production';
+ const inspected = util.inspect(res);
+ expect(inspected).toContain('b***@art.com');
+ expect(inspected).not.toContain('bob.ross@art.com');
+ expect(inspected).toContain('***-***-9999');
+ });
+
+ it('should protect diffAI result object inspection', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ formatted: '3 business days',
+ confidence: 0.95,
+ reasoning: 'Calculated for ticket user#42 (urgent contact 555-333-2222)',
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const res = await diffAI(new Tempo('2026-08-10T09:00:00Z'), new Tempo('2026-08-13T09:00:00Z'), 'in business days');
+ expect(res.reasoning).toContain('555-333-2222');
+
+ process.env.NODE_ENV = 'production';
+ const inspected = util.inspect(res);
+ expect(inspected).toContain('***-***-2222');
+ expect(inspected).not.toContain('555-333-2222');
+ });
+
+ it('should protect scheduleAI result object inspection', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ start: '2026-08-17T10:00:00Z',
+ end: '2026-08-17T11:00:00Z',
+ summary: 'Meeting with client client@enterprise.com',
+ reasoning: 'Found available 1hr slot for client@enterprise.com',
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const res = await scheduleAI('Schedule 1 hour with client@enterprise.com next Monday');
+ expect(res.summary).toBe('Meeting with client client@enterprise.com');
+
+ process.env.NODE_ENV = 'production';
+ const inspected = util.inspect(res);
+ expect(inspected).toContain('c***@enterprise.com');
+ expect(inspected).not.toContain('client@enterprise.com');
+ });
+
+ it('should protect recurrenceAI result object inspection', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ rrule: 'FREQ=WEEKLY;BYDAY=TU;BYHOUR=15',
+ summary: 'Weekly sync with dev-team@internal.org at 3pm',
+ reasoning: 'Configured recurring meeting for dev-team@internal.org',
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const res = await recurrenceAI('Every Tuesday at 3pm for dev-team@internal.org');
+ expect(res.summary).toBe('Weekly sync with dev-team@internal.org at 3pm');
+
+ process.env.NODE_ENV = 'production';
+ const inspected = util.inspect(res);
+ expect(inspected).toContain('d***@internal.org');
+ expect(inspected).not.toContain('dev-team@internal.org');
+ });
+ });
+});
diff --git a/packages/plugins/ai/test/extract.test.ts b/packages/plugins/ai/test/extract.test.ts
index cfa326bf..ef3afba7 100644
--- a/packages/plugins/ai/test/extract.test.ts
+++ b/packages/plugins/ai/test/extract.test.ts
@@ -12,6 +12,7 @@ import {
describe('AI Extract Plugin (extractAI)', () => {
beforeEach(async () => {
resetAI();
+ Tempo.cache.clear();
vi.spyOn(console, 'warn').mockImplementation(() => { });
vi.spyOn(console, 'error').mockImplementation(() => { });
vi.spyOn(console, 'log').mockImplementation(() => { });
@@ -20,6 +21,7 @@ describe('AI Extract Plugin (extractAI)', () => {
afterEach(() => {
resetAI();
+ Tempo.cache.clear();
vi.restoreAllMocks();
});
@@ -175,7 +177,7 @@ describe('AI Extract Plugin (extractAI)', () => {
set: vi.fn(async (key: string, val: string) => {
cacheStore.set(key, val);
}),
- };
+ }
const text = 'Dentist appointment on August 15 from 9am to 10am.';
const anchor = new Tempo('2026-08-01T00:00:00Z');
@@ -208,6 +210,14 @@ describe('AI Extract Plugin (extractAI)', () => {
});
it('should support force: true and cache: false bypass options', async () => {
+ const cacheStore = new Map();
+ const customAdapter: AiCacheAdapter = {
+ get: vi.fn(async (key: string) => cacheStore.get(key)),
+ set: vi.fn(async (key: string, val: string) => {
+ cacheStore.set(key, val);
+ }),
+ }
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const mockResponse = () => new Response(JSON.stringify({
choices: [{
@@ -227,54 +237,77 @@ describe('AI Extract Plugin (extractAI)', () => {
}],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
- fetchSpy.mockResolvedValueOnce(mockResponse()).mockResolvedValueOnce(mockResponse());
+ fetchSpy
+ .mockResolvedValueOnce(mockResponse())
+ .mockResolvedValueOnce(mockResponse())
+ .mockResolvedValueOnce(mockResponse());
const text = '1-on-1 catchup on Wednesday at 3pm.';
const anchor = new Tempo('2026-08-10T09:00:00Z');
- await extractAI(text, { anchor, timeZone: 'UTC' });
+ await extractAI(text, { anchor, timeZone: 'UTC', cacheAdapter: customAdapter });
expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(customAdapter.set).toHaveBeenCalledTimes(1);
// force: true should make a new fetch
- const forcedResult = await extractAI(text, { anchor, timeZone: 'UTC', force: true });
+ const forcedResult = await extractAI(text, { anchor, timeZone: 'UTC', force: true, cacheAdapter: customAdapter });
expect(forcedResult.provider).toBe('groq');
expect(fetchSpy).toHaveBeenCalledTimes(2);
+
+ // cache: false should skip writing to cache
+ customAdapter.set = vi.fn();
+ const uncachedResult = await extractAI('Another catchup on Thursday at 4pm.', {
+ anchor,
+ timeZone: 'UTC',
+ cache: false,
+ cacheAdapter: customAdapter,
+ });
+ expect(uncachedResult.provider).toBe('groq');
+ expect(customAdapter.set).not.toHaveBeenCalled();
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
});
- it('should reject invalid text and anchor inputs with TempoAiError(400)', async () => {
+ it('should reject invalid text and anchor inputs with TempoAiError(400) and preserve error cause', async () => {
await expect(extractAI(''))
- .rejects.toThrow(new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400));
+ .rejects.toMatchObject({ message: 'Invalid text input provided to extractAI: text must be a non-empty string.', status: 400 });
await expect(extractAI(' '))
- .rejects.toThrow(new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400));
+ .rejects.toMatchObject({ message: 'Invalid text input provided to extractAI: text must be a non-empty string.', status: 400 });
await expect(extractAI(null as any))
- .rejects.toThrow(new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400));
-
- await expect(extractAI('some text', { anchor: 'invalid-anchor-date' }))
- .rejects.toThrow(/Invalid anchor date provided to extractAI/i);
+ .rejects.toMatchObject({ message: 'Invalid text input provided to extractAI: text must be a non-empty string.', status: 400 });
+
+ let caughtErr: any;
+ try {
+ await extractAI('some text', { anchor: 'invalid-anchor-date' });
+ } catch (err: any) {
+ caughtErr = err;
+ }
+ expect(caughtErr).toBeInstanceOf(TempoAiError);
+ expect(caughtErr.status).toBe(400);
+ expect(caughtErr.cause).toBeDefined();
});
it('should validate minConfidence and reject non-finite and out-of-range thresholds before cache read or provider calls', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const customAdapter: AiCacheAdapter = {
get: vi.fn(async () => undefined),
- set: vi.fn(async () => {}),
- };
+ set: vi.fn(async () => { }),
+ }
// Non-finite
await expect(extractAI('some text', { minConfidence: NaN, cacheAdapter: customAdapter }))
- .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "NaN"', 400));
+ .rejects.toMatchObject({ message: 'Invalid minConfidence provided to extractAI: "NaN"', status: 400 });
await expect(extractAI('some text', { minConfidence: Infinity, cacheAdapter: customAdapter }))
- .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "Infinity"', 400));
+ .rejects.toMatchObject({ message: 'Invalid minConfidence provided to extractAI: "Infinity"', status: 400 });
// Out-of-bounds
await expect(extractAI('some text', { minConfidence: -0.5, cacheAdapter: customAdapter }))
- .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "-0.5"', 400));
+ .rejects.toMatchObject({ message: 'Invalid minConfidence provided to extractAI: "-0.5"', status: 400 });
await expect(extractAI('some text', { minConfidence: 1.2, cacheAdapter: customAdapter }))
- .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "1.2"', 400));
+ .rejects.toMatchObject({ message: 'Invalid minConfidence provided to extractAI: "1.2"', status: 400 });
expect(customAdapter.get).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
@@ -300,7 +333,7 @@ describe('AI Extract Plugin (extractAI)', () => {
it('should throw TempoAiError(400) when no providers are configured', async () => {
resetAI();
await expect(extractAI('Meeting tomorrow at 10am'))
- .rejects.toThrow(new TempoAiError('No AI providers configured. Please call initAI().', 400));
+ .rejects.toMatchObject({ message: 'No AI providers configured. Please call initAI().', status: 400 });
});
it('should support multi-provider race execution mode', async () => {
@@ -352,8 +385,13 @@ describe('AI Extract Plugin (extractAI)', () => {
it('should support batch array processing with softErrors', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
- fetchSpy
- .mockResolvedValueOnce(new Response(JSON.stringify({
+ fetchSpy.mockImplementation(async (_url, init) => {
+ const body = JSON.parse(init?.body as string);
+ const hasFailedPrompt = body.messages?.some((m: any) => m.content?.includes('Another event'));
+ if (hasFailedPrompt) {
+ return new Response('Internal Error', { status: 500 });
+ }
+ return new Response(JSON.stringify({
choices: [{
message: {
content: JSON.stringify({
@@ -362,8 +400,8 @@ describe('AI Extract Plugin (extractAI)', () => {
}),
},
}],
- }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
- .mockResolvedValueOnce(new Response('Internal Error', { status: 500 }));
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+ });
const inputs = ['Meeting tomorrow at 9am', 'Another event'];
const results = await extractAI(inputs, {
@@ -407,4 +445,47 @@ describe('AI Extract Plugin (extractAI)', () => {
clone.confidence = 0.5;
expect(clone.confidence).toBe(0.5);
});
+
+ it('should log isDebug warnings for malformed cache items or provider events without changing control flow', async () => {
+ const warnSpy = vi.spyOn(console, 'warn');
+ const customAdapter: AiCacheAdapter = {
+ get: vi.fn(async () => JSON.stringify({
+ events: [{ label: 'Malformed', start: null }],
+ confidence: 0.9,
+ })),
+ set: vi.fn(async () => { }),
+ }
+
+ const result = await extractAI('Dentist appointment tomorrow', {
+ debug: true,
+ cacheAdapter: customAdapter,
+ });
+ expect(result.events).toHaveLength(0);
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('[tempo-plugin-ai:extract] Failed to rehydrate cached event:'),
+ expect.anything(),
+ );
+
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
+ choices: [{
+ message: {
+ content: JSON.stringify({
+ events: [{ label: 'Malformed Provider Event', start: null }],
+ confidence: 0.95,
+ }),
+ },
+ }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+
+ const providerResult = await extractAI('Sync meeting', {
+ debug: true,
+ force: true,
+ });
+ expect(providerResult.events).toHaveLength(0);
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining("[tempo-plugin-ai:extract] Failed to parse event from provider 'groq':"),
+ expect.anything(),
+ );
+ });
});
diff --git a/packages/plugins/ai/test/format.test.ts b/packages/plugins/ai/test/format.test.ts
index 87d2a1ca..8dfdbb3c 100644
--- a/packages/plugins/ai/test/format.test.ts
+++ b/packages/plugins/ai/test/format.test.ts
@@ -225,20 +225,20 @@ describe('AI Format Plugin (formatAI)', () => {
// Non-finite values
await expect(formatAI('2026-08-07', 'test', { minConfidence: NaN, cacheAdapter: customAdapter }))
- .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "NaN"', 400));
+ .rejects.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "NaN"', status: 400 });
await expect(formatAI('2026-08-07', 'test', { minConfidence: Infinity, cacheAdapter: customAdapter }))
- .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "Infinity"', 400));
+ .rejects.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "Infinity"', status: 400 });
await expect(formatAI('2026-08-07', 'test', { minConfidence: -Infinity, cacheAdapter: customAdapter }))
- .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "-Infinity"', 400));
+ .rejects.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "-Infinity"', status: 400 });
// Out-of-range values
await expect(formatAI('2026-08-07', 'test', { minConfidence: -0.1, cacheAdapter: customAdapter }))
- .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "-0.1"', 400));
+ .rejects.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "-0.1"', status: 400 });
await expect(formatAI('2026-08-07', 'test', { minConfidence: 1.05, cacheAdapter: customAdapter }))
- .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "1.05"', 400));
+ .rejects.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "1.05"', status: 400 });
// Verify neither cache nor provider fetch was called
expect(customAdapter.get).not.toHaveBeenCalled();
@@ -253,7 +253,7 @@ describe('AI Format Plugin (formatAI)', () => {
});
await expect(formatAI('2026-08-07', 'test'))
- .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "1.5"', 400));
+ .rejects.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "1.5"', status: 400 });
});
it('should support multi-provider race execution mode', async () => {
@@ -302,8 +302,13 @@ describe('AI Format Plugin (formatAI)', () => {
it('should support batch array processing with softErrors', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
- fetchSpy
- .mockResolvedValueOnce(new Response(JSON.stringify({
+ fetchSpy.mockImplementation(async (_url, init) => {
+ const body = JSON.parse(init?.body as string);
+ const hasFailedPrompt = body.messages?.some((m: any) => m.content?.includes('item 2'));
+ if (hasFailedPrompt) {
+ return new Response('Server Error', { status: 500 });
+ }
+ return new Response(JSON.stringify({
choices: [{
message: {
content: JSON.stringify({
@@ -312,8 +317,8 @@ describe('AI Format Plugin (formatAI)', () => {
}),
},
}],
- }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
- .mockResolvedValueOnce(new Response('Server Error', { status: 500 }));
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+ });
const items = [
{ date: '2026-08-03', prompt: 'item 1' },
@@ -328,8 +333,13 @@ describe('AI Format Plugin (formatAI)', () => {
it('should reject with TempoAiError on batch failure when softErrors is false', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
- fetchSpy
- .mockResolvedValueOnce(new Response(JSON.stringify({
+ fetchSpy.mockImplementation(async (_url, init) => {
+ const body = JSON.parse(init?.body as string);
+ const hasFailedPrompt = body.messages?.some((m: any) => m.content?.includes('item 2'));
+ if (hasFailedPrompt) {
+ return new Response('Server Error', { status: 500 });
+ }
+ return new Response(JSON.stringify({
choices: [{
message: {
content: JSON.stringify({
@@ -338,8 +348,8 @@ describe('AI Format Plugin (formatAI)', () => {
}),
},
}],
- }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
- .mockResolvedValueOnce(new Response('Server Error', { status: 500 }));
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+ });
const items = [
{ date: '2026-08-03', prompt: 'item 1' },
diff --git a/packages/plugins/ai/test/parse.test.ts b/packages/plugins/ai/test/parse.test.ts
index a72d8dd3..1d20c875 100644
--- a/packages/plugins/ai/test/parse.test.ts
+++ b/packages/plugins/ai/test/parse.test.ts
@@ -1,4 +1,4 @@
-import { parseAI, initAI, clearAiCache, getAiRateLimits, getAiConfig, TempoAiError, AiMode } from '../src/index.js';
+import { parseAI, initAI, aiCache, getAiRateLimits, getAiConfig, TempoAiError, AiMode } from '../src/index.js';
import { BoundedCache } from '@magmacomputing/tempo/support';
import { Tempo } from '@magmacomputing/tempo';
@@ -156,7 +156,7 @@ describe('AI Parsing Plugin (parseAI)', () => {
it('should cache the result and mark provider as "cache"', async () => {
const anchorDate = '2026-05-10T12:00:00Z';
- clearAiCache('The Friday after Thanksgiving');
+ await aiCache.clear('The Friday after Thanksgiving');
const fetchSpy = vi.spyOn(globalThis, 'fetch');
if (!isLiveTest) {
@@ -463,13 +463,13 @@ describe('AI Parsing Plugin (parseAI)', () => {
expect(Array.from(cache.keys())).not.toContain('tempKey');
});
- it('should preserve clearAiCache functionality', async () => {
+ it('should preserve aiCache.clear functionality', async () => {
const cache = new BoundedCache(100);
cache.set('Thanksgiving::2026-05-10', '2026-11-26T00:00:00Z');
cache.set('Christmas::2026-05-10', '2026-12-25T00:00:00Z');
await initAI({ remoteConfigUrl: false, cache });
- clearAiCache('Thanksgiving');
+ await aiCache.clear('Thanksgiving');
expect(cache.has('Thanksgiving::2026-05-10')).toBe(false);
expect(cache.has('Christmas::2026-05-10')).toBe(true);
From a093d58548facaa64a57b8ae68c60aec62c57dd4 Mon Sep 17 00:00:00 2001
From: Michael McRae
Date: Sat, 15 Aug 2026 14:52:47 +1000
Subject: [PATCH 5/7] PR extractAI 2nd review
---
packages/plugins/.std/src/term.quarter.ts | 2 +-
packages/plugins/.std/src/term.season.ts | 2 +-
packages/plugins/ai/CHANGELOG.md | 4 +-
packages/plugins/ai/doc/init.md | 2 +-
packages/plugins/ai/doc/rate-limits.md | 4 +-
packages/plugins/ai/doc/schedule.md | 17 ++--
packages/plugins/ai/doc/security.md | 12 +--
packages/plugins/ai/src/core/cache.ts | 49 ++++++++++-
packages/plugins/ai/src/core/dispatch.ts | 15 +++-
packages/plugins/ai/src/core/init.ts | 5 +-
packages/plugins/ai/src/core/logger.ts | 44 ++++++----
packages/plugins/ai/src/core/support.ts | 10 +--
packages/plugins/ai/src/functions/diff.ts | 8 +-
packages/plugins/ai/src/functions/extract.ts | 9 +-
packages/plugins/ai/src/functions/parse.ts | 4 +-
.../plugins/ai/src/functions/recurrence.ts | 4 +-
packages/plugins/ai/src/functions/schedule.ts | 13 ++-
.../plugins/ai/src/types/schedule.type.ts | 15 +++-
packages/plugins/ai/test/debug.test.ts | 36 +++++++-
packages/plugins/ai/test/recurrence.test.ts | 4 +-
packages/plugins/astro/CHANGELOG.md | 5 ++
packages/plugins/astro/package.json | 2 +-
packages/plugins/astro/src/index.ts | 2 +-
.../doc/2-core-concepts/tempo.getters.md | 4 +-
.../tempo/doc/2-core-concepts/tempo.parse.md | 2 +-
.../tempo/doc/3-extending-tempo/tempo.term.md | 2 +-
.../community-traction-and-stars-strategy.md | 87 +++++++++++++++++++
.../tempo/src/engine/engine.normalizer.ts | 3 +-
packages/tempo/src/module/module.mutate.ts | 2 +-
packages/tempo/src/plugin/term/term.util.ts | 8 +-
packages/tempo/src/support/support.enum.ts | 2 +-
packages/tempo/src/tempo.class.ts | 5 +-
packages/tempo/src/tempo.type.ts | 3 +-
packages/tempo/test/core/accessors.test.ts | 10 ++-
.../tempo/test/core/static.getters.test.ts | 29 +++++++
packages/tempo/test/core/static.test.ts | 2 +-
36 files changed, 337 insertions(+), 90 deletions(-)
create mode 100644 packages/tempo/plan/community-traction-and-stars-strategy.md
diff --git a/packages/plugins/.std/src/term.quarter.ts b/packages/plugins/.std/src/term.quarter.ts
index 8440f6c9..f22b5ab7 100644
--- a/packages/plugins/.std/src/term.quarter.ts
+++ b/packages/plugins/.std/src/term.quarter.ts
@@ -18,7 +18,7 @@ const groups = defineRange([
/** resolve the full candidate list for the current context */
function resolve(t: Tempo, anchor?: any): any[] {
- if (t.config.sphere === undefined && anchor?.sphere === undefined) {
+ if (t.sphere === undefined && anchor?.sphere === undefined) {
logWarn(`[tempo] QuarterTerm requires 'sphere' configuration (e.g. Tempo.init({ sphere: 'north' }) or { sphere: 'south' }).`, t.config);
return [];
}
diff --git a/packages/plugins/.std/src/term.season.ts b/packages/plugins/.std/src/term.season.ts
index 222ec400..441e8b01 100644
--- a/packages/plugins/.std/src/term.season.ts
+++ b/packages/plugins/.std/src/term.season.ts
@@ -18,7 +18,7 @@ const groups = defineRange([
/** resolve the full candidate list for the current context */
function resolve(t: Tempo, anchor?: any) {
- if (t.config.sphere === undefined && anchor?.sphere === undefined) {
+ if (t.sphere === undefined && anchor?.sphere === undefined) {
logWarn(`[tempo] SeasonTerm requires 'sphere' configuration (e.g. Tempo.init({ sphere: 'north' }) or { sphere: 'south' }).`, t.config);
return [];
}
diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md
index 2bdb3c44..664e1e13 100644
--- a/packages/plugins/ai/CHANGELOG.md
+++ b/packages/plugins/ai/CHANGELOG.md
@@ -5,9 +5,10 @@ All notable changes to the `@magmacomputing/tempo-plugin-ai` project will be doc
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [0.3.0] - 2026-08-10
+## [1.0.0] - 2026-08-15
### Added
+- **Cache Eviction Synchronization**: Enhanced `aiCache.clear()` to flush matching keys and prefixes across both `Tempo.cache` and custom `AiCacheAdapter` storage engines, returning `Promise` to await async adapter eviction.
- **Temporal Difference & Relative Grounding (`diffAI`)**: Added natural language temporal difference calculation and narrative summarization between two `Tempo` points, dates, or timestamps.
- Pre-computes mathematical grounding metrics (`calendarDays`, `elapsedHours`, `businessDays` with weekend and holiday exclusion) to provide strict arithmetic backing for LLM narrative formatting.
- Supports domain-specific delta formatting (e.g. accounting terms, working days, human relative explanations, or business SLAs).
@@ -35,7 +36,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Cascading Cache TTL Hierarchy**: Implemented strict 4-tier TTL resolution (`options.ttl` > `provider.ttl` > `global config.ttl` > default 1 hour / 3,600,000 ms for `parseAI` or 24 hours / 86,400,000 ms for context/difference handlers) for fine-grained cache entry expiration control on stores enforcing TTL.
- **Fail-Open Storage Resilience**: Custom cache adapter errors during `get()` or `set()` operations fail open silently to direct LLM resolution, preserving application request uptime.
- **Dynamic Remote Provider Manifest (`loadRemoteManifest`)**: Lazily fetches remote provider defaults (`providers.v1.json`) on initialization with a 1500ms timeout and automatic air-gapped fallback to compiled `DEFAULT_PROVIDERS`.
-- **Cache Eviction Synchronization**: Enhanced `aiCache.clear()` to flush matching keys and prefixes across both `Tempo.cache` and custom `AiCacheAdapter` storage engines, returning `Promise` to await async adapter eviction.
### Changed & Hardened
- **Consensus Mode TTL Resolution**: Fixed a runtime bug where standard provider TTL lookups failed in Consensus mode due to the synthetic sentinel provider ID (`'consensus'`), which caused lookups on the winning provider array to return undefined. Now reduces over all participating provider configs to select the minimum (most conservative) TTL.
diff --git a/packages/plugins/ai/doc/init.md b/packages/plugins/ai/doc/init.md
index 3e518eaf..84265d11 100644
--- a/packages/plugins/ai/doc/init.md
+++ b/packages/plugins/ai/doc/init.md
@@ -80,7 +80,7 @@ const dt = await parseAI("Next Tuesday at 4pm", { timeout: 3000 });
**Operational Trace Logging**
Passing `debug: true` into `initAI` (or setting `{ debug: true }` on a per-request options object) outputs concise operational trace logs (prefixed with `[tempo-plugin-ai]`) to the developer console for monitoring provider fallback decisions and execution timing.
-Detailed diagnostic context—including `rawPrompt`, `normalizedPrompt`, `reasoning`, confidence scores, and rate limit snapshots—is attached directly to the returned `Tempo` instance via the `.ai` property for `parseAI`, or surfaced directly on the typed result object (`res.reasoning`, `res.confidence`, `res.ai`) for other AI functions when `debug: true` is enabled.
+Detailed diagnostic context—including provider resolution, execution lineage, confidence scores, and when `debug: true` is active, `rawPrompt`, `normalizedPrompt`, and rate-limit snapshots—is attached directly to the returned `Tempo` instance via the `.ai` property for `parseAI`. Structured functions (`formatAI`, `diffAI`, `extractAI`, `contextAI`, `scheduleAI`) surface their respective typed properties directly on the result object (such as `res.confidence`, `res.provider`, and optional `res.reasoning`).
> [!TIP]
> **Smart Debug & Proxy Introspection**: In production environments (`NODE_ENV === 'production'`), terminal logging via `console.log(date.ai)` or `console.log(result)` automatically sanitizes and masks PII (emails, phones, bearer tokens) while preserving 100% in-memory data integrity for application code. Refer to the [Security & Privacy Architecture Guide](./security.md).
diff --git a/packages/plugins/ai/doc/rate-limits.md b/packages/plugins/ai/doc/rate-limits.md
index 187017e0..a6c944fd 100644
--- a/packages/plugins/ai/doc/rate-limits.md
+++ b/packages/plugins/ai/doc/rate-limits.md
@@ -67,14 +67,14 @@ When you pass an array of inputs to AI functions (such as `parseAI`), the plugin
This is by design for three critical reasons:
1. **Cache Efficiency**: Individual processing allows AI functions to instantly resolve duplicate strings against `Tempo.cache`, saving massive amounts of API tokens. If you pass an array of 10,000 dates, but only 1,000 are unique, only 1,000 network requests are made.
2. **Token Economics**: A single request consumes ~100 tokens (System Prompt + User String + Output ISO). Given that frontier models cost cents per million tokens, the risk of array-misalignment bugs (see below) far outweighs the negligible savings of batching system prompts.
-3. **Deterministic Safety**: LLMs are language models, not arrays. If you pass 50 strings, smaller models often hallucinate and return 49 strings, completely breaking your array indexing. By querying sequentially, we guarantee a strict 1:1 mapping and ensure one invalid string doesn't crash the entire batch.
+3. **Deterministic Safety**: LLMs are language models, not arrays. If you pass 50 strings in a single prompt, smaller models often hallucinate and return 49 strings, completely breaking your array indexing. By dispatching items individually, we guarantee a strict 1:1 index alignment, prevent hallucinations from corrupting sibling entries, and allow granular per-item error isolation when `softErrors: true` is enabled.
> [!WARNING]
> **Granular Time Gotcha**: The cache key is automatically salted with the **calendar date** (`yyyy-mm-dd`) of the execution anchor. By default this uses the system execution date, but when `options.anchor` is explicitly set, it uses the caller-provided anchor date. Note that keeping a fixed anchor date retains the same cache key across midnight boundaries, so an automatic midnight cache miss is not guaranteed.
### Soft Errors in Array Batches
-When processing arrays of inputs, an unparseable input or provider failure on one item will throw an error by default, halting execution of the entire batch. Passing `softErrors: true` allows batch operations to gracefully complete the rest of the array:
+When processing arrays of inputs, an unparseable input or provider failure on one item will throw an error and reject the entire batch operation by default. Passing `softErrors: true` allows batch operations to continue processing all items and return per-item failure representations:
* **For `parseAI`**: Failed array items return an invalid `Tempo` instance (`isValid === false`).
* **For Structured Functions (`formatAI`, `extractAI`, `diffAI`, `contextAI`)**: Failed array items return the typed `TempoAiError` object directly in that array position.
diff --git a/packages/plugins/ai/doc/schedule.md b/packages/plugins/ai/doc/schedule.md
index 98e3b401..3a05ff6a 100644
--- a/packages/plugins/ai/doc/schedule.md
+++ b/packages/plugins/ai/doc/schedule.md
@@ -37,7 +37,7 @@ console.log(booking.ai?.conflictBumped); // true (pushed
| Option | Type | Description |
| :--- | :--- | :--- |
| **`anchor`** | `TempoDateInput` | Anchor reference time to evaluate relative dates from. Defaults to workstation/browser current time. |
-| **`events`** | `Array<{ start: any; end: any; title?: string } \| TempoInterval \| Interval>` | A list of existing busy calendar intervals that the meeting must not overlap with. |
+| **`events`** | `Array` | A list of existing busy calendar intervals or booked events that the meeting must not overlap with. |
| **`workingHours`** | `TempoWorkingHours` | Daily time window constraint (HH:MM formats) inside which slots must fit. |
| **`timeZone`** | `string` | Target IANA timezone to calculate the booking slot and boundaries within. |
| **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required to return a valid slot. |
@@ -51,14 +51,15 @@ export interface TempoInterval {
}
```
-### Event Input Shape (`TempoScheduleOptions.events`)
-The `events` option accepts raw event objects, continuous `TempoInterval` pairs, or native `Interval` instances:
+### Event Input Shape (`ScheduleEventInput`)
+The `events` (or `intervals`) option accepts raw event objects, continuous `TempoInterval` pairs, native `Interval` instances, or `[start, end]` tuples:
```typescript
-type ScheduleEventInput = {
- start: TempoDateInput;
- end: TempoDateInput;
- title?: string;
-} | TempoInterval | Interval;
+export type ScheduleEventInput =
+ | { start: TempoDateInput; end: TempoDateInput; title?: string; label?: string }
+ | TempoInterval
+ | Interval
+ | [TempoDateInput, TempoDateInput]
+ | TempoDateInput;
```
---
diff --git a/packages/plugins/ai/doc/security.md b/packages/plugins/ai/doc/security.md
index fd4c11e0..b0a00da6 100644
--- a/packages/plugins/ai/doc/security.md
+++ b/packages/plugins/ai/doc/security.md
@@ -111,14 +111,14 @@ const rawReasoning = result.reasoning;
## 5. Ephemeral Processing & Partitioned Caching
-### Zero Data Retention Policy
+### Zero External Telemetry Policy
* The plugin does not transmit telemetry, analytics, or prompt logs to external tracking servers.
-* Prompts and temporal calculations exist ephemerally in memory during execution.
+* Prompt processing and temporal computations occur ephemerally during request execution.
-### Tenant-Isolated Partitioned Caching
-* **Namespaced Cache Keys**: Cache keys are generated with multi-factor hashing (`ai:::`) incorporating the user prompt, target timezone, locale, calendar system, and anchor date.
-* **Zero Cross-Contamination**: Isolated cache keys prevent cross-tenant and cross-regional data leakage.
-* **Granular Bypass Controls**: Operations requiring zero cache persistence can supply `cache: false` or `force: true` on any individual request.
+### Partitioned Multi-Tier Caching
+* **Namespaced Cache Keys**: Cache keys are generated with multi-factor domain partitioning (e.g., `diff::`, `format::`, `extract::`) incorporating the prompt text, anchor epoch, target timezone, locale, calendar system, and regional parameters to prevent contextual collision.
+* **Storage Lifecycle**: Cached entries persist in the local `Tempo.cache` (`BoundedCache`) or caller-provided `AiCacheAdapter` (e.g. Redis, KV) strictly until TTL expiration or LRU capacity eviction.
+* **Granular Bypass Controls**: Operations requiring zero cache persistence can supply `cache: false` or `force: true` on any individual request, or programmatically flush entries using `await aiCache.clear()`.
---
diff --git a/packages/plugins/ai/src/core/cache.ts b/packages/plugins/ai/src/core/cache.ts
index 38a293f8..dce3283e 100644
--- a/packages/plugins/ai/src/core/cache.ts
+++ b/packages/plugins/ai/src/core/cache.ts
@@ -6,6 +6,8 @@ import type { AiCacheAdapter } from '../types/index.js';
export const AI_CACHE_NAMESPACE_PREFIX = 'ai:';
+const _entryExpiries = new Map();
+
/**
* Normalizes input string for deterministic cache lookups by trimming excess whitespace and lowercasing.
*/
@@ -50,8 +52,14 @@ export async function readMultiTierCache(
}
}
+ if (_entryExpiries.has(cacheKey) && Date.now() > _entryExpiries.get(cacheKey)!) {
+ _entryExpiries.delete(cacheKey);
+ Tempo.cache.delete(cacheKey);
+ return undefined;
+ }
+
const localVal = Tempo.cache.get(cacheKey);
- if (localVal) {
+ if (localVal !== undefined) {
logDebug(tag, `Cache hit (local): ${cacheKey}`, undefined, { debug: options.debug });
return localVal;
}
@@ -77,6 +85,11 @@ export async function writeMultiTierCache(
const tag = options.tag ?? 'tempo-plugin-ai';
Tempo.cache.set(cacheKey, value);
+ if (typeof ttl === 'number' && Number.isFinite(ttl) && ttl > 0) {
+ _entryExpiries.set(cacheKey, Date.now() + ttl);
+ } else {
+ _entryExpiries.delete(cacheKey);
+ }
const adapter = options.cacheAdapter || _state.config.cacheAdapter;
if (adapter) {
@@ -108,6 +121,7 @@ export const aiCache = secure({
if (!input) {
Tempo.cache.clear();
+ _entryExpiries.clear();
if (adapter?.clear) {
try {
await Promise.resolve(adapter.clear()).catch(() => { });
@@ -123,12 +137,28 @@ export const aiCache = secure({
Tempo.cache.delete(normalized);
Tempo.cache.delete(i);
Tempo.cache.deletePrefix(prefix);
+ _entryExpiries.delete(normalized);
+ _entryExpiries.delete(i);
+
+ const keysToDelete: string[] = [];
+ for (const [key] of Tempo.cache.entries()) {
+ if (key.includes(normalized) || key.includes(i)) {
+ keysToDelete.push(key);
+ }
+ }
+ for (const k of keysToDelete) {
+ Tempo.cache.delete(k);
+ _entryExpiries.delete(k);
+ }
if (adapter) {
try {
if (adapter.delete) {
await Promise.resolve(adapter.delete(normalized)).catch(() => { });
await Promise.resolve(adapter.delete(i)).catch(() => { });
+ for (const k of keysToDelete) {
+ await Promise.resolve(adapter.delete(k)).catch(() => { });
+ }
}
if (adapter.clear) {
await Promise.resolve(adapter.clear(prefix)).catch(() => { });
@@ -145,12 +175,11 @@ export const aiCache = secure({
* @returns True if the key was present in the in-memory cache, false otherwise
*/
async delete(key: string): Promise {
- const normalized = normalizeCacheInput(key);
- const deletedLocal = Tempo.cache.delete(normalized) || Tempo.cache.delete(key);
+ _entryExpiries.delete(key);
+ const deletedLocal = Tempo.cache.delete(key);
const adapter = _state.config.cacheAdapter;
if (adapter?.delete) {
try {
- await Promise.resolve(adapter.delete(normalized)).catch(() => { });
await Promise.resolve(adapter.delete(key)).catch(() => { });
} catch { }
}
@@ -164,6 +193,12 @@ export const aiCache = secure({
* @returns The cached string value, or undefined if not found
*/
async get(key: string): Promise {
+ if (_entryExpiries.has(key) && Date.now() > _entryExpiries.get(key)!) {
+ _entryExpiries.delete(key);
+ Tempo.cache.delete(key);
+ return undefined;
+ }
+
const adapter = _state.config.cacheAdapter;
if (adapter?.get) {
try {
@@ -194,6 +229,12 @@ export const aiCache = secure({
*/
async set(key: string, value: string, ttl?: number): Promise {
Tempo.cache.set(key, value);
+ if (typeof ttl === 'number' && Number.isFinite(ttl) && ttl > 0) {
+ _entryExpiries.set(key, Date.now() + ttl);
+ } else {
+ _entryExpiries.delete(key);
+ }
+
const adapter = _state.config.cacheAdapter;
if (adapter?.set) {
try {
diff --git a/packages/plugins/ai/src/core/dispatch.ts b/packages/plugins/ai/src/core/dispatch.ts
index 1f0808da..9b6c5a8c 100644
--- a/packages/plugins/ai/src/core/dispatch.ts
+++ b/packages/plugins/ai/src/core/dispatch.ts
@@ -422,9 +422,18 @@ export function filterCooldownProviders(
): AiProvider[] {
if (providers.length <= 1) return providers;
const now = Date.now();
- const available = providers.filter(p => !isProviderInCooldown(p, now));
- if (available.length > 0 && available.length < providers.length) {
- const skipped = providers.filter(p => isProviderInCooldown(p, now)).map(p => p.id);
+ const available: AiProvider[] = [];
+ const skipped: string[] = [];
+
+ for (const p of providers) {
+ if (isProviderInCooldown(p, now)) {
+ skipped.push(p.id);
+ } else {
+ available.push(p);
+ }
+ }
+
+ if (available.length > 0 && skipped.length > 0) {
logDebug(
options?.tag || 'tempo-plugin-ai',
`Proactively filtered ${skipped.length} provider(s) in active 429 cooldown: ${skipped.join(', ')}`,
diff --git a/packages/plugins/ai/src/core/init.ts b/packages/plugins/ai/src/core/init.ts
index ef4cf38a..94ffa31a 100644
--- a/packages/plugins/ai/src/core/init.ts
+++ b/packages/plugins/ai/src/core/init.ts
@@ -2,6 +2,7 @@ import { Tempo } from '@magmacomputing/tempo';
import { getResolvedProviderDefaults, loadRemoteManifest, resetManifestCache } from './manifest.js';
import { assertNoReservedProviderId } from './support.js';
+import { warnDebug } from './logger.js';
import type { AiConfig, AiRateLimits, AiProvider } from '../types/index.js';
/**
@@ -90,7 +91,9 @@ export function initAI(config: AiConfig): Promise {
let hookOptions: Partial | null = null;
try {
hookOptions = await fetchDefaults(normalizedId);
- } catch { }
+ } catch (err: any) {
+ warnDebug('tempo-plugin-ai:init', `fetchDefaults hook failed for provider '${normalizedId}'`, err, { debug: config.debug ?? _state.config.debug });
+ }
return {
...defaults,
...(hookOptions ?? {}),
diff --git a/packages/plugins/ai/src/core/logger.ts b/packages/plugins/ai/src/core/logger.ts
index 342b7f0d..ae7a676c 100644
--- a/packages/plugins/ai/src/core/logger.ts
+++ b/packages/plugins/ai/src/core/logger.ts
@@ -3,7 +3,7 @@ import { _state } from './init.js';
export const CUSTOM_INSPECT_SYMBOL = Symbol.for('nodejs.util.inspect.custom');
const RE_EMAIL = /[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g;
-const RE_PHONE = /(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?(\d{4})/g;
+const RE_PHONE = /(?:\+?\d{1,3}[-.\s])?\(?\d{3}\)?[-.\s]\d{3}[-.\s](\d{4})\b/g;
const RE_BEARER = /Bearer\s+[A-Za-z0-9_\-\.]+/gi;
const RE_API_KEY = /\b(?:sk-[a-zA-Z0-9_\-]{6,}|gsk_[a-zA-Z0-9_\-]{6,}|key-[a-zA-Z0-9_\-]{6,})\b/gi;
@@ -59,8 +59,13 @@ export function maskPii(input: string, isProd: boolean = isProductionEnvironment
/**
* Sanitizes arbitrary objects, arrays, or primitives for safe log printing.
+ * Protects against circular object references via a visited tracker.
*/
-export function sanitizeForLog(data: any, isProd: boolean = isProductionEnvironment()): any {
+export function sanitizeForLog(
+ data: any,
+ isProd: boolean = isProductionEnvironment(),
+ visited: WeakSet