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/.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 1f1864ca..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 `clearAiCache()` 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/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 8f7e7428..f6691dad 100644 --- a/packages/plugins/ai/doc/architecture.md +++ b/packages/plugins/ai/doc/architecture.md @@ -75,43 +75,164 @@ 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.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 +``` -### 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 Bearer JWT token + 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, enforces ingress quotas, attaches your private LLM API key, and forwards the validated 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 / 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); + if (!session) { + return new Response('Unauthorized', { status: 401 }); + } + + // 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. 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. 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: 'openai/gpt-oss-120b', + 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); + } +} ``` +--- + +## 🔒 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) +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 & 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` 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 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. +* **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/context.md b/packages/plugins/ai/doc/context.md index 94b76beb..663d4560 100644 --- a/packages/plugins/ai/doc/context.md +++ b/packages/plugins/ai/doc/context.md @@ -1,63 +1,126 @@ -# 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. +> [!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). -## Geographic Context +This is highly useful for user onboarding settings, automatic context mapping for calendar syncs, and geolocating inputs dynamically without maintaining static geographic mapping tables. -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. +--- -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]`* +## Basic Usage -### 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 } + ] +}); + +// 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! +--- + +## Configuration Options (`AiContextOptions`) -> [!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. +| 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 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. | -## The Decoupled Output Bridge +--- -To ensure deterministic behavior, the LLM is instructed to *only* return strict ISO 8601 strings. +## Result Schema (`TempoContext`) -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. +```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. + +### 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. +``` -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`. +### 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. -Every resolved `Tempo` instance returned by `parseAI` (and other AI functions) has a non-writable, frozen `.ai` metadata descriptor containing execution audit data: +### 4. Parallel Batch Processing +You can pass an array of strings to process multiple contexts concurrently: ```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 [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 98% rename from packages/plugins/ai/doc/diffAI.md rename to packages/plugins/ai/doc/diff.md index fc8bd5aa..fba1eb85 100644 --- a/packages/plugins/ai/doc/diffAI.md +++ b/packages/plugins/ai/doc/diff.md @@ -15,7 +15,7 @@ import { initAI, diffAI } 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' } + { id: 'groq', key: process.env.GROQ_API_KEY } ] }); diff --git a/packages/plugins/ai/doc/extract.md b/packages/plugins/ai/doc/extract.md new file mode 100644 index 00000000..c5602ada --- /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 } + ] +}); + +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/format.md b/packages/plugins/ai/doc/format.md new file mode 100644 index 00000000..ec83f5c4 --- /dev/null +++ b/packages/plugins/ai/doc/format.md @@ -0,0 +1,113 @@ +# `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 } + ] +}); + +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 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 EDT (in 5 days)" +console.log(result.confidence); // 0.98 +console.log(result.provider); // 'groq' +``` + +--- + +## Configuration Options (`AiFormatOptions`) + +| Option | Type | Description | +| :--- | :--- | :--- | +| **`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. | +| **`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/grounding.md b/packages/plugins/ai/doc/grounding.md new file mode 100644 index 00000000..87cb554a --- /dev/null +++ b/packages/plugins/ai/doc/grounding.md @@ -0,0 +1,77 @@ +# 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, type-safe behavior, the plugin enforces a strict decoupled bridge between AI text generation and JavaScript object hydration: + +* **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 + +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 Handling + +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` 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 +// } +``` + +*(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 c417d500..9d265930 100644 --- a/packages/plugins/ai/doc/index.md +++ b/packages/plugins/ai/doc/index.md @@ -6,18 +6,13 @@ npm version npm peer dependency version License TypeScript Ready

-> [!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,23 +38,44 @@ All AI functions return a standard ES Promise wrapped object. | Function | Input | Returns (`Promise<...>`) | Description | Doc | | :--- | :--- | :--- | :--- | :---: | +| **`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[]` \| `(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 | | -| **`parseAI`** | Natural language text string(s) | `Tempo` \| `Tempo[]` \| `(Tempo \| TempoAiError)[]` | Single point-in-time `Tempo` instance (or batch array) | | -| **`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`) | | + +### 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, Frontend Security) -- [Context & Natural Language Parsing](./context.md) (How Timezone and Locale are injected) +- [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) - [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). +> +> **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/init.md b/packages/plugins/ai/doc/init.md index 49c93810..84265d11 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 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`). -> [!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 17cd66b3..b1a599af 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 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 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. --- @@ -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. @@ -149,16 +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/parseAI.md b/packages/plugins/ai/doc/parse.md similarity index 95% rename from packages/plugins/ai/doc/parseAI.md rename to packages/plugins/ai/doc/parse.md index 5189c5bb..537ddd6b 100644 --- a/packages/plugins/ai/doc/parseAI.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..a6c944fd 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"); @@ -67,19 +67,31 @@ 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, 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 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. ```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/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 77% rename from packages/plugins/ai/doc/scheduleAI.md rename to packages/plugins/ai/doc/schedule.md index d0760c6b..3a05ff6a 100644 --- a/packages/plugins/ai/doc/scheduleAI.md +++ b/packages/plugins/ai/doc/schedule.md @@ -32,26 +32,36 @@ 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`** | `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. | | **`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; - title?: string; +export interface TempoInterval { + start: Tempo; + end: Tempo; } ``` +### Event Input Shape (`ScheduleEventInput`) +The `events` (or `intervals`) option accepts raw event objects, continuous `TempoInterval` pairs, native `Interval` instances, or `[start, end]` tuples: +```typescript +export type ScheduleEventInput = + | { start: TempoDateInput; end: TempoDateInput; title?: string; label?: string } + | TempoInterval + | Interval + | [TempoDateInput, TempoDateInput] + | TempoDateInput; +``` + --- ## Result Schema (`TempoScheduleResult`) diff --git a/packages/plugins/ai/doc/security.md b/packages/plugins/ai/doc/security.md new file mode 100644 index 00000000..5b2dc8ac --- /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` significantly mitigates this risk through **Smart Debug Infrastructure**: + +### Universal Environment Detection & Zero-Config Safety +* **Unified Flag**: Telemetry is enabled directly using `{ debug: true }` on individual requests or globally via `initAI({ debug: true })`. +* **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` → `j***@enterprise.com`). +* **Phone Numbers**: Masked to last four digits (e.g., `+1-555-867-5309` → `***-***-5309`). +* **Bearer & API Tokens**: Redacted with prefix/suffix preservation (e.g., `Bearer sk-proj-1234...` → `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 External Telemetry Policy +* The plugin does not transmit telemetry, analytics, or prompt logs to external tracking servers. +* Prompt processing and temporal computations occur ephemerally during request execution. + +### 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()`. + +--- + +## 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/package.json b/packages/plugins/ai/package.json index 59d49efd..ce53fb84 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": "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/formatAI.plan.md b/packages/plugins/ai/plan/formatAI.plan.md deleted file mode 100644 index dcaa60ee..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 } 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; - /** 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 or string to format. */ - date: Tempo | Date | string | number; - /** 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: any, 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/plan/provider-farm-auto-discovery.md b/packages/plugins/ai/plan/provider-farm-auto-discovery.md new file mode 100644 index 00000000..1469ea93 --- /dev/null +++ b/packages/plugins/ai/plan/provider-farm-auto-discovery.md @@ -0,0 +1,200 @@ +# Provider Farm Auto-Discovery & Zero-Config Architecture Plan + +## 1. Objective + +Enable seamless, zero-configuration auto-discovery of AI provider farm credentials, SLA defaults, and execution modes for `@magmacomputing/tempo-plugin-ai`. + +This design: +1. Replaces manual `initAI({ providers: [...] })` boilerplate with automated discovery. +2. Integrates directly with Tempo's unified configuration (`tempo.config.*` under `plugins.ai`). +3. Uses `@magmacomputing/tempo/library`'s `getContext()` for runtime-safe JavaScript engine identification (Node.js, Deno, Bun, Browser, Apps Script). +4. Enables instant execution of all AI functions (`parseAI`, `formatAI`, `diffAI`, `extractAI`, `recurrenceAI`, `scheduleAI`, `contextAI`) when standard environment variables or configuration files are present. + +--- + +## 2. Current State & Limitations + +- **Mandatory Initialization**: `initAI(config: AiConfig)` currently requires a full configuration object with explicit `providers` array mapping. +- **Immediate Rejection on Missing Config**: Directly calling `parseAI('...')` without prior `initAI()` throws a `TempoAiError('No AI providers configured. Please call initAI().', 400)`. +- **Configuration Sprawl**: There is no direct synchronization between Tempo core's `tempo.config.*` (discovered by `Tempo.bootstrap()`) and the AI plugin state. +- **Runtime Environment Fragility**: Ad-hoc checks like `typeof process !== 'undefined'` fail to leverage Tempo's standardized environment abstractions. + +--- + +## 3. Architectural Design + +``` + ┌──────────────────────────────────────────────┐ + │ AI Function Call (e.g. parseAI, formatAI) │ + └──────────────────────┬───────────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ Are providers loaded in _state? │ + └────────┬─────────────────┬──────┘ + (Yes) │ │ (No) + ▼ ▼ + ┌─────────────────┐ ┌────────────────────────────────┐ + │ Execute Handler │ │ resolveAutoDiscoveredConfig() │ + └─────────────────┘ └────────────────┬───────────────┘ + │ + ┌─────────────────────────────────────────┴─────────────────────────────────────────┐ + ▼ ▼ ▼ + ┌─────────────────────────────────┐ ┌─────────────────────────────────┐ ┌─────────────────────────────────┐ + │ 1. Active Tempo Config │ │ 2. Runtime File Resolution │ │ 3. Environment Variable Scan │ + │ Inspect `Tempo.config` for │ ───► │ If getContext() is NodeJS/Deno, │ ───► │ Scan process.env / global env │ + │ `plugins.ai` or `ai` block │ │ invoke Tempo's resolveConfig() │ │ for GROQ_API_KEY, OPENAI_..., │ + │ (e.g. from Tempo.bootstrap()) │ │ on `tempo.config.*` │ │ GEMINI_..., MISTRAL_API_KEY │ + └─────────────────────────────────┘ └─────────────────────────────────┘ └─────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ Interpolate ${ENV} Variables & │ + │ Merge with DEFAULT_PROVIDERS │ + └────────────────┬────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ Cache in _state & Execute │ + └─────────────────────────────────┘ +``` + +--- + +## 4. Key Components & Specifications + +### 4.1. Runtime Discovery via `getContext()` + +Instead of raw `process` checks, use `getContext()` from `@magmacomputing/tempo/library` to determine environment capabilities safely: + +```ts +import { getContext, CONTEXT } from '@magmacomputing/tempo/library'; + +export function isServerRuntime(): boolean { + const { type } = getContext(); + return type === CONTEXT.NodeJS || type === CONTEXT.Deno; +} + +export function isBrowserRuntime(): boolean { + const { type } = getContext(); + return type === CONTEXT.Browser; +} +``` + +### 4.2. Unified `tempo.config.*` Schema + +AI configuration is declared directly in `tempo.config.json`, `tempo.config.ts`, or `tempo.config.js`: + +```json +{ + "timeZone": "Australia/Sydney", + "locale": "en-AU", + "plugins": { + "ai": { + "mode": "fallback", + "timeout": 5000, + "minConfidence": 0.85, + "providers": [ + { "id": "groq", "key": "${GROQ_API_KEY}" }, + { "id": "openai", "key": "${OPENAI_API_KEY}", "model": "gpt-4o-mini" } + ] + }, + "snap": { + "mi": 15 + } + } +} +``` + +### 4.3. Environment Variable Interpolation + +Support `${VAR_NAME}` and `$env:VAR_NAME` template strings in configuration values: + +```ts +function interpolateEnvValue(value: string, env: Record): string { + return value.replace(/\$\{(?:env:)?([A-Z0-9_]+)\}/gi, (_, varName) => env[varName] ?? ''); +} +``` + +### 4.4. Well-Known Provider Auto-Detection Table + +When no explicit configuration file or provider list is provided, the engine scans the environment for standard provider tokens and maps them to built-in `DEFAULT_PROVIDERS`: + +| Provider ID | Target Environment Variable(s) | Default Model Target | +| :--- | :--- | :--- | +| `groq` | `GROQ_API_KEY` | `openai/gpt-oss-120b` | +| `openai` | `OPENAI_API_KEY` | `gpt-5.4-mini` | +| `gemini` | `GEMINI_API_KEY`, `GOOGLE_API_KEY` | `gemini-3.6-flash` | +| `mistral` | `MISTRAL_API_KEY` | `mistral-small-latest` | + +--- + +## 5. API Surface Changes + +### 5.1. Optional `initAI(config?: AiConfig)` + +`initAI` becomes fully optional-parameterized: + +```ts +/** + * Initializes the AI provider farm. + * If called with no arguments or omitted providers, automatically discovers + * configuration from Tempo.config, tempo.config.* files, or runtime environment variables. + * + * @param config - Optional AI configuration overrides + */ +export async function initAI(config: AiConfig = {}): Promise +``` + +### 5.2. Lazy Auto-Discovery in AI Handlers + +All AI entrypoints (`parseAI`, `formatAI`, `diffAI`, `extractAI`, `recurrenceAI`, `scheduleAI`, `contextAI`) resolve the provider farm dynamically if uninitialized: + +```ts +// src/functions/parse.ts +if (!availableProviders || availableProviders.length === 0) { + const discovered = await resolveAutoDiscoveredProviders(options?.debug); + if (!discovered || discovered.length === 0) { + throw new TempoAiError( + 'No AI providers configured. Set GROQ_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, or configure tempo.config.json.', + 400 + ); + } + availableProviders = discovered; +} +``` + +--- + +## 6. Implementation Roadmap + +### Phase 1: Discovery Subsystem (`src/core/discovery.ts`) +- [ ] Implement `isServerRuntime()` and `getRuntimeEnv()` using `getContext()`. +- [ ] Implement `interpolateEnv()` utility for recursive string expansion on config objects. +- [ ] Implement `scanWellKnownEnvProviders()` against `DEFAULT_PROVIDERS`. +- [ ] Implement `resolveTempoConfigAi()` to query `Tempo.config.plugins?.ai` or invoke `resolveConfig()`. + +### Phase 2: `initAI` Signature & Lifecycle Update (`src/core/init.ts`) +- [ ] Update `initAI(config: AiConfig = {})` default argument handling. +- [ ] Integrate discovery resolution into synchronous and asynchronous `initAI` initialization branches. +- [ ] Ensure key redaction (`[REDACTED]`) in `getAiConfig()` properly handles auto-discovered credentials. + +### Phase 3: JIT Lazy Discovery in AI Handlers +- [ ] Update `parseAI`, `formatAI`, `diffAI`, `extractAI`, `recurrenceAI`, `scheduleAI`, and `contextAI` to invoke lazy discovery before failing. +- [ ] Preserve custom call-site overrides (`options.providers`). + +### Phase 4: Testing & Verification +- [ ] **Unit Tests**: + - Test `getContext()` environment branching (mocking Browser vs Node.js vs Deno). + - Test `${ENV_VAR}` interpolation (found vs missing). + - Test provider farm construction from environment variables (`GROQ_API_KEY`, etc.). + - Test `Tempo.bootstrap()` loading `plugins.ai` config block. +- [ ] **Integration Tests**: + - Zero-arg `initAI()` with mock environment variables. + - Zero-call `parseAI('...')` direct invocation with mock environment variables. + - Error assertion when no keys and no config files are present. + +### Phase 5: Documentation +- [ ] Update `doc/init.md` with Zero-Config & Auto-Discovery instructions. +- [ ] Add `tempo.config.json` configuration recipe to `doc/index.md`. +- [ ] Document browser vs server auto-discovery patterns in `doc/architecture.md`. 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 3a9bbfa9..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). - ---- - -## 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` -* 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/cache.ts b/packages/plugins/ai/src/core/cache.ts new file mode 100644 index 00000000..dce3283e --- /dev/null +++ b/packages/plugins/ai/src/core/cache.ts @@ -0,0 +1,260 @@ +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:'; + +const _entryExpiries = new Map(); + +/** + * 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 }); + } + } + + 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 !== undefined) { + 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); + 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) { + 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(); + _entryExpiries.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); + _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(() => { }); + } + } 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 { + _entryExpiries.delete(key); + const deletedLocal = Tempo.cache.delete(key); + const adapter = _state.config.cacheAdapter; + if (adapter?.delete) { + try { + 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 { + 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 { + 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); + 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 { + 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/config.ts b/packages/plugins/ai/src/core/config.ts index 9a963d6c..29e901bf 100644 --- a/packages/plugins/ai/src/core/config.ts +++ b/packages/plugins/ai/src/core/config.ts @@ -33,7 +33,7 @@ export const RESERVED_PROVIDER_IDS: ReadonlySet = new Set(['native', 'ca export const DEFAULT_PROVIDERS: Readonly>> = secure({ groq: { url: 'https://api.groq.com/openai/v1/chat/completions', - model: 'llama-3.3-70b-versatile', + model: 'openai/gpt-oss-120b', tokenParam: 'max_tokens' }, openai: { diff --git a/packages/plugins/ai/src/core/dispatch.ts b/packages/plugins/ai/src/core/dispatch.ts index d6235dbe..9b6c5a8c 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 { @@ -387,6 +396,55 @@ 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; + const isExhausted = limits.remainingRequests === 0 || limits.remainingTokens === 0; + return isExhausted && 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: 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(', ')}`, + undefined, + { debug: options?.debug }, + ); + return available; + } + return providers; +} + /** * ## executeWithMode * Central multi-provider execution orchestrator for Tempo AI plugins. @@ -413,21 +471,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 +496,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/init.ts b/packages/plugins/ai/src/core/init.ts index 1ee7a9d1..94ffa31a 100644 --- a/packages/plugins/ai/src/core/init.ts +++ b/packages/plugins/ai/src/core/init.ts @@ -1,7 +1,8 @@ import { Tempo } from '@magmacomputing/tempo'; import { getResolvedProviderDefaults, loadRemoteManifest, resetManifestCache } from './manifest.js'; -import { normalizeCacheInput, assertNoReservedProviderId } from './support.js'; +import { assertNoReservedProviderId } from './support.js'; +import { warnDebug } from './logger.js'; import type { AiConfig, AiRateLimits, AiProvider } from '../types/index.js'; /** @@ -71,39 +72,41 @@ 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 { } - } + return (async () => { + if (remoteUrl !== false) { + try { + await loadRemoteManifest(remoteUrl, undefined, config.debug ?? _state.config.debug); + } catch { } + } - if (_state.revision !== currentRevision) return; + if (_state.revision !== currentRevision) return; - const fetchDefaults = config.fetchDefaults ?? _state.config.fetchDefaults; - const currentProviders = callerProviders; + 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); - } - })(); + 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 (err: any) { + warnDebug('tempo-plugin-ai:init', `fetchDefaults hook failed for provider '${normalizedId}'`, err, { debug: config.debug ?? _state.config.debug }); + } + 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 +122,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..ae7a676c --- /dev/null +++ b/packages/plugins/ai/src/core/logger.ts @@ -0,0 +1,192 @@ +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})\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; + +/** + * 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. + * Protects against circular object references via a visited tracker. + */ +export function sanitizeForLog( + data: any, + isProd: boolean = isProductionEnvironment(), + visited: WeakSet = new WeakSet(), +): 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' || typeof data === 'symbol' || typeof data === 'bigint') return data; + + if (typeof data === 'object') { + if (visited.has(data)) return '[CIRCULAR]'; + visited.add(data); + + if (Array.isArray(data)) + return data.map(item => sanitizeForLog(item, isProd, visited)); + + 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) : sanitizeForLog(val, isProd, visited); + } else { + result[key] = sanitizeForLog(val, isProd, visited); + } + } + 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, + }); + + if (typeof (target as any).toJSON !== 'function') { + 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 a43fa1f3..5ac4da8a 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -2,100 +2,116 @@ 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 { 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) { - 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 AI provider configuration.`, 400); + } } -export function normalizeCacheInput(input: string): string { - return input.trim().toLowerCase().replace(/\s+/g, ' '); +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 getNamespacedCacheKey(namespace: string, key: string): string { - return `ai:${namespace}::${key}`; +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 !== undefined && (Array.isArray(options.locale) ? options.locale.length > 0 : Boolean(options.locale))) + ? options.locale + : (fallbackTempo?.locale !== undefined && (Array.isArray(fallbackTempo.locale) ? fallbackTempo.locale.length > 0 : Boolean(fallbackTempo.locale))) + ? fallbackTempo.locale + : 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 }; } -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; - } - }); +export function attachAiMeta(instance: Tempo, meta: TempoParseAiMeta): Tempo { + 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, { + 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); + }, + }); } 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,10 +129,9 @@ 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})...`); + 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) @@ -176,10 +191,8 @@ Do not include markdown blocks or any text outside the JSON.`; if (typeof rawContent !== 'string') throw new TempoAiError(`Provider ${provider.id} returned invalid response payload.`, 422); - if (isDebug) { - const elapsed = Math.round(performance.now() - startTime); - console.log(`[tempo-plugin-ai] Received response from '${provider.id}' in ${elapsed}ms`); - } + const elapsed = Math.round(performance.now() - startTime); + logDebug('tempo-plugin-ai', `Received response from '${provider.id}' in ${elapsed}ms`, undefined, { debug: isDebug }); return { rawContent: rawContent.trim(), providerId: provider.id, rateLimits: limits }; } finally { diff --git a/packages/plugins/ai/src/functions/context.ts b/packages/plugins/ai/src/functions/context.ts index b9ce90b4..1053fbfe 100644 --- a/packages/plugins/ai/src/functions/context.ts +++ b/packages/plugins/ai/src/functions/context.ts @@ -1,9 +1,21 @@ 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 { normalizeCacheInput, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; +import { + getNamespacedCacheKey, + normalizeCacheInput, + readMultiTierCache, + 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'; @@ -20,24 +32,16 @@ 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; - 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 { @@ -48,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 { + logDebug('tempo-plugin-ai:context', `Cache hit: "${text}" -> ${cachedVal}`, undefined, { debug: isDebug }); + const cachedResult: TempoContext = { timeZone: parsedCache.timeZone, locale: parsedCache.locale, calendar: parsedCache.calendar, @@ -57,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 { @@ -163,35 +177,36 @@ 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); } - return finalResult; + 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', + }); + + 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 686bd33c..fccf8b4b 100644 --- a/packages/plugins/ai/src/functions/diff.ts +++ b/packages/plugins/ai/src/functions/diff.ts @@ -1,9 +1,22 @@ 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 { normalizeCacheInput, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; +import { + getNamespacedCacheKey, + normalizeCacheInput, + readMultiTierCache, + writeMultiTierCache, +} from '../core/cache.js'; +import { + assertNoReservedProviderId, + fetchFromProvider, + resolveProviderTtl, + resolveTzAndLocale, +} 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'; @@ -56,9 +69,8 @@ async function diffSingleInput( options?: AiDiffOptions, ): Promise { const isDebug = options?.debug ?? _state.config.debug ?? false; - const resolvedOptions = Tempo.options; - const tz = String(options?.timeZone || (Tempo.isTempo(start) ? start.tz : undefined) || (Tempo.isTempo(end) ? end.tz : undefined) || resolvedOptions.timeZone || 'UTC'); - const loc = String(Array.isArray(options?.locale) ? options?.locale[0] : (options?.locale || resolvedOptions.locale || 'en-US')); + const fallbackTempo = Tempo.isTempo(start) ? start : (Tempo.isTempo(end) ? end : null); + const { tz, loc } = resolveTzAndLocale(options, fallbackTempo); const startTempo = Tempo.isTempo(start) ? (start.tz === tz ? start : start.set({ timeZone: tz })) : new Tempo(start, { timeZone: tz }); const endTempo = Tempo.isTempo(end) ? (end.tz === tz ? end : end.set({ timeZone: tz })) : new Tempo(end, { timeZone: tz }); @@ -78,25 +90,19 @@ 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; 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 { @@ -106,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 { + logDebug('tempo-plugin-ai:diff', `Cache hit: "${cacheKey}"`, cachedVal, { debug: isDebug }); + const cachedResult: TempoAiDiffResult = { formatted: parsedCache.formatted, days: parsedCache.days ?? grounding.calendarDays, hours: parsedCache.hours ?? grounding.elapsedHours, @@ -117,6 +123,17 @@ async function diffSingleInput( 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 { @@ -204,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 }, ); @@ -226,36 +243,38 @@ Do not include markdown blocks or text outside the JSON.`; confidence, provider: providerId, 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); } - return finalResult; + 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', + }); + + 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 64579084..30a2a625 100644 --- a/packages/plugins/ai/src/functions/extract.ts +++ b/packages/plugins/ai/src/functions/extract.ts @@ -1,102 +1,433 @@ -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 { + normalizeCacheInput, + readMultiTierCache, + writeMultiTierCache, +} from '../core/cache.js'; +import { + assertNoReservedProviderId, + fetchFromProvider, + resolveProviderTtl, + resolveTzAndLocale, +} from '../core/support.js'; +import { logDebug, warnDebug, attachCustomInspect, maskPii } from '../core/logger.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, undefined, { cause: err }); + } + + 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}:{ss}')}::${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) { + 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, + 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 (err: any) { + warnDebug('tempo-plugin-ai:extract', 'Failed to rehydrate cached event', err, { debug: isDebug }); + } + } + + const reasoning = typeof parsedCache.reasoning === 'string' ? parsedCache.reasoning : undefined; + const cachedResult: TempoAiExtractResult = { + events: rehydratedEvents, + confidence: cachedConfidence, + provider: 'cache', + 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) { + warnDebug('tempo-plugin-ai:extract', 'Failed to parse cached payload', err, { debug: isDebug }); + } + } + + 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 (err: any) { + warnDebug('tempo-plugin-ai:extract', `Failed to parse event from provider '${providerId}'`, err, { debug: isDebug }); + } + } + + 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', + }); + + 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); } /** - * @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)) { + if (textOrTexts.length === 0) return []; + const opts = options || {}; + const softErrors = opts.softErrors ?? false; + let rawConcurrency = opts.concurrency; + if (rawConcurrency !== undefined && (typeof rawConcurrency !== 'number' || !Number.isFinite(rawConcurrency) || rawConcurrency < 1)) + rawConcurrency = 4; + + const validConcurrency = Math.floor(rawConcurrency ?? 4); + const concurrencyLimit = Math.max(1, Math.min(validConcurrency, textOrTexts.length)); + + 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 results; + } + + return extractSingleInput(textOrTexts, options); } diff --git a/packages/plugins/ai/src/functions/format.ts b/packages/plugins/ai/src/functions/format.ts index 1b16a57a..24bb1096 100644 --- a/packages/plugins/ai/src/functions/format.ts +++ b/packages/plugins/ai/src/functions/format.ts @@ -1,88 +1,375 @@ -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 { 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 { + normalizeCacheInput, + readMultiTierCache, + writeMultiTierCache, +} from '../core/cache.js'; +import { + assertNoReservedProviderId, + fetchFromProvider, + resolveProviderTtl, + resolveTzAndLocale, +} 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 }; + +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: TempoDateInput, + 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) { + 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) { + 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; + 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) { + 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) { + 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() : ''; + 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; + 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, { + 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) + ? Math.max(0.0, Math.min(1.0, parsedCache.confidence)) + : 1.0; + + if (effectiveMinConfidence !== undefined && cachedConfidence < effectiveMinConfidence) { + 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; + 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) { + warnDebug('tempo-plugin-ai:format', 'Failed to parse cached payload', err, { debug: isDebug }); + } + } + + 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 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. + +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 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. + +Output JSON Schema: +{ + "formatted": "string", + "confidence": 0.95, + "reasoning": "string" +}`; + + 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; + + 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); + } + + 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) + throw new TempoAiError(`Provider ${providerId} returned empty formatted string.`, 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; + + 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 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); + } + + 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', + }); + + attachCustomInspect(finalResult, (obj, isProd) => ({ + formatted: obj.formatted, + confidence: obj.confidence, + provider: obj.provider, + ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}), + })); + + return secure(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 * 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); * ``` */ 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: TempoDateInput, prompt?: string, options?: AiFormatOptions): Promise; export async function formatAI( - dateOrItems: any, - _promptOrOptions?: string | AiFormatOptions, - _options?: AiFormatOptions, + dateOrItems: TempoDateInput | FormatItem[], + promptOrOptions?: string | AiFormatOptions, + options?: AiFormatOptions, ): Promise { - throw new Error('formatAI is not yet implemented in tempo-plugin-ai.'); + 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; + } + } + } + }; + + const workers = Array.from({ length: concurrencyLimit }, () => worker()); + await Promise.all(workers); + + if (!softErrors && firstError) { + throw firstError; + } + + return results; + } + + 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..caad5aeb 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'; @@ -11,25 +13,47 @@ 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)) { - 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)); - sph = String(options!.sphere || options!.anchor.config.sphere || 'north'); + tz = String(options!.timeZone || options!.anchor.tz); + cal = String(options!.calendar || options!.anchor.cal); + const rawLoc = options!.locale || options!.anchor.locale; + loc = String(Array.isArray(rawLoc) ? rawLoc[0] : rawLoc); + sph = String(options!.sphere || options!.anchor.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()); } - 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; @@ -43,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 }); } } @@ -51,8 +75,8 @@ 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); + logDebug('tempo-plugin-ai:parse', `Cache hit: "${str}" -> ${cachedIso}`, undefined, { debug: isDebug }); + const cachedInstance = new Tempo(cachedIso, tempoConfig); return attachAiMeta(cachedInstance, { provider: 'cache', cached: true, @@ -67,14 +91,14 @@ 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()) || 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, @@ -140,7 +164,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, @@ -161,8 +185,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) { @@ -171,13 +197,13 @@ 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); } - 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 365ec4a9..24dcdb76 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; } /** @@ -116,19 +129,19 @@ 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 sph = options?.sphere || (options?.anchor instanceof Tempo ? options.anchor.config.sphere : undefined) || Tempo.options.sphere; + 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.locale : undefined) || Tempo.options.locale; + const scalarLoc = String(Array.isArray(loc) ? loc[0] : loc); + const sph = options?.sphere || (options?.anchor instanceof Tempo ? options.anchor.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) { 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, @@ -151,12 +164,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/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts index cff5952d..7ce8d3a1 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,75 @@ 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 (prop === CUSTOM_INSPECT_SYMBOL || prop === 'toJSON') { + return { + value: (inspectableMeta as any)[prop], + writable: false, + configurable: true, + enumerable: false, + }; + } + if (Object.hasOwn(inspectableMeta, prop)) { return { - value: (frozenMeta as any)[prop], + value: (inspectableMeta as any)[prop], writable: false, configurable: true, enumerable: true, @@ -148,9 +188,10 @@ function wrapScheduleInterval(interval: Interval, meta: TempoScheduleMeta return Reflect.getOwnPropertyDescriptor(target, prop); }, ownKeys(target) { - const keys = Reflect.ownKeys(target); - for (const k of Object.keys(frozenMeta)) { - if (!keys.includes(k)) keys.push(k); + const keys = Reflect.ownKeys(target).filter(k => k !== CUSTOM_INSPECT_SYMBOL && k !== 'toJSON'); + for (const k of Object.keys(inspectableMeta)) { + if (k !== 'toJSON' && !keys.includes(k)) + keys.push(k); } return keys; }, @@ -183,10 +224,10 @@ 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 }); + 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 +442,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 30c1ec71..b8d79ed8 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -6,26 +6,18 @@ 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'; +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 { contextAI } from './functions/context.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. - */ - -// /** 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/base.type.ts b/packages/plugins/ai/src/types/base.type.ts new file mode 100644 index 00000000..bce9747a --- /dev/null +++ b/packages/plugins/ai/src/types/base.type.ts @@ -0,0 +1,182 @@ +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 + * Telemetry and provenance metadata attached to parsed Tempo instances via `.ai`. + */ +export interface TempoBaseAiMeta { + /** Provider identifier that produced the result */ + readonly provider: string; + /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ + readonly confidence: number; + /** Indicates if the result was served from cache */ + readonly cached?: boolean | undefined; + /** Optional step-by-step reasoning or justification from LLM */ + readonly reasoning?: string | undefined; + /** Upstream rate-limit diagnostic telemetry (if provided by response headers) */ + readonly limits?: AiRateLimits | undefined; + /** Raw prompt passed by caller (available in debug mode) */ + readonly rawPrompt?: string | undefined; + /** 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; +} + +/** + * ## AiCacheAdapter + * Interface for synchronous or asynchronous custom storage engines (e.g. Redis, Cloudflare KV, Memcached). + */ +export interface AiCacheAdapter { + /** Retrieve a value by key */ + get(key: string): Promise | string | undefined; + /** 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 | 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 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 | undefined; + /** Optional custom API endpoint URL (e.g., for local Ollama or Azure OpenAI) */ + url?: string | undefined; + /** Optional custom model identifier (e.g., to override the provider's default model) */ + 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 | undefined; +} + +/** + * ## AiConfig + * Configuration options for the AI parsing plugin. + */ +export interface AiConfig { + /** An array of fallback providers to use for routing */ + providers?: AiProvider[] | undefined; + /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` or string literal) */ + mode?: AiMode | undefined; + /** Strict minimum confidence threshold (0.0 to 1.0) */ + minConfidence?: number | 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) */ + timeout?: number | undefined; + /** Optional remote manifest URL or false to disable remote defaults (default: 'https://tempo.magmacomputing.com.au/providers.v1.json') */ + remoteConfigUrl?: string | false | undefined; + /** Optional custom resolver hook to fetch provider default options by ID */ + fetchDefaults?: ((providerId: string) => Promise | null>) | undefined; + /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */ + hedgeDelay?: number | undefined; + /** If true, logs the spoon-fed LLM context prompt and raw LLM response to the console */ + debug?: boolean | undefined; +} diff --git a/packages/plugins/ai/src/types/common.type.ts b/packages/plugins/ai/src/types/common.type.ts deleted file mode 100644 index 6baf84bc..00000000 --- a/packages/plugins/ai/src/types/common.type.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { Tempo } from '@magmacomputing/tempo'; -import type { AiMode } from '../core/config.js'; - -/** - * ## TempoBaseAiMeta - * Fundamental AI resolution telemetry and metadata shared across all AI functions. - */ -export interface TempoBaseAiMeta { - /** Resolution source ('native', 'cache', or provider ID like 'groq', 'openai', 'ollama') */ - readonly provider: string; - /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ - readonly confidence: number; - /** Whether the result was retrieved from cache */ - readonly cached?: boolean | undefined; - /** Step-by-step reasoning or justification provided by the engine/LLM */ - readonly reasoning?: string | undefined; - /** Rate limit snapshot returned by the provider HTTP headers for this request */ - readonly limits?: AiRateLimits | undefined; - /** Raw prompt input (only included when debug: true) */ - readonly rawPrompt?: string | undefined; - /** Normalized prompt input (only included when debug: true) */ - readonly normalizedPrompt?: string | undefined; - /** Arbitrary provider-specific extra metadata */ - readonly [key: string]: any; -} - -/** - * ## AiCacheAdapter - * Interface for synchronous or asynchronous custom storage engines (e.g. Redis, Cloudflare KV, Memcached). - */ -export interface AiCacheAdapter { - /** Retrieve a value by key */ - get(key: string): Promise | string | undefined; - /** 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; - /** Clear entries, optionally matching a key prefix */ - clear?(prefix?: string): Promise | void; -} - -/** - * ## AiProvider - * Represents an LLM provider and its respective BYOK API key. - */ -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; - /** Optional custom API endpoint URL (e.g., for local Ollama or Azure OpenAI) */ - url?: string; - /** Optional custom model identifier (e.g., to override the provider's default model) */ - model?: string; - /** 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 LLM parameters (e.g. temperature, max_tokens, top_p) */ - options?: Record; -} - -/** - * ## AiConfig - * Configuration options for the AI parsing plugin. - */ -export interface AiConfig { - /** An array of fallback providers to use for routing */ - providers?: AiProvider[] | undefined; - /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` or string literal) */ - 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 storage engine (e.g., Redis, KV store) for storing parsed strings */ - cacheAdapter?: AiCacheAdapter | 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) */ - timeout?: number | undefined; - /** Optional remote manifest URL or false to disable remote defaults (default: 'https://tempo.magmacomputing.com.au/providers.v1.json') */ - remoteConfigUrl?: string | false | undefined; - /** Optional custom resolver hook to fetch provider default options by ID */ - fetchDefaults?: ((providerId: string) => Promise | null>) | undefined; - /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */ - hedgeDelay?: number | undefined; - /** 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..5d0df35b --- /dev/null +++ b/packages/plugins/ai/src/types/extract.type.ts @@ -0,0 +1,46 @@ +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; + /** Optional maximum number of concurrent extraction requests when processing arrays (default: 4) */ + concurrency?: number | undefined; +} 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..92be2c0f --- /dev/null +++ b/packages/plugins/ai/src/types/format.type.ts @@ -0,0 +1,36 @@ +import type { AiDateContextOptions, TempoBaseAiResult, TempoDateInput } from './base.type.js'; + +export type { TempoDateInput }; + +/** + * ## TempoAiFormatResult + * Structured contextual narrative formatting result returned by `formatAI`. + */ +export interface TempoAiFormatResult extends TempoBaseAiResult { + /** Human-friendly, contextual narrative text summarizing the date-time */ + formatted: string; +} + +/** + * ## 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; + /** 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 9e85801a..8e7c2b27 100644 --- a/packages/plugins/ai/src/types/index.ts +++ b/packages/plugins/ai/src/types/index.ts @@ -1,7 +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..d6c0d428 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 | 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..028985a7 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; } /** @@ -29,27 +29,38 @@ export interface TempoInterval { end: Tempo; } +/** + * ## ScheduleEventInput + * Valid calendar event or busy interval input shape for scheduling conflicts. + */ +export type ScheduleEventInput = + | { start: any; end: any; title?: string | undefined; label?: string | undefined } + | TempoInterval + | Interval + | [any, any] + | TempoDateInput; + /** * ## TempoScheduleOptions * Options passed to `scheduleAI(prompt, options)`. */ 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 | undefined; /** Alias for events */ - intervals?: Array<{ start: any; end: any; title?: string }> | Array>; + intervals?: 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 +105,3 @@ export interface TempoScheduleResult extends Interval, TempoScheduleMeta /** Resolved end boundary as a Tempo instance */ end: Tempo; } - 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/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/debug.test.ts b/packages/plugins/ai/test/debug.test.ts new file mode 100644 index 00000000..e384237e --- /dev/null +++ b/packages/plugins/ai/test/debug.test.ts @@ -0,0 +1,367 @@ +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; + const originalProd = process.env.PROD; + + beforeEach(async () => { + resetAI(); + Tempo.cache.clear(); + process.env.NODE_ENV = 'test'; + await initAI({ + remoteConfigUrl: false, + providers: [{ id: 'groq', key: 'gsk-1234567890abcdef1234567890' }], + }); + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalEnv; + } + if (originalProd === undefined) { + delete process.env.PROD; + } else { + process.env.PROD = originalProd; + } + 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 not false-positive mask numeric timestamps without phone separators', () => { + const raw = 'Timestamp is 20260815120000 and ID is 9876543210'; + const masked = maskPii(raw, true); + expect(masked).toBe(raw); + }); + + 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'); + }); + }); + + 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 handle circular object references gracefully without stack overflow', () => { + const circularObj: any = { + name: 'test', + nested: { + email: 'contact@secure.com', + }, + }; + circularObj.self = circularObj; + circularObj.nested.parent = circularObj; + + const sanitized = sanitizeForLog(circularObj, true); + expect(sanitized.name).toBe('test'); + expect(sanitized.nested.email).toBe('c***@secure.com'); + expect(sanitized.self).toBe('[CIRCULAR]'); + expect(sanitized.nested.parent).toBe('[CIRCULAR]'); + }); + + 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 () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + 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/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..ef3afba7 --- /dev/null +++ b/packages/plugins/ai/test/extract.test.ts @@ -0,0 +1,491 @@ +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(); + Tempo.cache.clear(); + 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(); + Tempo.cache.clear(); + 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 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: [{ + 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()) + .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', 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, 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) and preserve error cause', async () => { + await expect(extractAI('')) + .rejects.toMatchObject({ message: 'Invalid text input provided to extractAI: text must be a non-empty string.', status: 400 }); + + await expect(extractAI(' ')) + .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.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 () => { }), + } + + // Non-finite + await expect(extractAI('some text', { minConfidence: NaN, cacheAdapter: customAdapter })) + .rejects.toMatchObject({ message: 'Invalid minConfidence provided to extractAI: "NaN"', status: 400 }); + + await expect(extractAI('some text', { minConfidence: Infinity, cacheAdapter: customAdapter })) + .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.toMatchObject({ message: 'Invalid minConfidence provided to extractAI: "-0.5"', status: 400 }); + + await expect(extractAI('some text', { minConfidence: 1.2, cacheAdapter: customAdapter })) + .rejects.toMatchObject({ message: 'Invalid minConfidence provided to extractAI: "1.2"', status: 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.toMatchObject({ message: 'No AI providers configured. Please call initAI().', status: 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.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({ + 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' } }); + }); + + 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); + }); + + 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 new file mode 100644 index 00000000..8dfdbb3c --- /dev/null +++ b/packages/plugins/ai/test/format.test.ts @@ -0,0 +1,435 @@ +import { Tempo } from '@magmacomputing/tempo'; +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(() => { }); + await initAI({ remoteConfigUrl: false, providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] }); + }); + + afterEach(() => { + resetAI(); + Tempo.cache.clear(); + 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, 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); + 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', { timeZone: 'Australia/Sydney' }); + + 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 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({ + 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 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.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "NaN"', status: 400 }); + + await expect(formatAI('2026-08-07', 'test', { minConfidence: Infinity, cacheAdapter: customAdapter })) + .rejects.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "Infinity"', status: 400 }); + + await expect(formatAI('2026-08-07', 'test', { minConfidence: -Infinity, cacheAdapter: customAdapter })) + .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.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "-0.1"', status: 400 }); + + await expect(formatAI('2026-08-07', 'test', { minConfidence: 1.05, cacheAdapter: customAdapter })) + .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(); + 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.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "1.5"', status: 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({ + 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.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({ + formatted: 'Item 1 formatted', + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + 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); + }); + + it('should reject with TempoAiError on batch failure when softErrors is false', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + 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({ + formatted: 'Item 1 formatted', + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + 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 = { + 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(); + }); + + 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/plugins/ai/test/manifest.test.ts b/packages/plugins/ai/test/manifest.test.ts index c1970b26..3e8516ae 100644 --- a/packages/plugins/ai/test/manifest.test.ts +++ b/packages/plugins/ai/test/manifest.test.ts @@ -22,7 +22,7 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { const mockManifest = { version: '1.0', providers: { - groq: { model: 'llama-3.3-70b-versatile', tokenParam: 'max_tokens' }, + groq: { model: 'openai/gpt-oss-120b', tokenParam: 'max_tokens' }, openai: { model: 'gpt-5.4-mini', tokenParam: 'max_completion_tokens' } } }; diff --git a/packages/plugins/ai/test/parse.test.ts b/packages/plugins/ai/test/parse.test.ts index a72d8dd3..43d94eac 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, resetAI, aiCache, getAiRateLimits, getAiConfig, TempoAiError, AiMode, DEFAULT_PROVIDERS } from '../src/index.js'; import { BoundedCache } from '@magmacomputing/tempo/support'; import { Tempo } from '@magmacomputing/tempo'; @@ -8,6 +8,7 @@ describe('AI Parsing Plugin (parseAI)', () => { const isLiveTest = Boolean(process.env.LIVE_AI_TEST && liveApiKey); beforeEach(async () => { + resetAI(); vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -26,6 +27,7 @@ describe('AI Parsing Plugin (parseAI)', () => { }); afterEach(() => { + resetAI(); vi.restoreAllMocks(); }); @@ -46,7 +48,7 @@ describe('AI Parsing Plugin (parseAI)', () => { expect(config.providers).toHaveLength(1); expect(config.providers?.[0].id).toBe('groq'); expect(config.providers?.[0].key).toBe('[REDACTED]'); - expect(config.providers?.[0].model).toBe('llama-3.3-70b-versatile'); + expect(config.providers?.[0].model).toBe(DEFAULT_PROVIDERS.groq.model); }); it('should fall back to native parsing first and attach .ai metadata', async () => { @@ -58,6 +60,9 @@ describe('AI Parsing Plugin (parseAI)', () => { expect(result.ai?.cached).toBe(false); expect(result.ai?.confidence).toBe(1.0); expect(Object.isFrozen(result.ai)).toBe(true); + expect('ai' in result).toBe(true); + expect(() => Object.keys(result)).not.toThrow(); + expect(() => Reflect.ownKeys(result)).not.toThrow(); }); it('should throw TempoAiError if reserved provider ID "native" or "cache" is used in initAI', () => { @@ -65,7 +70,7 @@ describe('AI Parsing Plugin (parseAI)', () => { expect(() => initAI({ remoteConfigUrl: false, providers: [{ id: 'cache', key: '123' }] })).toThrow(TempoAiError); }); - it('should canonicalize Gemini provider ID and use gemini-3.6-flash model by default', async () => { + it('should canonicalize Gemini provider ID and use default Gemini model', async () => { await initAI({ remoteConfigUrl: false, providers: [{ id: 'Gemini', key: 'mock-gemini-key' }] @@ -77,7 +82,7 @@ describe('AI Parsing Plugin (parseAI)', () => { await parseAI('Christmas 2026', { force: true }); expect(fetchSpy).toHaveBeenCalled(); const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); - expect(body.model).toBe('gemini-3.6-flash'); + expect(body.model).toBe(DEFAULT_PROVIDERS.gemini.model); }); it('should throw TempoAiError if no key is configured and AI is needed', async () => { @@ -156,7 +161,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 +468,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); diff --git a/packages/plugins/ai/test/recurrence.test.ts b/packages/plugins/ai/test/recurrence.test.ts index 26106063..b91db655 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].config.sphere).toBe('south'); + expect(items[0].tz).toBe('Australia/Sydney'); + expect(items[0].cal).toBe('iso8601'); + expect(items[0].locale).toBe('en-AU'); + expect(items[0].sphere).toBe('south'); }); }); diff --git a/packages/plugins/astro/CHANGELOG.md b/packages/plugins/astro/CHANGELOG.md index 64544bda..281637ad 100644 --- a/packages/plugins/astro/CHANGELOG.md +++ b/packages/plugins/astro/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to the `@magmacomputing/tempo-plugin-astro` project will be documented in this file. +## [2.1.4] - 2026-08-15 + +### Changed +- **Performance**: Switched hemisphere resolution to access `t.sphere` directly from the `Tempo` instance, bypassing `t.config` proxy evaluation in the hot resolution path. + ## [2.1.2] - 2026-07-15 ### Fixed diff --git a/packages/plugins/astro/package.json b/packages/plugins/astro/package.json index 2e1c800a..8aae8108 100644 --- a/packages/plugins/astro/package.json +++ b/packages/plugins/astro/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo-plugin-astro", - "version": "2.1.3", + "version": "2.1.4", "description": "Tempo plugin that calculates precise astronomical seasons (solstices & equinoxes) using the Jean Meeus algorithm — hemisphere-aware, sub-minute accuracy", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/plugins/astro/src/index.ts b/packages/plugins/astro/src/index.ts index 22e579a9..6fc3f126 100644 --- a/packages/plugins/astro/src/index.ts +++ b/packages/plugins/astro/src/index.ts @@ -61,7 +61,7 @@ function calculateAstroMoment(year: number, quarter: ASTRO, timeZone: string) { } function resolve(t: Tempo, anchor?: any) { - const sphere = t.config.sphere; + const sphere = t.sphere; if (!sphere) return []; const year = anchor?.yy ?? anchor?.year ?? t.yy; diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index afa4d5f6..b6e2b541 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vitepress' import { withMermaid } from 'vitepress-plugin-mermaid' import { fileURLToPath } from 'node:url' import { Temporal } from '@js-temporal/polyfill' +import pluginsSidebar from './theme/data/plugins-sidebar.json' if (typeof (globalThis as any).Temporal === 'undefined') { Object.defineProperty(globalThis, 'Temporal', { @@ -33,91 +34,94 @@ export default withMermaid(defineConfig({ { text: 'Releases', link: '/doc/8-project-and-support/releases/' }, { text: 'Functions', link: 'https://magmacomputing.github.io/magma/functions/' } ], - sidebar: [ - { - text: 'Getting Started', - items: [ - { text: 'Introduction', link: '/README' }, - { text: 'Installation', link: '/doc/1-getting-started/installation' }, - { text: 'AI & IDE Integration', link: '/doc/1-getting-started/ai-integration' }, - { text: 'Cookbook', link: '/doc/1-getting-started/tempo.cookbook' } - ] - }, - { - text: 'Core Concepts', - items: [ - { text: 'Configuration', link: '/doc/2-core-concepts/tempo.config' }, - { text: 'Cache Management', link: '/doc/2-core-concepts/tempo.cache' }, - { text: 'Core Getters', link: '/doc/2-core-concepts/tempo.getters' }, - { text: 'Smart Parsing', link: '/doc/2-core-concepts/tempo.parse' }, - { text: 'Smart Formatting', link: '/doc/2-core-concepts/tempo.format' }, - { text: 'Duration Logic', link: '/doc/2-core-concepts/tempo.duration' }, - { text: 'Mutation & Math', link: '/doc/2-core-concepts/tempo.mutate' }, - { text: 'Intervals', link: '/doc/2-core-concepts/tempo.interval' } - ] - }, - { - text: 'Extending Tempo', - items: [ - { text: 'Modules', link: '/doc/3-extending-tempo/tempo.modularity' }, - { text: 'Registries', link: '/doc/3-extending-tempo/tempo.registry' }, - { text: 'Plugins', link: '/doc/3-extending-tempo/tempo.plugin' }, - { text: 'Layout Patterns', link: '/doc/3-extending-tempo/tempo.layout' }, - { text: 'Terms', link: '/doc/3-extending-tempo/tempo.term' }, - { text: 'Namespaces', link: '/doc/3-extending-tempo/tempo.namespace' }, - { text: 'Creating Custom Plugins', link: '/doc/3-extending-tempo/tempo.extension' }, - { text: 'Plugin Ecosystem', link: '/doc/3-extending-tempo/ecosystem' } - ] - }, - { - text: 'Advanced Reference', - items: [ - { text: 'Sandbox Factory', link: '/doc/4-advanced-reference/sandbox-factory' }, - { text: 'Parse Planner', link: '/doc/4-advanced-reference/tempo.planner' }, - { text: 'The Role of Locale', link: '/doc/4-advanced-reference/tempo.locale' }, - { text: 'Shorthand Engine', link: '/doc/4-advanced-reference/tempo.shorthand' }, - { text: 'Weekday Engine', link: '/doc/4-advanced-reference/tempo.weekday' }, - { text: 'Debugging', link: '/doc/4-advanced-reference/tempo.debugging' } - ] - }, - { - text: 'Architecture & Internals', - items: [ - { text: 'Core Architecture', link: '/doc/5-architecture-and-internals/architecture' }, - { text: 'Soft Freeze Strategy', link: '/doc/5-architecture-and-internals/soft_freeze_strategy' }, - { text: 'Lazy Evaluation', link: '/doc/5-architecture-and-internals/lazy-evaluation-pattern' }, - { text: 'Performance Benchmarks', link: '/doc/5-architecture-and-internals/tempo.benchmarks' } - ] - }, - { - text: 'Utility Library', - items: [ - { text: 'Library Overview', link: '/doc/6-utility-library/tempo.library' }, - { text: 'Enumerators', link: '/doc/6-utility-library/tempo.enumerators' }, - { text: 'Serializers', link: '/doc/6-utility-library/tempo.serializers' }, - { text: 'Decorators', link: '/doc/6-utility-library/tempo.decorators' }, - { text: 'Advanced Promises (Pledge)', link: '/doc/6-utility-library/tempo.pledge' } - ] - }, - { - text: 'Ecosystem', - items: [ - { text: 'Contribution Guide', link: '/CONTRIBUTING' }, - { text: 'Comparison', link: '/doc/7-ecosystem/comparison' }, - { text: 'Extending Temporal', link: '/doc/7-ecosystem/extending-temporal' }, - { text: 'Project Vision', link: '/doc/7-ecosystem/vision' } - ] - }, - { - text: 'Project & Support', - items: [ - { text: 'License Key Guide', link: '/doc/9-plugins/_setup.index' }, - { text: 'Migration Guide', link: '/doc/8-project-and-support/migration-guide' }, - { text: 'Release Notes', link: '/doc/8-project-and-support/releases/' }, - { text: 'Professional Services', link: '/doc/8-project-and-support/commercial' } - ] - } - ], + sidebar: { + '/doc/9-plugins/': pluginsSidebar, + '/': [ + { + text: 'Getting Started', + items: [ + { text: 'Introduction', link: '/README' }, + { text: 'Installation', link: '/doc/1-getting-started/installation' }, + { text: 'AI & IDE Integration', link: '/doc/1-getting-started/ai-integration' }, + { text: 'Cookbook', link: '/doc/1-getting-started/tempo.cookbook' } + ] + }, + { + text: 'Core Concepts', + items: [ + { text: 'Configuration', link: '/doc/2-core-concepts/tempo.config' }, + { text: 'Cache Management', link: '/doc/2-core-concepts/tempo.cache' }, + { text: 'Core Getters', link: '/doc/2-core-concepts/tempo.getters' }, + { text: 'Smart Parsing', link: '/doc/2-core-concepts/tempo.parse' }, + { text: 'Smart Formatting', link: '/doc/2-core-concepts/tempo.format' }, + { text: 'Duration Logic', link: '/doc/2-core-concepts/tempo.duration' }, + { text: 'Mutation & Math', link: '/doc/2-core-concepts/tempo.mutate' }, + { text: 'Intervals', link: '/doc/2-core-concepts/tempo.interval' } + ] + }, + { + text: 'Extending Tempo', + items: [ + { text: 'Modules', link: '/doc/3-extending-tempo/tempo.modularity' }, + { text: 'Registries', link: '/doc/3-extending-tempo/tempo.registry' }, + { text: 'Plugins', link: '/doc/3-extending-tempo/tempo.plugin' }, + { text: 'Layout Patterns', link: '/doc/3-extending-tempo/tempo.layout' }, + { text: 'Terms', link: '/doc/3-extending-tempo/tempo.term' }, + { text: 'Namespaces', link: '/doc/3-extending-tempo/tempo.namespace' }, + { text: 'Creating Custom Plugins', link: '/doc/3-extending-tempo/tempo.extension' }, + { text: 'Plugin Ecosystem', link: '/doc/3-extending-tempo/ecosystem' } + ] + }, + { + text: 'Advanced Reference', + items: [ + { text: 'Sandbox Factory', link: '/doc/4-advanced-reference/sandbox-factory' }, + { text: 'Parse Planner', link: '/doc/4-advanced-reference/tempo.planner' }, + { text: 'The Role of Locale', link: '/doc/4-advanced-reference/tempo.locale' }, + { text: 'Shorthand Engine', link: '/doc/4-advanced-reference/tempo.shorthand' }, + { text: 'Weekday Engine', link: '/doc/4-advanced-reference/tempo.weekday' }, + { text: 'Debugging', link: '/doc/4-advanced-reference/tempo.debugging' } + ] + }, + { + text: 'Architecture & Internals', + items: [ + { text: 'Core Architecture', link: '/doc/5-architecture-and-internals/architecture' }, + { text: 'Soft Freeze Strategy', link: '/doc/5-architecture-and-internals/soft_freeze_strategy' }, + { text: 'Lazy Evaluation', link: '/doc/5-architecture-and-internals/lazy-evaluation-pattern' }, + { text: 'Performance Benchmarks', link: '/doc/5-architecture-and-internals/tempo.benchmarks' } + ] + }, + { + text: 'Utility Library', + items: [ + { text: 'Library Overview', link: '/doc/6-utility-library/tempo.library' }, + { text: 'Enumerators', link: '/doc/6-utility-library/tempo.enumerators' }, + { text: 'Serializers', link: '/doc/6-utility-library/tempo.serializers' }, + { text: 'Decorators', link: '/doc/6-utility-library/tempo.decorators' }, + { text: 'Advanced Promises (Pledge)', link: '/doc/6-utility-library/tempo.pledge' } + ] + }, + { + text: 'Ecosystem', + items: [ + { text: 'Contribution Guide', link: '/CONTRIBUTING' }, + { text: 'Comparison', link: '/doc/7-ecosystem/comparison' }, + { text: 'Extending Temporal', link: '/doc/7-ecosystem/extending-temporal' }, + { text: 'Project Vision', link: '/doc/7-ecosystem/vision' } + ] + }, + { + text: 'Project & Support', + items: [ + { text: 'License Key Guide', link: '/doc/9-plugins/_setup.index' }, + { text: 'Migration Guide', link: '/doc/8-project-and-support/migration-guide' }, + { text: 'Release Notes', link: '/doc/8-project-and-support/releases/' }, + { text: 'Professional Services', link: '/doc/8-project-and-support/commercial' } + ] + } + ] + }, socialLinks: [ { icon: 'github', link: 'https://github.com/magmacomputing/magma/tree/main/packages/tempo' } ], @@ -200,7 +204,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..ab7b8e9a 100644 --- a/packages/tempo/.vitepress/theme/data/catalog.json +++ b/packages/tempo/.vitepress/theme/data/catalog.json @@ -6,7 +6,7 @@ "packageName": "@magmacomputing/tempo-plugin-astro", "plan": "community", "status": "active", - "version": "2.1.3" + "version": "2.1.4" }, { "id": "batch", @@ -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": "0.3.0" + "status": "active", + "version": "1.0.0" }, { "id": "ticker", diff --git a/packages/tempo/.vitepress/theme/data/plugins-sidebar.json b/packages/tempo/.vitepress/theme/data/plugins-sidebar.json new file mode 100644 index 00000000..98ad4718 --- /dev/null +++ b/packages/tempo/.vitepress/theme/data/plugins-sidebar.json @@ -0,0 +1,107 @@ +[ + { + "text": "Plugin Ecosystem", + "items": [ + { + "text": "← Back to Catalog", + "link": "/doc/3-extending-tempo/ecosystem" + }, + { + "text": "License Key Guide", + "link": "/doc/9-plugins/_setup.index" + } + ] + }, + { + "text": "AI Plugin (@magmacomputing/tempo-plugin-ai)", + "collapsed": false, + "items": [ + { + "text": "Overview", + "link": "/doc/9-plugins/ai.index" + }, + { + "text": "Initialization (initAI)", + "link": "/doc/9-plugins/ai.init" + }, + { + "text": "Smart Parsing (parseAI)", + "link": "/doc/9-plugins/ai.parse" + }, + { + "text": "Narrative Formatting (formatAI)", + "link": "/doc/9-plugins/ai.format" + }, + { + "text": "Entity Extraction (extractAI)", + "link": "/doc/9-plugins/ai.extract" + }, + { + "text": "Recurrence Rules (recurrenceAI)", + "link": "/doc/9-plugins/ai.recurrence" + }, + { + "text": "Conflict Scheduling (scheduleAI)", + "link": "/doc/9-plugins/ai.schedule" + }, + { + "text": "Time Differences (diffAI)", + "link": "/doc/9-plugins/ai.diff" + }, + { + "text": "Regional Context (contextAI)", + "link": "/doc/9-plugins/ai.context" + }, + { + "text": "Execution Modes", + "link": "/doc/9-plugins/ai.modes" + }, + { + "text": "Security & PII Protection", + "link": "/doc/9-plugins/ai.security" + }, + { + "text": "Grounding & Normalization", + "link": "/doc/9-plugins/ai.grounding" + }, + { + "text": "Rate Limits & Caching", + "link": "/doc/9-plugins/ai.rate-limits" + }, + { + "text": "Provider Architecture", + "link": "/doc/9-plugins/ai.architecture" + } + ] + }, + { + "text": "Community & Pro Plugins", + "collapsed": false, + "items": [ + { + "text": "Astro (Seasons & Solstices)", + "link": "/doc/9-plugins/astro.index" + }, + { + "text": "Batch (Multi-Threaded SAB)", + "link": "/doc/9-plugins/batch.index" + }, + { + "text": "Finance (Fiscal & Business)", + "link": "/doc/9-plugins/finance.index" + }, + { + "text": "Snap (Block Rounding)", + "link": "/doc/9-plugins/snap.index" + }, + { + "text": "Sync (Thread Synchronization)", + "link": "/doc/9-plugins/sync.index" + }, + { + "text": "Ticker (Execution Loop)", + "link": "/doc/9-plugins/ticker.index" + } + ] + } +] \ No newline at end of file diff --git a/packages/tempo/bin/harvest-plugins.mjs b/packages/tempo/bin/harvest-plugins.mjs index 2e880ce3..77a9288d 100644 --- a/packages/tempo/bin/harvest-plugins.mjs +++ b/packages/tempo/bin/harvest-plugins.mjs @@ -7,12 +7,72 @@ const __dirname = path.dirname(__filename); const pluginsDir = path.resolve(__dirname, '../../../packages/plugins'); const targetDir = path.resolve(__dirname, '../doc/9-plugins'); +const sidebarOutputFile = path.resolve(__dirname, '../.vitepress/theme/data/plugins-sidebar.json'); fs.rmSync(targetDir, { recursive: true, force: true }); fs.mkdirSync(targetDir, { recursive: true }); // Track normalised plugin IDs to detect collisions early (e.g. both '.setup/' and 'setup/' present) const usedPluginIds = new Map(); // pluginId -> original directory name +const harvestedByPlugin = new Map(); // pluginId -> Array<{ basename: string, title: string, link: string }> + +const KNOWN_TITLES = { + 'ai.index': 'Overview', + 'ai.init': 'Initialization (initAI)', + 'ai.parse': 'Smart Parsing (parseAI)', + 'ai.format': 'Narrative Formatting (formatAI)', + 'ai.extract': 'Entity Extraction (extractAI)', + 'ai.recurrence': 'Recurrence Rules (recurrenceAI)', + 'ai.schedule': 'Conflict Scheduling (scheduleAI)', + 'ai.diff': 'Time Differences (diffAI)', + 'ai.context': 'Regional Context (contextAI)', + 'ai.modes': 'Execution Modes', + 'ai.security': 'Security & PII Protection', + 'ai.grounding': 'Grounding & Normalization', + 'ai.rate-limits': 'Rate Limits & Caching', + 'ai.architecture': 'Provider Architecture', + 'astro.index': 'Astro (Seasons & Solstices)', + 'batch.index': 'Batch (Multi-Threaded SAB)', + 'finance.index': 'Finance (Fiscal & Business)', + 'snap.index': 'Snap (Block Rounding)', + 'sync.index': 'Sync (Thread Synchronization)', + 'ticker.index': 'Ticker (Execution Loop)' +}; + +const PREFERRED_AI_ORDER = [ + 'index', + 'init', + 'parse', + 'format', + 'extract', + 'recurrence', + 'schedule', + 'diff', + 'context', + 'modes', + 'security', + 'grounding', + 'rate-limits', + 'architecture' +]; + +function extractTitle(content, pluginId, basename) { + const key = `${pluginId}.${basename}`; + if (KNOWN_TITLES[key]) return KNOWN_TITLES[key]; + + const match = content.match(/^#\s+(.+)$/m); + if (match) { + return match[1] + .replace(/`([^`]+)`/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .trim(); + } + + return basename + .split(/[-_]/) + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} const nodeModulesDir = path.resolve(__dirname, '../../../node_modules/@magmacomputing'); @@ -37,6 +97,9 @@ function harvest(dir, pluginDirName, pluginId, isExternal = false) { } usedPluginIds.set(pluginId, pluginDirName); + if (!harvestedByPlugin.has(pluginId)) + harvestedByPlugin.set(pluginId, []); + for (const file of files) { let content = fs.readFileSync(path.join(docDir, file), 'utf8'); @@ -51,6 +114,11 @@ function harvest(dir, pluginDirName, pluginId, isExternal = false) { const basename = path.basename(file, '.md'); const outName = `${pluginId}.${basename}.md`; fs.writeFileSync(path.join(targetDir, outName), content); + + const title = extractTitle(content, pluginId, basename); + const link = `/doc/9-plugins/${pluginId}.${basename}`; + harvestedByPlugin.get(pluginId).push({ basename, title, link }); + console.log(`Harvested docs for plugin: ${pluginId} (${file} -> ${outName})`); } } @@ -74,3 +142,56 @@ if (fs.existsSync(nodeModulesDir)) { } } } + +// 3. Generate dynamic multi-sidebar structure for /doc/9-plugins/ +const sidebar = [ + { + text: 'Plugin Ecosystem', + items: [ + { text: '← Back to Catalog', link: '/doc/3-extending-tempo/ecosystem' }, + { text: 'License Key Guide', link: '/doc/9-plugins/_setup.index' } + ] + } +]; + +// Group AI plugin items +if (harvestedByPlugin.has('ai')) { + const aiItems = harvestedByPlugin.get('ai'); + aiItems.sort((a, b) => { + const indexA = PREFERRED_AI_ORDER.indexOf(a.basename); + const indexB = PREFERRED_AI_ORDER.indexOf(b.basename); + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + return a.basename.localeCompare(b.basename); + }); + + sidebar.push({ + text: 'AI Plugin (@magmacomputing/tempo-plugin-ai)', + collapsed: false, + items: aiItems.map(item => ({ text: item.title, link: item.link })) + }); +} + +// Group other community & pro plugins +const otherPlugins = []; +for (const [pluginId, items] of harvestedByPlugin.entries()) { + if (pluginId === 'ai' || pluginId === '_setup') continue; + for (const item of items) { + otherPlugins.push({ text: item.title, link: item.link }); + } +} + +otherPlugins.sort((a, b) => a.text.localeCompare(b.text)); + +if (otherPlugins.length > 0) { + sidebar.push({ + text: 'Community & Pro Plugins', + collapsed: false, + items: otherPlugins + }); +} + +fs.mkdirSync(path.dirname(sidebarOutputFile), { recursive: true }); +fs.writeFileSync(sidebarOutputFile, JSON.stringify(sidebar, null, 2), 'utf8'); +console.log(`Generated dynamic plugins sidebar -> ${sidebarOutputFile}`); diff --git a/packages/tempo/doc/2-core-concepts/tempo.getters.md b/packages/tempo/doc/2-core-concepts/tempo.getters.md index b7bc4665..9ce7b838 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.getters.md +++ b/packages/tempo/doc/2-core-concepts/tempo.getters.md @@ -46,12 +46,14 @@ The `Tempo` class provides an extensive array of zero-cost getters that allow yo | `.wkd` | `string` | Full English weekday name | `'Saturday'` | | `.www` | `string` | Short English weekday name | `'Sat'` | -## 🌍 Timezone & System Properties +## 🌍 Regional, Timezone & System Properties | Getter | Type | Description | Example | | :--- | :--- | :--- | :--- | | `.tz` | `string` | IANA Time Zone ID | `'Australia/Sydney'` | | `.cal` | `string` | Temporal Calendar ID | `'iso8601'` | +| `.locale` | `string \| string[]` | Resolved BCP 47 locale | `'en-AU'`, `['en-US', 'fr-FR']` | +| `.sphere` | `'north' \| 'south' \| undefined` | Resolved hemisphere configuration | `'south'` | | `.ts` | `number \| bigint` | Unix timestamp (`number` for `ss`/`ms`/`us`, `bigint` for `ns`) | `1792843200000` | | `.nano` | `bigint` | Nanoseconds since Unix epoch | `1792843200000000000n` | | `.iso` | `string` | Standard ISO 8601 string (RFC 3339) in UTC | `'2026-10-24T04:30:00Z'` | diff --git a/packages/tempo/doc/2-core-concepts/tempo.parse.md b/packages/tempo/doc/2-core-concepts/tempo.parse.md index d2afb4a6..5bc9bae9 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.parse.md +++ b/packages/tempo/doc/2-core-concepts/tempo.parse.md @@ -268,7 +268,7 @@ Functional Alias Context is a powerful API for creating dynamic, self-referentia - `this.toDateTime()`: Resolve the context to a `Temporal.ZonedDateTime`. - `this.yy` / `this.mm` / `this.dd`: Access current date components. - `this.hh` / `this.mi` / `this.ss`: Access current time components. -- `this.tz` / `this.cal` / `this.config`: Access instance metadata. +- `this.tz` / `this.cal` / `this.locale` / `this.sphere` / `this.config`: Access instance metadata. ```javascript // Example: Dynamic 'meeting' alias diff --git a/packages/tempo/doc/3-extending-tempo/tempo.term.md b/packages/tempo/doc/3-extending-tempo/tempo.term.md index e738ff82..87d518fa 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.term.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.term.md @@ -182,7 +182,7 @@ const ranges = defineRange([ /** 2. Resolve the candidate list for the current anchor/context */ function resolve(t: Tempo, anchor?: any): any[] { const source = anchor ?? t; - const sphere = source.config?.sphere ?? t.config.sphere; + const sphere = source.sphere ?? t.sphere; const template = ranges[sphere] ?? []; // resolveCycleWindow handle the ±1 year shifting for boundaries diff --git a/packages/tempo/plan/community-traction-and-stars-strategy.md b/packages/tempo/plan/community-traction-and-stars-strategy.md new file mode 100644 index 00000000..f0203c85 --- /dev/null +++ b/packages/tempo/plan/community-traction-and-stars-strategy.md @@ -0,0 +1,87 @@ +# Tempo Community Traction & GitHub Stars Strategy + +## Objective +Establish high-visibility developer traction, increase GitHub star count, and drive organic adoption for `@magmacomputing/tempo` and `@magmacomputing/tempo-plugin-ai`. + +--- + +## 1. Initial Milestone: Community Growth & Early Stargazers + +Building early community momentum and developer engagement on GitHub: + +### Action Items +- [ ] **Internal Network**: Coordinate with team members, co-founders, and peers to star [magmacomputing/magma](https://github.com/magmacomputing/magma). +- [ ] **Personal Channels**: Post a succinct call-to-action on LinkedIn, Twitter/X, and developer channels: + > *"We’re building Tempo — an ultra-lightweight, TC39 Temporal-native date engine with first-class AI parsing & smart scheduling. If you're looking for a modern date library, check out our repo and drop us a ⭐: https://github.com/magmacomputing/magma"* + +--- + +## 2. README & Repository Conversion Optimization + +Ensure visiting developers convert into stargazers and adopters by optimizing repo assets: + +### Action Items +- [ ] **GitHub Stars Badge & CTA**: Add a star badge and friendly CTA in the root and package READMEs: + ```markdown + [![GitHub Stars](https://img.shields.io/github/stars/magmacomputing/magma?style=social)](https://github.com/magmacomputing/magma) + ``` + > *"⭐ If you find Tempo helpful, please consider starring the repository — it helps others discover the project!"* +- [ ] **GitHub Open Graph Social Preview**: + - Navigate to **GitHub Repo Settings → General → Social Preview**. + - Upload a high-resolution banner highlighting: + - *Tempo Logo* + - *"TC39 Stage 4 Temporal-Native Date Engine"* + - *"Zero-Bloat AI Parsing, Smart Scheduling & 6 Multi-Provider Modes"* +- [ ] **NPM Package Manifest Links**: + - Verify that `packages/tempo/package.json` and `packages/plugins/ai/package.json` contain the canonical repository URL: + ```json + "repository": { + "type": "git", + "url": "https://github.com/magmacomputing/magma.git" + } + ``` + +--- + +## 3. Developer Community Showcases (50–500+ Stars) + +Leverage Tempo's unique architectural strengths (TC39 Temporal core, sub-millisecond benchmarking, deterministic AI grounding, 6-mode dispatch, PII protection): + +### 1. Show HN (Hacker News) +- **Proposed Title**: `Show HN: Tempo – A TC39 Temporal-native date engine with AI parsing and multi-provider consensus` +- **Focus Areas**: + - Why legacy date libraries (Moment, Day.js, Luxon) are structurally obsolete compared to TC39 `Temporal`. + - The deterministic grounding bridge (preventing calendar math hallucinations). + - Multi-provider resilient execution (`Hedged`, `Adaptive`, `Consensus`). + - Zero-token PII masking and Proxy inspection. + +### 2. Reddit Showcases +- **Target Subreddits**: `r/javascript`, `r/typescript`, `r/node`, `r/webdev`. +- **Content Style**: Technical, problem-solution format with short GIF/terminal animations showing: + - Relative temporal parsing (`parseAI("the penultimate Tuesday before Thanksgiving in 2026")`). + - Real-time provider failover and consensus voting. + - Performance benchmarks vs legacy libraries. + +### 3. Technical Engineering Articles +- **Target Platforms**: Dev.to, Hashnode, Medium. +- **Article Topics**: + 1. *"Why TC39 Temporal Makes Moment.js Obsolete (and How Tempo Bridges the Gap)"* + 2. *"Building Production-Grade AI Date Parsing: Multi-Provider Consensus & Zero-Cost PII Redaction"* + 3. *"Benchmarking JavaScript Date Engines in 2026: The Cost of Timezone Conversions"* + +### 4. Curated 'Awesome' Lists PRs +Submit pull requests to high-visibility curated repositories: +- [ ] `awesome-typescript` +- [ ] `awesome-nodejs` +- [ ] `awesome-devtools` +- [ ] `awesome-temporal` + +--- + +## 4. Tracking & Metrics + +| Milestone | Target | Key Benefit | +| :--- | :--- | :--- | +| **Tier 1** | **Early Traction** | Initial developer validation and organic engagement | +| **Tier 2** | **100 Stars** | GitHub search discoverability & community social proof | +| **Tier 3** | **500+ Stars** | Category leader positioning for TC39 Temporal & AI toolchains | diff --git a/packages/tempo/src/engine/engine.normalizer.ts b/packages/tempo/src/engine/engine.normalizer.ts index 4d2907f1..6758f800 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,8 @@ export function getAliasContext(ctx: NormalizerContext, dateTime: Temporal.Zoned get ss() { return dateTime.second }, get tz() { return tz }, get cal() { return cal }, + get locale() { return state.config.locale ?? Default.locale }, + get sphere() { return state.config.sphere ?? Default.sphere }, config: state.config, [sym.$Identity]: true, } as t.AliasContext @@ -205,7 +207,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 +255,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/module/module.mutate.ts b/packages/tempo/src/module/module.mutate.ts index 38b892c3..33067a44 100644 --- a/packages/tempo/src/module/module.mutate.ts +++ b/packages/tempo/src/module/module.mutate.ts @@ -28,7 +28,7 @@ function mutate(this: Tempo, type: 'add' | 'subtract' | 'set', args?: any, optio const overrides = { timeZone: options.timeZone ?? this.tz, calendar: options.calendar ?? this.cal, - sphere: options.sphere ?? this.config.sphere + sphere: options.sphere ?? this.sphere } as Required; if (type === 'set' && isObject(args) && args.constructor === Object) { diff --git a/packages/tempo/src/plugin/term/term.util.ts b/packages/tempo/src/plugin/term/term.util.ts index dd973784..b3bd1cce 100644 --- a/packages/tempo/src/plugin/term/term.util.ts +++ b/packages/tempo/src/plugin/term/term.util.ts @@ -183,11 +183,11 @@ export function getRange(entry: any, t: Tempo, anchor?: any, group?: string): Ra list = list.filter(r => keys.every((key: string) => { if (key === 'sphere') { const valA = String(r[key] ?? '').toLowerCase(); - const valB = String((t.config as any)[key] ?? '').toLowerCase(); + const valB = String(t.sphere ?? (t.config as any)[key] ?? '').toLowerCase(); if (valA === '' || valB === '') return false; return valB.includes(valA); } - return r[key] === (t.config as any)[key]; + return r[key] === ((t as any)[key] ?? (t.config as any)[key]); })); } @@ -297,7 +297,7 @@ export function resolveCycleWindow(source: Tempo | any, template: Range[] | Reco if (!Array.isArray(template) && groupBy.length > 0) { const groupKey = groupBy - .map(key => options[key] ?? anchor?.[key] ?? t.config[key] ?? (t as any)[key] ?? '') + .map(key => options[key] ?? anchor?.[key] ?? (t as any)[key] ?? t.config[key] ?? '') .join('.'); list = (template as any)[groupKey] ?? []; @@ -335,7 +335,7 @@ export function resolveCycleWindow(source: Tempo | any, template: Range[] | Reco } if (list.length === 0) { - const missing = groupBy.filter(k => isUndefined(options[k]) && isUndefined(anchor?.[k]) && isUndefined(t.config[k])); + const missing = groupBy.filter(k => isUndefined(options[k]) && isUndefined(anchor?.[k]) && isUndefined((t as any)[k]) && isUndefined(t.config[k])); const msg = missing.length > 0 ? `Missing grouping criteria: ${missing.join(', ')}` : `No ranges found for group: ${groupKey}`; (t.constructor as any)[TermError]?.(t.config, msg); return []; diff --git a/packages/tempo/src/support/support.enum.ts b/packages/tempo/src/support/support.enum.ts index 9d234ecb..8022540b 100644 --- a/packages/tempo/src/support/support.enum.ts +++ b/packages/tempo/src/support/support.enum.ts @@ -3,7 +3,7 @@ import { enumify, Enum } from '#library/enumerate.library.js'; import { proxify } from '#library/proxy.library.js'; import { allDescriptors } from '#library/reflection.library.js'; import { looseIndex } from '#library/object.library.js'; -import type { OwnOf, KeyOf, ValueOf, LooseUnion, Mutable } from '#library/type.library.js'; +import type { OwnOf, KeyOf, ValueOf, LooseUnion } from '#library/type.library.js'; /** calendar seasons */ export const SEASON = enumify({ diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index c939ae15..a0dda3f3 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -11,7 +11,7 @@ import { getAccessors, omit } from '#library/reflection.library.js'; import { pad, trimAll } from '#library/string.library.js'; import { getType } from '#library/type.library.js'; import { clone } from '#library/serialize.library.js'; -import { isEmpty, isDefined, isUndefined, isString, isObject, isSymbol, isFunction, isClass, isZonedDateTime, isDurationLike, isError, isNumber } from '#library/assertion.library.js'; +import { isEmpty, isDefined, isUndefined, isString, isObject, isSymbol, isFunction, isClass, isZonedDateTime, isDurationLike, isNumber } from '#library/assertion.library.js'; import { instant, getTemporalIds } from '#library/temporal.library.js'; import { getDateTimeFormat, getHemisphere, canonicalLocale, getISOWeekOfYear } from '#library/international.library.js'; import { LOG } from '#library/logger.class.js'; @@ -1553,6 +1553,8 @@ 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 locale() { return (this.#local.config.locale ?? (this as any)[$Internal]().config.locale ?? Default.locale) as string | string[] } + /** Resolved hemisphere ('north' | 'south') */ get sphere() { return (this.#local.config.sphere ?? (this as any)[$Internal]().config.sphere ?? Default.sphere) as t.COMPASS | undefined } /** 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..23f6a43f 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -71,6 +71,8 @@ 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 locale: string | string[]; + /** Resolved hemisphere */ readonly sphere: enums.COMPASS | undefined; /** 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..7a99cb65 100644 --- a/packages/tempo/test/core/accessors.test.ts +++ b/packages/tempo/test/core/accessors.test.ts @@ -17,4 +17,14 @@ describe(`${label}`, () => { test(`${label} get the right day-of-month (${date.getDate()})`, () => { expect(tempo.dd).toBe(date.getDate()) }) + + test(`${label} get instance locale and sphere getters`, () => { + const tDefault = new Tempo('2024-05-20'); + expect(tDefault.locale).toBeDefined(); + expect(tDefault.sphere).toBeDefined(); + + const tCustom = new Tempo('2024-05-20', { locale: 'fr-FR', sphere: 'south' }); + expect(tCustom.locale).toBe('fr-FR'); + expect(tCustom.sphere).toBe('south'); + }) }) \ No newline at end of file diff --git a/packages/tempo/test/core/static.getters.test.ts b/packages/tempo/test/core/static.getters.test.ts index c710cff2..c8faf91c 100644 --- a/packages/tempo/test/core/static.getters.test.ts +++ b/packages/tempo/test/core/static.getters.test.ts @@ -182,6 +182,10 @@ describe(`${label} properties`, () => { expect(Tempo.properties).toContain('yy'); expect(Tempo.properties).toContain('mm'); expect(Tempo.properties).toContain('dd'); + expect(Tempo.properties).toContain('tz'); + expect(Tempo.properties).toContain('cal'); + expect(Tempo.properties).toContain('locale'); + expect(Tempo.properties).toContain('sphere'); }) test('properties does not include Symbol keys', () => { @@ -190,6 +194,31 @@ describe(`${label} properties`, () => { }) +describe(`${label} instance context getters`, () => { + + test('resolves default context values on instance', () => { + const t = new Tempo('2026-08-15T12:00:00Z'); + expect(typeof t.tz).toBe('string'); + expect(typeof t.cal).toBe('string'); + expect(t.locale).toBeDefined(); + expect(t.sphere).toBe(Tempo.config.sphere); + }) + + test('resolves custom context options on instance', () => { + const t = new Tempo('2026-08-15T12:00:00', { + timeZone: 'Australia/Sydney', + calendar: 'iso8601', + locale: 'en-AU', + sphere: 'south', + }); + expect(t.tz).toBe('Australia/Sydney'); + expect(t.cal).toBe('iso8601'); + expect(t.locale).toBe('en-AU'); + expect(t.sphere).toBe('south'); + }) + +}) + describe(`${label} config`, () => { test('config returns an object', () => { diff --git a/packages/tempo/test/core/static.test.ts b/packages/tempo/test/core/static.test.ts index 2a376ab1..697617fb 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', 'locale', 'sphere', '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`, () => {