From ef7eaa85b7a83b7537f79cc18f46b666b3c9d811 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Thu, 13 Aug 2026 10:25:19 +1000 Subject: [PATCH 1/7] PR formatAI new --- packages/plugins/ai/doc/architecture.md | 126 +++++-- packages/plugins/ai/doc/formatAI.md | 112 ++++++ packages/plugins/ai/doc/index.md | 3 +- packages/plugins/ai/package.json | 2 +- packages/plugins/ai/plan/formatAI.plan.md | 10 +- packages/plugins/ai/plan/v0.3.0-roadmap.md | 10 +- packages/plugins/ai/src/core/support.ts | 235 ++++++++---- packages/plugins/ai/src/functions/context.ts | 74 ++-- packages/plugins/ai/src/functions/diff.ts | 72 ++-- packages/plugins/ai/src/functions/format.ts | 344 ++++++++++++++---- packages/plugins/ai/src/functions/parse.ts | 6 +- .../plugins/ai/src/functions/recurrence.ts | 6 +- packages/plugins/ai/src/functions/schedule.ts | 2 +- packages/plugins/ai/src/index.ts | 4 +- packages/plugins/ai/src/types/common.type.ts | 8 +- packages/plugins/ai/src/types/format.type.ts | 56 +++ packages/plugins/ai/src/types/index.ts | 1 + packages/plugins/ai/test/format.test.ts | 259 +++++++++++++ packages/plugins/ai/test/recurrence.test.ts | 6 +- packages/tempo/.vitepress/config.ts | 2 +- .../tempo/.vitepress/theme/data/catalog.json | 2 +- .../tempo/src/engine/engine.normalizer.ts | 7 +- packages/tempo/src/tempo.class.ts | 1 + packages/tempo/src/tempo.type.ts | 1 + packages/tempo/test/core/accessors.test.ts | 8 + packages/tempo/test/core/static.test.ts | 2 +- 26 files changed, 1082 insertions(+), 277 deletions(-) create mode 100644 packages/plugins/ai/doc/formatAI.md create mode 100644 packages/plugins/ai/src/types/format.type.ts create mode 100644 packages/plugins/ai/test/format.test.ts diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md index 8f7e7428..7ad9e075 100644 --- a/packages/plugins/ai/doc/architecture.md +++ b/packages/plugins/ai/doc/architecture.md @@ -75,43 +75,123 @@ By default, `@magmacomputing/tempo-plugin-ai` lazily fetches provider defaults ( ### Frontend Security Warning > [!CAUTION] -> **Never** expose a raw LLM API key in a client-side browser bundle (like React or Vue) or store it in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). Any Cross-Site Scripting (XSS) vulnerability, compromised NPM package, or malicious browser extension can easily inspect client-side storage and steal secret keys, leading to quota exhaustion, billing fraud, or permanent provider bans. BYOK keys are *only* safe on backend servers or edge workers. +> **Never** expose a raw LLM API key in a client-side browser bundle (like React, Vue, or Svelte) or store it in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). Any Cross-Site Scripting (XSS) vulnerability, compromised NPM dependency, or malicious browser extension can inspect client-side memory/storage and extract secret keys, leading to quota drainage, unexpected billing spikes, or account bans. BYOK provider keys are *only* safe on backend servers and edge workers. -## The Proxy Architecture +## Browser & Client-Side Proxy Architecture -If you need to execute AI functions directly on a public frontend application, you must route requests through a secure backend proxy. +To execute AI functions within client-side browser applications safely, route requests through a secure self-hosted backend proxy or unified AI Gateway (such as a Cloudflare Worker, Next.js API route, Express server, OpenRouter, Portkey, or LiteLLM): -A standard proxy architecture (e.g. using Cloudflare Workers or a custom Node/Express backend) involves: -1. **Frontend Request**: The browser sends the prompt or temporal data to your own backend API (e.g., `/api/parse-date`). -2. **Backend Authentication**: Your API validates the user's session or API token to prevent abuse. -3. **LLM Inference**: Your backend runs the Tempo AI function (such as `parseAI`) using your securely stored BYOK keys. -4. **Response**: Your backend returns the resulting ISO 8601 string to the frontend, where it can be instantiated into a native `Tempo` object. +```mermaid +flowchart LR + subgraph Browser ["Client-Side Browser (SPA)"] + Client["Tempo AI Plugin
(initAI / parseAI / diffAI)"] + end -Because LLM API calls typically take ~300-800ms, the ~20ms overhead of routing the request through your own backend proxy is negligible. + subgraph Backend ["Self-Hosted Proxy / AI Gateway"] + Proxy["Your Backend API / AI Gateway
• User Authentication & Rate Limits
• Secure Secret Management"] + end -## Fallback Loops & Execution Modes + subgraph Providers ["Upstream LLM Providers"] + LLM["Groq • OpenAI • Gemini • Anthropic"] + end -Because third-party APIs can experience downtime or aggressive rate limiting, the plugin supports flexible multi-provider execution strategies: - -### 1. Fallback Mode (Default) -When configured with multiple providers in `initAI()`, AI functions execute requests sequentially. If the primary provider hits a timeout or a `429 Too Many Requests` limit, the plugin instantly and silently fails over to the next provider in the array. Rate limit headers are updated based on the successful provider response or error resolution. + Client -- "1. HTTPS (TLS 1.3)
Session Token / Auth Header" --> Proxy + Proxy -- "2. HTTPS (TLS 1.3)
Private Provider API Key" --> LLM + LLM -- "3. HTTPS (TLS 1.3)
Raw JSON Completion" --> Proxy + Proxy -- "4. HTTPS (TLS 1.3)
Validated Payload" --> Client +``` -### 2. Race Mode (`mode: 'race'`) -Dispatches requests to all available providers simultaneously using `Promise.allSettled`. Returns the fastest resolving provider response to minimize user-perceived latency. +### 1. Browser Configuration Example +Configure `initAI` in your browser code to target your backend proxy or AI Gateway URL: ```typescript -const result = await parseAI("Thanksgiving 2026", { mode: 'race' }); +import { initAI, parseAI } from '@magmacomputing/tempo-plugin-ai'; + +// Safe for browser deployment: No private LLM API keys are bundled +await initAI({ + providers: [ + { + id: 'my-gateway', + url: 'https://api.mycompany.com/v1/ai/chat/completions', // Your secure proxy endpoint + key: userSessionToken, // Short-lived user JWT or session cookie + model: 'llama-3.3-70b-instruct' + } + ] +}); + +// All Tempo AI functions now execute securely through your proxy +const date = await parseAI("Team standup next Wednesday at 9:30am"); ``` -### 3. Consensus Mode (`mode: 'consensus'`) -Executes all providers concurrently. If multiple providers agree on the resolved ISO timestamp, confidence score is boosted (to `1.0`) and the consensus result is returned. Rate limits are applied from the consensus provider. +### 2. Backend Proxy Handler Example (Next.js / Cloudflare Worker / Express) +Your backend endpoint receives the request, validates the user's session, attaches your private LLM API key, and forwards the payload to the upstream provider: ```typescript -const result = await parseAI("The penultimate Tuesday before Thanksgiving", { - mode: 'consensus', - minConfidence: 0.85 -}); +// Example: Next.js API Route / Cloudflare Worker +export async function POST(req: Request) { + // 1. Authenticate user session + const authHeader = req.headers.get('Authorization'); + if (!isValidUserSession(authHeader)) { + return new Response('Unauthorized', { status: 401 }); + } + + // 2. Forward request to upstream LLM with private BYOK key + const body = await req.json(); + const upstreamResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.GROQ_API_KEY}` + }, + body: JSON.stringify(body) + }); + + // 3. Return provider payload to client + const data = await upstreamResponse.json(); + return new Response(JSON.stringify(data), { + status: upstreamResponse.status, + headers: { 'Content-Type': 'application/json' } + }); +} ``` +--- + +## 🔒 Security & Privacy Guarantees + +Whether running directly on backend servers (Node.js, Deno, Bun, Edge Workers) or through a client-side browser proxy, `@magmacomputing/tempo-plugin-ai` enforces strict security and privacy standards: + +### 1. End-to-End Encryption (TLS 1.3) +All transport communication—both from browser to proxy and from proxy/server to upstream LLM endpoints—is strictly enforced over HTTPS utilizing **TLS 1.3**. Plaintext HTTP endpoints are disallowed in production environments (permitted only on `localhost` during local development). + +### 2. Ephemeral Processing & Zero Data Retention +Temporal processing payloads (dates, times, context snippets, prompts) are processed ephemerally. The plugin does not send telemetry or store user prompt data on external analytics servers. Information is used exclusively during the execution of the requested AI function and discarded immediately after response resolution. + +### 3. In-Memory Credential Redaction & Immutability +* **Automated Key Redaction**: Calling `getAiConfig()` returns a sanitized, deeply read-only snapshot of active configurations with all provider `key` values replaced with `[REDACTED]`, preventing accidental exposure in log files, APM traces, or crash dumps. +* **Frozen Metadata**: All diagnostic metadata attached to `Tempo` instances via `.ai` is deeply frozen with `Object.freeze()` and guarded via runtime `Proxy` wrappers, eliminating prototype pollution and runtime state mutation. + +### 4. Deterministic Schema Guardrails & Hallucination Traps +All LLM prompts are paired with rigid, machine-verifiable JSON schemas. Responses undergo strict boundary validation, regex parsing, and ISO verification before any native `Tempo` date object is instantiated. If an LLM returns malformed or non-chronological data, the plugin throws a typed `TempoAiError` or triggers automatic fallback rather than silently returning an invalid date. + +### 5. Partitioned Caching & Fail-Open Storage Resilience +* **Strict Cache Key Partitioning**: Caches are namespaced and hashed (`ai:::...`) with timezone, locale, calendar, and anchor date isolation to prevent cross-tenant or cross-regional cache poisoning. +* **Fail-Open Protection**: If a custom distributed cache adapter (e.g. Redis or Cloudflare KV) encounters network disruption or errors, the plugin automatically fails open to direct LLM resolution, preserving application uptime. + +## Multi-Provider Execution Strategies (`AiMode`) + +Because third-party APIs can experience downtime, latency spikes, or quota exhaustion, `@magmacomputing/tempo-plugin-ai` provides six dedicated dispatch strategies configured via `AiMode` (or string literals): + +| Strategy | Enum (`AiMode`) | Primary Advantage | Typical Use Case | +| :--- | :--- | :--- | :--- | +| **Fallback** *(Default)* | `AiMode.Fallback` | Minimum token cost (sequential cascade) | Default production baseline & background tasks | +| **Hedged** | `AiMode.Hedged` | Ultra-fast latency with low token overhead (~1.15x) | Latency-sensitive interactive search & chatbots | +| **RoundRobin** | `AiMode.RoundRobin` | Cyclic rotation across multi-key pools | High-throughput batch ingestion across API keys | +| **Adaptive** | `AiMode.Adaptive` | Telemetry-driven rate-limit avoidance | Multi-tier provider pools with mixed quotas | +| **Race** | `AiMode.Race` | Absolute minimum response latency | Real-time typeahead & autocomplete | +| **Consensus** | `AiMode.Consensus` | Cross-LLM verification & hallucination trapping | High-stakes legal, financial, and contract dates | + +👉 For detailed architecture breakdowns, Mermaid decision trees, and configuration guides for each mode, see the **[Multi-Provider Execution Modes Guide (`modes.md`)](./modes.md)**. + ### Provider ID Canonicalization Provider IDs are normalized case-insensitively during `initAI` lookup (e.g. `'Gemini'`, `'gemini'`, `'OpenAI'`), automatically applying default endpoints and models while preserving the caller's registered identifier for logging and metadata. diff --git a/packages/plugins/ai/doc/formatAI.md b/packages/plugins/ai/doc/formatAI.md new file mode 100644 index 00000000..6c265139 --- /dev/null +++ b/packages/plugins/ai/doc/formatAI.md @@ -0,0 +1,112 @@ +# `formatAI` — Contextual & Narrative Date Formatting + +`formatAI()` formats a `Tempo` instance, TC39 `Temporal` object, Date, or timestamp into human-friendly, contextual narrative text tailored to specific UI tones, relative time frames, or business domains. + +While core `Tempo` provides token-based template formatting (`t.format('{yyyy}-{mm}-{dd}')`), `formatAI` bridges the gap to contextual, localized human descriptions that token patterns alone cannot capture (e.g. countdowns, calendar invites, conversational reminders, and domain summaries), backed by mathematical grounding. + +--- + +## Basic Usage + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { initAI, formatAI } from '@magmacomputing/tempo-plugin-ai'; + +// 1. Initialize AI providers +await initAI({ + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY, model: 'llama-3.3-70b-versatile' } + ] +}); + +const target = new Tempo('2026-08-07T17:00:00[America/New_York]'); + +// "this Friday at 5:00 PM EST (in 5 days)" +const result = await formatAI(target, 'friendly reminder tone with relative countdown'); + +console.log(result.formatted); // "this Friday at 5:00 PM EST (in 5 days)" +console.log(result.confidence); // 0.98 +console.log(result.provider); // 'groq' +``` + +--- + +## Configuration Options (`AiFormatOptions`) + +| Option | Type | Description | +| :--- | :--- | :--- | +| **`anchor`** | `Tempo.DateTime` | Reference anchor date for relative delta calculations (defaults to current time). | +| **`style`** | `string` | Narrative style or tone hint (e.g. `'casual'`, `'formal'`, `'compact'`, `'countdown'`). | +| **`region`** | `string` | Regional context (e.g., `'AU-NSW'`, `'US-CA'`) passed to LLM grounding. | +| **`timeZone`** | `string` | Target IANA timezone for output formatting. | +| **`locale`** | `string \| string[]` | Target BCP 47 locale or language tag (e.g. `'fr-FR'`, `'en-US'`). | +| **`force`** | `boolean` | If true, bypasses the cache to initiate a fresh LLM query. | +| **`cache`** | `boolean` | If false, disables writing to and reading from cache adapters. | +| **`cacheAdapter`** | `AiCacheAdapter` | Custom cache engine (e.g., Redis, Cloudflare KV) for caching results. | +| **`ttl`** | `number` | Time-to-live override in milliseconds for cached results (defaults to 24h). | +| **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required. Throws `TempoAiError(422)` if lower. | +| **`mode`** | `AiMode` | Concurrency routing strategy (`fallback`, `race`, `consensus`, `hedged`, `roundrobin`, `adaptive`). Refer to the [Multi-Provider Execution Modes Guide](./modes.md). | +| **`softErrors`** | `boolean` | If true, returns `TempoAiError` into array indices instead of rejecting batch queries. | + +--- + +## Result Schema (`TempoAiFormatResult`) + +```typescript +export interface TempoAiFormatResult { + /** Formatted narrative string. */ + formatted: string; + + /** Confidence score between 0.0 and 1.0. */ + confidence: number; + + /** ID of the provider that fulfilled the request (or 'cache'). */ + provider: string; + + /** Optional step-by-step rationale from the LLM. */ + reasoning?: string | undefined; +} +``` + +--- + +## Key Architectural Behaviors + +### 1. Native Grounding Context +To eliminate LLM date and day-of-week hallucinations, `formatAI` computes deterministic grounding metrics before constructing the prompt: +- Exact ISO timestamp and timezone +- Day of the week name and ordinal (e.g. `Friday`, Day 5) +- Relative delta in calendar days and elapsed hours compared to anchor +- Directionality (`'past'`, `'present'`, `'future'`) + +These metrics are injected into the system prompt as immutable constraints. + +### 2. TC39 Temporal & Universal Interoperability +`formatAI` seamlessly accepts `Tempo` instances, native JavaScript `Date` objects, ISO strings, timestamps, and TC39 `Temporal` objects (`Temporal.ZonedDateTime`, `Temporal.Instant`, `Temporal.PlainDateTime`, `Temporal.PlainDate`): + +```typescript +import { Temporal } from '@magmacomputing/tempo/library'; + +const zdt = Temporal.ZonedDateTime.from('2026-08-05T15:00:00+10:00[Australia/Sydney]'); +const result = await formatAI(zdt, 'compact relative format'); +``` + +### 3. Multi-Tier Distributed Caching +`formatAI` integrates multi-tier caching (in-memory + optional asynchronous `AiCacheAdapter` such as Redis or Cloudflare KV). Cache keys incorporate input timestamp, anchor timestamp, normalized prompt, timezone, locale, region, and style to ensure complete cache correctness: + +```typescript +const result = await formatAI(target, 'casual invitation', { + cacheAdapter: redisCacheAdapter, + ttl: 3_600_000, // 1 hour +}); +``` + +### 4. Parallel Batch Formatting +Format multiple dates and prompts concurrently with optional `softErrors` resilience: + +```typescript +const results = await formatAI([ + { date: '2026-08-03T09:00:00Z', prompt: 'calendar invite' }, + { date: '2026-08-05T18:00:00Z', prompt: 'flight departure notification' }, +], { softErrors: true }); +``` diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md index c417d500..12cd1218 100644 --- a/packages/plugins/ai/doc/index.md +++ b/packages/plugins/ai/doc/index.md @@ -49,6 +49,7 @@ All AI functions return a standard ES Promise wrapped object. | **`scheduleAI`** | Booking prompt + busy constraints | `TempoScheduleResult` | **Resolved appointment slot** (`start`, `end`, `slot`, `alternatives`, `ai.conflictBumped`) | | | **`contextAI`** | Context text string(s) | `TempoContext` \| `TempoContext[]` \| `(TempoContext \| TempoAiError)[]` | **Inferred regional context** (`timeZone`, `locale`, `calendar`, `sphere`) | | | **`diffAI`** | Start & End dates + prompt | `TempoAiDiffResult` \| `TempoAiDiffResult[]` \| `(TempoAiDiffResult \| TempoAiError)[]` | **Narrative time delta & business days** (`formatted`, `businessDays`, `days`, `hours`, `holidays`) | | +| **`formatAI`** | Date-time + prompt / style | `TempoAiFormatResult` \| `TempoAiFormatResult[]` \| `(TempoAiFormatResult \| TempoAiError)[]` | **Contextual narrative date formatting** (`formatted`, `confidence`, `provider`, `reasoning`) | | ## Architecture & Infrastructure Guides @@ -56,7 +57,7 @@ All AI functions return a standard ES Promise wrapped object. > **Production Recommendation**: Due to the complexities of LLM APIs, including caching gotchas, context injection, rate limits, and calendar math hallucinations, we politely but firmly recommend reading the dedicated guides below before deploying this plugin in a production environment. - [Multi-Provider Execution Modes](./modes.md) (Hedged, RoundRobin, Adaptive, Race, Consensus, Fallback) -- [Provider Architecture & Security](./architecture.md) (BYOK vs Proxy patterns, Frontend Security) +- [Provider Architecture & Security](./architecture.md) (BYOK vs Proxy patterns, Browser Security, TLS 1.3 & Privacy Guarantees) - [Context & Natural Language Parsing](./context.md) (How Timezone and Locale are injected) - [Rate Limits & Cache Management](./rate-limits.md) (Tracking API quotas, handling 429 errors, and custom Redis caches) diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index 59d49efd..7c5bca52 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo-plugin-ai", - "version": "0.3.0", + "version": "4.0.0", "description": "Tempo community plugin for LLM-powered natural language parsing.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/plugins/ai/plan/formatAI.plan.md b/packages/plugins/ai/plan/formatAI.plan.md index dcaa60ee..b6081518 100644 --- a/packages/plugins/ai/plan/formatAI.plan.md +++ b/packages/plugins/ai/plan/formatAI.plan.md @@ -11,13 +11,13 @@ By combining deterministic date-time grounding (formatted ISO components, day of ### 2.1 Types (`packages/plugins/ai/src/types/format.type.ts`) ```typescript -import type { Tempo } from '@magmacomputing/tempo'; +import type { Tempo, DateTime } from '@magmacomputing/tempo'; import type { AiOptions } from './common.type.js'; import type { TempoAiError } from '../core/error.js'; export interface AiFormatOptions extends AiOptions { /** Reference anchor date for relative calculations (defaults to now). */ - anchor?: Tempo | Date | string | number; + anchor?: DateTime; /** Target IANA timezone (defaults to Tempo instance timezone or global options). */ timeZone?: string; /** Target BCP 47 locale or language tag (defaults to global options or 'en-US'). */ @@ -29,8 +29,8 @@ export interface AiFormatOptions extends AiOptions { } export interface FormatItem { - /** Date-time instance or string to format. */ - date: Tempo | Date | string | number; + /** Date-time instance, Temporal object, or string to format. */ + date: DateTime; /** Prompt instructions guiding the output narrative. */ prompt?: string; } @@ -50,7 +50,7 @@ export interface TempoAiFormatResult { ### 2.2 Function Signature (`packages/plugins/ai/src/functions/format.ts`) ```typescript export async function formatAI(items: FormatItem[], options?: AiFormatOptions): Promise<(TempoAiFormatResult | TempoAiError)[]>; -export async function formatAI(date: any, prompt?: string, options?: AiFormatOptions): Promise; +export async function formatAI(date: DateTime, prompt?: string, options?: AiFormatOptions): Promise; ``` --- diff --git a/packages/plugins/ai/plan/v0.3.0-roadmap.md b/packages/plugins/ai/plan/v0.3.0-roadmap.md index 3a9bbfa9..ad1dd460 100644 --- a/packages/plugins/ai/plan/v0.3.0-roadmap.md +++ b/packages/plugins/ai/plan/v0.3.0-roadmap.md @@ -18,17 +18,17 @@ This document captures the planned feature set, architectural requirements, and ### 1.4 ✅ `diffAI(start: any, end: any, prompt?: string, options?: AiDiffOptions): Promise` * Calculates and summarizes the delta between two `Tempo` instances in human, business, or operational terms (e.g., `"5 business days (48 hours)"`), backed by native grounding metrics (calendar days, hours, business days with weekend & holiday exclusion). +### 1.5 ✅ `formatAI(date: Tempo.DateTime, prompt?: string, options?: AiFormatOptions): Promise` +* Formats a `Tempo` instance, TC39 `Temporal` object, Date, or timestamp into human-friendly, contextual narrative text tailored to UI tones, relative countdowns, or domain summaries. +* **Example**: `"this Friday at 5:00 PM EST (in 5 days)"`. + --- ## 2. Upcoming AI Function Handlers (Post-v0.3.0 Roadmap) The following functions remain scaffolded for upcoming releases: -### 2.1 `formatAI(date: any, prompt?: string, options?: AiFormatOptions): Promise` -* Formats a `Tempo` instance into human-friendly, contextual narrative text tailored to UI tones or relative countdowns. -* **Example**: `"this Friday at 5:00 PM EST (in 5 days)"`. - -### 2.2 `extractAI(text: string, options?: AiExtractOptions): Promise` +### 2.1 `extractAI(text: string, options?: AiExtractOptions): Promise` * Scans unstructured text (emails, transcripts, task notes) to extract embedded temporal entities into structured `TempoAiExtractResult` records (`events: TempoExtractedEvent[]`). diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index a43fa1f3..336b08dc 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -2,100 +2,183 @@ import { Tempo } from '@magmacomputing/tempo'; import { TempoAiError } from './error.js'; import { RESERVED_PROVIDER_IDS } from './config.js'; import { updateRateLimitsFromResponse, _state } from './init.js'; -import type { AiProvider, TempoAiMeta } from '../types/index.js'; +import type { AiCacheAdapter, AiProvider, TempoAiMeta } from '../types/index.js'; export function assertNoReservedProviderId(providers: Partial[]): void { - for (const p of providers) { - if (p.id && RESERVED_PROVIDER_IDS.has(p.id.toLowerCase())) { - throw new TempoAiError(`Provider ID '${p.id}' is a reserved keyword in parseAI.`, 400); - } - } + for (const p of providers) { + if (p.id && RESERVED_PROVIDER_IDS.has(p.id.toLowerCase())) + throw new TempoAiError(`Provider ID '${p.id}' is a reserved keyword in parseAI.`, 400); + } } export function normalizeCacheInput(input: string): string { - return input.trim().toLowerCase().replace(/\s+/g, ' '); + return input.trim().toLowerCase().replace(/\s+/g, ' '); } export function getNamespacedCacheKey(namespace: string, key: string): string { - return `ai:${namespace}::${key}`; + return `ai:${namespace}::${key}`; +} + +export function resolveProviderTtl( + providerId: string, + availableProviders: AiProvider[], + callTtl?: number, + defaultTtl: number = 86_400_000, +): number { + const providerTtl = providerId === 'consensus' + ? availableProviders.reduce((min, p) => p.ttl === undefined ? min : (min === undefined ? p.ttl : Math.min(min, p.ttl)), undefined) + : availableProviders.find(p => p.id === providerId)?.ttl; + return callTtl ?? providerTtl ?? _state.config.ttl ?? defaultTtl; +} + +export function resolveTzAndLocale( + options?: { timeZone?: string | undefined; locale?: string | string[] | undefined } | undefined, + fallbackTempo?: Tempo | null, +): { tz: string; loc: string } { + const resolvedOptions = (Tempo as any).options ?? {}; + const tz = String(options?.timeZone || fallbackTempo?.tz || resolvedOptions.timeZone || _state.config.timeZone || 'UTC'); + const rawLoc = options?.locale || fallbackTempo?.loc || resolvedOptions.locale || _state.config.locale || 'en-US'; + const loc = String(Array.isArray(rawLoc) ? rawLoc[0] : rawLoc); + return { tz, loc }; +} + +export async function readMultiTierCache( + cacheKey: string, + options: { + force?: boolean | undefined; + cache?: boolean | undefined; + cacheAdapter?: AiCacheAdapter | undefined; + debug?: boolean | undefined; + tag?: string | undefined; + }, +): Promise { + if (options.force) return undefined; + if (options.cache === false || _state.config.cache === false) return undefined; + + const adapter = options.cacheAdapter || _state.config.cacheAdapter; + if (adapter) { + try { + const val = await adapter.get(cacheKey); + if (val !== undefined && val !== null) { + if (options.debug) console.log(`[${options.tag ?? 'tempo-plugin-ai'}] Cache hit (adapter): ${cacheKey}`); + return val; + } + } catch (err: any) { + if (options.debug) console.warn(`[${options.tag ?? 'tempo-plugin-ai'}] Cache adapter get failed for ${cacheKey}:`, err?.message ?? err); + } + } + + const localVal = Tempo.cache.get(cacheKey); + if (localVal) { + if (options.debug) console.log(`[${options.tag ?? 'tempo-plugin-ai'}] Cache hit (local): ${cacheKey}`); + return localVal; + } + + return undefined; +} + +export async function writeMultiTierCache( + cacheKey: string, + value: string, + ttl: number, + options: { + cache?: boolean | undefined; + cacheAdapter?: AiCacheAdapter | undefined; + debug?: boolean | undefined; + tag?: string | undefined; + }, +): Promise { + if (options.cache === false || _state.config.cache === false) return; + + Tempo.cache.set(cacheKey, value); + + const adapter = options.cacheAdapter || _state.config.cacheAdapter; + if (adapter) { + try { + const res = adapter.set(cacheKey, value, ttl); + if (res instanceof Promise) await res; + } catch (err: any) { + if (options.debug) console.warn(`[${options.tag ?? 'tempo-plugin-ai'}] Cache adapter set failed for ${cacheKey}:`, err?.message ?? err); + } + } } export function attachAiMeta(instance: Tempo, meta: TempoAiMeta): Tempo { - const frozenMeta = Object.freeze(meta); - const boundMethodCache = new Map(); - - return new Proxy(instance, { - get(target, prop, _receiver) { - if (prop === 'ai') return frozenMeta; - if (prop === 'isValid') { - if (meta.confidence === 0.0 || meta.rawIso === 'INVALID' || meta.ambiguous === true || !target.isValid) - return false; - } - if (prop === 'constructor') - return Reflect.get(target, prop, target); - - if (boundMethodCache.has(prop)) - return boundMethodCache.get(prop); - - const val = Reflect.get(target, prop, target); - if (typeof val === 'function') { - const bound = val.bind(target); - boundMethodCache.set(prop, bound); - return bound; - } - return val; - }, - has(target, prop) { - if (prop === 'ai') return true; - return Reflect.has(target, prop); - }, - getOwnPropertyDescriptor(target, prop) { - if (prop === 'ai') { - return { - value: frozenMeta, - writable: false, - configurable: true, - enumerable: true - }; - } - return Reflect.getOwnPropertyDescriptor(target, prop); - }, - ownKeys(target) { - const keys = Reflect.ownKeys(target); - if (!keys.includes('ai')) keys.push('ai'); - return keys; - } - }); + const frozenMeta = Object.freeze(meta); + const boundMethodCache = new Map(); + + return new Proxy(instance, { + get(target, prop, _receiver) { + if (prop === 'ai') return frozenMeta; + if (prop === 'isValid') { + if (meta.confidence === 0.0 || meta.rawIso === 'INVALID' || meta.ambiguous === true || !target.isValid) + return false; + } + if (prop === 'constructor') + return Reflect.get(target, prop, target); + + if (boundMethodCache.has(prop)) + return boundMethodCache.get(prop); + + const val = Reflect.get(target, prop, target); + if (typeof val === 'function') { + const bound = val.bind(target); + boundMethodCache.set(prop, bound); + return bound; + } + return val; + }, + has(target, prop) { + if (prop === 'ai') return true; + return Reflect.has(target, prop); + }, + getOwnPropertyDescriptor(target, prop) { + if (prop === 'ai') { + return { + value: frozenMeta, + writable: false, + configurable: true, + enumerable: true + }; + } + return Reflect.getOwnPropertyDescriptor(target, prop); + }, + ownKeys(target) { + const keys = Reflect.ownKeys(target); + if (!keys.includes('ai')) keys.push('ai'); + return keys; + } + }); } export async function fetchFromProvider( - provider: AiProvider, - str: string, - contextString: string, - isDebug: boolean, - parentSignal?: AbortSignal, - timeoutOverride?: number, - customSystemPrompt?: string + provider: AiProvider, + str: string, + contextString: string, + isDebug: boolean, + parentSignal?: AbortSignal, + timeoutOverride?: number, + customSystemPrompt?: string ): Promise<{ rawContent: string; providerId: string; rateLimits: ReturnType }> { - const url = provider.url!; - const model = provider.model!; + const url = provider.url!; + const model = provider.model!; - if (!url || typeof url !== 'string') - throw new TempoAiError(`Provider ${provider.id} missing valid endpoint URL.`, 400); + if (!url || typeof url !== 'string') + throw new TempoAiError(`Provider ${provider.id} missing valid endpoint URL.`, 400); - if (!model || typeof model !== 'string') - throw new TempoAiError(`Provider ${provider.id} missing valid model identifier.`, 400); + if (!model || typeof model !== 'string') + throw new TempoAiError(`Provider ${provider.id} missing valid model identifier.`, 400); - try { - const parsed = new URL(url); - if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'))) - throw new TempoAiError(`Provider ${provider.id} endpoint URL '${url}' must use secure HTTPS protocol.`, 400); - } catch (err: any) { - if (err instanceof TempoAiError) throw err; - throw new TempoAiError(`Provider ${provider.id} has invalid endpoint URL '${url}'.`, 400); - } + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'))) + throw new TempoAiError(`Provider ${provider.id} endpoint URL '${url}' must use secure HTTPS protocol.`, 400); + } catch (err: any) { + if (err instanceof TempoAiError) throw err; + throw new TempoAiError(`Provider ${provider.id} has invalid endpoint URL '${url}'.`, 400); + } - const defaultSystemPrompt = `You are a high-performance date parser. Read the user's string and the provided context. Return ONLY a valid JSON object matching this exact schema: + const defaultSystemPrompt = `You are a high-performance date parser. Read the user's string and the provided context. Return ONLY a valid JSON object matching this exact schema: { "reasoning": "Step-by-step calendar math from Current Time.", "iso": "Local ISO 8601 string (YYYY-MM-DDThh:mm:ss) without offset or Z suffix, or 'INVALID' if ambiguous/unparseable.", @@ -113,7 +196,7 @@ Ambiguity Rules: Do not include markdown blocks or any text outside the JSON.`; - const systemPrompt = customSystemPrompt ?? defaultSystemPrompt; + const systemPrompt = customSystemPrompt ?? defaultSystemPrompt; if (isDebug) console.log(`[tempo-plugin-ai] Querying provider '${provider.id}' (model: ${model})...`); diff --git a/packages/plugins/ai/src/functions/context.ts b/packages/plugins/ai/src/functions/context.ts index b9ce90b4..b0a88dc7 100644 --- a/packages/plugins/ai/src/functions/context.ts +++ b/packages/plugins/ai/src/functions/context.ts @@ -3,7 +3,14 @@ import { TempoAiError } from '../core/error.js'; import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; import { executeWithMode } from '../core/dispatch.js'; -import { normalizeCacheInput, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; +import { + assertNoReservedProviderId, + fetchFromProvider, + normalizeCacheInput, + readMultiTierCache, + resolveProviderTtl, + writeMultiTierCache, +} from '../core/support.js'; import { RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX } from '../core/patterns.js'; import type { TempoContext, AiContextOptions } from '../types/index.js'; @@ -23,21 +30,13 @@ async function contextSingleInput(text: string, options?: AiContextOptions): Pro const cacheKey = `context::${normalizedStr}::${tz}::${loc}::${cal}::${sph}`; const adapter = cacheAdapter ?? _state.config.cacheAdapter; - let cachedVal: string | undefined; - if (!force && aiCacheOption !== false) { - if (adapter) { - try { - const val = await adapter.get(cacheKey); - if (val) { - cachedVal = val; - } - } catch (err: any) { - if (isDebug) console.log('[tempo-plugin-ai:context] Cache adapter read error:', err?.message); - } - } - - cachedVal ??= Tempo.cache.get(cacheKey); - } + const cachedVal = await readMultiTierCache(cacheKey, { + force, + cache: aiCacheOption, + cacheAdapter: adapter, + debug: isDebug, + tag: 'tempo-plugin-ai:context', + }); if (cachedVal) { try { @@ -163,34 +162,25 @@ Do not include markdown blocks or text outside the JSON.`; confidence, provider: providerId, reasoning, - }; - - if (aiCacheOption !== false) { - const providerTtl = providerId === AiMode.Consensus - ? availableProviders.reduce((min, p) => p.ttl === undefined ? min : (min === undefined ? p.ttl : Math.min(min, p.ttl)), undefined) - : availableProviders.find(p => p.id === providerId)?.ttl; - const resolvedTtl = ttl ?? providerTtl ?? _state.config.ttl ?? 86_400_000; // Default to 24 hours for context - - const cacheVal = JSON.stringify({ - timeZone: finalResult.timeZone, - locale: finalResult.locale, - calendar: finalResult.calendar, - sphere: finalResult.sphere, - confidence: finalResult.confidence, - reasoning: finalResult.reasoning, - }); - - if (adapter) { - try { - const res = adapter.set(cacheKey, cacheVal, resolvedTtl); - if (res instanceof Promise) await res; - } catch (err: any) { - if (isDebug) console.log('[tempo-plugin-ai:context] Cache adapter write error:', err?.message); - } - } - Tempo.cache.set(cacheKey, cacheVal); } + const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000); + const cacheVal = JSON.stringify({ + timeZone: finalResult.timeZone, + locale: finalResult.locale, + calendar: finalResult.calendar, + sphere: finalResult.sphere, + confidence: finalResult.confidence, + reasoning: finalResult.reasoning, + }); + + await writeMultiTierCache(cacheKey, cacheVal, resolvedTtl, { + cache: aiCacheOption, + cacheAdapter: adapter, + debug: isDebug, + tag: 'tempo-plugin-ai:context', + }); + return finalResult; } diff --git a/packages/plugins/ai/src/functions/diff.ts b/packages/plugins/ai/src/functions/diff.ts index 686bd33c..6936bd5c 100644 --- a/packages/plugins/ai/src/functions/diff.ts +++ b/packages/plugins/ai/src/functions/diff.ts @@ -3,7 +3,14 @@ import { TempoAiError } from '../core/error.js'; import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; import { executeWithMode } from '../core/dispatch.js'; -import { normalizeCacheInput, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; +import { + assertNoReservedProviderId, + fetchFromProvider, + normalizeCacheInput, + readMultiTierCache, + resolveProviderTtl, + writeMultiTierCache, +} from '../core/support.js'; import { RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX } from '../core/patterns.js'; import type { TempoAiDiffResult, AiDiffOptions, DiffPair } from '../types/index.js'; @@ -84,19 +91,13 @@ async function diffSingleInput( const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence; const effectiveHedgeDelay = hedgeDelay ?? _state.config.hedgeDelay; - let cachedVal: string | undefined; - if (!force && aiCacheOption !== false) { - if (adapter) { - try { - const val = await adapter.get(cacheKey); - if (val) cachedVal = val; - } catch (err: any) { - if (isDebug) console.log('[tempo-plugin-ai:diff] Cache adapter read error:', err?.message); - } - } - - cachedVal ??= Tempo.cache.get(cacheKey); - } + const cachedVal = await readMultiTierCache(cacheKey, { + force, + cache: aiCacheOption, + cacheAdapter: adapter, + debug: isDebug, + tag: 'tempo-plugin-ai:diff', + }); if (cachedVal) { try { @@ -228,32 +229,23 @@ Do not include markdown blocks or text outside the JSON.`; reasoning: parsedData.reasoning, }; - if (aiCacheOption !== false) { - const providerTtl = providerId === AiMode.Consensus - ? availableProviders.reduce((min, p) => p.ttl === undefined ? min : (min === undefined ? p.ttl : Math.min(min, p.ttl)), undefined) - : availableProviders.find(p => p.id === providerId)?.ttl; - const resolvedTtl = ttl ?? providerTtl ?? _state.config.ttl ?? 86_400_000; - - const cacheVal = JSON.stringify({ - formatted: finalResult.formatted, - days: finalResult.days, - hours: finalResult.hours, - businessDays: finalResult.businessDays, - holidays: finalResult.holidays, - confidence: finalResult.confidence, - reasoning: finalResult.reasoning, - }); - - if (adapter) { - try { - const res = adapter.set(cacheKey, cacheVal, resolvedTtl); - if (res instanceof Promise) await res; - } catch (err: any) { - if (isDebug) console.log('[tempo-plugin-ai:diff] Cache adapter write error:', err?.message); - } - } - Tempo.cache.set(cacheKey, cacheVal); - } + const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000); + const cacheVal = JSON.stringify({ + formatted: finalResult.formatted, + days: finalResult.days, + hours: finalResult.hours, + businessDays: finalResult.businessDays, + holidays: finalResult.holidays, + confidence: finalResult.confidence, + reasoning: finalResult.reasoning, + }); + + await writeMultiTierCache(cacheKey, cacheVal, resolvedTtl, { + cache: aiCacheOption, + cacheAdapter: adapter, + debug: isDebug, + tag: 'tempo-plugin-ai:diff', + }); return finalResult; } diff --git a/packages/plugins/ai/src/functions/format.ts b/packages/plugins/ai/src/functions/format.ts index 1b16a57a..3b835418 100644 --- a/packages/plugins/ai/src/functions/format.ts +++ b/packages/plugins/ai/src/functions/format.ts @@ -1,72 +1,266 @@ -import type { Tempo } from '@magmacomputing/tempo'; -import type { TempoAiError } from '../core/error.js'; -import type { AiMode } from '../core/config.js'; -import type { AiCacheAdapter, AiProvider } from '../types/common.type.js'; - -export interface FormatItem { - /** Date-time instance or string to format. */ - date: Tempo | Date | string | number; - /** Prompt instructions guiding the output narrative. */ - prompt?: string | undefined; +import { Tempo } from '@magmacomputing/tempo'; +import { TempoAiError } from '../core/error.js'; +import { AiMode } from '../core/config.js'; +import { _state } from '../core/init.js'; +import { executeWithMode } from '../core/dispatch.js'; +import { + assertNoReservedProviderId, + fetchFromProvider, + normalizeCacheInput, + readMultiTierCache, + resolveProviderTtl, + resolveTzAndLocale, + writeMultiTierCache, +} from '../core/support.js'; +import type { AiFormatOptions, FormatItem, TempoAiFormatResult } from '../types/format.type.js'; + +export type { AiFormatOptions, FormatItem, TempoAiFormatResult }; + +interface FormatGroundingMetrics { + iso: string; + timeZone: string; + dayOfWeek: string; + dayOfWeekOrdinal: number; + calendarDays: number; + elapsedHours: number; + direction: 'past' | 'present' | 'future'; } -export interface TempoAiFormatResult { - /** Formatted narrative string. */ - formatted: string; - /** Confidence score between 0.0 and 1.0. */ - confidence: number; - /** ID of the provider that fulfilled the request (or 'cache'). */ - provider: string; - /** Optional step-by-step rationale from the LLM. */ - reasoning?: string | undefined; +function calculateFormatGroundingMetrics(targetTempo: Tempo, anchorTempo: Tempo): FormatGroundingMetrics { + const iso = targetTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}'); + const timeZone = targetTempo.tz || 'UTC'; + const dayOfWeekOrdinal = targetTempo.dow; + const weekdayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + const dayOfWeek = weekdayNames[dayOfWeekOrdinal] || targetTempo.format('{www}'); + + const calendarDays = Math.round(anchorTempo.until(targetTempo, 'day') * 100) / 100; + const elapsedHours = Math.round(anchorTempo.until(targetTempo, 'hour') * 100) / 100; + + let direction: 'past' | 'present' | 'future' = 'future'; + if (calendarDays < 0 || elapsedHours < 0) { + direction = 'past'; + } else if (calendarDays === 0 && elapsedHours === 0) { + direction = 'present'; + } + + return { + iso, + timeZone, + dayOfWeek, + dayOfWeekOrdinal, + calendarDays, + elapsedHours, + direction, + }; } -export interface AiFormatOptions { - /** Reference anchor date for relative calculations (defaults to now). */ - anchor?: Tempo | Date | string | number | undefined; - /** Target IANA timezone (defaults to Tempo instance timezone or global options). */ - timeZone?: string | undefined; - /** Target BCP 47 locale or language tag (defaults to global options or 'en-US'). */ - locale?: string | string[] | undefined; - /** Desired narrative tone or formatting style hint (e.g. 'casual', 'formal', 'compact', 'countdown'). */ - style?: string | undefined; - /** Custom regional context (e.g. 'AU-NSW', 'US-CA'). */ - region?: string | undefined; - /** If true, bypasses cache to force a fresh LLM fetch */ - force?: boolean | undefined; - /** If false, disables reading and writing to cache */ - cache?: boolean | undefined; - /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */ - cacheAdapter?: AiCacheAdapter | undefined; - /** Optional TTL override in milliseconds for cached result */ - ttl?: number | undefined; - /** If true, logs prompt context and LLM payloads to console */ - debug?: boolean | undefined; - /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` | `AiMode.Hedged` | `AiMode.RoundRobin` | `AiMode.Adaptive` or string literal) */ - mode?: AiMode | undefined; - /** Per-request provider configuration overrides */ - providers?: AiProvider[] | undefined; - /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */ - minConfidence?: number | undefined; - /** If true, returns TempoAiError into array index position instead of rejecting batch */ - softErrors?: boolean | undefined; - /** Optional request timeout in milliseconds (overrides provider and global timeout) */ - timeout?: number | undefined; - /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */ - hedgeDelay?: number | undefined; - /** Allow extra custom properties */ - [key: string]: any; +async function formatSingleInput( + date: Tempo.DateTime, + prompt?: string, + options?: AiFormatOptions, +): Promise { + const isDebug = options?.debug ?? _state.config.debug ?? false; + const { tz, loc } = resolveTzAndLocale(options, Tempo.isTempo(date) ? date : null); + + let targetTempo: Tempo; + try { + targetTempo = Tempo.isTempo(date) + ? (date.tz === tz ? date : date.set({ timeZone: tz })) + : new Tempo(date as any, { timeZone: tz }); + } catch (err: any) { + throw new TempoAiError(`Invalid date provided to formatAI: "${String(date)}"`, 400); + } + + if (!targetTempo.isValid) { + throw new TempoAiError(`Invalid date provided to formatAI: "${String(date)}"`, 400); + } + + const anchor = options?.anchor; + let anchorTempo: Tempo; + try { + anchorTempo = anchor !== undefined + ? (Tempo.isTempo(anchor) + ? (anchor.tz === tz ? anchor : anchor.set({ timeZone: tz })) + : new Tempo(anchor as any, { timeZone: tz })) + : new Tempo(undefined, { timeZone: tz }); + } catch (err: any) { + throw new TempoAiError(`Invalid anchor date provided to formatAI: "${String(anchor)}"`, 400); + } + + if (!anchorTempo.isValid) { + throw new TempoAiError(`Invalid anchor date provided to formatAI: "${String(anchor)}"`, 400); + } + + const style = options?.style ? String(options.style).trim() : ''; + const region = options?.region ? String(options.region).trim() : ''; + const grounding = calculateFormatGroundingMetrics(targetTempo, anchorTempo); + + const promptText = prompt?.trim() || 'Express this date and time in a clear, human-friendly narrative.'; + const normalizedPrompt = normalizeCacheInput(promptText); + + const { + force, + mode: aiMode, + providers, + minConfidence, + cache: aiCacheOption, + timeout: callTimeout, + ttl, + cacheAdapter, + hedgeDelay, + } = options || {}; + + const cacheKey = `format::${targetTempo.epoch.ms}::${anchorTempo.epoch.ms}::${normalizedPrompt}::${tz}::${loc}::${region}::${style}`; + const adapter = cacheAdapter ?? _state.config.cacheAdapter; + + const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence; + const effectiveHedgeDelay = hedgeDelay ?? _state.config.hedgeDelay; + + const cachedVal = await readMultiTierCache(cacheKey, { + force, + cache: aiCacheOption, + cacheAdapter: adapter, + debug: isDebug, + tag: 'tempo-plugin-ai:format', + }); + + if (cachedVal) { + try { + const parsedCache = JSON.parse(cachedVal); + if (typeof parsedCache?.formatted === 'string' && parsedCache.formatted.trim().length > 0) { + const cachedConfidence = typeof parsedCache.confidence === 'number' && Number.isFinite(parsedCache.confidence) + ? parsedCache.confidence + : 1.0; + if (effectiveMinConfidence === undefined || cachedConfidence >= effectiveMinConfidence) { + if (isDebug) console.log(`[tempo-plugin-ai:format] Cache hit: "${cacheKey}" -> ${cachedVal}`); + return { + formatted: parsedCache.formatted, + confidence: cachedConfidence, + provider: 'cache', + reasoning: parsedCache.reasoning, + }; + } + } + } catch { + // If cached value is corrupted, proceed to fetch + } + } + + const availableProviders = providers || _state.config.providers; + if (!availableProviders || availableProviders.length === 0) { + throw new TempoAiError('No AI providers configured. Please call initAI().', 400); + } + + assertNoReservedProviderId(availableProviders); + + const mode = aiMode || _state.config.mode || AiMode.Fallback; + + const contextString = `Grounding Context: +- Target Date-Time: ${grounding.iso} (${grounding.timeZone}) +- Day of Week: ${grounding.dayOfWeek} (Day ${grounding.dayOfWeekOrdinal}) +- Reference Anchor: ${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')} (${anchorTempo.tz || tz}) +- Relative Delta: ${grounding.calendarDays >= 0 ? '+' : ''}${grounding.calendarDays} calendar days (${grounding.elapsedHours >= 0 ? '+' : ''}${grounding.elapsedHours} hours) in the ${grounding.direction.toUpperCase()} +- Target Locale: ${loc} +${style ? `- Desired Style/Tone: ${style}` : ''} +${region ? `- Regional Context: ${region}` : ''} +- Formatting Instructions: "${promptText}"`; + + const systemPrompt = `You are a high-performance narrative date formatter. Your task is to format the given Target Date-Time according to the Formatting Instructions, Style, and Target Locale, strictly respecting the mathematical Grounding Context provided. Return ONLY a valid JSON object matching this schema: +{ + "formatted": "Contextual narrative string (e.g. 'this Friday at 5:00 PM EST (in 2 days)')", + "confidence": 0.98, + "reasoning": "Brief explanation of how the narrative reflects the grounding context and prompt." +} + +Rules: +- Never hallucinate the weekday, date, or relative offset; adhere strictly to the Grounding Context. +- Apply the requested tone/style and locale conventions. +- Confidence must be a float between 0.0 and 1.0. +- Do not include markdown blocks or any text outside the JSON.`; + + const winningCandidate = await executeWithMode( + mode, + availableProviders, + async (provider, signal) => { + const { rawContent, providerId, rateLimits } = await fetchFromProvider( + provider, + promptText, + contextString, + isDebug, + signal, + callTimeout, + systemPrompt, + ); + + let parsedData: any; + try { + parsedData = JSON.parse(rawContent); + } catch (err: any) { + throw new TempoAiError(`Provider ${providerId} returned invalid JSON: ${err?.message}`, 422); + } + + const formatted = typeof parsedData?.formatted === 'string' ? parsedData.formatted.trim() : ''; + if (!formatted) { + throw new TempoAiError(`Provider ${providerId} returned empty formatted string.`, 422); + } + + const confidence = typeof parsedData?.confidence === 'number' && Number.isFinite(parsedData.confidence) + ? parsedData.confidence + : 0.9; + const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined; + + return { + data: { + formatted, + reasoning, + }, + providerId, + rateLimits, + confidence, + consensusKey: formatted.toLowerCase(), + }; + }, + { minConfidence: effectiveMinConfidence, debug: isDebug, tag: 'tempo-plugin-ai:format', hedgeDelay: effectiveHedgeDelay }, + ); + + _state.limits = winningCandidate.rateLimits ?? null; + + const { data: parsedData, providerId } = winningCandidate; + const confidence = typeof winningCandidate.confidence === 'number' && Number.isFinite(winningCandidate.confidence) + ? winningCandidate.confidence + : 0.9; + + if (effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence) { + throw new TempoAiError(`formatAI confidence (${confidence}) is below the required threshold of ${effectiveMinConfidence}.`, 422); + } + + const finalResult: TempoAiFormatResult = { + formatted: parsedData.formatted, + confidence, + provider: providerId, + reasoning: parsedData.reasoning, + }; + + const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000); + const cacheVal = JSON.stringify(finalResult); + await writeMultiTierCache(cacheKey, cacheVal, resolvedTtl, { + cache: aiCacheOption, + cacheAdapter: adapter, + debug: isDebug, + tag: 'tempo-plugin-ai:format', + }); + + return finalResult; } /** - * @internal Draft implementation scaffolded for future releases. - * ## formatAI (Upcoming Export) - * Formats a `Tempo` instance into human-friendly, contextual narrative text + * ## formatAI + * Formats a `Tempo` instance, Temporal object, Date, or timestamp into human-friendly, contextual narrative text * tailored to specific UI tones, relative time frames, or business domains. * * ### Why it fits Tempo: * Expands core `.format('{yyyy}-{mm}-{dd}')` into contextual, localized human - * descriptions that token patterns alone cannot capture. + * descriptions that token patterns alone cannot capture, backed by mathematical grounding. * * ### Example Usage: * ```ts @@ -78,11 +272,35 @@ export interface AiFormatOptions { * ``` */ export async function formatAI(items: FormatItem[], options?: AiFormatOptions): Promise<(TempoAiFormatResult | TempoAiError)[]>; -export async function formatAI(date: any, prompt?: string, options?: AiFormatOptions): Promise; +export async function formatAI(date: Tempo.DateTime, prompt?: string, options?: AiFormatOptions): Promise; export async function formatAI( - dateOrItems: any, - _promptOrOptions?: string | AiFormatOptions, - _options?: AiFormatOptions, + dateOrItems: Tempo.DateTime | FormatItem[], + promptOrOptions?: string | AiFormatOptions, + options?: AiFormatOptions, ): Promise { - throw new Error('formatAI is not yet implemented in tempo-plugin-ai.'); + if (Array.isArray(dateOrItems)) { + const opts = (typeof promptOrOptions === 'object' && promptOrOptions !== null ? promptOrOptions : options) || {}; + const softErrors = opts.softErrors ?? false; + + if (softErrors) { + const settled = await Promise.allSettled( + dateOrItems.map(item => formatSingleInput(item.date, item.prompt, opts)), + ); + return settled.map((res, index) => { + if (res.status === 'fulfilled') return res.value; + const rawReason = res.reason; + if (rawReason instanceof TempoAiError) return rawReason; + return new TempoAiError( + rawReason?.message || `Failed to format date at index ${index}`, + typeof rawReason?.status === 'number' ? rawReason.status : 500, + ); + }); + } + + return Promise.all(dateOrItems.map(item => formatSingleInput(item.date, item.prompt, opts))); + } + + const prompt = typeof promptOrOptions === 'string' ? promptOrOptions : undefined; + const opts = typeof promptOrOptions === 'object' && promptOrOptions !== null ? promptOrOptions : options; + return formatSingleInput(dateOrItems, prompt, opts); } diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts index b9f7ed33..8fd1cf4d 100644 --- a/packages/plugins/ai/src/functions/parse.ts +++ b/packages/plugins/ai/src/functions/parse.ts @@ -15,9 +15,9 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< let tz: string, cal: string, loc: string, sph: string, anchorStr: string; if (Tempo.isTempo(options?.anchor)) { - tz = String(options!.timeZone || options!.anchor.config.timeZone); - cal = String(options!.calendar || options!.anchor.config.calendar); - loc = String(Array.isArray(options!.locale) ? options!.locale[0] : (options!.locale || options!.anchor.config.locale)); + tz = String(options!.timeZone || options!.anchor.tz); + cal = String(options!.calendar || options!.anchor.cal); + loc = String(Array.isArray(options!.locale) ? options!.locale[0] : (options!.locale || options!.anchor.loc)); sph = String(options!.sphere || options!.anchor.config.sphere || 'north'); anchorStr = options!.anchor.toString(); } else { diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts index 365ec4a9..10380662 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -116,9 +116,9 @@ export async function recurrenceAI( const isRRule = isRRuleString(input); // Resolve full Tempo context hierarchy - const tz = options?.timeZone || (options?.anchor instanceof Tempo ? options.anchor.config.timeZone : undefined) || Tempo.options.timeZone; - const cal = options?.calendar || (options?.anchor instanceof Tempo ? options.anchor.config.calendar : undefined) || Tempo.options.calendar; - const loc = options?.locale || (options?.anchor instanceof Tempo ? options.anchor.config.locale : undefined) || Tempo.options.locale; + const tz = options?.timeZone || (options?.anchor instanceof Tempo ? options.anchor.tz : undefined) || Tempo.options.timeZone; + const cal = options?.calendar || (options?.anchor instanceof Tempo ? options.anchor.cal : undefined) || Tempo.options.calendar; + const loc = options?.locale || (options?.anchor instanceof Tempo ? options.anchor.loc : undefined) || Tempo.options.locale; const sph = options?.sphere || (options?.anchor instanceof Tempo ? options.anchor.config.sphere : undefined) || Tempo.options.sphere; const contextConfig = { timeZone: tz, calendar: cal, locale: loc, sphere: sph }; diff --git a/packages/plugins/ai/src/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts index cff5952d..330c9224 100644 --- a/packages/plugins/ai/src/functions/schedule.ts +++ b/packages/plugins/ai/src/functions/schedule.ts @@ -183,7 +183,7 @@ export async function scheduleAI( assertNoReservedProviderId(availableProviders); const resolvedTz = options?.timeZone - || (options?.anchor instanceof Tempo ? options.anchor.config?.timeZone || options.anchor.tz : undefined) + || (options?.anchor instanceof Tempo ? options.anchor.tz : undefined) || Tempo.options?.timeZone || 'UTC'; const anchorTempo = new Tempo(options?.anchor, { timeZone: resolvedTz }); diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts index 30c1ec71..357ac12d 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -15,6 +15,7 @@ export { recurrenceAI } from './functions/recurrence.js'; export { scheduleAI } from './functions/schedule.js'; export { contextAI } from './functions/context.js'; export { diffAI } from './functions/diff.js'; +export { formatAI } from './functions/format.js'; /* * ============================================================================ @@ -24,8 +25,5 @@ export { diffAI } from './functions/diff.js'; * Uncomment these exports as their implementations are finalized. */ -// /** Formats a Tempo instance into human-friendly, contextual narrative text */ -// export { formatAI, type TempoAiFormatResult, type FormatItem, type AiFormatOptions } from './functions/format.js'; - // /** Scans unstructured text and extracts embedded temporal entities & events */ // export { extractAI, type TempoAiExtractResult, type TempoExtractedEvent, type TempoEvent, type AiExtractOptions } from './functions/extract.js'; diff --git a/packages/plugins/ai/src/types/common.type.ts b/packages/plugins/ai/src/types/common.type.ts index 6baf84bc..e8d27d0c 100644 --- a/packages/plugins/ai/src/types/common.type.ts +++ b/packages/plugins/ai/src/types/common.type.ts @@ -73,10 +73,14 @@ export interface AiConfig { mode?: AiMode | undefined; /** Strict minimum confidence threshold (0.0 to 1.0) */ minConfidence?: number | undefined; - /** Optional custom cache implementation for storing parsed strings */ - cache?: Map | undefined; + /** Optional custom cache implementation for storing parsed strings or boolean flag to enable/disable */ + cache?: Map | boolean | undefined; /** Optional custom cache storage engine (e.g., Redis, KV store) for storing parsed strings */ cacheAdapter?: AiCacheAdapter | undefined; + /** Optional default IANA timezone for AI operations */ + timeZone?: string | undefined; + /** Optional default BCP 47 locale for AI operations */ + locale?: string | string[] | undefined; /** Optional global cache TTL in milliseconds for AI parsing entries (default: 3600000ms / 1 hour) */ ttl?: number | undefined; /** Optional global timeout in milliseconds for AI requests (default: 15000ms) */ diff --git a/packages/plugins/ai/src/types/format.type.ts b/packages/plugins/ai/src/types/format.type.ts new file mode 100644 index 00000000..0210cffa --- /dev/null +++ b/packages/plugins/ai/src/types/format.type.ts @@ -0,0 +1,56 @@ +import type { Tempo } from '@magmacomputing/tempo'; +import type { AiMode } from '../core/config.js'; +import type { AiCacheAdapter, AiProvider } from './common.type.js'; + +export interface FormatItem { + /** Date-time instance, Temporal object, or string to format. */ + date: Tempo.DateTime; + /** Prompt instructions guiding the output narrative. */ + prompt?: string | undefined; +} + +export interface TempoAiFormatResult { + /** Formatted narrative string. */ + formatted: string; + /** Confidence score between 0.0 and 1.0. */ + confidence: number; + /** ID of the provider that fulfilled the request (or 'cache'). */ + provider: string; + /** Optional step-by-step rationale from the LLM. */ + reasoning?: string | undefined; +} + +export interface AiFormatOptions { + /** Reference anchor date for relative calculations (defaults to now). */ + anchor?: Tempo.DateTime | undefined; + /** Target IANA timezone (defaults to Tempo instance timezone or global options). */ + timeZone?: string | undefined; + /** Target BCP 47 locale or language tag (defaults to global options or 'en-US'). */ + locale?: string | string[] | undefined; + /** Desired narrative tone or formatting style hint (e.g. 'casual', 'formal', 'compact', 'countdown'). */ + style?: string | undefined; + /** Custom regional context (e.g. 'AU-NSW', 'US-CA'). */ + region?: string | undefined; + /** If true, bypasses cache to force a fresh LLM fetch */ + force?: boolean | undefined; + /** If false, disables reading and writing to cache */ + cache?: boolean | undefined; + /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */ + cacheAdapter?: AiCacheAdapter | undefined; + /** Optional TTL override in milliseconds for cached result */ + ttl?: number | undefined; + /** If true, logs prompt context and LLM payloads to console */ + debug?: boolean | undefined; + /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` | `AiMode.Hedged` | `AiMode.RoundRobin` | `AiMode.Adaptive` or string literal) */ + mode?: AiMode | undefined; + /** Per-request provider configuration overrides */ + providers?: AiProvider[] | undefined; + /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */ + minConfidence?: number | undefined; + /** Optional request timeout in milliseconds for this operation */ + timeout?: number | undefined; + /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged */ + hedgeDelay?: number | undefined; + /** If true, returns an array containing both successful results and TempoAiErrors instead of throwing */ + softErrors?: boolean | undefined; +} diff --git a/packages/plugins/ai/src/types/index.ts b/packages/plugins/ai/src/types/index.ts index 9e85801a..f35d3bc9 100644 --- a/packages/plugins/ai/src/types/index.ts +++ b/packages/plugins/ai/src/types/index.ts @@ -4,4 +4,5 @@ export * from './recurrence.type.js'; export * from './schedule.type.js'; export * from './context.type.js'; export * from './diff.type.js'; +export * from './format.type.js'; diff --git a/packages/plugins/ai/test/format.test.ts b/packages/plugins/ai/test/format.test.ts new file mode 100644 index 00000000..389bf4b4 --- /dev/null +++ b/packages/plugins/ai/test/format.test.ts @@ -0,0 +1,259 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { formatAI, initAI, TempoAiError, type TempoAiFormatResult, type AiCacheAdapter } from '../src/index.js'; + +describe('AI Format Plugin (formatAI)', () => { + beforeEach(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => { }); + vi.spyOn(console, 'error').mockImplementation(() => { }); + vi.spyOn(console, 'log').mockImplementation(() => { }); + await initAI({ remoteConfigUrl: false, providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should calculate native grounding metrics and format natural narrative date', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: 'this Friday at 5:00 PM EST (in 5 days)', + confidence: 0.98, + reasoning: 'Target date is a Friday, exactly 5 calendar days away.', + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const target = new Tempo('2026-08-07T17:00:00Z'); + const anchor = new Tempo('2026-08-02T17:00:00Z'); + + const result = await formatAI(target, 'friendly reminder tone with countdown', { anchor }); + expect(result).toBeDefined(); + expect(result.formatted).toBe('this Friday at 5:00 PM EST (in 5 days)'); + expect(result.confidence).toBe(0.98); + expect(result.provider).toBe('groq'); + expect(result.reasoning).toContain('Friday'); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + const systemPrompt = requestBody.messages[0].content; + expect(systemPrompt).toContain('Grounding Context:'); + expect(systemPrompt).toContain('Day of Week: Friday'); + expect(systemPrompt).toContain('+5 calendar days'); + expect(systemPrompt).toContain('in the FUTURE'); + }); + + it('should accept TC39 Temporal instances as valid date inputs', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: 'Tomorrow afternoon at 3:00 PM', + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const temporalZdt = new Tempo('2026-08-05T15:00:00+10:00[Australia/Sydney]').toDateTime(); + const result = await formatAI(temporalZdt, 'compact relative format'); + + expect(result.formatted).toBe('Tomorrow afternoon at 3:00 PM'); + expect(result.confidence).toBe(0.95); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + expect(requestBody.messages[0].content).toContain('(Australia/Sydney)'); + }); + + it('should propagate style, region, and target locale to provider prompt', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: 'Vendredi prochain à 17h00', + confidence: 0.96, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const target = '2026-08-07T17:00:00Z'; + const result = await formatAI(target, 'format for French invite', { + style: 'formal', + locale: 'fr-FR', + region: 'FR-IDF', + }); + + expect(result.formatted).toBe('Vendredi prochain à 17h00'); + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + const promptContext = requestBody.messages[0].content; + expect(promptContext).toContain('Target Locale: fr-FR'); + expect(promptContext).toContain('Desired Style/Tone: formal'); + expect(promptContext).toContain('Regional Context: FR-IDF'); + }); + + it('should check cache and skip network fetch on cache hits', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: 'Pre-cached formatted string', + confidence: 0.99, + reasoning: 'Generated once', + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const target = new Tempo('2026-08-07T17:00:00Z'); + const anchor = new Tempo('2026-08-02T17:00:00Z'); + + const result1 = await formatAI(target, 'cached prompt', { anchor }); + expect(result1.provider).toBe('groq'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + const result2 = await formatAI(target, 'cached prompt', { anchor }); + expect(result2.formatted).toBe('Pre-cached formatted string'); + expect(result2.provider).toBe('cache'); + expect(result2.confidence).toBe(0.99); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should support custom async AiCacheAdapter storage', async () => { + const cacheStore = new Map(); + const customAdapter: AiCacheAdapter = { + get: vi.fn(async (key: string) => cacheStore.get(key)), + set: vi.fn(async (key: string, val: string) => { cacheStore.set(key, val); }), + }; + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: 'Distributed adapter cached', + confidence: 0.97, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const target = new Tempo('2026-08-07T17:00:00Z'); + const anchor = new Tempo('2026-08-02T17:00:00Z'); + + const result1 = await formatAI(target, 'adapter prompt', { anchor, cacheAdapter: customAdapter }); + expect(result1.formatted).toBe('Distributed adapter cached'); + expect(customAdapter.set).toHaveBeenCalledTimes(1); + + // Clear local Tempo memory cache to ensure it reads from custom adapter + Tempo.cache.clear(); + + const result2 = await formatAI(target, 'adapter prompt', { anchor, cacheAdapter: customAdapter }); + expect(result2.formatted).toBe('Distributed adapter cached'); + expect(result2.provider).toBe('cache'); + expect(customAdapter.get).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should throw TempoAiError if confidence is below minConfidence', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: 'Uncertain format', + confidence: 0.45, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + await expect(formatAI('2026-08-07', 'test', { minConfidence: 0.8 })) + .rejects.toThrow(/formatAI confidence \(0.45\) is below the required threshold of 0.8/i); + }); + + it('should throw TempoAiError(400) for invalid date or anchor', async () => { + await expect(formatAI('invalid-date-string', 'prompt')) + .rejects.toThrow(/Invalid date provided to formatAI/i); + + await expect(formatAI('2026-08-07', 'prompt', { anchor: 'invalid-anchor-date' })) + .rejects.toThrow(/Invalid anchor date provided to formatAI/i); + }); + + it('should support multi-provider race execution mode', async () => { + let slowWasAborted = false; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockImplementation(async (_url, init) => { + const body = JSON.parse(init?.body as string); + const signal = init?.signal as AbortSignal | undefined; + if (body.model === 'fast-model') { + return new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: 'Fast winner formatted narrative', + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return new Promise((_resolve, reject) => { + if (signal?.aborted) { + slowWasAborted = true; + reject(new DOMException('Aborted', 'AbortError')); + return; + } + signal?.addEventListener('abort', () => { + slowWasAborted = true; + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + }); + + const result = await formatAI('2026-08-07', 'quick race format', { + mode: 'race', + providers: [ + { id: 'slow-provider', key: 'k1', url: 'https://api.openai.com/v1/chat/completions', model: 'slow-model' }, + { id: 'fast-provider', key: 'k2', url: 'https://api.groq.com/v1/chat/completions', model: 'fast-model' }, + ], + }); + + expect(result.formatted).toBe('Fast winner formatted narrative'); + expect(result.provider).toBe('fast-provider'); + expect(slowWasAborted).toBe(true); + }); + + it('should support batch array processing with softErrors', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: 'Item 1 formatted', + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + .mockResolvedValueOnce(new Response('Server Error', { status: 500 })); + + const items = [ + { date: '2026-08-03', prompt: 'item 1' }, + { date: '2026-08-05', prompt: 'item 2' }, + ]; + + const results = await formatAI(items, { softErrors: true }); + expect(results).toHaveLength(2); + expect((results[0] as TempoAiFormatResult).formatted).toBe('Item 1 formatted'); + expect(results[1]).toBeInstanceOf(TempoAiError); + }); +}); diff --git a/packages/plugins/ai/test/recurrence.test.ts b/packages/plugins/ai/test/recurrence.test.ts index 26106063..aa819722 100644 --- a/packages/plugins/ai/test/recurrence.test.ts +++ b/packages/plugins/ai/test/recurrence.test.ts @@ -258,9 +258,9 @@ describe('AI Recurrence Plugin (recurrenceAI)', () => { const items = result.take(3); expect(items).toHaveLength(3); - expect(items[0].config.timeZone).toBe('Australia/Sydney'); - expect(items[0].config.calendar).toBe('iso8601'); - expect(items[0].config.locale).toBe('en-AU'); + expect(items[0].tz).toBe('Australia/Sydney'); + expect(items[0].cal).toBe('iso8601'); + expect(items[0].loc).toBe('en-AU'); expect(items[0].config.sphere).toBe('south'); }); }); diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index afa4d5f6..bcb5b197 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -200,7 +200,7 @@ export default withMermaid(defineConfig({ ssr: { // Prevent Vite from externalising these packages during SSR so the aliases // above are honoured in the server-side rendering pass as well. - noExternal: ['@magmacomputing/tempo', '@magmacomputing/library'] + noExternal: ['@magmacomputing/tempo', '@magmacomputing/library', 'vue', '@vue/server-renderer'] } } })) diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json index f40e7958..14f68039 100644 --- a/packages/tempo/.vitepress/theme/data/catalog.json +++ b/packages/tempo/.vitepress/theme/data/catalog.json @@ -51,7 +51,7 @@ "packageName": "@magmacomputing/tempo-plugin-ai", "plan": "community", "status": "experimental", - "version": "0.3.0" + "version": "4.0.0" }, { "id": "ticker", diff --git a/packages/tempo/src/engine/engine.normalizer.ts b/packages/tempo/src/engine/engine.normalizer.ts index 4d2907f1..162bf25a 100644 --- a/packages/tempo/src/engine/engine.normalizer.ts +++ b/packages/tempo/src/engine/engine.normalizer.ts @@ -3,7 +3,7 @@ import { getTemporalIds, instant } from '#library/temporal.library.js'; import { ownKeys } from '#library/primitive.library.js'; import type { TypeValue } from '#library/type.library.js'; -import { getRuntime, sym, Match, logError, logDebug, TempoError } from '#tempo/support'; +import { getRuntime, sym, Match, logError, logDebug, TempoError, Default } from '#tempo/support'; import { prefix, parseWeekday, parseDate, parseTime, parseZone } from './engine.lexer.js'; import { resolveTermMutation } from './engine.term.js'; import enums from '#tempo/support/support.enum.js'; @@ -83,6 +83,7 @@ export function getAliasContext(ctx: NormalizerContext, dateTime: Temporal.Zoned get ss() { return dateTime.second }, get tz() { return tz }, get cal() { return cal }, + get loc() { return state.config.locale ?? Default.locale }, config: state.config, [sym.$Identity]: true, } as t.AliasContext @@ -205,7 +206,7 @@ export function resolveAliases( const host = getAliasContext(ctx, dateTime); const res = aliasEngine?.resolveAlias(key as any, host); if (!res) continue; - + logDebug(`[Normalizer] Resolved alias '${aliasKey}'`, state.config); try { @@ -253,7 +254,7 @@ export function resolveAliases( if (isDefined(groups["mm"]) && !isNumeric(groups["mm"])) { const rawMm = String(groups["mm"]).replace(/\.$/, '').toLowerCase(); const mappedMm = state.parse.monthMap?.[rawMm]; - + if (isDefined(mappedMm)) { groups["mm"] = mappedMm.value.toString().padStart(2, '0'); logDebug(`[Normalizer] Normalized localized month string '${groups["mm"]}'`, state.config); diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index c939ae15..e9ca0e07 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1553,6 +1553,7 @@ export class Tempo { /** Fractional seconds (e.g., 0.123456789) */ get ff() { return +(`0.${pad(this.ms, 3)}${pad(this.us, 3)}${pad(this.ns, 3)}`) } /** IANA Time Zone ID (e.g., 'Australia/Sydney') */ get tz() { return this.#temporalIds()[0] } /** Temporal Calendar ID (e.g., 'iso8601' | 'gregory') */ get cal() { return this.#temporalIds()[1] } + /** Resolved BCP 47 locale (e.g., 'en-US') */ get loc() { return (this.#local.config.locale ?? (this as any)[$Internal]().config.locale ?? Default.locale) as string | string[] } /** Unix timestamp (defaults to milliseconds) */ get ts() { return this.epoch[this.#local.config.timeStamp] } /** Short month name (e.g., 'Jan') */ get mmm() { return Tempo.MONTH.keyOf(this.toDateTime().month as t.Month) } /** Full month name (e.g., 'January') */ get mon() { return Tempo.MONTHS.keyOf(this.toDateTime().month as t.Month) } diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 6b6e7976..83a5456d 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -71,6 +71,7 @@ export interface AliasContext { /** Second (0-59) */ readonly ss: IntRange<0, 59>; /** IANA TimeZone identifier */ readonly tz: string; /** Calendar identifier */ readonly cal: string; + /** Resolved BCP 47 locale */ readonly loc: string | string[]; /** Current configuration state */ readonly config: Internal.Config; } diff --git a/packages/tempo/test/core/accessors.test.ts b/packages/tempo/test/core/accessors.test.ts index 4886b5e2..21ca13a0 100644 --- a/packages/tempo/test/core/accessors.test.ts +++ b/packages/tempo/test/core/accessors.test.ts @@ -17,4 +17,12 @@ describe(`${label}`, () => { test(`${label} get the right day-of-month (${date.getDate()})`, () => { expect(tempo.dd).toBe(date.getDate()) }) + + test(`${label} get instance locale via loc getters`, () => { + const tDefault = new Tempo('2024-05-20'); + expect(tDefault.loc).toBeDefined(); + + const tCustom = new Tempo('2024-05-20', { locale: 'fr-FR' }); + expect(tCustom.loc).toBe('fr-FR'); + }) }) \ No newline at end of file diff --git a/packages/tempo/test/core/static.test.ts b/packages/tempo/test/core/static.test.ts index 2a376ab1..1bfa7586 100644 --- a/packages/tempo/test/core/static.test.ts +++ b/packages/tempo/test/core/static.test.ts @@ -9,7 +9,7 @@ describe(`${label}`, () => { test(`${label} get the properties`, () => { expect(Tempo.properties.toSorted()) - .toEqual(['yy', 'yw', 'mm', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'fmt', 'ww', 'wy', 'tz', 'cal', 'ts', 'dow', 'mmm', 'mon', 'www', 'wkd', 'day', 'nano', 'term', 'terms', 'config', 'epoch', 'parse', 'ranges', 'isValid', 'iso', 'era', 'eraYear', 'eon'].toSorted()) + .toEqual(['yy', 'yw', 'mm', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'fmt', 'ww', 'wy', 'tz', 'cal', 'loc', 'ts', 'dow', 'mmm', 'mon', 'www', 'wkd', 'day', 'nano', 'term', 'terms', 'config', 'epoch', 'parse', 'ranges', 'isValid', 'iso', 'era', 'eraYear', 'eon'].toSorted()) }) test(`${label} get the elements`, () => { From a2f0fd4ef43a69a8351361477ef8ab147938e1cd Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Thu, 13 Aug 2026 18:01:53 +1000 Subject: [PATCH 2/7] PR formatAI 1st review --- packages/plugins/ai/doc/architecture.md | 47 ++++--- packages/plugins/ai/doc/formatAI.md | 5 +- packages/plugins/ai/plan/formatAI.plan.md | 116 ------------------ packages/plugins/ai/src/core/support.ts | 5 +- packages/plugins/ai/src/functions/format.ts | 75 ++++++----- packages/plugins/ai/src/functions/parse.ts | 6 +- .../plugins/ai/src/functions/recurrence.ts | 5 +- packages/plugins/ai/src/types/format.type.ts | 11 +- packages/plugins/ai/test/format.test.ts | 45 ++++++- 9 files changed, 140 insertions(+), 175 deletions(-) delete mode 100644 packages/plugins/ai/plan/formatAI.plan.md diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md index 7ad9e075..a65f3c2d 100644 --- a/packages/plugins/ai/doc/architecture.md +++ b/packages/plugins/ai/doc/architecture.md @@ -95,10 +95,10 @@ flowchart LR LLM["Groq • OpenAI • Gemini • Anthropic"] end - Client -- "1. HTTPS (TLS 1.3)
Session Token / Auth Header" --> Proxy - Proxy -- "2. HTTPS (TLS 1.3)
Private Provider API Key" --> LLM - LLM -- "3. HTTPS (TLS 1.3)
Raw JSON Completion" --> Proxy - Proxy -- "4. HTTPS (TLS 1.3)
Validated Payload" --> Client + Client -- "1. HTTPS (TLS 1.2+)
Bearer Token / Auth Header" --> Proxy + Proxy -- "2. HTTPS (TLS 1.2+)
Private Provider API Key" --> LLM + LLM -- "3. HTTPS (TLS 1.2+)
Raw JSON Completion" --> Proxy + Proxy -- "4. HTTPS (TLS 1.2+)
Validated Payload" --> Client ``` ### 1. Browser Configuration Example @@ -113,7 +113,7 @@ await initAI({ { id: 'my-gateway', url: 'https://api.mycompany.com/v1/ai/chat/completions', // Your secure proxy endpoint - key: userSessionToken, // Short-lived user JWT or session cookie + key: userSessionToken, // Short-lived user Bearer JWT token model: 'llama-3.3-70b-instruct' } ] @@ -124,29 +124,42 @@ const date = await parseAI("Team standup next Wednesday at 9:30am"); ``` ### 2. Backend Proxy Handler Example (Next.js / Cloudflare Worker / Express) -Your backend endpoint receives the request, validates the user's session, attaches your private LLM API key, and forwards the payload to the upstream provider: +Your backend endpoint receives the request, validates the user's session, enforces ingress quotas, attaches your private LLM API key, and forwards the validated payload to the upstream provider: ```typescript // Example: Next.js API Route / Cloudflare Worker export async function POST(req: Request) { // 1. Authenticate user session const authHeader = req.headers.get('Authorization'); - if (!isValidUserSession(authHeader)) { + const session = await validateUserSession(authHeader); + if (!session) { return new Response('Unauthorized', { status: 401 }); } - // 2. Forward request to upstream LLM with private BYOK key + // 2. Ingress validation & per-user quota enforcement const body = await req.json(); + if (typeof body?.prompt !== 'string' || body.prompt.length > 4096) { + return new Response('Invalid prompt or payload exceeds size limit', { status: 400 }); + } + if (!checkUserRateLimit(session.userId)) { + return new Response('Too Many Requests', { status: 429 }); + } + + // 3. Construct sanitized upstream payload with private BYOK key const upstreamResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.GROQ_API_KEY}` }, - body: JSON.stringify(body) + body: JSON.stringify({ + model: 'llama-3.3-70b-versatile', + messages: body.messages, + temperature: 0.1, + }) }); - // 3. Return provider payload to client + // 4. Return provider payload to client const data = await upstreamResponse.json(); return new Response(JSON.stringify(data), { status: upstreamResponse.status, @@ -161,18 +174,18 @@ export async function POST(req: Request) { Whether running directly on backend servers (Node.js, Deno, Bun, Edge Workers) or through a client-side browser proxy, `@magmacomputing/tempo-plugin-ai` enforces strict security and privacy standards: -### 1. End-to-End Encryption (TLS 1.3) -All transport communication—both from browser to proxy and from proxy/server to upstream LLM endpoints—is strictly enforced over HTTPS utilizing **TLS 1.3**. Plaintext HTTP endpoints are disallowed in production environments (permitted only on `localhost` during local development). +### 1. Transport Security (HTTPS / TLS) +All network communication—both from client to proxy and from proxy/server to upstream LLM endpoints—is required over HTTPS. Negotiated TLS versions (such as TLS 1.2 or TLS 1.3) depend on deployment environment and server configuration unless strictly enforced by your reverse proxy. Plaintext HTTP endpoints are disallowed in production environments (permitted only on `localhost` during local development). -### 2. Ephemeral Processing & Zero Data Retention -Temporal processing payloads (dates, times, context snippets, prompts) are processed ephemerally. The plugin does not send telemetry or store user prompt data on external analytics servers. Information is used exclusively during the execution of the requested AI function and discarded immediately after response resolution. +### 2. Ephemeral Processing & Cache Retention Controls +Temporal processing payloads (dates, times, context snippets, prompts) are processed ephemerally. The plugin does not send telemetry or store user prompt data on external analytics servers. However, functions supporting caching (e.g. `parseAI`, `formatAI`, `diffAI`) may retain prompt-derived cache keys and final results in local memory or configured custom cache adapters according to the resolved TTL. Requests requiring zero cache retention must explicitly pass `cache: false`. ### 3. In-Memory Credential Redaction & Immutability * **Automated Key Redaction**: Calling `getAiConfig()` returns a sanitized, deeply read-only snapshot of active configurations with all provider `key` values replaced with `[REDACTED]`, preventing accidental exposure in log files, APM traces, or crash dumps. -* **Frozen Metadata**: All diagnostic metadata attached to `Tempo` instances via `.ai` is deeply frozen with `Object.freeze()` and guarded via runtime `Proxy` wrappers, eliminating prototype pollution and runtime state mutation. +* **Frozen Metadata**: All diagnostic metadata attached to `Tempo` instances via `.ai` is deeply frozen with `Object.freeze()` and guarded via runtime `Proxy` wrappers, protecting against direct runtime mutation of the `.ai` metadata. -### 4. Deterministic Schema Guardrails & Hallucination Traps -All LLM prompts are paired with rigid, machine-verifiable JSON schemas. Responses undergo strict boundary validation, regex parsing, and ISO verification before any native `Tempo` date object is instantiated. If an LLM returns malformed or non-chronological data, the plugin throws a typed `TempoAiError` or triggers automatic fallback rather than silently returning an invalid date. +### 4. Deterministic Schema Guardrails & Confidence Range Verification +All LLM prompts are paired with rigid, machine-verifiable JSON schemas. Responses undergo strict boundary validation, regex parsing, confidence range verification (`0.0` to `1.0`), and ISO verification before any native `Tempo` date object or result payload is instantiated. If an LLM returns malformed, out-of-range, or non-chronological data, the plugin throws a typed `TempoAiError` or triggers automatic fallback rather than silently returning an invalid date. ### 5. Partitioned Caching & Fail-Open Storage Resilience * **Strict Cache Key Partitioning**: Caches are namespaced and hashed (`ai:::...`) with timezone, locale, calendar, and anchor date isolation to prevent cross-tenant or cross-regional cache poisoning. diff --git a/packages/plugins/ai/doc/formatAI.md b/packages/plugins/ai/doc/formatAI.md index 6c265139..3fa2cb95 100644 --- a/packages/plugins/ai/doc/formatAI.md +++ b/packages/plugins/ai/doc/formatAI.md @@ -20,9 +20,10 @@ await initAI({ }); const target = new Tempo('2026-08-07T17:00:00[America/New_York]'); +const anchor = new Tempo('2026-08-02T17:00:00[America/New_York]'); // "this Friday at 5:00 PM EST (in 5 days)" -const result = await formatAI(target, 'friendly reminder tone with relative countdown'); +const result = await formatAI(target, 'friendly reminder tone with relative countdown', { anchor }); console.log(result.formatted); // "this Friday at 5:00 PM EST (in 5 days)" console.log(result.confidence); // 0.98 @@ -35,7 +36,7 @@ console.log(result.provider); // 'groq' | Option | Type | Description | | :--- | :--- | :--- | -| **`anchor`** | `Tempo.DateTime` | Reference anchor date for relative delta calculations (defaults to current time). | +| **`anchor`** | `TempoDateInput` | Reference anchor date for relative delta calculations (defaults to current time). | | **`style`** | `string` | Narrative style or tone hint (e.g. `'casual'`, `'formal'`, `'compact'`, `'countdown'`). | | **`region`** | `string` | Regional context (e.g., `'AU-NSW'`, `'US-CA'`) passed to LLM grounding. | | **`timeZone`** | `string` | Target IANA timezone for output formatting. | diff --git a/packages/plugins/ai/plan/formatAI.plan.md b/packages/plugins/ai/plan/formatAI.plan.md deleted file mode 100644 index b6081518..00000000 --- a/packages/plugins/ai/plan/formatAI.plan.md +++ /dev/null @@ -1,116 +0,0 @@ -# Implementation Plan: `formatAI` - -## 1. Overview & Goal -`formatAI` transforms a `Tempo` instance, `Date`, timestamp, or ISO string into human-friendly, contextual narrative text tailored to specific prompts, UI tones, business domains, or relative countdown styles (e.g., *"this Friday at 5:00 PM EST (in 5 days)"*, *"Q3 Fiscal Close — 14 business days remaining"*). - -By combining deterministic date-time grounding (formatted ISO components, day of week, relative difference to anchor/now, season, quarter) with LLM prompt execution, `formatAI` eliminates date hallucination while delivering expressive, localized language. - ---- - -## 2. Public API & Type Definitions - -### 2.1 Types (`packages/plugins/ai/src/types/format.type.ts`) -```typescript -import type { Tempo, DateTime } from '@magmacomputing/tempo'; -import type { AiOptions } from './common.type.js'; -import type { TempoAiError } from '../core/error.js'; - -export interface AiFormatOptions extends AiOptions { - /** Reference anchor date for relative calculations (defaults to now). */ - anchor?: DateTime; - /** Target IANA timezone (defaults to Tempo instance timezone or global options). */ - timeZone?: string; - /** Target BCP 47 locale or language tag (defaults to global options or 'en-US'). */ - locale?: string | string[]; - /** Desired narrative tone or formatting style hint (e.g. 'casual', 'formal', 'compact', 'countdown'). */ - style?: string; - /** Custom regional context (e.g. 'AU-NSW', 'US-CA'). */ - region?: string; -} - -export interface FormatItem { - /** Date-time instance, Temporal object, or string to format. */ - date: DateTime; - /** Prompt instructions guiding the output narrative. */ - prompt?: string; -} - -export interface TempoAiFormatResult { - /** Formatted narrative string. */ - formatted: string; - /** Confidence score between 0.0 and 1.0. */ - confidence: number; - /** ID of the provider that fulfilled the request (or 'cache'). */ - provider: string; - /** Optional step-by-step rationale from the LLM. */ - reasoning?: string; -} -``` - -### 2.2 Function Signature (`packages/plugins/ai/src/functions/format.ts`) -```typescript -export async function formatAI(items: FormatItem[], options?: AiFormatOptions): Promise<(TempoAiFormatResult | TempoAiError)[]>; -export async function formatAI(date: DateTime, prompt?: string, options?: AiFormatOptions): Promise; -``` - ---- - -## 3. Mathematical Grounding & Prompt Strategy - -### 3.1 Grounding Calculation -To guarantee accuracy, pre-compute: -* **Canonical ISO Representation**: `targetTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')` -* **Target Timezone & Offset**: `targetTempo.tz`, `targetTempo.offset` -* **Day of Week & Ordinal**: `targetTempo.dow` (1=Mon, 7=Sun), weekday name -* **Relative Delta to Anchor**: - * Calendar days: `Math.round(anchorTempo.until(targetTempo, 'day') * 100) / 100` - * Elapsed hours: `Math.round(anchorTempo.until(targetTempo, 'hour') * 100) / 100` - * Relative direction: Past / Present / Future - -### 3.2 Context & System Prompt -```markdown -Grounding Context: -- Target Date-Time: 2026-08-14T17:00:00 (America/New_York) -- Day of Week: Friday (Day 5) -- Reference Anchor: 2026-08-12T08:00:00 (America/New_York) -- Relative Delta: +2.38 days (+57.0 hours) in the Future -- Target Locale: en-US -- Formatting Style: casual -- Prompt: "Express as an upcoming meeting reminder with relative countdown" -``` - -Schema enforcement: -```json -{ - "formatted": "this Friday at 5:00 PM EST (in 2 days)", - "confidence": 0.98, - "reasoning": "Target timestamp is in 2 days on Friday afternoon." -} -``` - ---- - -## 4. Caching & Dispatch Pipeline - -1. **Cache Key Partition**: - `format::${targetTempo.epoch.ms}::${anchorTempo.epoch.ms}::${normalizedPrompt}::${tz}::${loc}::${style}::${region}` -2. **Multi-tier Caching**: - - Check `AiCacheAdapter` (Redis / Cloudflare KV) then local `Tempo.cache`. - - Validate non-empty `formatted` and check `effectiveMinConfidence`. -3. **Execution Modes**: - - Dispatch via `executeWithMode` supporting all 6 modes (`Fallback`, `Race`, `Consensus`, `Hedged`, `RoundRobin`, `Adaptive`). -4. **Batch Processing**: - - Concurrent `Promise.all` / `Promise.allSettled` (with `softErrors: true` normalizing rejections to `TempoAiError`). - ---- - -## 5. Verification & Test Plan -* **Unit Tests (`packages/plugins/ai/test/format.test.ts`)**: - - Valid date formatting across diverse prompt instructions (business SLA, casual relative, compact countdown). - - Timezone normalization (preserves target instant in requested timeZone). - - Cache hit preservation and region/style cache isolation. - - Multi-provider execution modes (Race, Consensus, Hedged). - - Batch array processing with `softErrors: true`. - - Confidence threshold rejection (`minConfidence`). -* **Documentation (`packages/plugins/ai/doc/formatAI.md`)**: - - TSDoc, basic usage with `initAI`, options guide, batch examples. diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index 336b08dc..59e25b38 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -7,7 +7,7 @@ import type { AiCacheAdapter, AiProvider, TempoAiMeta } from '../types/index.js' export function assertNoReservedProviderId(providers: Partial[]): void { for (const p of providers) { if (p.id && RESERVED_PROVIDER_IDS.has(p.id.toLowerCase())) - throw new TempoAiError(`Provider ID '${p.id}' is a reserved keyword in parseAI.`, 400); + throw new TempoAiError(`Provider ID '${p.id}' is a reserved keyword in AI provider configuration.`, 400); } } @@ -95,8 +95,7 @@ export async function writeMultiTierCache( const adapter = options.cacheAdapter || _state.config.cacheAdapter; if (adapter) { try { - const res = adapter.set(cacheKey, value, ttl); - if (res instanceof Promise) await res; + await adapter.set(cacheKey, value, ttl); } catch (err: any) { if (options.debug) console.warn(`[${options.tag ?? 'tempo-plugin-ai'}] Cache adapter set failed for ${cacheKey}:`, err?.message ?? err); } diff --git a/packages/plugins/ai/src/functions/format.ts b/packages/plugins/ai/src/functions/format.ts index 3b835418..4d4ddd28 100644 --- a/packages/plugins/ai/src/functions/format.ts +++ b/packages/plugins/ai/src/functions/format.ts @@ -12,9 +12,9 @@ import { resolveTzAndLocale, writeMultiTierCache, } from '../core/support.js'; -import type { AiFormatOptions, FormatItem, TempoAiFormatResult } from '../types/format.type.js'; +import type { AiFormatOptions, FormatItem, TempoAiFormatResult, TempoDateInput } from '../types/format.type.js'; -export type { AiFormatOptions, FormatItem, TempoAiFormatResult }; +export type { AiFormatOptions, FormatItem, TempoAiFormatResult, TempoDateInput }; interface FormatGroundingMetrics { iso: string; @@ -55,7 +55,7 @@ function calculateFormatGroundingMetrics(targetTempo: Tempo, anchorTempo: Tempo) } async function formatSingleInput( - date: Tempo.DateTime, + date: TempoDateInput, prompt?: string, options?: AiFormatOptions, ): Promise { @@ -82,7 +82,7 @@ async function formatSingleInput( ? (Tempo.isTempo(anchor) ? (anchor.tz === tz ? anchor : anchor.set({ timeZone: tz })) : new Tempo(anchor as any, { timeZone: tz })) - : new Tempo(undefined, { timeZone: tz }); + : new Tempo(Math.floor(Date.now() / 60_000) * 60_000, { timeZone: tz }); } catch (err: any) { throw new TempoAiError(`Invalid anchor date provided to formatAI: "${String(anchor)}"`, 400); } @@ -128,11 +128,13 @@ async function formatSingleInput( try { const parsedCache = JSON.parse(cachedVal); if (typeof parsedCache?.formatted === 'string' && parsedCache.formatted.trim().length > 0) { - const cachedConfidence = typeof parsedCache.confidence === 'number' && Number.isFinite(parsedCache.confidence) - ? parsedCache.confidence + const cachedConfidence = typeof parsedCache?.confidence === 'number' && Number.isFinite(parsedCache.confidence) + ? Math.max(0.0, Math.min(1.0, parsedCache.confidence)) : 1.0; - if (effectiveMinConfidence === undefined || cachedConfidence >= effectiveMinConfidence) { - if (isDebug) console.log(`[tempo-plugin-ai:format] Cache hit: "${cacheKey}" -> ${cachedVal}`); + + if (effectiveMinConfidence !== undefined && cachedConfidence < effectiveMinConfidence) { + if (isDebug) console.log(`[tempo-plugin-ai:format] Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`); + } else { return { formatted: parsedCache.formatted, confidence: cachedConfidence, @@ -141,8 +143,8 @@ async function formatSingleInput( }; } } - } catch { - // If cached value is corrupted, proceed to fetch + } catch (err: any) { + if (isDebug) console.warn(`[tempo-plugin-ai:format] Failed to parse cached payload:`, err?.message ?? err); } } @@ -153,7 +155,29 @@ async function formatSingleInput( assertNoReservedProviderId(availableProviders); - const mode = aiMode || _state.config.mode || AiMode.Fallback; + const systemPrompt = `You are an expert natural language temporal formatting engine. +Generate human-friendly, contextual narrative representations of dates and times based on the grounding context. + +Grounding Context: +- Target Date-Time: ${grounding.iso} (${tz}) +- Target Day of Week: ${grounding.dayOfWeek} (Day ${grounding.dayOfWeekOrdinal}) +- Reference Anchor: ${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')} (${tz}) +- Relative Time Delta: ${grounding.calendarDays >= 0 ? '+' : ''}${grounding.calendarDays} calendar days (${grounding.elapsedHours >= 0 ? '+' : ''}${grounding.elapsedHours} hours) in the ${grounding.direction.toUpperCase()} +- Target Locale: ${loc}${region ? `\n- Regional Context: ${region}` : ''}${style ? `\n- Desired Style/Tone: ${style}` : ''} + +Rules: +1. Always return a single, valid JSON object matching the schema below. +2. The "formatted" field must contain the contextual narrative string (e.g., "this Friday at 5:00 PM EST (in 5 days)", "Tomorrow afternoon at 3:00 PM"). +3. Respect the target locale, style, and timezone conventions. +4. "confidence" must be a float between 0.0 and 1.0 representing certainty. +5. "reasoning" should briefly describe how the formatted output was constructed. + +Output JSON Schema: +{ + "formatted": "string", + "confidence": 0.95, + "reasoning": "string" +}`; const contextString = `Grounding Context: - Target Date-Time: ${grounding.iso} (${grounding.timeZone}) @@ -165,18 +189,7 @@ ${style ? `- Desired Style/Tone: ${style}` : ''} ${region ? `- Regional Context: ${region}` : ''} - Formatting Instructions: "${promptText}"`; - const systemPrompt = `You are a high-performance narrative date formatter. Your task is to format the given Target Date-Time according to the Formatting Instructions, Style, and Target Locale, strictly respecting the mathematical Grounding Context provided. Return ONLY a valid JSON object matching this schema: -{ - "formatted": "Contextual narrative string (e.g. 'this Friday at 5:00 PM EST (in 2 days)')", - "confidence": 0.98, - "reasoning": "Brief explanation of how the narrative reflects the grounding context and prompt." -} - -Rules: -- Never hallucinate the weekday, date, or relative offset; adhere strictly to the Grounding Context. -- Apply the requested tone/style and locale conventions. -- Confidence must be a float between 0.0 and 1.0. -- Do not include markdown blocks or any text outside the JSON.`; + const mode = aiMode || _state.config.mode || AiMode.Fallback; const winningCandidate = await executeWithMode( mode, @@ -199,14 +212,17 @@ Rules: throw new TempoAiError(`Provider ${providerId} returned invalid JSON: ${err?.message}`, 422); } + if (typeof parsedData !== 'object' || parsedData === null) + throw new TempoAiError(`Provider ${providerId} returned non-object JSON payload.`, 422); + const formatted = typeof parsedData?.formatted === 'string' ? parsedData.formatted.trim() : ''; - if (!formatted) { + if (!formatted) throw new TempoAiError(`Provider ${providerId} returned empty formatted string.`, 422); - } - const confidence = typeof parsedData?.confidence === 'number' && Number.isFinite(parsedData.confidence) + const rawConfidence = typeof parsedData?.confidence === 'number' && Number.isFinite(parsedData.confidence) ? parsedData.confidence : 0.9; + const confidence = Math.max(0.0, Math.min(1.0, rawConfidence)); const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined; return { @@ -226,9 +242,10 @@ Rules: _state.limits = winningCandidate.rateLimits ?? null; const { data: parsedData, providerId } = winningCandidate; - const confidence = typeof winningCandidate.confidence === 'number' && Number.isFinite(winningCandidate.confidence) + const rawConfidence = typeof winningCandidate.confidence === 'number' && Number.isFinite(winningCandidate.confidence) ? winningCandidate.confidence : 0.9; + const confidence = Math.max(0.0, Math.min(1.0, rawConfidence)); if (effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence) { throw new TempoAiError(`formatAI confidence (${confidence}) is below the required threshold of ${effectiveMinConfidence}.`, 422); @@ -272,9 +289,9 @@ Rules: * ``` */ export async function formatAI(items: FormatItem[], options?: AiFormatOptions): Promise<(TempoAiFormatResult | TempoAiError)[]>; -export async function formatAI(date: Tempo.DateTime, prompt?: string, options?: AiFormatOptions): Promise; +export async function formatAI(date: TempoDateInput, prompt?: string, options?: AiFormatOptions): Promise; export async function formatAI( - dateOrItems: Tempo.DateTime | FormatItem[], + dateOrItems: TempoDateInput | FormatItem[], promptOrOptions?: string | AiFormatOptions, options?: AiFormatOptions, ): Promise { diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts index 8fd1cf4d..468a1f24 100644 --- a/packages/plugins/ai/src/functions/parse.ts +++ b/packages/plugins/ai/src/functions/parse.ts @@ -17,14 +17,16 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< if (Tempo.isTempo(options?.anchor)) { tz = String(options!.timeZone || options!.anchor.tz); cal = String(options!.calendar || options!.anchor.cal); - loc = String(Array.isArray(options!.locale) ? options!.locale[0] : (options!.locale || options!.anchor.loc)); + const rawLoc = options!.locale || options!.anchor.loc; + loc = String(Array.isArray(rawLoc) ? rawLoc[0] : rawLoc); sph = String(options!.sphere || options!.anchor.config.sphere || 'north'); anchorStr = options!.anchor.toString(); } else { const resolvedOptions = Tempo.options; tz = String(options?.timeZone || resolvedOptions.timeZone); cal = String(options?.calendar || resolvedOptions.calendar); - loc = String(Array.isArray(options?.locale) ? options?.locale[0] : (options?.locale || resolvedOptions.locale)); + const rawLoc = options?.locale || resolvedOptions.locale; + loc = String(Array.isArray(rawLoc) ? rawLoc[0] : rawLoc); sph = String(options?.sphere || resolvedOptions.sphere || 'north'); anchorStr = String(options?.anchor || new Tempo().toString()); } diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts index 10380662..a27d4af7 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -119,6 +119,7 @@ export async function recurrenceAI( const tz = options?.timeZone || (options?.anchor instanceof Tempo ? options.anchor.tz : undefined) || Tempo.options.timeZone; const cal = options?.calendar || (options?.anchor instanceof Tempo ? options.anchor.cal : undefined) || Tempo.options.calendar; const loc = options?.locale || (options?.anchor instanceof Tempo ? options.anchor.loc : undefined) || Tempo.options.locale; + const scalarLoc = String(Array.isArray(loc) ? loc[0] : loc); const sph = options?.sphere || (options?.anchor instanceof Tempo ? options.anchor.config.sphere : undefined) || Tempo.options.sphere; const contextConfig = { timeZone: tz, calendar: cal, locale: loc, sphere: sph }; @@ -151,12 +152,12 @@ export async function recurrenceAI( const mode = options?.mode || _state.config.mode || AiMode.Fallback; const effectiveMinConfidence = options?.minConfidence ?? _state.config.minConfidence; const callTimeout = options?.timeout; - const contextString = `Current Time: ${anchorTempo.format('{wkd}, {yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${loc}, Hemisphere: ${sph}.`; + const contextString = `Current Time: ${anchorTempo.format('{wkd}, {yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${scalarLoc}, Hemisphere: ${sph}.`; const systemPrompt = `You are a calendar recurrence compiler. Read the user's natural language schedule and context. Return ONLY a valid JSON object matching this exact schema: { "rrule": "Standard RFC 5545 RRULE string without RRULE: prefix (e.g., 'FREQ=WEEKLY;BYDAY=TU;BYHOUR=15')", - "summary": "Clear, concise human-friendly description localized to locale '${loc}' (e.g., 'Every Tuesday at 15:00')", + "summary": "Clear, concise human-friendly description localized to locale '${scalarLoc}' (e.g., 'Every Tuesday at 15:00')", "reasoning": "Step-by-step calendar math explanation", "confidence": 0.95 } diff --git a/packages/plugins/ai/src/types/format.type.ts b/packages/plugins/ai/src/types/format.type.ts index 0210cffa..b284c7b2 100644 --- a/packages/plugins/ai/src/types/format.type.ts +++ b/packages/plugins/ai/src/types/format.type.ts @@ -2,9 +2,16 @@ import type { Tempo } from '@magmacomputing/tempo'; import type { AiMode } from '../core/config.js'; import type { AiCacheAdapter, AiProvider } from './common.type.js'; +/** + * ## TempoDateInput + * Flexible date-time input representation accepted by `formatAI`. + * Supports `Tempo` instances, `Date`, ISO strings, timestamps, and TC39 `Temporal` objects. + */ +export type TempoDateInput = Tempo | Date | string | number | bigint | object; + export interface FormatItem { /** Date-time instance, Temporal object, or string to format. */ - date: Tempo.DateTime; + date: TempoDateInput; /** Prompt instructions guiding the output narrative. */ prompt?: string | undefined; } @@ -22,7 +29,7 @@ export interface TempoAiFormatResult { export interface AiFormatOptions { /** Reference anchor date for relative calculations (defaults to now). */ - anchor?: Tempo.DateTime | undefined; + anchor?: TempoDateInput | undefined; /** Target IANA timezone (defaults to Tempo instance timezone or global options). */ timeZone?: string | undefined; /** Target BCP 47 locale or language tag (defaults to global options or 'en-US'). */ diff --git a/packages/plugins/ai/test/format.test.ts b/packages/plugins/ai/test/format.test.ts index 389bf4b4..d4829355 100644 --- a/packages/plugins/ai/test/format.test.ts +++ b/packages/plugins/ai/test/format.test.ts @@ -30,7 +30,7 @@ describe('AI Format Plugin (formatAI)', () => { const target = new Tempo('2026-08-07T17:00:00Z'); const anchor = new Tempo('2026-08-02T17:00:00Z'); - const result = await formatAI(target, 'friendly reminder tone with countdown', { anchor }); + const result = await formatAI(target, 'friendly reminder tone with countdown', { anchor, timeZone: 'UTC' }); expect(result).toBeDefined(); expect(result.formatted).toBe('this Friday at 5:00 PM EST (in 5 days)'); expect(result.confidence).toBe(0.98); @@ -60,7 +60,7 @@ describe('AI Format Plugin (formatAI)', () => { }), { status: 200, headers: { 'Content-Type': 'application/json' } })); const temporalZdt = new Tempo('2026-08-05T15:00:00+10:00[Australia/Sydney]').toDateTime(); - const result = await formatAI(temporalZdt, 'compact relative format'); + const result = await formatAI(temporalZdt, 'compact relative format', { timeZone: 'Australia/Sydney' }); expect(result.formatted).toBe('Tomorrow afternoon at 3:00 PM'); expect(result.confidence).toBe(0.95); @@ -256,4 +256,45 @@ describe('AI Format Plugin (formatAI)', () => { expect((results[0] as TempoAiFormatResult).formatted).toBe('Item 1 formatted'); expect(results[1]).toBeInstanceOf(TempoAiError); }); + + it('should honor force: true, cache: false, and ttl override options', async () => { + const cacheStore = new Map(); + const customAdapter: AiCacheAdapter = { + get: vi.fn(async (key: string) => cacheStore.get(key)), + set: vi.fn(async (key: string, val: string) => { cacheStore.set(key, val); }), + }; + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockImplementation(async () => new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: 'Fresh result', + confidence: 0.96, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const target = new Tempo('2026-08-07T17:00:00Z'); + const anchor = new Tempo('2026-08-02T17:00:00Z'); + + // 1. Initial fetch with ttl override + const res1 = await formatAI(target, 'test prompt', { anchor, ttl: 5000, cacheAdapter: customAdapter }); + expect(res1.formatted).toBe('Fresh result'); + expect(customAdapter.set).toHaveBeenCalledWith(expect.any(String), expect.any(String), 5000); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // 2. force: true should bypass existing cache and invoke provider again + const res2 = await formatAI(target, 'test prompt', { anchor, force: true, cacheAdapter: customAdapter }); + expect(res2.formatted).toBe('Fresh result'); + expect(res2.provider).toBe('groq'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + // 3. cache: false should skip writing to cache + customAdapter.set = vi.fn(); + const res3 = await formatAI('2026-09-01', 'uncached prompt', { anchor, cache: false, cacheAdapter: customAdapter }); + expect(res3.formatted).toBe('Fresh result'); + expect(customAdapter.set).not.toHaveBeenCalled(); + }); }); From 51ffbf82849eef8ac71aec46cd2ef755adf5daa8 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 14 Aug 2026 11:28:17 +1000 Subject: [PATCH 3/7] PR extractAI new --- .../doc/functions/scheduling/cron.md | 4 +- packages/plugins/.setup/catalog.json | 2 +- packages/plugins/ai/README.md | 12 +- packages/plugins/ai/doc/architecture.md | 67 ++- packages/plugins/ai/doc/context.md | 144 ++++-- packages/plugins/ai/doc/contextAI.md | 123 ----- .../plugins/ai/doc/{diffAI.md => diff.md} | 0 packages/plugins/ai/doc/extract.md | 145 ++++++ .../plugins/ai/doc/{formatAI.md => format.md} | 4 +- packages/plugins/ai/doc/grounding.md | 69 +++ packages/plugins/ai/doc/index.md | 29 +- packages/plugins/ai/doc/modes.md | 42 +- .../plugins/ai/doc/{parseAI.md => parse.md} | 0 .../ai/doc/{recurrenceAI.md => recurrence.md} | 2 +- .../ai/doc/{scheduleAI.md => schedule.md} | 16 +- packages/plugins/ai/package.json | 2 +- packages/plugins/ai/plan/extractAI.plan.md | 137 ------ packages/plugins/ai/plan/v0.3.0-roadmap.md | 66 --- packages/plugins/ai/src/core/dispatch.ts | 49 +- packages/plugins/ai/src/core/error.ts | 12 +- packages/plugins/ai/src/core/support.ts | 13 +- packages/plugins/ai/src/functions/context.ts | 10 +- packages/plugins/ai/src/functions/diff.ts | 12 +- packages/plugins/ai/src/functions/extract.ts | 430 ++++++++++++++---- packages/plugins/ai/src/functions/format.ts | 119 +++-- packages/plugins/ai/src/functions/parse.ts | 38 +- .../plugins/ai/src/functions/recurrence.ts | 2 +- packages/plugins/ai/src/functions/schedule.ts | 3 +- packages/plugins/ai/src/index.ts | 15 +- .../types/{common.type.ts => base.type.ts} | 126 +++-- packages/plugins/ai/src/types/context.type.ts | 41 +- packages/plugins/ai/src/types/diff.type.ts | 35 +- packages/plugins/ai/src/types/extract.type.ts | 44 ++ packages/plugins/ai/src/types/format.type.ts | 83 ++-- packages/plugins/ai/src/types/index.ts | 4 +- packages/plugins/ai/src/types/parse.type.ts | 41 +- .../plugins/ai/src/types/recurrence.type.ts | 17 +- .../plugins/ai/src/types/schedule.type.ts | 27 +- packages/plugins/ai/test/context.test.ts | 31 +- packages/plugins/ai/test/diff.test.ts | 27 ++ packages/plugins/ai/test/dispatch.test.ts | 53 +++ packages/plugins/ai/test/extract.test.ts | 410 +++++++++++++++++ packages/plugins/ai/test/format.test.ts | 127 +++++- .../tempo/.vitepress/theme/data/catalog.json | 4 +- 44 files changed, 1822 insertions(+), 815 deletions(-) delete mode 100644 packages/plugins/ai/doc/contextAI.md rename packages/plugins/ai/doc/{diffAI.md => diff.md} (100%) create mode 100644 packages/plugins/ai/doc/extract.md rename packages/plugins/ai/doc/{formatAI.md => format.md} (97%) create mode 100644 packages/plugins/ai/doc/grounding.md rename packages/plugins/ai/doc/{parseAI.md => parse.md} (100%) rename packages/plugins/ai/doc/{recurrenceAI.md => recurrence.md} (99%) rename packages/plugins/ai/doc/{scheduleAI.md => schedule.md} (85%) delete mode 100644 packages/plugins/ai/plan/extractAI.plan.md delete mode 100644 packages/plugins/ai/plan/v0.3.0-roadmap.md rename packages/plugins/ai/src/types/{common.type.ts => base.type.ts} (51%) create mode 100644 packages/plugins/ai/src/types/extract.type.ts create mode 100644 packages/plugins/ai/test/extract.test.ts diff --git a/packages/functions/doc/functions/scheduling/cron.md b/packages/functions/doc/functions/scheduling/cron.md index 9eee297e..9078144c 100644 --- a/packages/functions/doc/functions/scheduling/cron.md +++ b/packages/functions/doc/functions/scheduling/cron.md @@ -16,7 +16,7 @@ const start = new Tempo('2026-07-01T08:00:00Z'); // Every 5 minutes between 9 AM and 5 PM, Monday-Friday const next = nextCron(start, '*/5 9-17 * * 1-5'); -console.log(next.format('{hhmiss}')); // '09:00:00' +console.log(next.format('{hh}:{mi}:{ss}')); // '09:00:00' ``` ### `prevCron` @@ -31,7 +31,7 @@ const start = new Tempo('2026-07-01T18:00:00Z'); // Every 5 minutes between 9 AM and 5 PM, Monday-Friday const prev = prevCron(start, '*/5 9-17 * * 1-5'); -console.log(prev.format('{hhmiss}')); // '17:55:00' +console.log(prev.format('{hh}:{mi}:{ss}')); // '17:55:00' ``` ### `parseCron` diff --git a/packages/plugins/.setup/catalog.json b/packages/plugins/.setup/catalog.json index e31c38e5..1644885b 100644 --- a/packages/plugins/.setup/catalog.json +++ b/packages/plugins/.setup/catalog.json @@ -45,7 +45,7 @@ "description": "Tempo community plugin for LLM-powered natural language processing and parsing.", "packageName": "@magmacomputing/tempo-plugin-ai", "plan": "community", - "status": "experimental" + "status": "active" }, { "id": "ticker", diff --git a/packages/plugins/ai/README.md b/packages/plugins/ai/README.md index cd51be0a..adeb6d3c 100644 --- a/packages/plugins/ai/README.md +++ b/packages/plugins/ai/README.md @@ -48,11 +48,13 @@ console.log(dt.ai?.confidence); // 0.98 | Endpoint | Description | Doc | | :--- | :--- | :---: | -| **`parseAI`** | Parse relative/point-in-time dates (e.g. *"next Friday at 4pm"*) | | -| **`recurrenceAI`** | Convert repeating patterns (e.g. *"every 2 weeks on Friday"*) to RRULEs | | -| **`diffAI`** | Calculate natural language difference & business days between dates | | -| **`scheduleAI`** | Book appointment slots around busy calendar event bounds | | -| **`contextAI`** | Infer timezone, locale, and calendar from user profiles/bios | | +| **`parseAI`** | Parse relative/point-in-time dates (e.g. *"next Friday at 4pm"*) | | +| **`formatAI`** | Format contextual narrative dates & relative countdowns | | +| **`extractAI`** | Extract embedded temporal entities & calendar events from prose | | +| **`recurrenceAI`** | Convert repeating patterns (e.g. *"every 2 weeks on Friday"*) to RRULEs | | +| **`scheduleAI`** | Book appointment slots around busy calendar event bounds | | +| **`diffAI`** | Calculate natural language difference & business days between dates | | +| **`contextAI`** | Infer timezone, locale, and calendar from user profiles/bios | | --- diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md index a65f3c2d..5f1a7059 100644 --- a/packages/plugins/ai/doc/architecture.md +++ b/packages/plugins/ai/doc/architecture.md @@ -127,8 +127,8 @@ const date = await parseAI("Team standup next Wednesday at 9:30am"); Your backend endpoint receives the request, validates the user's session, enforces ingress quotas, attaches your private LLM API key, and forwards the validated payload to the upstream provider: ```typescript -// Example: Next.js API Route / Cloudflare Worker -export async function POST(req: Request) { +// Example: Next.js API Route / Cloudflare Worker / Express Proxy Handler +export async function POST(req: Request, env?: { GROQ_API_KEY?: string }) { // 1. Authenticate user session const authHeader = req.headers.get('Authorization'); const session = await validateUserSession(authHeader); @@ -145,26 +145,51 @@ export async function POST(req: Request) { return new Response('Too Many Requests', { status: 429 }); } - // 3. Construct sanitized upstream payload with private BYOK key - const upstreamResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${process.env.GROQ_API_KEY}` - }, - body: JSON.stringify({ - model: 'llama-3.3-70b-versatile', - messages: body.messages, - temperature: 0.1, - }) - }); + // 3. Resolve API key (Cloudflare Worker env binding or Node/Next.js process.env) + const apiKey = env?.GROQ_API_KEY || (typeof process !== 'undefined' ? process.env?.GROQ_API_KEY : undefined); + if (!apiKey) { + return new Response('Provider key configuration missing', { status: 500 }); + } - // 4. Return provider payload to client - const data = await upstreamResponse.json(); - return new Response(JSON.stringify(data), { - status: upstreamResponse.status, - headers: { 'Content-Type': 'application/json' } - }); + // 4. Construct upstream fetch with bounded timeout and cleanup + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s upstream limit + + try { + const upstreamResponse = await fetch('https://api.groq.com/openai/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model: 'llama-3.3-70b-versatile', + messages: body.messages, + temperature: 0.1, + }), + signal: controller.signal + }); + + // 5. Return provider payload to client + const data = await upstreamResponse.json(); + return new Response(JSON.stringify(data), { + status: upstreamResponse.status, + headers: { 'Content-Type': 'application/json' } + }); + } catch (err: any) { + if (err.name === 'AbortError' || controller.signal.aborted) { + return new Response(JSON.stringify({ error: 'Upstream provider gateway timeout' }), { + status: 504, + headers: { 'Content-Type': 'application/json' } + }); + } + return new Response(JSON.stringify({ error: 'Upstream connection failure' }), { + status: 502, + headers: { 'Content-Type': 'application/json' } + }); + } finally { + clearTimeout(timeoutId); + } } ``` diff --git a/packages/plugins/ai/doc/context.md b/packages/plugins/ai/doc/context.md index 94b76beb..6d6175e1 100644 --- a/packages/plugins/ai/doc/context.md +++ b/packages/plugins/ai/doc/context.md @@ -1,63 +1,123 @@ -# Context & Natural Language Parsing +# `contextAI` — Context & Regional Inference -Because natural language dates are entirely relative (e.g., "next Tuesday") and often geographically ambiguous (e.g., "11/12"), an LLM cannot reliably parse them in a vacuum. +`contextAI()` is designed to analyze unstructured or ambiguous text (e.g. user biographies, locations, or email bodies) and infer their regional configuration settings. It resolves these properties to a standard configuration object containing timezone, locale, calendar system, and hemisphere. -The Tempo AI plugin solves this by automatically wrapping your input with rich environmental context before sending it to the LLM. +This is highly useful for user onboarding settings, automatic context mapping for calendar syncs, and geolocating inputs dynamically without maintaining static geographic mapping tables. -## Geographic Context +--- -The plugin automatically reads from the global `Tempo.config` to fetch the default TimeZone, Calendar, and Locale, and establishes the "current anchor time" the moment you call it. +## Basic Usage -Along with your string, the plugin passes a hidden context payload to the LLM: -*`Current Time: [Anchor], Timezone: [TZ], Calendar: [Cal], Locale: [Locale], Hemisphere: [Sphere]`* - -### Overriding Context -You can explicitly override any of these global settings on a per-request basis by passing an `options` object as the second argument, identical to how you pass options to a standard `new Tempo()` constructor: +> [!NOTE] +> Like all Tempo AI functions, `contextAI` requires prior initialization with at least one active provider via `initAI()`. ```typescript -// Explicitly evaluate this complex query from the perspective of September 1st -const dt = await parseAI("The penultimate Tuesday before Thanksgiving", { anchor: '2026-09-01T00:00:00Z' }); - -// Explicitly parse assuming a Japanese locale and timezone -const tokyoDt = await parseAI("The second Sunday of May", { locale: 'ja-JP', timeZone: 'Asia/Tokyo' }); +import { initAI, contextAI } from '@magmacomputing/tempo-plugin-ai'; + +// 1. Configure the AI provider farm +await initAI({ + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY, model: 'llama-3.3-70b-versatile' } + ] +}); + +// 2. Infer contextual settings from unstructured text +const context = await contextAI("I'm a photographer based in Sydney, Australia."); + +console.log(context.timeZone); // "Australia/Sydney" +console.log(context.locale); // "en-AU" +console.log(context.calendar); // "gregory" +console.log(context.sphere); // "south" +console.log(context.confidence); // 0.98 ``` -### Why Locale is Critical -Passing the `Locale` is absolutely critical for the LLM to know whether "11/12" means November 12th (US format) or 11th of December (UK/EU format). The plugin handles this transparently based on your standard Tempo configuration! +--- -> [!WARNING] -> **Calendar Math Hallucinations**: LLMs are language predictors, not calculators. While they excel at parsing conversational times (like `"tomorrow at 5pm"`), smaller models are notoriously prone to hallucinations on complex, cross-year calendar math. For example, asking a lightweight model for `"Thanksgiving in 2026"` may result in a hallucinated day of the week because the model doesn't natively compute "the fourth Thursday of November 2026." If your application relies on heavy holiday logic or complex multi-year math, you *must* use a capable frontier model or rely on deterministic plugins instead of AI. +## Configuration Options (`AiContextOptions`) -## The Decoupled Output Bridge +| Option | Type | Description | +| :--- | :--- | :--- | +| **`force`** | `boolean` | If true, bypasses the cache to initiate a fresh LLM query. | +| **`cache`** | `boolean` | If false, disables writing to and reading from cache adapters. | +| **`cacheAdapter`** | `AiCacheAdapter` | Custom cache engine (e.g., Redis) for caching results on this request. | +| **`ttl`** | `number` | Time-to-live override in milliseconds for cached results. | +| **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required to return a valid slot. Throws `TempoAiError(422)` if lower. | +| **`mode`** | `AiMode` | Concurrency routing strategy (`fallback`, `race`, `consensus`, `hedged`, `roundrobin`, `adaptive`). Refer to the [Multi-Provider Execution Modes Guide](./modes.md). | +| **`providers`** | `AiProvider[]` | Per-request provider configuration overrides. | +| **`timeout`** | `number` | Per-request timeout in milliseconds (overrides provider and global timeouts). | +| **`hedgeDelay`** | `number` | Delay in milliseconds before initiating speculative hedging in `AiMode.Hedged` (default: `800ms`). | +| **`debug`** | `boolean` | If true, logs prompt context and LLM payloads to console. | +| **`softErrors`** | `boolean` | If true, returns `TempoAiError` into array index position instead of rejecting the entire batch query. | -To ensure deterministic behavior, the LLM is instructed to *only* return strict ISO 8601 strings. +--- -The plugin executes the network request, the LLM returns a local ISO string without a timezone offset or 'Z' suffix (like `"2026-11-26T00:00:00"`), and the plugin immediately passes that string back into the native `new Tempo()` constructor. The provider response must omit timezone suffixes to match the local ISO contract enforced by Tempo AI functions. The developer seamlessly receives a valid, native `Tempo` instance. This eliminates AST-construction ambiguity, creating a decoupled bridge between AI text generation and native Tempo conversion. +## Result Schema (`TempoContext`) + +```typescript +export interface TempoContext { + /** Inferred IANA time zone identifier (e.g. 'America/New_York') */ + timeZone: string; + + /** Inferred BCP 47 language/region tag (e.g. 'en-US') */ + locale: string; + + /** Inferred Unicode calendar system type (e.g. 'gregory') */ + calendar: string; + + /** Inferred hemisphere, or undefined if ambiguous */ + sphere?: 'north' | 'south' | undefined; + + /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ + confidence: number; + + /** Resolution source ('cache' or provider ID like 'groq', 'gemini', 'openai') */ + provider: string; + + /** Step-by-step reasoning or justification provided by the engine/LLM */ + reasoning?: string | undefined; +} +``` -### Relative Date Ambiguity Tie-Breakers +--- -To eliminate model variance on idioms like "Next Friday" or "Last Tuesday", the plugin enforces static system prompt ambiguity rules: -* `"next [weekday/unit]"`: Evaluated as the immediate next chronological occurrence after `Current Time`. -* `"last [weekday/unit]"` / `"previous [weekday/unit]"`: Evaluated as the most recent past occurrence prior to `Current Time`. -* `"this [weekday]"`: Evaluated as the occurrence within the current calendar week containing `Current Time`. +## Key Architectural Behaviors -### Confidence Thresholds & Metadata (`.ai`) +### 1. Workspace Baseline Context +`contextAI` inspects the host runtime or current `Tempo` configuration (`Tempo.options.timeZone`, `Tempo.options.locale`, etc.) as a fallback baseline. If an input like `"at home"` is provided, the LLM will ground its inference in the workstation's baseline defaults. -When `minConfidence` is supplied in options (e.g. `parseAI("...", { minConfidence: 0.8 })`), any LLM response returning a confidence score below that threshold produces a `Tempo` instance with `isValid === false`. +### 2. Strict Confidence Thresholds +Using `minConfidence`, developers can guarantee that low-certainty or completely ambiguous inputs (e.g., `"in the park"`) throw a `TempoAiError(422)` rather than silently returning guessed context parameters: -Every resolved `Tempo` instance returned by `parseAI` (and other AI functions) has a non-writable, frozen `.ai` metadata descriptor containing execution audit data: ```typescript -const dt = await parseAI("Christmas 2026", { debug: true }); -console.log(dt.ai); -// { -// provider: 'openai', -// cached: false, -// confidence: 0.95, -// ambiguous: false, -// granularity: 'day', -// rawIso: '2026-12-25T00:00:00', -// rawPrompt: 'Christmas 2026', // Present when debug is enabled -// normalizedPrompt: 'christmas 2026' // Present when debug is enabled -// } +const context = await contextAI("meeting somewhere online", { minConfidence: 0.9 }); +// Throws TempoAiError(422): Inferred context confidence (0.4) is below the required threshold of 0.9. ``` +### 3. Timezone Validation +Before returning, the returned IANA timezone string is dynamically validated against the runtime's native JavaScript `Intl` API. If the LLM returns an unsupported or fake timezone identifier, `contextAI` throws a `TempoAiError(422)` to prevent application runtime failures. + +### 4. Parallel Batch Processing +You can pass an array of strings to process multiple contexts concurrently: +```typescript +const [context1, context2] = await contextAI([ + "Working from Kyoto", + "Living in Melbourne" +]); +``` + +### Combining `contextAI` with `parseAI` (The Pivot Flow) + +Often, a user will mention their location in one sentence and a relative time in another. You can chain these APIs together to form a seamless date-resolution pipeline: + +```typescript +import { contextAI, parseAI } from '@magmacomputing/tempo-plugin-ai'; + +// 1. Deduces the context +const ticketContext = await contextAI("customer issue from our London office"); +// ticketContext = { timeZone: 'Europe/London', locale: 'en-GB', sphere: 'north' } + +// 2. Feed the output context directly as options into parseAI +const resolutionTime = await parseAI("issue occurred on 04/05/2026 at 3 PM", ticketContext); +// 1. Correctly parses 04/05 to May 4th (UK format) rather than April 5th. +// 2. Adjusts to BST/GMT (Europe/London). +``` diff --git a/packages/plugins/ai/doc/contextAI.md b/packages/plugins/ai/doc/contextAI.md deleted file mode 100644 index 6d6175e1..00000000 --- a/packages/plugins/ai/doc/contextAI.md +++ /dev/null @@ -1,123 +0,0 @@ -# `contextAI` — Context & Regional Inference - -`contextAI()` is designed to analyze unstructured or ambiguous text (e.g. user biographies, locations, or email bodies) and infer their regional configuration settings. It resolves these properties to a standard configuration object containing timezone, locale, calendar system, and hemisphere. - -This is highly useful for user onboarding settings, automatic context mapping for calendar syncs, and geolocating inputs dynamically without maintaining static geographic mapping tables. - ---- - -## Basic Usage - -> [!NOTE] -> Like all Tempo AI functions, `contextAI` requires prior initialization with at least one active provider via `initAI()`. - -```typescript -import { initAI, contextAI } from '@magmacomputing/tempo-plugin-ai'; - -// 1. Configure the AI provider farm -await initAI({ - providers: [ - { id: 'groq', key: process.env.GROQ_API_KEY, model: 'llama-3.3-70b-versatile' } - ] -}); - -// 2. Infer contextual settings from unstructured text -const context = await contextAI("I'm a photographer based in Sydney, Australia."); - -console.log(context.timeZone); // "Australia/Sydney" -console.log(context.locale); // "en-AU" -console.log(context.calendar); // "gregory" -console.log(context.sphere); // "south" -console.log(context.confidence); // 0.98 -``` - ---- - -## Configuration Options (`AiContextOptions`) - -| Option | Type | Description | -| :--- | :--- | :--- | -| **`force`** | `boolean` | If true, bypasses the cache to initiate a fresh LLM query. | -| **`cache`** | `boolean` | If false, disables writing to and reading from cache adapters. | -| **`cacheAdapter`** | `AiCacheAdapter` | Custom cache engine (e.g., Redis) for caching results on this request. | -| **`ttl`** | `number` | Time-to-live override in milliseconds for cached results. | -| **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required to return a valid slot. Throws `TempoAiError(422)` if lower. | -| **`mode`** | `AiMode` | Concurrency routing strategy (`fallback`, `race`, `consensus`, `hedged`, `roundrobin`, `adaptive`). Refer to the [Multi-Provider Execution Modes Guide](./modes.md). | -| **`providers`** | `AiProvider[]` | Per-request provider configuration overrides. | -| **`timeout`** | `number` | Per-request timeout in milliseconds (overrides provider and global timeouts). | -| **`hedgeDelay`** | `number` | Delay in milliseconds before initiating speculative hedging in `AiMode.Hedged` (default: `800ms`). | -| **`debug`** | `boolean` | If true, logs prompt context and LLM payloads to console. | -| **`softErrors`** | `boolean` | If true, returns `TempoAiError` into array index position instead of rejecting the entire batch query. | - ---- - -## Result Schema (`TempoContext`) - -```typescript -export interface TempoContext { - /** Inferred IANA time zone identifier (e.g. 'America/New_York') */ - timeZone: string; - - /** Inferred BCP 47 language/region tag (e.g. 'en-US') */ - locale: string; - - /** Inferred Unicode calendar system type (e.g. 'gregory') */ - calendar: string; - - /** Inferred hemisphere, or undefined if ambiguous */ - sphere?: 'north' | 'south' | undefined; - - /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ - confidence: number; - - /** Resolution source ('cache' or provider ID like 'groq', 'gemini', 'openai') */ - provider: string; - - /** Step-by-step reasoning or justification provided by the engine/LLM */ - reasoning?: string | undefined; -} -``` - ---- - -## Key Architectural Behaviors - -### 1. Workspace Baseline Context -`contextAI` inspects the host runtime or current `Tempo` configuration (`Tempo.options.timeZone`, `Tempo.options.locale`, etc.) as a fallback baseline. If an input like `"at home"` is provided, the LLM will ground its inference in the workstation's baseline defaults. - -### 2. Strict Confidence Thresholds -Using `minConfidence`, developers can guarantee that low-certainty or completely ambiguous inputs (e.g., `"in the park"`) throw a `TempoAiError(422)` rather than silently returning guessed context parameters: - -```typescript -const context = await contextAI("meeting somewhere online", { minConfidence: 0.9 }); -// Throws TempoAiError(422): Inferred context confidence (0.4) is below the required threshold of 0.9. -``` - -### 3. Timezone Validation -Before returning, the returned IANA timezone string is dynamically validated against the runtime's native JavaScript `Intl` API. If the LLM returns an unsupported or fake timezone identifier, `contextAI` throws a `TempoAiError(422)` to prevent application runtime failures. - -### 4. Parallel Batch Processing -You can pass an array of strings to process multiple contexts concurrently: -```typescript -const [context1, context2] = await contextAI([ - "Working from Kyoto", - "Living in Melbourne" -]); -``` - -### Combining `contextAI` with `parseAI` (The Pivot Flow) - -Often, a user will mention their location in one sentence and a relative time in another. You can chain these APIs together to form a seamless date-resolution pipeline: - -```typescript -import { contextAI, parseAI } from '@magmacomputing/tempo-plugin-ai'; - -// 1. Deduces the context -const ticketContext = await contextAI("customer issue from our London office"); -// ticketContext = { timeZone: 'Europe/London', locale: 'en-GB', sphere: 'north' } - -// 2. Feed the output context directly as options into parseAI -const resolutionTime = await parseAI("issue occurred on 04/05/2026 at 3 PM", ticketContext); -// 1. Correctly parses 04/05 to May 4th (UK format) rather than April 5th. -// 2. Adjusts to BST/GMT (Europe/London). -``` diff --git a/packages/plugins/ai/doc/diffAI.md b/packages/plugins/ai/doc/diff.md similarity index 100% rename from packages/plugins/ai/doc/diffAI.md rename to packages/plugins/ai/doc/diff.md diff --git a/packages/plugins/ai/doc/extract.md b/packages/plugins/ai/doc/extract.md new file mode 100644 index 00000000..38abee37 --- /dev/null +++ b/packages/plugins/ai/doc/extract.md @@ -0,0 +1,145 @@ +# `extractAI` — Unstructured Text & Calendar Event Extraction + +`extractAI()` scans unstructured, multi-paragraph text (emails, meeting transcripts, chat logs, task notes, calendar invitations) to automatically identify, parse, and extract all embedded temporal entities and time-bound events into structured `TempoAiExtractResult` records containing native `Tempo` instances. + +Relative expressions (such as *"tomorrow at 10am"*, *"next Tuesday from 1 to 3pm"*, *"final deliverables due Friday EOD"*) are resolved and mathematically grounded against an explicit or current reference `anchor` timestamp, timezone, and calendar system. + +--- + +## Basic Usage + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { initAI, extractAI } from '@magmacomputing/tempo-plugin-ai'; + +// 1. Initialize AI providers +await initAI({ + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY, model: 'llama-3.3-70b-versatile' } + ] +}); + +const emailText = ` +Hi team, +Let's schedule our Sprint Review tomorrow from 10:00 AM to 11:30 AM in Room 4A. +Also, reminder that all pull requests and documentation are due next Friday by 5:00 PM. +`; + +const anchor = new Tempo('2026-08-10T09:00:00Z'); // Monday morning + +const result = await extractAI(emailText, { anchor, timeZone: 'America/New_York' }); + +for (const event of result.events) { + console.log(`[${event.type}] ${event.label}`); + console.log(` Start: ${event.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')}`); + if (event.end) { + console.log(` End: ${event.end.format('{yyyy}-{mm}-{dd} {hh}:{mi}')}`); + } + console.log(` Source: "${event.rawText}" (Confidence: ${event.confidence})`); +} +``` + +--- + +## Configuration Options (`AiExtractOptions`) + +| Option | Type | Description | +| :--- | :--- | :--- | +| **`anchor`** | `TempoDateInput` | Reference anchor date for relative expressions (defaults to current time). | +| **`timeZone`** | `string` | Target IANA timezone for grounding and output Tempo instances. | +| **`locale`** | `string \| string[]` | Target BCP 47 locale or language tag (e.g. `'en-US'`, `'fr-FR'`). | +| **`calendar`** | `string` | Calendar system (e.g. `'gregory'`, `'hebrew'`, `'islamic'`). | +| **`categories`** | `string[]` | Optional list of categories to filter entities (e.g. `['meeting', 'deadline']`). | +| **`region`** | `string` | Regional context (e.g. `'AU-NSW'`, `'US-NY'`) passed to LLM grounding. | +| **`force`** | `boolean` | If true, bypasses cache to force a fresh LLM query. | +| **`cache`** | `boolean` | If false, disables writing to and reading from cache adapters. | +| **`cacheAdapter`** | `AiCacheAdapter` | Custom cache engine (e.g., Redis, Cloudflare KV) for caching results. | +| **`ttl`** | `number` | Time-to-live override in milliseconds for cached results (defaults to 24h). | +| **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required. Throws `TempoAiError(422)` if lower. | +| **`mode`** | `AiMode` | Concurrency routing strategy (`fallback`, `race`, `consensus`, `hedged`, `roundrobin`, `adaptive`). Refer to the [Multi-Provider Execution Modes Guide](./modes.md). | +| **`softErrors`** | `boolean` | If true, returns `TempoAiError` into array indices instead of rejecting batch queries. | + +--- + +## Result Schema (`TempoAiExtractResult`) + +```typescript +export interface TempoAiExtractResult { + /** Array of extracted events with instantiated Tempo objects. */ + events: TempoExtractedEvent[]; + + /** Overall extraction confidence score between 0.0 and 1.0. */ + confidence: number; + + /** ID of the provider that fulfilled the request (or 'cache'). */ + provider: string; + + /** Optional summary or reasoning from the LLM. */ + reasoning?: string | undefined; +} + +export interface TempoExtractedEvent { + /** Short descriptive label or title of the extracted event/activity. */ + label: string; + + /** Start date-time point as an instantiated Tempo instance. */ + start: Tempo; + + /** Optional end date-time point (if an interval or duration was mentioned). */ + end?: Tempo | undefined; + + /** Classification category ('point' | 'interval' | 'deadline' | 'recurrence' | 'tentative'). */ + type: TempoEventType; + + /** Raw text snippet extracted from the source document. */ + rawText?: string | undefined; + + /** Confidence score for this specific entity extraction (0.0 to 1.0). */ + confidence: number; +} +``` + +--- + +## Key Architectural Behaviors + +### 1. Mathematical Grounding & Hallucination Suppression +To prevent hallucinated dates, `extractAI` calculates grounding anchor coordinates before dispatching to the LLM: +- Localized ISO reference timestamp and timezone +- Day of the week name and ordinal index +- Target calendar system and regional context +- Constrained JSON schema ensuring valid ISO dates + +### 2. Native `Tempo` Instances +Extracted start and end points are immediately instantiated as live `Tempo` objects, ready for subsequent date math, interval arithmetic, or timezone shifting: + +```typescript +const result = await extractAI(transcript); +const meeting = result.events[0]; + +// Instant date operations with Tempo +const reminderTime = meeting.start.subtract('15 minutes'); +console.log(`Set alarm for: ${reminderTime.format('{h12}:{mi} {mer}')}`); +``` + +### 3. Multi-Tier Distributed Caching +`extractAI` integrates multi-tier caching (in-memory and optional asynchronous `AiCacheAdapter` such as Redis or Cloudflare KV). Cached ISO timestamps are rehydrated into live `Tempo` objects upon cache hits: + +```typescript +const result = await extractAI(documentText, { + cacheAdapter: redisCacheAdapter, + ttl: 86_400_000, // 24 hours +}); +``` + +### 4. Parallel Batch Extraction +Process arrays of documents concurrently with optional `softErrors` fault-tolerance: + +```typescript +const documents = [ + "Team offsite next Thursday from 9am to 5pm.", + "Project proposal submission deadline is August 20 at midnight." +]; + +const results = await extractAI(documents, { softErrors: true }); +``` diff --git a/packages/plugins/ai/doc/formatAI.md b/packages/plugins/ai/doc/format.md similarity index 97% rename from packages/plugins/ai/doc/formatAI.md rename to packages/plugins/ai/doc/format.md index 3fa2cb95..c242f510 100644 --- a/packages/plugins/ai/doc/formatAI.md +++ b/packages/plugins/ai/doc/format.md @@ -22,10 +22,10 @@ await initAI({ const target = new Tempo('2026-08-07T17:00:00[America/New_York]'); const anchor = new Tempo('2026-08-02T17:00:00[America/New_York]'); -// "this Friday at 5:00 PM EST (in 5 days)" +// "this Friday at 5:00 PM EDT (in 5 days)" const result = await formatAI(target, 'friendly reminder tone with relative countdown', { anchor }); -console.log(result.formatted); // "this Friday at 5:00 PM EST (in 5 days)" +console.log(result.formatted); // "this Friday at 5:00 PM EDT (in 5 days)" console.log(result.confidence); // 0.98 console.log(result.provider); // 'groq' ``` diff --git a/packages/plugins/ai/doc/grounding.md b/packages/plugins/ai/doc/grounding.md new file mode 100644 index 00000000..72e44063 --- /dev/null +++ b/packages/plugins/ai/doc/grounding.md @@ -0,0 +1,69 @@ +# Grounding & Natural Language Parsing + +Because natural language dates are entirely relative (e.g., *"next Tuesday"*) and culturally ambiguous (e.g., *"11/12"*), an LLM cannot reliably parse them in a vacuum. + +The Tempo AI plugin solves this by automatically injecting **deterministic temporal and regional grounding coordinates** before dispatching queries to the LLM. + +## Temporal & Regional Grounding + +The plugin automatically resolves the active `Tempo.config` to establish the exact reference time and regional coordinates: +- **Anchor Reference Clock**: The exact ISO timestamp at the moment of invocation. +- **Regional Coordinates**: TimeZone (e.g., `America/New_York`), Calendar system (`iso8601`), Locale (`en-US`), and Hemisphere (`northern`). + +Along with your text query, the plugin passes these grounding coordinates directly to the model's system prompt: +> *`Grounding Anchor: [Anchor], Timezone: [TZ], Calendar: [Cal], Locale: [Loc], Hemisphere: [Sphere]`* + +### Custom Grounding Anchors & Options +You can explicitly override any grounding coordinate on a per-request basis by passing an options object as the second argument, identical to how you pass configuration options to a standard `new Tempo()` constructor: + +```typescript +// Explicitly evaluate this complex relative query from the perspective of September 1st +const dt = await parseAI("The penultimate Tuesday before Thanksgiving", { + anchor: '2026-09-01T00:00:00Z' +}); + +// Explicitly parse assuming a Japanese locale and timezone +const tokyoDt = await parseAI("The second Sunday of May", { + locale: 'ja-JP', + timeZone: 'Asia/Tokyo' +}); +``` + +### Why Cultural & Regional Grounding is Critical +Passing the `Locale` and `TimeZone` is critical for the LLM to know whether `"11/12"` represents November 12th (US format) or 11th of December (UK/EU format). The plugin grounds these ambiguous tokens transparently based on your standard Tempo configuration! + +> [!WARNING] +> **Calendar Math Hallucinations**: LLMs are language predictors, not calculators. While they excel at parsing conversational times (like `"tomorrow at 5pm"`), smaller models are notoriously prone to hallucinations on complex, cross-year calendar math. For example, asking a lightweight model for `"Thanksgiving in 2026"` may result in a hallucinated day of the week because the model doesn't natively compute "the fourth Thursday of November 2026." If your application relies on heavy holiday logic or complex multi-year math, you *must* use a capable frontier model or rely on deterministic plugins instead of AI. + +## The Decoupled Output Bridge + +To ensure deterministic behavior, the LLM is instructed to *only* return strict ISO 8601 strings. + +The plugin executes the network request, the LLM returns a local ISO string without a timezone offset or 'Z' suffix (like `"2026-11-26T00:00:00"`), and the plugin immediately passes that string back into the native `new Tempo()` constructor. The provider response must omit timezone suffixes to match the local ISO contract enforced by Tempo AI functions. The developer seamlessly receives a valid, native `Tempo` instance. This eliminates AST-construction ambiguity, creating a decoupled bridge between AI text generation and native Tempo conversion. + +### Relative Date Ambiguity Tie-Breakers + +To eliminate model variance on idioms like "Next Friday" or "Last Tuesday", the plugin enforces static system prompt ambiguity rules: +* `"next [weekday/unit]"`: Evaluated as the immediate next chronological occurrence after the grounding anchor. +* `"last [weekday/unit]"` / `"previous [weekday/unit]"`: Evaluated as the most recent past occurrence prior to the grounding anchor. +* `"this [weekday]"`: Evaluated as the occurrence within the current calendar week containing the grounding anchor. + +### Confidence Thresholds & Metadata (`.ai`) + +When `minConfidence` is supplied in options (e.g. `parseAI("...", { minConfidence: 0.8 })`), any LLM response returning a confidence score below that threshold produces a `Tempo` instance with `isValid === false`. + +Every resolved `Tempo` instance returned by `parseAI` (and other AI functions) has a non-writable, frozen `.ai` metadata descriptor containing execution audit data: +```typescript +const dt = await parseAI("Christmas 2026", { debug: true }); +console.log(dt.ai); +// { +// provider: 'openai', +// cached: false, +// confidence: 0.95, +// ambiguous: false, +// granularity: 'day', +// rawIso: '2026-12-25T00:00:00', +// rawPrompt: 'Christmas 2026', // Present when debug is enabled +// normalizedPrompt: 'christmas 2026' // Present when debug is enabled +// } +``` diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md index 12cd1218..9aaf11ff 100644 --- a/packages/plugins/ai/doc/index.md +++ b/packages/plugins/ai/doc/index.md @@ -6,18 +6,13 @@ 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,13 +38,14 @@ All AI functions return a standard ES Promise wrapped object. | Function | Input | Returns (`Promise<...>`) | Description | Doc | | :--- | :--- | :--- | :--- | :---: | -| **`initAI`** | Provider config & API keys | `void` | Configured AI provider farm | | | **`parseAI`** | Natural language text string(s) | `Tempo` \| `Tempo[]` \| `(Tempo \| TempoAiError)[]` | Single point-in-time `Tempo` instance (or batch array) | | +| **`formatAI`** | Date-time + prompt / style | `TempoAiFormatResult` \| `TempoAiFormatResult[]` \| `(TempoAiFormatResult \| TempoAiError)[]` | **Contextual narrative date formatting** (`formatted`, `confidence`, `provider`, `reasoning`) | | +| **`extractAI`** | Unstructured text string(s) | `TempoAiExtractResult` \| `(TempoAiExtractResult \| TempoAiError)[]` | **Extracted temporal entities & calendar events** (`events: TempoExtractedEvent[]`, `confidence`, `reasoning`) | | | **`recurrenceAI`** | Natural language pattern or RRULE string | `TempoRecurrenceResult` | **Iterable series of `Tempo` dates** (with `.take(n)`, `[Symbol.iterator]`, & RRULE string) | | | **`scheduleAI`** | Booking prompt + busy constraints | `TempoScheduleResult` | **Resolved appointment slot** (`start`, `end`, `slot`, `alternatives`, `ai.conflictBumped`) | | -| **`contextAI`** | Context text string(s) | `TempoContext` \| `TempoContext[]` \| `(TempoContext \| TempoAiError)[]` | **Inferred regional context** (`timeZone`, `locale`, `calendar`, `sphere`) | | | **`diffAI`** | Start & End dates + prompt | `TempoAiDiffResult` \| `TempoAiDiffResult[]` \| `(TempoAiDiffResult \| TempoAiError)[]` | **Narrative time delta & business days** (`formatted`, `businessDays`, `days`, `hours`, `holidays`) | | -| **`formatAI`** | Date-time + prompt / style | `TempoAiFormatResult` \| `TempoAiFormatResult[]` \| `(TempoAiFormatResult \| TempoAiError)[]` | **Contextual narrative date formatting** (`formatted`, `confidence`, `provider`, `reasoning`) | | +| **`contextAI`** | Context text string(s) | `TempoContext` \| `TempoContext[]` \| `(TempoContext \| TempoAiError)[]` | **Inferred regional context** (`timeZone`, `locale`, `calendar`, `sphere`) | | +| **`initAI`** | Provider config & API keys | `void` | Configured AI provider farm | | ## Architecture & Infrastructure Guides @@ -58,9 +54,18 @@ All AI functions return a standard ES Promise wrapped object. - [Multi-Provider Execution Modes](./modes.md) (Hedged, RoundRobin, Adaptive, Race, Consensus, Fallback) - [Provider Architecture & Security](./architecture.md) (BYOK vs Proxy patterns, Browser Security, TLS 1.3 & Privacy Guarantees) -- [Context & Natural Language Parsing](./context.md) (How Timezone and Locale are injected) +- [Grounding & Natural Language Parsing](./grounding.md) (How Timezone and Locale are injected) - [Rate Limits & Cache Management](./rate-limits.md) (Tracking API quotas, handling 429 errors, and custom Redis caches) +## Community Feedback & Production Notice + +> [!NOTE] +> **Community Feedback & Prompt Engineering** +> While `@magmacomputing/tempo-plugin-ai` utilizes deterministic grounding, schema enforcement, and confidence validation, LLM outputs can vary across models and prompt styles. We actively welcome community feedback and prompt optimizations—please report any edge cases or suggestions on the [Magma GitHub Issue Tracker](https://github.com/magmacomputing/magma/issues/new?template=bug_report_ai.yml). + +> [!CAUTION] +> **Production Notice & "As-Is" Disclaimer**: Magma Computing Solutions and the Tempo core maintainers provide `@magmacomputing/tempo-plugin-ai` "as-is" without warranty of any kind. Large Language Models operate probabilistically; developers and system architects are responsible for validating AI-generated temporal outputs before committing them to financial, legal, medical, or life-critical applications. + ## Licensing This is a **Community** plugin. It is completely free and open-source for personal and commercial use. No license token is required. diff --git a/packages/plugins/ai/doc/modes.md b/packages/plugins/ai/doc/modes.md index 17cd66b3..52a88c94 100644 --- a/packages/plugins/ai/doc/modes.md +++ b/packages/plugins/ai/doc/modes.md @@ -28,12 +28,36 @@ const dt = await parseAI('next friday at 3pm', { | Mode | Dispatch | Token Cost | Latency | Rate-Limit Resilience | | :--- | :--- | :---: | :---: | :---: | -| **`Fallback`** *(Default)* | Sequential | 🟢 1 request | 🟡 Moderate | 🟡 Reactive | -| **`Hedged`** | Staggered (primary + timer) | 🟢 ~1.15 avg | 🟢 Ultra-Fast | 🟡 Reactive | -| **`RoundRobin`** | Cyclic rotation | 🟢 1 request | 🟡 Moderate | 🟢 High | -| **`Adaptive`** | Quota-sorted rotation | 🟢 1 request | 🟡 Moderate | 🟢 Maximum | -| **`Race`** | Full parallel | 🔴 N requests | 🟢 Ultra-Fast | 🟡 Reactive | -| **`Consensus`** | Full parallel + voting | 🔴 N requests | 🟡 Moderate | 🟡 Reactive | +| **`Fallback`** *(Default)* | Sequential | 🟢 1 request | 🟡 Moderate | 🟢 Proactive Cooldown Filter | +| **`Hedged`** | Staggered (primary + timer) | 🟢 ~1.15 avg | 🟢 Ultra-Fast | 🟢 Proactive Cooldown Filter | +| **`RoundRobin`** | Cyclic rotation | 🟢 1 request | 🟡 Moderate | 🟢 High (Cyclic + Filter) | +| **`Adaptive`** | Quota-sorted rotation | 🟢 1 request | 🟡 Moderate | 🟢 Maximum (Telemetry-Ranked) | +| **`Race`** | Full parallel | 🔴 N requests | 🟢 Ultra-Fast | 🟢 Proactive Cooldown Filter | +| **`Consensus`** | Full parallel + voting | 🔴 N requests | 🟡 Moderate | 🟢 Proactive Cooldown Filter | + +--- + +## Global Telemetry & Cooldown Filtering + +Regardless of the execution mode chosen, the AI dispatch engine actively monitors per-provider rate-limiting metadata across all network responses (`x-ratelimit-remaining-requests`, `x-ratelimit-remaining-tokens`, `x-ratelimit-reset-requests`, `retry-after`). + +```mermaid +flowchart LR + A["Incoming Request\n(Any AiMode)"] --> B{"Check Provider Farm\nCooldown State"} + B -- "Exhausted (remaining === 0\n& resetAt > now)" --> C["🚫 Proactively Filter Out\n(Skip 429 endpoints)"] + B -- "Ready / High Quota" --> D["✅ Active Provider Pool"] + C -. "If ALL in cooldown" .-> D + D --> E["Dispatch via Selected Mode\n(Fallback, Race, Hedged, etc.)"] +``` + +### Proactive Cooldown Avoidance +Before dispatching any request: +1. **Cooldown Detection**: The orchestrator checks if any configured provider has exhausted its request quota (`remainingRequests === 0`) and is within an active reset window (`resetAt > now`). +2. **Pre-Dispatch Filtering**: In `Fallback`, `Race`, `Hedged`, and `RoundRobin` modes, exhausted providers are automatically removed from the active candidate pool for that request. + - **`Fallback` & `Hedged`**: Avoids stalling on primary providers that are guaranteed to reject with HTTP 429. + - **`Race`**: Saves network bandwidth and avoid firing wasted requests to rate-limited models. + - **`RoundRobin`**: Skips over cooling-down keys without breaking the cyclic load-balancing progression. +3. **Fail-Open Resilience**: If *all* providers in the farm are currently in a cooldown window, the orchestrator keeps all providers available rather than failing prematurely, allowing the request to cascade or surface accurate rate-limit errors. --- @@ -59,7 +83,6 @@ flowchart TD Audit --> Consensus["🗳️ AiMode.Consensus\nCross-LLM voting • Highest accuracy"] ``` - --- ## Mode Deep-Dives & Code Examples @@ -93,7 +116,7 @@ const dt = await parseAI('schedule team sync for next wednesday at 2pm', { ``` > [!TIP] -> `hedgeDelay` can also be set globally in `initAI({ hedgeDelay: 600 })` so it applies to all functions (`parseAI`, `recurrenceAI`, `scheduleAI`). +> `hedgeDelay` can also be set globally in `initAI({ hedgeDelay: 600 })` so it applies to all functions (`parseAI`, `recurrenceAI`, `scheduleAI`, `extractAI`). --- @@ -118,7 +141,7 @@ await initAI({ ### 4. `AiMode.Adaptive` — Rate-Limit Telemetry Prioritization -Reads `x-ratelimit-*` HTTP headers after every provider response and stores per-provider quota snapshots. On the next request, providers with `remainingRequests === 0` in an active reset window are automatically deprioritized; remaining providers are sorted by highest available quota. +Reads `x-ratelimit-*` HTTP headers after every provider response and stores per-provider quota snapshots. On subsequent requests, providers are ranked dynamically by highest remaining quota descending, guaranteeing that providers with ample headroom are prioritized ahead of constrained models. **Best for:** Multi-tier production gateways — mixed free/paid provider pools where proactively avoiding `429 Too Many Requests` is essential. @@ -162,3 +185,4 @@ if (dt.ai?.ambiguous) { console.warn('Providers disagreed — treat this result with caution.'); } ``` + diff --git a/packages/plugins/ai/doc/parseAI.md b/packages/plugins/ai/doc/parse.md similarity index 100% rename from packages/plugins/ai/doc/parseAI.md rename to packages/plugins/ai/doc/parse.md diff --git a/packages/plugins/ai/doc/recurrenceAI.md b/packages/plugins/ai/doc/recurrence.md similarity index 99% rename from packages/plugins/ai/doc/recurrenceAI.md rename to packages/plugins/ai/doc/recurrence.md index e2127cf8..5646f698 100644 --- a/packages/plugins/ai/doc/recurrenceAI.md +++ b/packages/plugins/ai/doc/recurrence.md @@ -70,7 +70,7 @@ const schedule = await recurrenceAI("Every Friday"); for (const occurrence of schedule) { // Always include a termination condition for open-ended schedules - if (occurrence.year > 2028) break; + if (occurrence.yy > 2028) break; console.log(occurrence.format('{yyyy}-{mm}-{dd}')); } diff --git a/packages/plugins/ai/doc/scheduleAI.md b/packages/plugins/ai/doc/schedule.md similarity index 85% rename from packages/plugins/ai/doc/scheduleAI.md rename to packages/plugins/ai/doc/schedule.md index d0760c6b..3efe8b98 100644 --- a/packages/plugins/ai/doc/scheduleAI.md +++ b/packages/plugins/ai/doc/schedule.md @@ -32,22 +32,22 @@ console.log(booking.ai?.conflictBumped); // true (pushed --- -## Configuration Options (`AiScheduleOptions`) +## Configuration Options (`TempoScheduleOptions`) | Option | Type | Description | | :--- | :--- | :--- | -| **`anchor`** | `Tempo \| Date \| string \| number` | Anchor reference time to evaluate relative dates from. Defaults to workstation/browser current time. | -| **`events`** | `TempoEvent[]` | A list of existing busy calendar intervals that the meeting must not overlap with. | -| **`workingHours`** | `{ start: string; end: string }` | Daily time window constraint (HH:MM formats) inside which slots must fit. | +| **`anchor`** | `TempoDateInput` | Anchor reference time to evaluate relative dates from. Defaults to workstation/browser current time. | +| **`events`** | `TempoInterval[]` | A list of existing busy calendar intervals that the meeting must not overlap with. | +| **`workingHours`** | `TempoWorkingHours` | Daily time window constraint (HH:MM formats) inside which slots must fit. | | **`timeZone`** | `string` | Target IANA timezone to calculate the booking slot and boundaries within. | | **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required to return a valid slot. | | **`mode`** | `AiMode` | Concurrency routing strategy (`fallback`, `race`, `consensus`, `hedged`, `roundrobin`, `adaptive`). Refer to the [Multi-Provider Execution Modes Guide](./modes.md). | -### `TempoEvent` Interface +### `TempoInterval` Interface ```typescript -interface TempoEvent { - start: Tempo | Date | string | number; - end: Tempo | Date | string | number; +interface TempoInterval { + start: TempoDateInput; + end: TempoDateInput; title?: string; } ``` diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index 7c5bca52..ce53fb84 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo-plugin-ai", - "version": "4.0.0", + "version": "1.0.0", "description": "Tempo community plugin for LLM-powered natural language parsing.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/plugins/ai/plan/extractAI.plan.md b/packages/plugins/ai/plan/extractAI.plan.md deleted file mode 100644 index 43e8e1ef..00000000 --- a/packages/plugins/ai/plan/extractAI.plan.md +++ /dev/null @@ -1,137 +0,0 @@ -# Implementation Plan: `extractAI` - -## 1. Overview & Goal -`extractAI` scans unstructured, multi-paragraph text (emails, transcripts, chat logs, meeting agendas, task notes) to identify, parse, and extract all embedded temporal entities and time-bound events into structured `TempoAiExtractResult` records containing `TempoExtractedEvent[]` (`label`, `start`, `end`, `type`, `rawText`, `confidence`). - -It anchors relative mentions (e.g., *"tomorrow at 2pm"*, *"next Tuesday from 9 to 11am"*, *"the last day of next month"*) against an explicit or current reference `anchor` timestamp and timezone. - ---- - -## 2. Public API & Type Definitions - -### 2.1 Types (`packages/plugins/ai/src/types/extract.type.ts`) -```typescript -import type { Tempo } from '@magmacomputing/tempo'; -import type { AiOptions } from './common.type.js'; -import type { TempoAiError } from '../core/error.js'; - -export type TempoEventType = 'point' | 'interval' | 'deadline' | 'recurrence' | 'tentative'; - -export interface TempoExtractedEvent { - /** Short descriptive label or title of the extracted event/activity. */ - label: string; - /** Start date-time point as an instantiated Tempo instance. */ - start: Tempo; - /** Optional end date-time point (if an interval or duration was mentioned). */ - end?: Tempo; - /** Classification category of the temporal mention. */ - type: TempoEventType; - /** Raw text snippet extracted from the source document. */ - rawText?: string; - /** Confidence score for this specific entity extraction (0.0 to 1.0). */ - confidence: number; -} - -/** Backward compatibility alias for TempoExtractedEvent */ -export type TempoEvent = TempoExtractedEvent; - -export interface TempoAiExtractResult { - /** Array of extracted events with instantiated Tempo objects. */ - events: TempoExtractedEvent[]; - /** Overall confidence score. */ - confidence: number; - /** Provider ID that fulfilled the request (or 'cache'). */ - provider: string; - /** Optional summary or reasoning from the LLM. */ - reasoning?: string; -} - -export interface AiExtractOptions extends AiOptions { - /** Reference anchor date-time for relative expressions (defaults to now). */ - anchor?: Tempo | Date | string | number; - /** Reference IANA timezone (defaults to global options or 'UTC'). */ - timeZone?: string; - /** Reference BCP 47 locale (defaults to global options or 'en-US'). */ - locale?: string | string[]; - /** Preferred calendar system (e.g. 'gregory', 'islamic', 'hebrew'). */ - calendar?: string; - /** Optional category filter to restrict extracted entities (e.g. ['meeting', 'deadline']). */ - categories?: string[]; - /** Optional regional context (e.g. 'US-NY', 'GB'). */ - region?: string; -} -``` - -### 2.2 Function Signature (`packages/plugins/ai/src/functions/extract.ts`) -```typescript -export async function extractAI(texts: string[], options?: AiExtractOptions): Promise<(TempoAiExtractResult | TempoAiError)[]>; -export async function extractAI(text: string, options?: AiExtractOptions): Promise; -``` - ---- - -## 3. Grounding & Prompt Strategy - -### 3.1 Context Construction -Pass anchor metadata so the LLM has a solid temporal baseline: -* **Reference Anchor**: `anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')` (`anchorTempo.tz`) -* **Reference Day of Week**: `anchorTempo.dow` / weekday name -* **Current Year / Month / Day**: Pre-resolved ISO components -* **Target Categories / Constraints**: e.g., Filter meetings, deadlines, or flights - -### 3.2 System Prompt & Schema -```markdown -You are an expert temporal entity extraction engine. -Scan the user-provided text for all temporal expressions, deadlines, meetings, and intervals. -Resolve relative references ("tomorrow", "next Monday", "in 2 hours") strictly against the Reference Anchor date and timezone. - -Return ONLY a valid JSON object matching this schema: -{ - "events": [ - { - "label": "Brief descriptive title", - "start": "ISO 8601 string (e.g. 2026-08-13T14:00:00)", - "end": "ISO 8601 string or null", - "type": "point | interval | deadline | recurrence | tentative", - "rawText": "Exact text fragment from the input", - "confidence": 0.95 - } - ], - "confidence": 0.95, - "reasoning": "Identified 2 scheduled meetings and 1 project deadline." -} -``` - -### 3.3 Post-Processing & Validation -1. For each item in `events`, validate `start` using `new Tempo(item.start, { timeZone: tz, locale: loc, calendar: cal })`. -2. If `item.end` is present, construct `new Tempo(item.end, { timeZone: tz, locale: loc, calendar: cal })`. -3. Filter out invalid date results gracefully. -4. Ensure returned `start` and `end` are native `Tempo` instances for immediate date arithmetic. - ---- - -## 4. Caching & Dispatch Pipeline - -1. **Cache Key Partition**: - `extract::${normalizedTextHash}::${anchorTempo.format('{yyyy}-{mm}-{dd}')}::${tz}::${loc}::${cal}::${region}` -2. **Multi-tier Caching**: - - Check `AiCacheAdapter` then `Tempo.cache`. - - Reconstitute cached ISO strings into `Tempo` instances upon cache hit. -3. **Execution Modes**: - - Dispatch via `executeWithMode` supporting all 6 modes. -4. **Batch Processing**: - - `Promise.all` / `Promise.allSettled` (with `softErrors` normalization). - ---- - -## 5. Verification & Test Plan -* **Unit Tests (`packages/plugins/ai/test/extract.test.ts`)**: - - Extract multiple events from email text (e.g., meeting + follow-up deadline). - - Resolve relative dates against custom `anchor` timestamps. - - Interval extraction with start and end times. - - Handle inputs containing no temporal entities (returns `events: []`). - - Cache hit rehydration into `Tempo` instances. - - Multi-provider execution modes (Fallback, Race, Adaptive). - - Batch processing with `softErrors: true`. -* **Documentation (`packages/plugins/ai/doc/extractAI.md`)**: - - TSDoc, usage examples with email text, calendar creation, and options guide. diff --git a/packages/plugins/ai/plan/v0.3.0-roadmap.md b/packages/plugins/ai/plan/v0.3.0-roadmap.md deleted file mode 100644 index ad1dd460..00000000 --- a/packages/plugins/ai/plan/v0.3.0-roadmap.md +++ /dev/null @@ -1,66 +0,0 @@ -# @magmacomputing/tempo-plugin-ai: v0.3.0 Release Roadmap & Requirements - -This document captures the planned feature set, architectural requirements, and design specifications for the **v0.3.0** release of `@magmacomputing/tempo-plugin-ai`. - ---- - -## 1. AI Function Handler Implementations (v0.3.0 Status) - -### 1.1 ✅ `scheduleAI(prompt: string, options?: TempoScheduleOptions): Promise` -* Resolves natural language scheduling prompts against working hours, existing calendar events, and timezones into an optimal start/end `TempoScheduleResult` interval (`slot`, `alternatives`, `ai.conflictBumped`). - -### 1.2 ✅ `recurrenceAI(prompt: string, options?: TempoRecurrenceOptions): Promise` -* Translates complex natural language repeating schedule descriptions into standard RFC 5545 RRULE strings and stateful `Tempo` date batches (`rule.take(count)`). - -### 1.3 ✅ `contextAI(text: string, options?: AiContextOptions): Promise` -* Infers `timeZone`, `locale`, and preferred `calendar` system from ambiguous location descriptions or user bios. - -### 1.4 ✅ `diffAI(start: any, end: any, prompt?: string, options?: AiDiffOptions): Promise` -* Calculates and summarizes the delta between two `Tempo` instances in human, business, or operational terms (e.g., `"5 business days (48 hours)"`), backed by native grounding metrics (calendar days, hours, business days with weekend & holiday exclusion). - -### 1.5 ✅ `formatAI(date: Tempo.DateTime, prompt?: string, options?: AiFormatOptions): Promise` -* Formats a `Tempo` instance, TC39 `Temporal` object, Date, or timestamp into human-friendly, contextual narrative text tailored to UI tones, relative countdowns, or domain summaries. -* **Example**: `"this Friday at 5:00 PM EST (in 5 days)"`. - ---- - -## 2. Upcoming AI Function Handlers (Post-v0.3.0 Roadmap) - -The following functions remain scaffolded for upcoming releases: - -### 2.1 `extractAI(text: string, options?: AiExtractOptions): Promise` -* Scans unstructured text (emails, transcripts, task notes) to extract embedded temporal entities into structured `TempoAiExtractResult` records (`events: TempoExtractedEvent[]`). - - ---- -Conceptually, the execution modes actually govern two different, orthogonal concerns: - -Concurrency Strategy (How do we invoke providers?): - -Sequential: Call one-by-one (e.g., Fallback, RoundRobin, Adaptive). -Speculative: Call with a staggered delay (e.g., Hedged). -Parallel: Call all at once (e.g., Race, Consensus). -Provider Prioritization Strategy (Which providers do we call, and in what order?): - -Static: Configured order (e.g., Fallback, Hedged, Race, Consensus). -Cyclic: Round-robin rotation (e.g., RoundRobin). -Telemetry-Aware: Sorted dynamically by remaining rate-limit quota (e.g., Adaptive). -Should Telemetry (Adaptive) be "on" for all modes? -Yes! Conceptually, Telemetry-Awareness (filtering out exhausted providers in an active cooldown window) should ideally be active globally, regardless of the concurrency strategy: - -In Hedged / Fallback: Instead of starting with a hardcoded primary provider (which might have 0 remaining requests), we should start with the provider that telemetry reports has the highest quota, and hedge to the one with the second-highest. -In Race / Consensus: If we know a provider is currently rate-limited (in a 429 cooldown reset window), launching a request to it is a waste of network resources and will immediately fail. We should filter it out of the race/consensus pool before firing. -Why it isn't "on" everywhere by default in practice -Stale Telemetry Risks: Telemetry depends on header ingestion from previous calls. If the reset window is short (e.g., resets in 2 seconds), but our cache reports "exhausted" for another 3 seconds, we might unnecessarily skip a provider that has recovered. Sequential fallback acts as a natural check. -Simple/Deterministic expectation: Users choosing Race or Fallback often expect absolute determinism based strictly on their provider configuration array order. -Single-Provider setups: For single-provider setups, telemetry-aware prioritization has no effect. -Future Design Direction -If we wanted to support combining them in a future version of Tempo, we could decouple the selection strategy from the concurrency mode: - -```typescript -await initAI({ - mode: AiMode.Hedged, // How we invoke - prioritization: 'telemetry' // How we order/filter (static | cyclic | telemetry) -}); -``` -For the current codebase, keeping them as separate named presets (RoundRobin vs. Adaptive) keeps the setup simple and easy to reason about, but we could certainly update the other strategies to check for and skip active cooldown limits in a future update! \ No newline at end of file diff --git a/packages/plugins/ai/src/core/dispatch.ts b/packages/plugins/ai/src/core/dispatch.ts index d6235dbe..17122473 100644 --- a/packages/plugins/ai/src/core/dispatch.ts +++ b/packages/plugins/ai/src/core/dispatch.ts @@ -387,6 +387,42 @@ async function executeAdaptiveMode( return executeFallbackMode(sortedProviders, task, options); } +/** + * Checks if a provider has exhausted its request quota and is currently within an active cooldown window. + * + * @internal + */ +export function isProviderInCooldown(provider: AiProvider, now = Date.now()): boolean { + const limits = _state.providerLimits.get(provider.id); + if (!limits) return false; + const resetMs = limits.resetAt?.epoch?.ms ?? now; + return limits.remainingRequests === 0 && resetMs > now; +} + +/** + * Filters out providers currently in an active rate-limit cooldown window, + * provided there is at least one non-exhausted provider available. + * If all providers are in cooldown, returns all providers so execution can attempt or fail naturally. + * + * @internal + */ +export function filterCooldownProviders( + providers: AiProvider[], + options?: ExecuteModeOptions, +): AiProvider[] { + if (providers.length <= 1) return providers; + const now = Date.now(); + const available = providers.filter(p => !isProviderInCooldown(p, now)); + if (available.length > 0 && available.length < providers.length) { + if (options?.debug) { + const skipped = providers.filter(p => isProviderInCooldown(p, now)).map(p => p.id); + console.log(`[${options?.tag || 'tempo-plugin-ai'}] Proactively filtered ${skipped.length} provider(s) in active 429 cooldown: ${skipped.join(', ')}`); + } + return available; + } + return providers; +} + /** * ## executeWithMode * Central multi-provider execution orchestrator for Tempo AI plugins. @@ -413,21 +449,23 @@ export async function executeWithMode( task: ProviderTask, options?: ExecuteModeOptions, ): Promise> { + const effectiveProviders = filterCooldownProviders(providers, options); + switch (mode) { case AiMode.Fallback: - return executeFallbackMode(providers, task, options); + return executeFallbackMode(effectiveProviders, task, options); case AiMode.Race: - return executeRaceMode(providers, task, options); + return executeRaceMode(effectiveProviders, task, options); case AiMode.Consensus: - return executeConsensusMode(providers, task); + return executeConsensusMode(effectiveProviders, task); case AiMode.Hedged: - return executeHedgedMode(providers, task, options); + return executeHedgedMode(effectiveProviders, task, options); case AiMode.RoundRobin: - return executeRoundRobinMode(providers, task, options); + return executeRoundRobinMode(effectiveProviders, task, options); case AiMode.Adaptive: return executeAdaptiveMode(providers, task, options); @@ -436,3 +474,4 @@ export async function executeWithMode( throw new TempoAiError(`Invalid execution mode: '${mode}'. Supported modes: ${Object.values(AiMode).map(m => `'${m}'`).join(', ')}.`, 400); } } + diff --git a/packages/plugins/ai/src/core/error.ts b/packages/plugins/ai/src/core/error.ts index 4dce5b55..bcadddc1 100644 --- a/packages/plugins/ai/src/core/error.ts +++ b/packages/plugins/ai/src/core/error.ts @@ -11,12 +11,12 @@ export class TempoAiError extends Error { /** A Tempo instance representing the rate limit reset time (extracted from Headers) */ #retryAt?: Tempo | undefined; - constructor(message: string, code: number, retryAt?: Tempo) { - super(message); - this.name = 'TempoAiError'; - this.#code = code; - this.#retryAt = retryAt; - } + constructor(message: string, code: number, retryAt?: Tempo, options?: ErrorOptions) { + super(message, options); + this.name = 'TempoAiError'; + this.#code = code; + this.#retryAt = retryAt; + } get code(): number { return this.#code; diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index 59e25b38..7d24d5f7 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -2,7 +2,7 @@ import { Tempo } from '@magmacomputing/tempo'; import { TempoAiError } from './error.js'; import { RESERVED_PROVIDER_IDS } from './config.js'; import { updateRateLimitsFromResponse, _state } from './init.js'; -import type { AiCacheAdapter, AiProvider, TempoAiMeta } from '../types/index.js'; +import type { AiCacheAdapter, AiProvider, TempoParseAiMeta } from '../types/index.js'; export function assertNoReservedProviderId(providers: Partial[]): void { for (const p of providers) { @@ -37,8 +37,13 @@ export function resolveTzAndLocale( ): { tz: string; loc: string } { const resolvedOptions = (Tempo as any).options ?? {}; const tz = String(options?.timeZone || fallbackTempo?.tz || resolvedOptions.timeZone || _state.config.timeZone || 'UTC'); - const rawLoc = options?.locale || fallbackTempo?.loc || resolvedOptions.locale || _state.config.locale || 'en-US'; - const loc = String(Array.isArray(rawLoc) ? rawLoc[0] : rawLoc); + const rawLoc = (options?.locale !== undefined && (Array.isArray(options.locale) ? options.locale.length > 0 : Boolean(options.locale))) + ? options.locale + : (fallbackTempo?.loc !== undefined && (Array.isArray(fallbackTempo.loc) ? fallbackTempo.loc.length > 0 : Boolean(fallbackTempo.loc))) + ? fallbackTempo.loc + : resolvedOptions.locale || _state.config.locale || 'en-US'; + const firstLoc = Array.isArray(rawLoc) ? rawLoc[0] : rawLoc; + const loc = typeof firstLoc === 'string' && firstLoc.trim().length > 0 ? firstLoc.trim() : 'en-US'; return { tz, loc }; } @@ -102,7 +107,7 @@ export async function writeMultiTierCache( } } -export function attachAiMeta(instance: Tempo, meta: TempoAiMeta): Tempo { +export function attachAiMeta(instance: Tempo, meta: TempoParseAiMeta): Tempo { const frozenMeta = Object.freeze(meta); const boundMethodCache = new Map(); diff --git a/packages/plugins/ai/src/functions/context.ts b/packages/plugins/ai/src/functions/context.ts index b0a88dc7..ac76cd29 100644 --- a/packages/plugins/ai/src/functions/context.ts +++ b/packages/plugins/ai/src/functions/context.ts @@ -1,4 +1,5 @@ import { Tempo } from '@magmacomputing/tempo'; +import { secure } from '@magmacomputing/tempo/library'; import { TempoAiError } from '../core/error.js'; import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; @@ -6,6 +7,7 @@ import { executeWithMode } from '../core/dispatch.js'; import { assertNoReservedProviderId, fetchFromProvider, + getNamespacedCacheKey, normalizeCacheInput, readMultiTierCache, resolveProviderTtl, @@ -27,7 +29,7 @@ async function contextSingleInput(text: string, options?: AiContextOptions): Pro const loc = String(Array.isArray(options?.locale) ? options?.locale[0] : (options?.locale || resolvedOptions.locale)); const sph = String(options?.sphere || resolvedOptions.sphere || 'north'); - const cacheKey = `context::${normalizedStr}::${tz}::${loc}::${cal}::${sph}`; + const cacheKey = getNamespacedCacheKey('context', `${normalizedStr}::${tz}::${loc}::${cal}::${sph}`); const adapter = cacheAdapter ?? _state.config.cacheAdapter; const cachedVal = await readMultiTierCache(cacheKey, { @@ -48,7 +50,7 @@ async function contextSingleInput(text: string, options?: AiContextOptions): Pro : 1.0; if (effectiveMinConfidence === undefined || cachedConfidence >= effectiveMinConfidence) { if (isDebug) console.log(`[tempo-plugin-ai:context] Cache hit: "${text}" -> ${cachedVal}`); - return { + return secure({ timeZone: parsedCache.timeZone, locale: parsedCache.locale, calendar: parsedCache.calendar, @@ -56,7 +58,7 @@ async function contextSingleInput(text: string, options?: AiContextOptions): Pro confidence: cachedConfidence, provider: 'cache', reasoning: parsedCache.reasoning, - } + }); } } } catch { @@ -181,7 +183,7 @@ Do not include markdown blocks or text outside the JSON.`; tag: 'tempo-plugin-ai:context', }); - return finalResult; + return secure(finalResult); } /** diff --git a/packages/plugins/ai/src/functions/diff.ts b/packages/plugins/ai/src/functions/diff.ts index 6936bd5c..0cc0b880 100644 --- a/packages/plugins/ai/src/functions/diff.ts +++ b/packages/plugins/ai/src/functions/diff.ts @@ -1,4 +1,5 @@ import { Tempo } from '@magmacomputing/tempo'; +import { secure } from '@magmacomputing/tempo/library'; import { TempoAiError } from '../core/error.js'; import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; @@ -6,6 +7,7 @@ import { executeWithMode } from '../core/dispatch.js'; import { assertNoReservedProviderId, fetchFromProvider, + getNamespacedCacheKey, normalizeCacheInput, readMultiTierCache, resolveProviderTtl, @@ -85,7 +87,7 @@ async function diffSingleInput( const { force, mode: aiMode, providers, minConfidence, cache: aiCacheOption, timeout: callTimeout, ttl, cacheAdapter, hedgeDelay } = options || {}; const sortedHolidays = holidays ? [...holidays].sort().join(',') : ''; - const cacheKey = `diff::${startTempo.epoch.ms}::${endTempo.epoch.ms}::${normalizedPrompt}::${tz}::${loc}::${region}::${sortedHolidays}`; + const cacheKey = getNamespacedCacheKey('diff', `${startTempo.epoch.ms}::${endTempo.epoch.ms}::${normalizedPrompt}::${tz}::${loc}::${region}::${sortedHolidays}`); const adapter = cacheAdapter ?? _state.config.cacheAdapter; const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence; @@ -108,7 +110,7 @@ async function diffSingleInput( : 1.0; if (effectiveMinConfidence === undefined || cachedConfidence >= effectiveMinConfidence) { if (isDebug) console.log(`[tempo-plugin-ai:diff] Cache hit: "${cacheKey}" -> ${cachedVal}`); - return { + return secure({ formatted: parsedCache.formatted, days: parsedCache.days ?? grounding.calendarDays, hours: parsedCache.hours ?? grounding.elapsedHours, @@ -117,7 +119,7 @@ async function diffSingleInput( confidence: cachedConfidence, provider: 'cache', reasoning: parsedCache.reasoning, - }; + }); } } } catch { @@ -227,7 +229,7 @@ Do not include markdown blocks or text outside the JSON.`; confidence, provider: providerId, reasoning: parsedData.reasoning, - }; + } const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000); const cacheVal = JSON.stringify({ @@ -247,7 +249,7 @@ Do not include markdown blocks or text outside the JSON.`; tag: 'tempo-plugin-ai:diff', }); - return finalResult; + return secure(finalResult); } /** diff --git a/packages/plugins/ai/src/functions/extract.ts b/packages/plugins/ai/src/functions/extract.ts index 64579084..83011298 100644 --- a/packages/plugins/ai/src/functions/extract.ts +++ b/packages/plugins/ai/src/functions/extract.ts @@ -1,102 +1,366 @@ -import type { Tempo } from '@magmacomputing/tempo'; -import type { TempoAiError } from '../core/error.js'; -import type { AiMode } from '../core/config.js'; -import type { AiCacheAdapter, AiProvider } from '../types/common.type.js'; - -export type TempoEventType = 'point' | 'interval' | 'deadline' | 'recurrence' | 'tentative'; - -export interface TempoExtractedEvent { - /** Short descriptive label or title of the extracted event/activity. */ - label: string; - /** Start date-time point as an instantiated Tempo instance. */ - start: Tempo; - /** Optional end date-time point (if an interval or duration was mentioned). */ - end?: Tempo | undefined; - /** Classification category of the temporal mention. */ - type: TempoEventType; - /** Raw text snippet extracted from the source document. */ - rawText?: string | undefined; - /** Confidence score for this specific entity extraction (0.0 to 1.0). */ - confidence: number; -} +import { Tempo } from '@magmacomputing/tempo'; +import { secure } from '@magmacomputing/tempo/library'; +import { TempoAiError } from '../core/error.js'; +import { AiMode } from '../core/config.js'; +import { _state } from '../core/init.js'; +import { executeWithMode } from '../core/dispatch.js'; +import { + assertNoReservedProviderId, + fetchFromProvider, + normalizeCacheInput, + readMultiTierCache, + resolveProviderTtl, + resolveTzAndLocale, + writeMultiTierCache, +} from '../core/support.js'; +import type { + AiExtractOptions, + TempoAiExtractResult, + TempoExtractedEvent, + TempoEventType, +} from '../types/extract.type.js'; -/** - * Backward compatibility alias for TempoExtractedEvent. - */ -export type TempoEvent = TempoExtractedEvent; - -export interface TempoAiExtractResult { - /** Array of extracted events with instantiated Tempo objects. */ - events: TempoExtractedEvent[]; - /** Overall confidence score. */ - confidence: number; - /** Provider ID that fulfilled the request (or 'cache'). */ - provider: string; - /** Optional summary or reasoning from the LLM. */ - reasoning?: string | undefined; +export type { + AiExtractOptions, + TempoAiExtractResult, + TempoExtractedEvent, + TempoEventType, +}; + +async function extractSingleInput( + text: string, + options?: AiExtractOptions, +): Promise { + if (typeof text !== 'string' || !text.trim()) { + throw new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400); + } + + const isDebug = options?.debug ?? _state.config.debug ?? false; + const anchor = options?.anchor; + const { tz, loc } = resolveTzAndLocale(options, Tempo.isTempo(anchor) ? anchor : null); + + let anchorTempo: Tempo; + try { + anchorTempo = anchor !== undefined + ? (Tempo.isTempo(anchor) + ? (anchor.tz === tz ? anchor : anchor.set({ timeZone: tz })) + : new Tempo(anchor as any, { timeZone: tz })) + : new Tempo(Math.floor(Date.now() / 60_000) * 60_000, { timeZone: tz }); + } catch (err: any) { + throw new TempoAiError(`Invalid anchor date provided to extractAI: "${String(anchor)}"`, 400); + } + + if (!anchorTempo.isValid) { + throw new TempoAiError(`Invalid anchor date provided to extractAI: "${String(anchor)}"`, 400); + } + + const cal = options?.calendar || 'gregory'; + const region = options?.region ? String(options.region).trim() : ''; + const categories = options?.categories ? options.categories.map(c => String(c).trim()).filter(Boolean) : []; + const categoriesStr = categories.sort().join(','); + + const { + force, + mode: aiMode, + providers, + minConfidence, + cache: aiCacheOption, + timeout: callTimeout, + ttl, + cacheAdapter, + hedgeDelay, + } = options || {}; + + const normalizedText = normalizeCacheInput(text); + const cacheKey = `extract::${normalizedText}::${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}')}::${tz}::${loc}::${cal}::${region}::${categoriesStr}`; + const adapter = cacheAdapter ?? _state.config.cacheAdapter; + + const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence; + if ( + effectiveMinConfidence !== undefined && + (typeof effectiveMinConfidence !== 'number' || + !Number.isFinite(effectiveMinConfidence) || + effectiveMinConfidence < 0.0 || + effectiveMinConfidence > 1.0) + ) { + throw new TempoAiError(`Invalid minConfidence provided to extractAI: "${String(effectiveMinConfidence)}"`, 400); + } + + const effectiveHedgeDelay = hedgeDelay ?? _state.config.hedgeDelay; + + const cachedVal = await readMultiTierCache(cacheKey, { + force, + cache: aiCacheOption, + cacheAdapter: adapter, + debug: isDebug, + tag: 'tempo-plugin-ai:extract', + }); + + if (cachedVal) { + try { + const parsedCache = JSON.parse(cachedVal); + if (Array.isArray(parsedCache?.events)) { + const cachedConfidence = typeof parsedCache.confidence === 'number' && Number.isFinite(parsedCache.confidence) + ? Math.max(0.0, Math.min(1.0, parsedCache.confidence)) + : 1.0; + + if (effectiveMinConfidence !== undefined && cachedConfidence < effectiveMinConfidence) { + if (isDebug) console.log(`[tempo-plugin-ai:extract] Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`); + } else { + const rehydratedEvents: TempoExtractedEvent[] = []; + for (const ev of parsedCache.events) { + try { + const start = new Tempo(ev.start, { timeZone: tz, locale: loc, calendar: cal }); + if (!start.isValid) continue; + const end = ev.end ? new Tempo(ev.end, { timeZone: tz, locale: loc, calendar: cal }) : undefined; + if (end && !end.isValid) continue; + rehydratedEvents.push({ + label: String(ev.label || 'Event'), + start, + end, + type: ev.type || 'point', + rawText: ev.rawText ? String(ev.rawText) : undefined, + confidence: typeof ev.confidence === 'number' && Number.isFinite(ev.confidence) + ? Math.max(0.0, Math.min(1.0, ev.confidence)) + : 1.0, + }); + } catch { } + } + + return secure({ + events: rehydratedEvents, + confidence: cachedConfidence, + provider: 'cache', + reasoning: parsedCache.reasoning, + }); + } + } + } catch (err: any) { + if (isDebug) console.warn(`[tempo-plugin-ai:extract] Failed to parse cached payload:`, err?.message ?? err); + } + } + + const availableProviders = providers || _state.config.providers; + if (!availableProviders || availableProviders.length === 0) { + throw new TempoAiError('No AI providers configured. Please call initAI().', 400); + } + + assertNoReservedProviderId(availableProviders); + + const weekdayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + const anchorWeekday = weekdayNames[anchorTempo.dow] || anchorTempo.format('{www}'); + const anchorIso = anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}'); + + const systemPrompt = `You are an expert temporal entity and calendar event extraction engine. +Scan the user-provided text for all temporal expressions, deadlines, appointments, meetings, intervals, and time-bound events. +Resolve all relative references ("tomorrow", "next Tuesday", "in 2 hours", "at 5pm") strictly against the Reference Anchor date and timezone. + +Return ONLY a valid JSON object matching this exact schema: +{ + "events": [ + { + "label": "Brief descriptive title of the event or task", + "start": "ISO 8601 string without offset or Z (e.g. 2026-08-14T10:00:00)", + "end": "ISO 8601 string without offset or Z or null if point in time", + "type": "point | interval | deadline | recurrence | tentative", + "rawText": "Exact text snippet from the input mentioning this event", + "confidence": 0.95 + } + ], + "confidence": 0.95, + "reasoning": "Summary of temporal entities identified" } -export interface AiExtractOptions { - /** Reference anchor date-time for relative expressions (defaults to now). */ - anchor?: Tempo | Date | string | number | undefined; - /** Reference IANA timezone (defaults to global options or 'UTC'). */ - timeZone?: string | undefined; - /** Reference BCP 47 locale (defaults to global options or 'en-US'). */ - locale?: string | string[] | undefined; - /** Preferred calendar system (e.g. 'gregory', 'islamic', 'hebrew'). */ - calendar?: string | undefined; - /** Optional category filter to restrict extracted entities (e.g. ['meeting', 'deadline']). */ - categories?: string[] | undefined; - /** Optional regional context (e.g. 'US-NY', 'GB'). */ - region?: string | undefined; - /** If true, bypasses cache to force a fresh LLM fetch */ - force?: boolean | undefined; - /** If false, disables reading and writing to cache */ - cache?: boolean | undefined; - /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */ - cacheAdapter?: AiCacheAdapter | undefined; - /** Optional TTL override in milliseconds for cached result */ - ttl?: number | undefined; - /** If true, logs prompt context and LLM payloads to console */ - debug?: boolean | undefined; - /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` | `AiMode.Hedged` | `AiMode.RoundRobin` | `AiMode.Adaptive` or string literal) */ - mode?: AiMode | undefined; - /** Per-request provider configuration overrides */ - providers?: AiProvider[] | undefined; - /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */ - minConfidence?: number | undefined; - /** If true, returns TempoAiError into array index position instead of rejecting batch */ - softErrors?: boolean | undefined; - /** Optional request timeout in milliseconds (overrides provider and global timeout) */ - timeout?: number | undefined; - /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */ - hedgeDelay?: number | undefined; - /** Allow extra custom properties */ - [key: string]: any; +Rules: +1. "events": Array of extracted event objects. If no temporal entities are mentioned, return an empty array []. +2. "start": Local ISO 8601 representation (YYYY-MM-DDThh:mm:ss) anchored to the reference date and timezone. +3. "end": Local ISO 8601 string for interval end / duration, or null. +4. "type": Must be one of 'point', 'interval', 'deadline', 'recurrence', 'tentative'. +5. "confidence": Float score between 0.0 and 1.0 representing extraction certainty. +${categories.length > 0 ? `6. Only extract events matching one of these categories: ${categories.join(', ')}.` : ''}`; + + const contextString = `Grounding Context: +- Reference Anchor Date-Time: ${anchorIso} (${tz}) +- Reference Day of Week: ${anchorWeekday} (Day ${anchorTempo.dow}) +- Target TimeZone: ${tz} +- Target Locale: ${loc} +- Calendar System: ${cal} +${region ? `- Region Context: ${region}\n` : ''}${categories.length > 0 ? `- Filter Categories: ${categories.join(', ')}\n` : ''}`; + + const mode = aiMode || _state.config.mode || AiMode.Fallback; + + const winningCandidate = await executeWithMode( + mode, + availableProviders, + async (provider, signal) => { + const { rawContent, providerId, rateLimits } = await fetchFromProvider( + provider, + text, + contextString, + isDebug, + signal, + callTimeout, + systemPrompt, + ); + + let parsedData: any; + try { + parsedData = JSON.parse(rawContent); + } catch (err: any) { + throw new TempoAiError(`Provider ${providerId} returned invalid JSON: ${err?.message}`, 422); + } + + if (typeof parsedData !== 'object' || parsedData === null) + throw new TempoAiError(`Provider ${providerId} returned non-object JSON payload.`, 422); + + if (!Array.isArray(parsedData?.events)) + throw new TempoAiError(`Provider ${providerId} returned invalid response: 'events' array missing.`, 422); + + const rawConfidence = typeof parsedData?.confidence === 'number' && Number.isFinite(parsedData.confidence) + ? parsedData.confidence + : 0.9; + const confidence = Math.max(0.0, Math.min(1.0, rawConfidence)); + const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined; + + const validEvents: TempoExtractedEvent[] = []; + const rawEventItems: any[] = []; + for (const item of parsedData.events) { + if (!item || typeof item !== 'object') continue; + try { + const start = new Tempo(item.start, { timeZone: tz, locale: loc, calendar: cal }); + if (!start.isValid) continue; + + let end: Tempo | undefined; + if (item.end && typeof item.end === 'string') { + const parsedEnd = new Tempo(item.end, { timeZone: tz, locale: loc, calendar: cal }); + if (parsedEnd.isValid) end = parsedEnd; + } + + const allowedTypes: TempoEventType[] = ['point', 'interval', 'deadline', 'recurrence', 'tentative']; + const type: TempoEventType = allowedTypes.includes(item.type) ? item.type : 'point'; + const label = typeof item.label === 'string' && item.label.trim() ? item.label.trim() : 'Event'; + const rawText = typeof item.rawText === 'string' ? item.rawText : undefined; + const itemConf = typeof item.confidence === 'number' && Number.isFinite(item.confidence) + ? Math.max(0.0, Math.min(1.0, item.confidence)) + : confidence; + + validEvents.push({ + label, + start, + end, + type, + rawText, + confidence: itemConf, + }); + + rawEventItems.push({ + label, + start: start.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}'), + end: end ? end.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}') : null, + type, + rawText, + confidence: itemConf, + }); + } catch { } + } + + return { + data: { + events: validEvents, + rawEvents: rawEventItems, + reasoning, + }, + providerId, + rateLimits, + confidence, + consensusKey: JSON.stringify(rawEventItems), + }; + }, + { minConfidence: effectiveMinConfidence, debug: isDebug, tag: 'tempo-plugin-ai:extract', hedgeDelay: effectiveHedgeDelay }, + ); + + _state.limits = winningCandidate.rateLimits ?? null; + + const { data: parsedData, providerId } = winningCandidate; + const rawConfidence = typeof winningCandidate.confidence === 'number' && Number.isFinite(winningCandidate.confidence) + ? winningCandidate.confidence + : 0.9; + const confidence = Math.max(0.0, Math.min(1.0, rawConfidence)); + + if (effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence) { + throw new TempoAiError(`extractAI confidence (${confidence}) is below the required threshold of ${effectiveMinConfidence}.`, 422); + } + + const finalResult: TempoAiExtractResult = { + events: parsedData.events, + confidence, + provider: providerId, + reasoning: parsedData.reasoning, + } + + const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000); + const cacheVal = JSON.stringify({ + events: parsedData.rawEvents, + confidence, + provider: providerId, + reasoning: parsedData.reasoning, + }); + + await writeMultiTierCache(cacheKey, cacheVal, resolvedTtl, { + cache: aiCacheOption, + cacheAdapter: adapter, + debug: isDebug, + tag: 'tempo-plugin-ai:extract', + }); + + return secure(finalResult); } /** - * @internal Draft implementation scaffolded for future releases. - * ## extractAI (Upcoming Export) - * Scans unstructured text (emails, transcripts, task notes) and extracts all - * embedded temporal entities, deadlines, and events into structured `TempoAiExtractResult` records. + * ## extractAI + * Scans unstructured multi-paragraph text (emails, meeting transcripts, chat logs, task notes) + * and extracts all embedded temporal entities, deadlines, appointments, and intervals into structured `TempoAiExtractResult` records. * * ### Why it fits Tempo: - * Essential for calendar apps and document processing workflows where temporal references - * are buried inside unstructured text. + * Translates messy unstructured prose into typed, validated `Tempo` instances anchored to reference timezones and calendar contexts. * * ### Example Usage: * ```ts - * const text = "Let's meet tomorrow at 10am. Final deliverables due next Friday EOD."; - * const result = await extractAI(text, { anchor: new Tempo() }); - * // returns TempoAiExtractResult with parsed Tempo instances in result.events + * const email = "Let's meet tomorrow at 10am for sprint planning. Deliverables due next Friday by 5pm."; + * const result = await extractAI(email, { anchor: new Tempo('2026-08-10T09:00:00Z') }); + * + * for (const event of result.events) { + * console.log(event.label, event.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')); + * } * ``` */ export async function extractAI(texts: string[], options?: AiExtractOptions): Promise<(TempoAiExtractResult | TempoAiError)[]>; export async function extractAI(text: string, options?: AiExtractOptions): Promise; export async function extractAI( textOrTexts: string | string[], - _options?: AiExtractOptions, + options?: AiExtractOptions, ): Promise { - throw new Error('extractAI is not yet implemented in tempo-plugin-ai.'); + if (Array.isArray(textOrTexts)) { + const opts = options || {}; + const softErrors = opts.softErrors ?? false; + + if (softErrors) { + const settled = await Promise.allSettled( + textOrTexts.map(t => extractSingleInput(t, opts)), + ); + return settled.map((res, index) => { + if (res.status === 'fulfilled') return res.value; + const rawReason = res.reason; + if (rawReason instanceof TempoAiError) return rawReason; + return new TempoAiError( + rawReason?.message || `Failed to extract events at index ${index}`, + typeof rawReason?.status === 'number' ? rawReason.status : 500, + ); + }); + } + + return Promise.all(textOrTexts.map(t => extractSingleInput(t, opts))); + } + + return extractSingleInput(textOrTexts, options); } diff --git a/packages/plugins/ai/src/functions/format.ts b/packages/plugins/ai/src/functions/format.ts index 4d4ddd28..c4505441 100644 --- a/packages/plugins/ai/src/functions/format.ts +++ b/packages/plugins/ai/src/functions/format.ts @@ -1,4 +1,5 @@ import { Tempo } from '@magmacomputing/tempo'; +import { secure } from '@magmacomputing/tempo/library'; import { TempoAiError } from '../core/error.js'; import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; @@ -68,11 +69,13 @@ async function formatSingleInput( ? (date.tz === tz ? date : date.set({ timeZone: tz })) : new Tempo(date as any, { timeZone: tz }); } catch (err: any) { - throw new TempoAiError(`Invalid date provided to formatAI: "${String(date)}"`, 400); + const safeDateRep = typeof date === 'object' && date !== null ? JSON.stringify(date) : String(date); + throw new TempoAiError(`Invalid date provided to formatAI: "${safeDateRep}"`, 400, undefined, { cause: err }); } if (!targetTempo.isValid) { - throw new TempoAiError(`Invalid date provided to formatAI: "${String(date)}"`, 400); + const safeDateRep = typeof date === 'object' && date !== null ? JSON.stringify(date) : String(date); + throw new TempoAiError(`Invalid date provided to formatAI: "${safeDateRep}"`, 400); } const anchor = options?.anchor; @@ -84,11 +87,13 @@ async function formatSingleInput( : new Tempo(anchor as any, { timeZone: tz })) : new Tempo(Math.floor(Date.now() / 60_000) * 60_000, { timeZone: tz }); } catch (err: any) { - throw new TempoAiError(`Invalid anchor date provided to formatAI: "${String(anchor)}"`, 400); + const safeAnchorRep = typeof anchor === 'object' && anchor !== null ? JSON.stringify(anchor) : String(anchor); + throw new TempoAiError(`Invalid anchor date provided to formatAI: "${safeAnchorRep}"`, 400, undefined, { cause: err }); } if (!anchorTempo.isValid) { - throw new TempoAiError(`Invalid anchor date provided to formatAI: "${String(anchor)}"`, 400); + const safeAnchorRep = typeof anchor === 'object' && anchor !== null ? JSON.stringify(anchor) : String(anchor); + throw new TempoAiError(`Invalid anchor date provided to formatAI: "${safeAnchorRep}"`, 400); } const style = options?.style ? String(options.style).trim() : ''; @@ -114,6 +119,16 @@ async function formatSingleInput( const adapter = cacheAdapter ?? _state.config.cacheAdapter; const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence; + if ( + effectiveMinConfidence !== undefined && + (typeof effectiveMinConfidence !== 'number' || + !Number.isFinite(effectiveMinConfidence) || + effectiveMinConfidence < 0.0 || + effectiveMinConfidence > 1.0) + ) { + throw new TempoAiError(`Invalid minConfidence provided to formatAI: "${String(effectiveMinConfidence)}"`, 400); + } + const effectiveHedgeDelay = hedgeDelay ?? _state.config.hedgeDelay; const cachedVal = await readMultiTierCache(cacheKey, { @@ -135,12 +150,13 @@ async function formatSingleInput( if (effectiveMinConfidence !== undefined && cachedConfidence < effectiveMinConfidence) { if (isDebug) console.log(`[tempo-plugin-ai:format] Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`); } else { - return { + const reasoning = typeof parsedCache?.reasoning === 'string' ? parsedCache.reasoning : undefined; + return secure({ formatted: parsedCache.formatted, confidence: cachedConfidence, provider: 'cache', - reasoning: parsedCache.reasoning, - }; + reasoning, + }); } } } catch (err: any) { @@ -158,16 +174,9 @@ async function formatSingleInput( const systemPrompt = `You are an expert natural language temporal formatting engine. Generate human-friendly, contextual narrative representations of dates and times based on the grounding context. -Grounding Context: -- Target Date-Time: ${grounding.iso} (${tz}) -- Target Day of Week: ${grounding.dayOfWeek} (Day ${grounding.dayOfWeekOrdinal}) -- Reference Anchor: ${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')} (${tz}) -- Relative Time Delta: ${grounding.calendarDays >= 0 ? '+' : ''}${grounding.calendarDays} calendar days (${grounding.elapsedHours >= 0 ? '+' : ''}${grounding.elapsedHours} hours) in the ${grounding.direction.toUpperCase()} -- Target Locale: ${loc}${region ? `\n- Regional Context: ${region}` : ''}${style ? `\n- Desired Style/Tone: ${style}` : ''} - Rules: 1. Always return a single, valid JSON object matching the schema below. -2. The "formatted" field must contain the contextual narrative string (e.g., "this Friday at 5:00 PM EST (in 5 days)", "Tomorrow afternoon at 3:00 PM"). +2. The "formatted" field must contain the contextual narrative string (e.g., "this Friday at 5:00 PM EDT (in 5 days)", "Tomorrow afternoon at 3:00 PM"). 3. Respect the target locale, style, and timezone conventions. 4. "confidence" must be a float between 0.0 and 1.0 representing certainty. 5. "reasoning" should briefly describe how the formatted output was constructed. @@ -179,15 +188,18 @@ Output JSON Schema: "reasoning": "string" }`; - const contextString = `Grounding Context: -- Target Date-Time: ${grounding.iso} (${grounding.timeZone}) -- Day of Week: ${grounding.dayOfWeek} (Day ${grounding.dayOfWeekOrdinal}) -- Reference Anchor: ${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')} (${anchorTempo.tz || tz}) -- Relative Delta: ${grounding.calendarDays >= 0 ? '+' : ''}${grounding.calendarDays} calendar days (${grounding.elapsedHours >= 0 ? '+' : ''}${grounding.elapsedHours} hours) in the ${grounding.direction.toUpperCase()} -- Target Locale: ${loc} -${style ? `- Desired Style/Tone: ${style}` : ''} -${region ? `- Regional Context: ${region}` : ''} -- Formatting Instructions: "${promptText}"`; + const contextParts = [ + 'Grounding Context:', + `- Target Date-Time: ${grounding.iso} (${grounding.timeZone})`, + `- Day of Week: ${grounding.dayOfWeek} (Day ${grounding.dayOfWeekOrdinal})`, + `- Reference Anchor: ${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')} (${anchorTempo.tz || tz})`, + `- Relative Delta: ${grounding.calendarDays >= 0 ? '+' : ''}${grounding.calendarDays} calendar days (${grounding.elapsedHours >= 0 ? '+' : ''}${grounding.elapsedHours} hours) in the ${grounding.direction.toUpperCase()}`, + `- Target Locale: ${loc}`, + ]; + if (style) contextParts.push(`- Desired Style/Tone: ${style}`); + if (region) contextParts.push(`- Regional Context: ${region}`); + contextParts.push(`- Formatting Instructions: "${promptText}"`); + const contextString = contextParts.join('\n'); const mode = aiMode || _state.config.mode || AiMode.Fallback; @@ -234,7 +246,7 @@ ${region ? `- Regional Context: ${region}` : ''} rateLimits, confidence, consensusKey: formatted.toLowerCase(), - }; + } }, { minConfidence: effectiveMinConfidence, debug: isDebug, tag: 'tempo-plugin-ai:format', hedgeDelay: effectiveHedgeDelay }, ); @@ -256,7 +268,7 @@ ${region ? `- Regional Context: ${region}` : ''} confidence, provider: providerId, reasoning: parsedData.reasoning, - }; + } const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000); const cacheVal = JSON.stringify(finalResult); @@ -267,7 +279,7 @@ ${region ? `- Regional Context: ${region}` : ''} tag: 'tempo-plugin-ai:format', }); - return finalResult; + return secure(finalResult); } /** @@ -283,7 +295,7 @@ ${region ? `- Regional Context: ${region}` : ''} * ```ts * const t = new Tempo('2026-08-07T17:00:00[America/New_York]'); * - * // "this Friday at 5:00 PM EST (in 5 days)" + * // "this Friday at 5:00 PM EDT (in 5 days)" * const result = await formatAI(t, 'friendly reminder tone with relative countdown'); * console.log(result.formatted); * ``` @@ -296,25 +308,48 @@ export async function formatAI( options?: AiFormatOptions, ): Promise { if (Array.isArray(dateOrItems)) { + if (dateOrItems.length === 0) return []; const opts = (typeof promptOrOptions === 'object' && promptOrOptions !== null ? promptOrOptions : options) || {}; const softErrors = opts.softErrors ?? false; + const concurrencyLimit = Math.max(1, Math.min(opts.concurrency ?? 4, dateOrItems.length)); + + const results: (TempoAiFormatResult | TempoAiError)[] = new Array(dateOrItems.length); + let nextIdx = 0; + let firstError: any = null; + + const worker = async () => { + while (nextIdx < dateOrItems.length) { + if (!softErrors && firstError) break; + const currentIndex = nextIdx++; + const item = dateOrItems[currentIndex]; + const itemOpts = item.options ? { ...opts, ...item.options } : opts; + try { + const res = await formatSingleInput(item.date, item.prompt, itemOpts); + results[currentIndex] = res; + } catch (err: any) { + if (softErrors) { + results[currentIndex] = err instanceof TempoAiError + ? err + : new TempoAiError( + err?.message || `Failed to format date at index ${currentIndex}`, + typeof err?.status === 'number' ? err.status : 500, + ); + } else { + if (!firstError) firstError = err; + break; + } + } + } + }; - if (softErrors) { - const settled = await Promise.allSettled( - dateOrItems.map(item => formatSingleInput(item.date, item.prompt, opts)), - ); - return settled.map((res, index) => { - if (res.status === 'fulfilled') return res.value; - const rawReason = res.reason; - if (rawReason instanceof TempoAiError) return rawReason; - return new TempoAiError( - rawReason?.message || `Failed to format date at index ${index}`, - typeof rawReason?.status === 'number' ? rawReason.status : 500, - ); - }); + const workers = Array.from({ length: concurrencyLimit }, () => worker()); + await Promise.all(workers); + + if (!softErrors && firstError) { + throw firstError; } - return Promise.all(dateOrItems.map(item => formatSingleInput(item.date, item.prompt, opts))); + return results; } const prompt = typeof promptOrOptions === 'string' ? promptOrOptions : undefined; diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts index 468a1f24..b0473ea8 100644 --- a/packages/plugins/ai/src/functions/parse.ts +++ b/packages/plugins/ai/src/functions/parse.ts @@ -11,7 +11,26 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< const isDebug = options?.debug ?? _state.config.debug ?? false; const normalizedStr = normalizeCacheInput(str); - const { force, debug, mode: aiMode, providers, minConfidence, softErrors, cache: aiCacheOption, timeout: callTimeout, ttl, cacheAdapter, anchor, hedgeDelay, ...coreOptions } = options || {}; + const { + force, + debug, + mode: aiMode, + providers, + minConfidence, + softErrors, + cache: aiCacheOption, + timeout: callTimeout, + ttl, + cacheAdapter, + anchor, + hedgeDelay, + timeZone: _tz, + locale: _loc, + calendar: _cal, + region: _reg, + sphere: _sph, + ...coreOptions + } = options || {}; let tz: string, cal: string, loc: string, sph: string, anchorStr: string; if (Tempo.isTempo(options?.anchor)) { @@ -31,7 +50,8 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< anchorStr = String(options?.anchor || new Tempo().toString()); } - const anchorTempo = new Tempo(anchorStr, { ...coreOptions, timeZone: tz, calendar: cal, locale: loc, sphere: sph as any }); + const tempoConfig = { ...coreOptions, timeZone: tz, calendar: cal, locale: loc, sphere: sph as any }; + const anchorTempo = new Tempo(anchorStr, tempoConfig); const cacheSalt = anchorTempo.format('{yyyy}-{mm}-{dd}'); const cacheKey = `${normalizedStr}::${cacheSalt}::${tz}::${cal}::${loc}::${sph}`; const adapter = cacheAdapter ?? _state.config.cacheAdapter; @@ -54,7 +74,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< if (cachedIso) { if (isDebug) console.log(`[tempo-plugin-ai] Cache hit: "${str}" -> ${cachedIso}`); - const cachedInstance = new Tempo(cachedIso, coreOptions); + const cachedInstance = new Tempo(cachedIso, tempoConfig); return attachAiMeta(cachedInstance, { provider: 'cache', cached: true, @@ -69,7 +89,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< if (!force) { try { - const native = new Tempo(str, { ...coreOptions, silent: true }); + const native = new Tempo(str, { ...tempoConfig, silent: true }); const hasNativeMatches = Tempo.cache.has(str) || Tempo.cache.has(normalizedStr) || RE_ISO_DATE_PREFIX.test(str.trim()) @@ -142,7 +162,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< const isBelowMinConfidence = effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence; if (rawIso === 'INVALID' || isBelowMinConfidence) { - const invalidInstance = new Tempo('INVALID', { ...coreOptions, catch: true }); + const invalidInstance = new Tempo('INVALID', { ...tempoConfig, catch: true }); return attachAiMeta(invalidInstance, { provider: providerId, cached: false, @@ -163,8 +183,10 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< // In Consensus mode, providerId is the synthetic sentinel 'consensus' (not a real provider id), // so use the minimum TTL across all participating providers as the conservative policy. const providerTtl = providerId === AiMode.Consensus - ? availableProviders.reduce((min, p) => p.ttl === undefined ? min : (min === undefined ? p.ttl : Math.min(min, p.ttl)), undefined) - : availableProviders.find(p => p.id === providerId)?.ttl; + ? (availableProviders) + .reduce((min: number | undefined, p: any) => + p.ttl === undefined ? min : (min === undefined ? p.ttl : Math.min(min, p.ttl)), undefined) + : (availableProviders).find((p: any) => p.id === providerId)?.ttl; const resolvedTtl = ttl ?? providerTtl ?? _state.config.ttl ?? 3_600_000; if (aiCacheOption !== false) { @@ -179,7 +201,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< Tempo.cache.set(cacheKey, parsedIso); } - const finalInstance = new Tempo(parsedIso, coreOptions); + const finalInstance = new Tempo(parsedIso, tempoConfig); return attachAiMeta(finalInstance, { provider: providerId, diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts index a27d4af7..1bfb84a8 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -123,7 +123,7 @@ export async function recurrenceAI( const sph = options?.sphere || (options?.anchor instanceof Tempo ? options.anchor.config.sphere : undefined) || Tempo.options.sphere; const contextConfig = { timeZone: tz, calendar: cal, locale: loc, sphere: sph }; - const anchorTempo = new Tempo(options?.anchor, contextConfig); + const anchorTempo = new Tempo(options?.anchor as any, contextConfig); const defaultBatchSize = options?.count ?? 5; if (isRRule) { diff --git a/packages/plugins/ai/src/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts index 330c9224..aed395c0 100644 --- a/packages/plugins/ai/src/functions/schedule.ts +++ b/packages/plugins/ai/src/functions/schedule.ts @@ -186,7 +186,7 @@ export async function scheduleAI( || (options?.anchor instanceof Tempo ? options.anchor.tz : undefined) || Tempo.options?.timeZone || 'UTC'; - const anchorTempo = new Tempo(options?.anchor, { timeZone: resolvedTz }); + const anchorTempo = new Tempo(options?.anchor as any, { timeZone: resolvedTz }); const timeZone = options?.timeZone || anchorTempo.tz || 'UTC'; const workingHours: TempoWorkingHours = { start: options?.workingHours?.start ?? '09:00', @@ -401,6 +401,7 @@ export async function scheduleAI( ai: { provider: providerId, confidence, + cached: false, conflictBumped, originalSlot, reasoning, diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts index 357ac12d..1a42b67a 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -11,19 +11,10 @@ export { initAI, resetAI, clearAiCache, getAiRateLimits, getAiProviderRateLimits // AI Function Handlers export { parseAI } from './functions/parse.js'; +export { formatAI } from './functions/format.js'; +export { extractAI } from './functions/extract.js'; export { recurrenceAI } from './functions/recurrence.js'; export { scheduleAI } from './functions/schedule.js'; -export { contextAI } from './functions/context.js'; export { diffAI } from './functions/diff.js'; -export { formatAI } from './functions/format.js'; - -/* - * ============================================================================ - * Upcoming AI Function Exports (Scaffolded for Future Releases) - * ============================================================================ - * The following exports lay the groundwork for expanding tempo-plugin-ai. - * Uncomment these exports as their implementations are finalized. - */ +export { contextAI } from './functions/context.js'; -// /** Scans unstructured text and extracts embedded temporal entities & events */ -// export { extractAI, type TempoAiExtractResult, type TempoExtractedEvent, type TempoEvent, type AiExtractOptions } from './functions/extract.js'; diff --git a/packages/plugins/ai/src/types/common.type.ts b/packages/plugins/ai/src/types/base.type.ts similarity index 51% rename from packages/plugins/ai/src/types/common.type.ts rename to packages/plugins/ai/src/types/base.type.ts index e8d27d0c..bce9747a 100644 --- a/packages/plugins/ai/src/types/common.type.ts +++ b/packages/plugins/ai/src/types/base.type.ts @@ -1,24 +1,90 @@ import type { Tempo } from '@magmacomputing/tempo'; import type { AiMode } from '../core/config.js'; +/** + * Universal date-time input representation accepted across AI operations. + * Accepts any native Tempo instance, Temporal object, ISO string, Date, timestamp, or Tempo.DateTime. + */ +export type TempoDateInput = Tempo | Tempo.DateTime | (Record & { readonly isValid?: boolean }); + +/** + * ## AiBaseOptions + * Fundamental execution, caching, timeout, and dispatch routing options + * accepted by all AI plugin functions. + */ +export interface AiBaseOptions { + /** If true, bypasses cache to force a fresh LLM fetch */ + force?: boolean | undefined; + /** If false, disables reading and writing to cache */ + cache?: boolean | undefined; + /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */ + cacheAdapter?: AiCacheAdapter | undefined; + /** Optional TTL override in milliseconds for cached result */ + ttl?: number | undefined; + /** If true, logs prompt context and LLM payloads to console */ + debug?: boolean | undefined; + /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` | `AiMode.Hedged` | `AiMode.RoundRobin` | `AiMode.Adaptive` or string literal) */ + mode?: AiMode | undefined; + /** Per-request provider configuration overrides */ + providers?: AiProvider[] | undefined; + /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */ + minConfidence?: number | undefined; + /** Optional request timeout in milliseconds (overrides provider and global timeout) */ + timeout?: number | undefined; + /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */ + hedgeDelay?: number | undefined; + /** If true, returns an array containing both successful results and TempoAiErrors instead of throwing */ + softErrors?: boolean | undefined; +} + +/** + * ## AiDateContextOptions + * Base options for operations requiring relative anchor dates, timezone, and calendar grounding. + */ +export interface AiDateContextOptions extends AiBaseOptions { + /** Reference anchor date for relative calculations (defaults to current time). */ + anchor?: TempoDateInput | undefined; + /** Target IANA timezone. */ + timeZone?: string | undefined; + /** Target BCP 47 locale or language tag. */ + locale?: string | string[] | undefined; + /** Preferred calendar system (e.g. 'gregory', 'islamic', 'hebrew'). */ + calendar?: string | undefined; + /** Custom regional context (e.g. 'AU-NSW', 'US-CA'). */ + region?: string | undefined; +} + +/** + * ## TempoBaseAiResult + * Standard base result structure shared across all AI operations returning structured metadata. + */ +export interface TempoBaseAiResult { + /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ + confidence: number; + /** Provider ID responsible for processing (e.g., 'groq', 'gemini', 'openai', 'cache', 'native') */ + provider: string; + /** Optional step-by-step reasoning or justification provided by the engine/LLM */ + reasoning?: string | undefined; +} + /** * ## TempoBaseAiMeta - * Fundamental AI resolution telemetry and metadata shared across all AI functions. + * Telemetry and provenance metadata attached to parsed Tempo instances via `.ai`. */ export interface TempoBaseAiMeta { - /** Resolution source ('native', 'cache', or provider ID like 'groq', 'openai', 'ollama') */ + /** Provider identifier that produced the result */ readonly provider: string; /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ readonly confidence: number; - /** Whether the result was retrieved from cache */ + /** Indicates if the result was served from cache */ readonly cached?: boolean | undefined; - /** Step-by-step reasoning or justification provided by the engine/LLM */ + /** Optional step-by-step reasoning or justification from LLM */ readonly reasoning?: string | undefined; - /** Rate limit snapshot returned by the provider HTTP headers for this request */ + /** Upstream rate-limit diagnostic telemetry (if provided by response headers) */ readonly limits?: AiRateLimits | undefined; - /** Raw prompt input (only included when debug: true) */ + /** Raw prompt passed by caller (available in debug mode) */ readonly rawPrompt?: string | undefined; - /** Normalized prompt input (only included when debug: true) */ + /** Normalized prompt used for caching and token counting (available in debug mode) */ readonly normalizedPrompt?: string | undefined; /** Arbitrary provider-specific extra metadata */ readonly [key: string]: any; @@ -34,32 +100,52 @@ export interface AiCacheAdapter { /** Store a value by key with optional TTL in milliseconds */ set(key: string, value: string, ttlMs?: number): Promise | void; /** Delete a specific entry by key */ - delete?(key: string): Promise | void; + delete?(key: string): Promise | boolean | void; /** Clear entries, optionally matching a key prefix */ clear?(prefix?: string): Promise | void; } +/** + * ## AiRateLimits + * Exposes the rate limit and billing statistics returned in the HTTP headers + * of the most recent LLM proxy request. + */ +export interface AiRateLimits { + /** Number of remaining requests allowed in the current time window, or null if unknown */ + remainingRequests: number | null; + /** Number of remaining tokens allowed in the current time window, or null if unknown */ + remainingTokens: number | null; + /** A Tempo instance representing the exact time the limits reset, or null if unknown */ + resetAt: Tempo | null; +} + /** * ## AiProvider - * Represents an LLM provider and its respective BYOK API key. + * Represents an LLM provider and its respective BYOK API key and configuration options. */ export interface AiProvider { /** The provider identifier (e.g., 'groq', 'gemini', 'openai', 'mistral', 'custom') */ id: string; /** The raw API key for the respective provider */ - key: string; + key?: string | undefined; /** Optional custom API endpoint URL (e.g., for local Ollama or Azure OpenAI) */ - url?: string; + url?: string | undefined; /** Optional custom model identifier (e.g., to override the provider's default model) */ - model?: string; + model?: string | undefined; /** Optional parameter name for max token limit (e.g. 'max_tokens' or 'max_completion_tokens') */ tokenParam?: string | undefined; /** Optional cache TTL override in milliseconds for entries produced by this provider */ ttl?: number | undefined; /** Optional HTTP request timeout override in milliseconds for requests to this provider */ timeout?: number | undefined; + /** Optional provider weight for probabilistic routing */ + weight?: number | undefined; + /** Requests-per-minute quota limit for client-side Adaptive dispatch throttling */ + rpm?: number | undefined; + /** Tokens-per-minute quota limit for client-side Adaptive dispatch throttling */ + tpm?: number | undefined; /** Optional LLM parameters (e.g. temperature, max_tokens, top_p) */ - options?: Record; + options?: Record | undefined; } /** @@ -94,17 +180,3 @@ export interface AiConfig { /** If true, logs the spoon-fed LLM context prompt and raw LLM response to the console */ debug?: boolean | undefined; } - -/** - * ## AiRateLimits - * Exposes the rate limit and billing statistics returned in the HTTP headers - * of the most recent LLM proxy request. - */ -export interface AiRateLimits { - /** Number of remaining requests allowed in the current time window, or null if unknown */ - remainingRequests: number | null; - /** Number of remaining tokens allowed in the current time window, or null if unknown */ - remainingTokens: number | null; - /** A Tempo instance representing the exact time the limits reset, or null if unknown */ - resetAt: Tempo | null; -} diff --git a/packages/plugins/ai/src/types/context.type.ts b/packages/plugins/ai/src/types/context.type.ts index ef4f00eb..8e4aa633 100644 --- a/packages/plugins/ai/src/types/context.type.ts +++ b/packages/plugins/ai/src/types/context.type.ts @@ -1,11 +1,10 @@ -import type { AiMode } from '../core/config.js'; -import type { AiCacheAdapter, AiProvider } from './common.type.js'; +import type { AiBaseOptions, TempoBaseAiResult } from './base.type.js'; /** * ## TempoContext * The inferred regional and calendar settings resolved by `contextAI`. */ -export interface TempoContext { +export interface TempoContext extends TempoBaseAiResult { /** Inferred IANA time zone identifier (e.g. 'America/New_York') */ timeZone: string; /** Inferred BCP 47 language/region tag (e.g. 'en-US') */ @@ -14,39 +13,21 @@ export interface TempoContext { calendar: string; /** Inferred hemisphere, constrained strictly to 'north' or 'south' (omitted if unknowable) */ sphere?: 'north' | 'south' | undefined; - /** Confidence score between 0.0 (highly ambiguous) and 1.0 (certain) */ - confidence: number; - /** The identifier of the AI provider that successfully produced this context */ - provider: string; - /** Step-by-step reasoning explaining the inference */ - reasoning?: string | undefined; } /** * ## AiContextOptions * Configuration options passed to `contextAI(text, options)`. */ -export interface AiContextOptions { - /** If true, bypasses cache to force a fresh LLM fetch */ - force?: boolean | undefined; - /** If false, disables reading and writing to cache */ - cache?: boolean | undefined; - /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */ - cacheAdapter?: AiCacheAdapter | undefined; - /** Optional TTL override in milliseconds for cached result */ - ttl?: number | undefined; - /** If true, logs prompt context and LLM payloads to console */ - debug?: boolean | undefined; - /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` or string literal) */ - mode?: AiMode | undefined; - /** Per-request provider configuration overrides */ - providers?: AiProvider[] | undefined; - /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */ - minConfidence?: number | undefined; - /** Optional request timeout in milliseconds (overrides provider and global timeout) */ - timeout?: number | undefined; - /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */ - hedgeDelay?: number | undefined; +export interface AiContextOptions extends AiBaseOptions { + /** Target timeZone override if evaluating against a specific baseline */ + timeZone?: string | undefined; + /** Target locale override if evaluating against a specific baseline */ + locale?: string | string[] | undefined; + /** Target calendar override if evaluating against a specific baseline */ + calendar?: string | undefined; + /** Target sphere override if evaluating against a specific baseline */ + sphere?: string | undefined; /** Allow extra custom properties */ [key: string]: any; } diff --git a/packages/plugins/ai/src/types/diff.type.ts b/packages/plugins/ai/src/types/diff.type.ts index 7d3c1e7e..9fc3d019 100644 --- a/packages/plugins/ai/src/types/diff.type.ts +++ b/packages/plugins/ai/src/types/diff.type.ts @@ -1,11 +1,10 @@ -import type { AiMode } from '../core/config.js'; -import type { AiCacheAdapter, AiProvider } from './common.type.js'; +import type { AiBaseOptions, TempoBaseAiResult } from './base.type.js'; /** * ## TempoAiDiffResult * The calculated and AI-formatted natural difference between two date-time points. */ -export interface TempoAiDiffResult { +export interface TempoAiDiffResult extends TempoBaseAiResult { /** Human-friendly, contextual narrative text summarizing the difference */ formatted: string; /** Total calendar days between start and end */ @@ -16,12 +15,6 @@ export interface TempoAiDiffResult { businessDays?: number | undefined; /** List of holiday dates (YYYY-MM-DD) encountered within the interval */ holidays?: string[] | undefined; - /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ - confidence: number; - /** Resolution source ('cache' or provider ID like 'groq', 'gemini', 'openai') */ - provider: string; - /** Step-by-step reasoning or justification provided by the engine/LLM */ - reasoning?: string | undefined; } /** @@ -38,7 +31,7 @@ export interface DiffPair { * ## AiDiffOptions * Configuration options passed to `diffAI(start, end, prompt, options)`. */ -export interface AiDiffOptions { +export interface AiDiffOptions extends AiBaseOptions { /** Optional target timeZone for relative calculation and business day boundaries */ timeZone?: string | undefined; /** Optional target locale override for language/formatting specific output */ @@ -47,28 +40,6 @@ export interface AiDiffOptions { holidays?: string[] | undefined; /** Expected country/region code (e.g. 'AU', 'US') */ region?: string | undefined; - /** If true, bypasses cache to force a fresh LLM fetch */ - force?: boolean | undefined; - /** If false, disables reading and writing to cache */ - cache?: boolean | undefined; - /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */ - cacheAdapter?: AiCacheAdapter | undefined; - /** Optional TTL override in milliseconds for cached result */ - ttl?: number | undefined; - /** If true, logs prompt context and LLM payloads to console */ - debug?: boolean | undefined; - /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` | `AiMode.Hedged` | `AiMode.RoundRobin` | `AiMode.Adaptive` or string literal) */ - mode?: AiMode | undefined; - /** Per-request provider configuration overrides */ - providers?: AiProvider[] | undefined; - /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */ - minConfidence?: number | undefined; - /** If true, returns TempoAiError into array index position instead of rejecting batch */ - softErrors?: boolean | undefined; - /** Optional request timeout in milliseconds (overrides provider and global timeout) */ - timeout?: number | undefined; - /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */ - hedgeDelay?: number | undefined; /** Allow extra custom properties */ [key: string]: any; } diff --git a/packages/plugins/ai/src/types/extract.type.ts b/packages/plugins/ai/src/types/extract.type.ts new file mode 100644 index 00000000..0547c22f --- /dev/null +++ b/packages/plugins/ai/src/types/extract.type.ts @@ -0,0 +1,44 @@ +import type { Tempo } from '@magmacomputing/tempo'; +import type { AiDateContextOptions, TempoBaseAiResult } from './base.type.js'; + +/** + * Categorical classifications for extracted calendar events and temporal entities. + */ +export type TempoEventType = 'event' | 'deadline' | 'reminder' | 'point' | 'interval' | string; + +/** + * ## TempoExtractedEvent + * A single temporal entity or calendar event extracted from unstructured text. + */ +export interface TempoExtractedEvent { + /** Human-readable event title or description */ + label: string; + /** Start date-time of the event as a native Tempo instance */ + start: Tempo; + /** Optional end date-time of the event as a native Tempo instance */ + end?: Tempo | undefined; + /** Entity category classification */ + type: TempoEventType; + /** The exact text snippet/substring extracted from the source input */ + rawText?: string | undefined; + /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ + confidence: number; +} + +/** + * ## TempoAiExtractResult + * The structured result returned by `extractAI` containing all extracted calendar events. + */ +export interface TempoAiExtractResult extends TempoBaseAiResult { + /** Array of extracted events and temporal entities */ + events: TempoExtractedEvent[]; +} + +/** + * ## AiExtractOptions + * Configuration options passed to `extractAI(text, options)`. + */ +export interface AiExtractOptions extends AiDateContextOptions { + /** Optional category filters to guide event identification (e.g. ['meeting', 'deadline']) */ + categories?: string[] | undefined; +} diff --git a/packages/plugins/ai/src/types/format.type.ts b/packages/plugins/ai/src/types/format.type.ts index b284c7b2..92be2c0f 100644 --- a/packages/plugins/ai/src/types/format.type.ts +++ b/packages/plugins/ai/src/types/format.type.ts @@ -1,63 +1,36 @@ -import type { Tempo } from '@magmacomputing/tempo'; -import type { AiMode } from '../core/config.js'; -import type { AiCacheAdapter, AiProvider } from './common.type.js'; +import type { AiDateContextOptions, TempoBaseAiResult, TempoDateInput } from './base.type.js'; + +export type { TempoDateInput }; /** - * ## TempoDateInput - * Flexible date-time input representation accepted by `formatAI`. - * Supports `Tempo` instances, `Date`, ISO strings, timestamps, and TC39 `Temporal` objects. + * ## TempoAiFormatResult + * Structured contextual narrative formatting result returned by `formatAI`. */ -export type TempoDateInput = Tempo | Date | string | number | bigint | object; - -export interface FormatItem { - /** Date-time instance, Temporal object, or string to format. */ - date: TempoDateInput; - /** Prompt instructions guiding the output narrative. */ - prompt?: string | undefined; -} - -export interface TempoAiFormatResult { - /** Formatted narrative string. */ +export interface TempoAiFormatResult extends TempoBaseAiResult { + /** Human-friendly, contextual narrative text summarizing the date-time */ formatted: string; - /** Confidence score between 0.0 and 1.0. */ - confidence: number; - /** ID of the provider that fulfilled the request (or 'cache'). */ - provider: string; - /** Optional step-by-step rationale from the LLM. */ - reasoning?: string | undefined; } -export interface AiFormatOptions { - /** Reference anchor date for relative calculations (defaults to now). */ - anchor?: TempoDateInput | undefined; - /** Target IANA timezone (defaults to Tempo instance timezone or global options). */ - timeZone?: string | undefined; - /** Target BCP 47 locale or language tag (defaults to global options or 'en-US'). */ - locale?: string | string[] | undefined; - /** Desired narrative tone or formatting style hint (e.g. 'casual', 'formal', 'compact', 'countdown'). */ +/** + * ## AiFormatOptions + * Configuration options passed to `formatAI(date, prompt, options)`. + */ +export interface AiFormatOptions extends AiDateContextOptions { + /** Desired formatting style or tone (e.g., 'casual', 'formal', 'concise', 'relative') */ style?: string | undefined; - /** Custom regional context (e.g. 'AU-NSW', 'US-CA'). */ - region?: string | undefined; - /** If true, bypasses cache to force a fresh LLM fetch */ - force?: boolean | undefined; - /** If false, disables reading and writing to cache */ - cache?: boolean | undefined; - /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */ - cacheAdapter?: AiCacheAdapter | undefined; - /** Optional TTL override in milliseconds for cached result */ - ttl?: number | undefined; - /** If true, logs prompt context and LLM payloads to console */ - debug?: boolean | undefined; - /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` | `AiMode.Hedged` | `AiMode.RoundRobin` | `AiMode.Adaptive` or string literal) */ - mode?: AiMode | undefined; - /** Per-request provider configuration overrides */ - providers?: AiProvider[] | undefined; - /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */ - minConfidence?: number | undefined; - /** Optional request timeout in milliseconds for this operation */ - timeout?: number | undefined; - /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged */ - hedgeDelay?: number | undefined; - /** If true, returns an array containing both successful results and TempoAiErrors instead of throwing */ - softErrors?: boolean | undefined; + /** Optional max concurrent provider requests for batch formatting (defaults to 4) */ + concurrency?: number | undefined; +} + +/** + * ## FormatItem + * Input item for batch date formatting requests. + */ +export interface FormatItem { + /** Target date-time input to format */ + date: TempoDateInput; + /** Optional specific prompt/question for this item */ + prompt?: string | undefined; + /** Per-item option overrides */ + options?: AiFormatOptions | undefined; } diff --git a/packages/plugins/ai/src/types/index.ts b/packages/plugins/ai/src/types/index.ts index f35d3bc9..8e7c2b27 100644 --- a/packages/plugins/ai/src/types/index.ts +++ b/packages/plugins/ai/src/types/index.ts @@ -1,8 +1,8 @@ -export * from './common.type.js'; +export * from './base.type.js'; export * from './parse.type.js'; export * from './recurrence.type.js'; export * from './schedule.type.js'; export * from './context.type.js'; export * from './diff.type.js'; export * from './format.type.js'; - +export * from './extract.type.js'; diff --git a/packages/plugins/ai/src/types/parse.type.ts b/packages/plugins/ai/src/types/parse.type.ts index 045e3ebe..81cb84d1 100644 --- a/packages/plugins/ai/src/types/parse.type.ts +++ b/packages/plugins/ai/src/types/parse.type.ts @@ -1,6 +1,4 @@ -import type { Tempo } from '@magmacomputing/tempo'; -import type { AiMode } from '../core/config.js'; -import type { AiCacheAdapter, AiProvider, TempoBaseAiMeta } from './common.type.js'; +import type { AiDateContextOptions, TempoBaseAiMeta } from './base.type.js'; declare module '@magmacomputing/tempo' { interface Tempo { @@ -24,46 +22,13 @@ export interface TempoParseAiMeta extends TempoBaseAiMeta { readonly rawIso?: string | undefined; } -/** Backward-compatible alias for TempoParseAiMeta */ -export type TempoAiMeta = TempoParseAiMeta; - /** * ## AiParseOptions * Options passed to `parseAI(input, options)`. */ -export interface AiParseOptions { - /** Reference anchor date/time instance or string */ - anchor?: Tempo | Date | string | number | undefined; - /** Target timeZone override */ - timeZone?: string; - /** Target calendar override */ - calendar?: string; - /** Target locale override */ - locale?: string | string[]; +export interface AiParseOptions extends AiDateContextOptions { /** Target sphere override */ - sphere?: string; - /** If true, bypasses cache and native parsing to force an LLM fetch */ - force?: boolean; - /** If false, disables reading and writing to cache */ - cache?: boolean; - /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */ - cacheAdapter?: AiCacheAdapter; - /** Optional TTL override in milliseconds for cached result */ - ttl?: number; - /** If true, logs prompt context and LLM payloads to console */ - debug?: boolean; - /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` or string literal) */ - mode?: AiMode; - /** Per-request provider configuration overrides */ - providers?: AiProvider[]; - /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */ - minConfidence?: number; - /** If true, places TempoAiError into array index position instead of rejecting batch */ - softErrors?: boolean; - /** Optional request timeout in milliseconds (overrides provider and global timeout) */ - timeout?: number; - /** Optional delay in milliseconds before initiating speculative hedging in AiMode.Hedged (default: 800ms) */ - hedgeDelay?: number; + sphere?: string | undefined; /** Allow extra options */ [key: string]: any; } diff --git a/packages/plugins/ai/src/types/recurrence.type.ts b/packages/plugins/ai/src/types/recurrence.type.ts index cb9dc5c5..e53ad281 100644 --- a/packages/plugins/ai/src/types/recurrence.type.ts +++ b/packages/plugins/ai/src/types/recurrence.type.ts @@ -1,4 +1,5 @@ import type { Tempo } from '@magmacomputing/tempo'; +import type { TempoBaseAiResult, TempoDateInput } from './base.type.js'; import type { AiParseOptions } from './parse.type.js'; /** @@ -7,20 +8,20 @@ import type { AiParseOptions } from './parse.type.js'; */ export interface TempoRecurrenceOptions extends AiParseOptions { /** Start date/time window for occurrence expansion */ - after?: Tempo | Date | string | number | undefined; + after?: TempoDateInput | undefined; /** End date/time window for occurrence expansion */ - before?: Tempo | Date | string | number | undefined; + before?: TempoDateInput | undefined; /** Number of occurrences to pull per batch (default: 5) */ - count?: number; + count?: number | undefined; /** Preferred BCP 47 locale tag for summary output (e.g. 'en-US', 'fr-FR', 'es-ES') */ - locale?: string; + locale?: string | undefined; } /** * ## TempoRecurrenceResult * Structured multi-directional recurrence result returned by `recurrenceAI`. */ -export interface TempoRecurrenceResult { +export interface TempoRecurrenceResult extends TempoBaseAiResult { /** Standard RFC 5545 RRULE string (e.g. 'FREQ=WEEKLY;BYDAY=TU') */ rrule: string; /** Localized human-friendly summary of the schedule (e.g. 'Every Tuesday at 15:00') */ @@ -33,10 +34,4 @@ export interface TempoRecurrenceResult { take(count?: number): Tempo[]; /** Lazy generator yielding Tempo instances on demand */ [Symbol.iterator](): Generator; - /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ - confidence: number; - /** Provider ID responsible for processing or 'rrule-parser' for native RRULE inputs */ - provider: string; - /** Reasoning / explanation of the recurrence pattern */ - reasoning?: string | undefined; } diff --git a/packages/plugins/ai/src/types/schedule.type.ts b/packages/plugins/ai/src/types/schedule.type.ts index c45c730f..727289a5 100644 --- a/packages/plugins/ai/src/types/schedule.type.ts +++ b/packages/plugins/ai/src/types/schedule.type.ts @@ -1,6 +1,6 @@ import type { Tempo, Interval } from '@magmacomputing/tempo'; import type { DayKey } from '@magmacomputing/tempo/library'; -import type { TempoBaseAiMeta } from './common.type.js'; +import type { TempoBaseAiMeta, TempoDateInput } from './base.type.js'; import type { AiParseOptions } from './parse.type.js'; /** @@ -9,13 +9,13 @@ import type { AiParseOptions } from './parse.type.js'; */ export interface TempoWorkingHours { /** Start time of working day in HH:mm format (default: '09:00') */ - start?: string; + start?: string | undefined; /** End time of working day in HH:mm format (default: '17:00') */ - end?: string; + end?: string | undefined; /** Active working weekdays (1 = Monday, ... 7 = Sunday; or tokens like 'MO', 'MON'; default: [1, 2, 3, 4, 5]) */ - days?: Array; + days?: Array | undefined; /** Target timeZone for working hours (defaults to anchor or options timeZone) */ - timeZone?: string; + timeZone?: string | undefined; } /** @@ -35,21 +35,21 @@ export interface TempoInterval { */ export interface TempoScheduleOptions extends AiParseOptions { /** Target slot duration in minutes (if not explicitly specified in prompt) */ - durationMinutes?: number; + durationMinutes?: number | undefined; /** Working hours configuration for slot resolution */ - workingHours?: TempoWorkingHours; + workingHours?: TempoWorkingHours | undefined; /** Existing booked events or busy intervals to avoid */ - events?: Array<{ start: any; end: any; title?: string }> | Array>; + events?: Array<{ start: any; end: any; title?: string }> | Array> | undefined; /** Alias for events */ - intervals?: Array<{ start: any; end: any; title?: string }> | Array>; + intervals?: Array<{ start: any; end: any; title?: string }> | Array> | undefined; /** Search window start constraint */ - after?: any; + after?: TempoDateInput | undefined; /** Search window end constraint */ - before?: any; + before?: TempoDateInput | undefined; /** Preferred slot positioning ('earliest' | 'latest' | 'morning' | 'afternoon' | string) */ - preference?: string; + preference?: string | undefined; /** Number of alternative slots to return if requesting multiple options */ - count?: number; + count?: number | undefined; } /** @@ -94,4 +94,3 @@ export interface TempoScheduleResult extends Interval, TempoScheduleMeta /** Resolved end boundary as a Tempo instance */ end: Tempo; } - diff --git a/packages/plugins/ai/test/context.test.ts b/packages/plugins/ai/test/context.test.ts index da4a438f..4b44151d 100644 --- a/packages/plugins/ai/test/context.test.ts +++ b/packages/plugins/ai/test/context.test.ts @@ -76,7 +76,7 @@ describe('AI Context Plugin (contextAI)', () => { const cal = String(Tempo.options.calendar); const loc = String(Array.isArray(Tempo.options.locale) ? Tempo.options.locale[0] : Tempo.options.locale); const sph = String(Tempo.options.sphere || 'north'); - Tempo.cache.set(`context::cached prompt::${tz}::${loc}::${cal}::${sph}`, JSON.stringify({ + Tempo.cache.set(`ai:context::cached prompt::${tz}::${loc}::${cal}::${sph}`, JSON.stringify({ timeZone: 'Europe/London', locale: 'en-GB', calendar: 'gregory', @@ -230,7 +230,7 @@ describe('AI Context Plugin (contextAI)', () => { const loc = String(Array.isArray(Tempo.options.locale) ? Tempo.options.locale[0] : Tempo.options.locale); const sph = String(Tempo.options.sphere || 'north'); - Tempo.cache.set(`context::low confidence::${tz}::${loc}::${cal}::${sph}`, JSON.stringify({ + Tempo.cache.set(`ai:context::low confidence::${tz}::${loc}::${cal}::${sph}`, JSON.stringify({ timeZone: 'Europe/Berlin', locale: 'de-DE', calendar: 'gregory', @@ -293,4 +293,31 @@ describe('AI Context Plugin (contextAI)', () => { await expect(contextAI('Berlin tech hub')).rejects.toThrow(/invalid confidence score/i); }); + + it('should return a secure() protected immutable object supporting .toJSON() clone', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + timeZone: 'America/Chicago', + locale: 'en-US', + calendar: 'gregory', + sphere: 'north', + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const result = await contextAI('Chicago downtown'); + expect(() => { + (result as any).timeZone = 'America/New_York'; + }).toThrow(TypeError); + + const clone = (result as any).toJSON(); + expect(clone.timeZone).toBe('America/Chicago'); + clone.timeZone = 'America/New_York'; + expect(clone.timeZone).toBe('America/New_York'); + }); }); diff --git a/packages/plugins/ai/test/diff.test.ts b/packages/plugins/ai/test/diff.test.ts index f71cad33..4471fedd 100644 --- a/packages/plugins/ai/test/diff.test.ts +++ b/packages/plugins/ai/test/diff.test.ts @@ -302,4 +302,31 @@ describe('AI Diff Plugin (diffAI)', () => { const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); expect(requestBody.messages[0].content).toContain('(Europe/London)'); }); + + it('should return a secure() protected immutable object supporting .toJSON() clone', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: '3 business days difference', + days: 3, + hours: 72, + businessDays: 3, + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const result = await diffAI('2026-08-03', '2026-08-06', 'summarize'); + expect(() => { + (result as any).formatted = 'hacked'; + }).toThrow(TypeError); + + const clone = (result as any).toJSON(); + expect(clone.formatted).toBe('3 business days difference'); + clone.formatted = 'modified'; + expect(clone.formatted).toBe('modified'); + }); }); diff --git a/packages/plugins/ai/test/dispatch.test.ts b/packages/plugins/ai/test/dispatch.test.ts index 5df9f94b..f73d4a34 100644 --- a/packages/plugins/ai/test/dispatch.test.ts +++ b/packages/plugins/ai/test/dispatch.test.ts @@ -369,6 +369,59 @@ describe('AI Dispatch Helper (executeWithMode)', () => { }); }); + describe('Global Telemetry Cooldown Filtering', () => { + it('should skip exhausted cooldown providers in Fallback mode', async () => { + const resetFuture = new Tempo().add('2 minutes'); + _state.providerLimits.set('provider-a', { + remainingRequests: 0, + remainingTokens: 0, + resetAt: resetFuture, + }); + + const task = vi.fn().mockImplementation(async (provider: AiProvider) => { + return { data: { id: provider.id }, providerId: provider.id, confidence: 0.95 }; + }); + + const winner = await executeWithMode(AiMode.Fallback, mockProviders, task); + expect(winner.providerId).toBe('provider-b'); + expect(task).not.toHaveBeenCalledWith(mockProviders[0]); + }); + + it('should skip exhausted cooldown providers in Race mode', async () => { + const resetFuture = new Tempo().add('2 minutes'); + _state.providerLimits.set('provider-a', { + remainingRequests: 0, + remainingTokens: 0, + resetAt: resetFuture, + }); + + const task = vi.fn().mockImplementation(async (provider: AiProvider) => { + return { data: { id: provider.id }, providerId: provider.id, confidence: 0.95 }; + }); + + const winner = await executeWithMode(AiMode.Race, mockProviders, task); + expect(['provider-b', 'provider-c']).toContain(winner.providerId); + expect(task).not.toHaveBeenCalledWith(mockProviders[0], expect.anything()); + }); + + it('should skip exhausted cooldown providers in Hedged mode', async () => { + const resetFuture = new Tempo().add('2 minutes'); + _state.providerLimits.set('provider-a', { + remainingRequests: 0, + remainingTokens: 0, + resetAt: resetFuture, + }); + + const task = vi.fn().mockImplementation(async (provider: AiProvider) => { + return { data: { id: provider.id }, providerId: provider.id, confidence: 0.95 }; + }); + + const winner = await executeWithMode(AiMode.Hedged, mockProviders, task, { hedgeDelay: 100 }); + expect(winner.providerId).toBe('provider-b'); + expect(task).not.toHaveBeenCalledWith(mockProviders[0], expect.anything()); + }); + }); + describe('Invalid Modes', () => { it('should throw TempoAiError with status 400 for invalid mode', async () => { const task = vi.fn(); diff --git a/packages/plugins/ai/test/extract.test.ts b/packages/plugins/ai/test/extract.test.ts new file mode 100644 index 00000000..cfa326bf --- /dev/null +++ b/packages/plugins/ai/test/extract.test.ts @@ -0,0 +1,410 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { + extractAI, + initAI, + resetAI, + TempoAiError, + AiMode, + type TempoAiExtractResult, + type AiCacheAdapter, +} from '../src/index.js'; + +describe('AI Extract Plugin (extractAI)', () => { + beforeEach(async () => { + resetAI(); + vi.spyOn(console, 'warn').mockImplementation(() => { }); + vi.spyOn(console, 'error').mockImplementation(() => { }); + vi.spyOn(console, 'log').mockImplementation(() => { }); + await initAI({ remoteConfigUrl: false, providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] }); + }); + + afterEach(() => { + resetAI(); + vi.restoreAllMocks(); + }); + + it('should scan unstructured text and extract multiple temporal events with native Tempo instances', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + events: [ + { + label: 'Sprint Planning', + start: '2026-08-11T10:00:00', + end: '2026-08-11T11:30:00', + type: 'interval', + rawText: 'tomorrow from 10:00 AM to 11:30 AM', + confidence: 0.98, + }, + { + label: 'Q3 Deliverables Deadline', + start: '2026-08-14T17:00:00', + end: null, + type: 'deadline', + rawText: 'due next Friday by 5:00 PM', + confidence: 0.95, + }, + ], + confidence: 0.96, + reasoning: 'Extracted 1 scheduled meeting interval and 1 project deadline.', + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const text = "Hi team, let's meet tomorrow from 10:00 AM to 11:30 AM for Sprint Planning. Also, all Q3 deliverables are due next Friday by 5:00 PM."; + const anchor = new Tempo('2026-08-10T09:00:00Z'); + + const result = await extractAI(text, { anchor, timeZone: 'UTC' }); + + expect(result).toBeDefined(); + expect(result.events).toHaveLength(2); + expect(result.confidence).toBe(0.96); + expect(result.provider).toBe('groq'); + expect(result.reasoning).toContain('scheduled meeting'); + + const event1 = result.events[0]; + expect(event1.label).toBe('Sprint Planning'); + expect(Tempo.isTempo(event1.start)).toBe(true); + expect(event1.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 10:00'); + expect(Tempo.isTempo(event1.end)).toBe(true); + expect(event1.end?.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 11:30'); + expect(event1.type).toBe('interval'); + expect(event1.rawText).toBe('tomorrow from 10:00 AM to 11:30 AM'); + + const event2 = result.events[1]; + expect(event2.label).toBe('Q3 Deliverables Deadline'); + expect(Tempo.isTempo(event2.start)).toBe(true); + expect(event2.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-14 17:00'); + expect(event2.end).toBeUndefined(); + expect(event2.type).toBe('deadline'); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + const systemPrompt = requestBody.messages[0].content; + expect(systemPrompt).toContain('Grounding Context:'); + expect(systemPrompt).toContain('Reference Anchor Date-Time: 2026-08-10T09:00:00 (UTC)'); + expect(systemPrompt).toContain('Reference Day of Week: Monday'); + }); + + it('should return empty events array when text contains no temporal entities', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + events: [], + confidence: 1.0, + reasoning: 'No temporal expressions or events were detected in the input text.', + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const text = 'The quick brown fox jumps over the lazy dog. Just some generic prose without any dates.'; + const result = await extractAI(text); + + expect(result).toBeDefined(); + expect(result.events).toHaveLength(0); + expect(result.confidence).toBe(1.0); + expect(result.provider).toBe('groq'); + }); + + it('should include category filter in grounding context when specified', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + events: [ + { + label: 'Product Demo', + start: '2026-08-12T14:00:00', + type: 'point', + confidence: 0.95, + }, + ], + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const text = 'Product demo on Wednesday at 2pm. Flights booked for Thursday at 6am.'; + const result = await extractAI(text, { + categories: ['meeting', 'demo'], + region: 'US-NY', + }); + + expect(result.events).toHaveLength(1); + expect(result.events[0].label).toBe('Product Demo'); + + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + const systemPrompt = requestBody.messages[0].content; + expect(systemPrompt).toContain('Filter Categories: demo, meeting'); + expect(systemPrompt).toContain('Region Context: US-NY'); + }); + + it('should write to and read from multi-tier cache with Tempo instance rehydration', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + events: [ + { + label: 'Dentist Appointment', + start: '2026-08-15T09:00:00', + end: '2026-08-15T10:00:00', + type: 'interval', + confidence: 0.99, + }, + ], + confidence: 0.99, + reasoning: 'Extracted dentist appointment.', + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const cacheStore = new Map(); + const customAdapter: AiCacheAdapter = { + get: vi.fn(async (key: string) => cacheStore.get(key)), + set: vi.fn(async (key: string, val: string) => { + cacheStore.set(key, val); + }), + }; + + const text = 'Dentist appointment on August 15 from 9am to 10am.'; + const anchor = new Tempo('2026-08-01T00:00:00Z'); + + // First call - should query provider and populate cache + const result1 = await extractAI(text, { + anchor, + cacheAdapter: customAdapter, + timeZone: 'UTC', + }); + expect(result1.provider).toBe('groq'); + expect(result1.events).toHaveLength(1); + expect(Tempo.isTempo(result1.events[0].start)).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(customAdapter.set).toHaveBeenCalledTimes(1); + + // Second call - should return rehydrated cache + const result2 = await extractAI(text, { + anchor, + cacheAdapter: customAdapter, + timeZone: 'UTC', + }); + expect(result2.provider).toBe('cache'); + expect(result2.events).toHaveLength(1); + expect(Tempo.isTempo(result2.events[0].start)).toBe(true); + expect(result2.events[0].start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-15 09:00'); + expect(Tempo.isTempo(result2.events[0].end)).toBe(true); + expect(result2.events[0].end?.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-15 10:00'); + expect(fetchSpy).toHaveBeenCalledTimes(1); // No new network call + }); + + it('should support force: true and cache: false bypass options', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const mockResponse = () => new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + events: [ + { + label: 'One-on-One', + start: '2026-08-12T15:00:00', + type: 'point', + confidence: 0.95, + }, + ], + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + + fetchSpy.mockResolvedValueOnce(mockResponse()).mockResolvedValueOnce(mockResponse()); + + const text = '1-on-1 catchup on Wednesday at 3pm.'; + const anchor = new Tempo('2026-08-10T09:00:00Z'); + + await extractAI(text, { anchor, timeZone: 'UTC' }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // force: true should make a new fetch + const forcedResult = await extractAI(text, { anchor, timeZone: 'UTC', force: true }); + expect(forcedResult.provider).toBe('groq'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('should reject invalid text and anchor inputs with TempoAiError(400)', async () => { + await expect(extractAI('')) + .rejects.toThrow(new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400)); + + await expect(extractAI(' ')) + .rejects.toThrow(new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400)); + + await expect(extractAI(null as any)) + .rejects.toThrow(new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400)); + + await expect(extractAI('some text', { anchor: 'invalid-anchor-date' })) + .rejects.toThrow(/Invalid anchor date provided to extractAI/i); + }); + + it('should validate minConfidence and reject non-finite and out-of-range thresholds before cache read or provider calls', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const customAdapter: AiCacheAdapter = { + get: vi.fn(async () => undefined), + set: vi.fn(async () => {}), + }; + + // Non-finite + await expect(extractAI('some text', { minConfidence: NaN, cacheAdapter: customAdapter })) + .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "NaN"', 400)); + + await expect(extractAI('some text', { minConfidence: Infinity, cacheAdapter: customAdapter })) + .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "Infinity"', 400)); + + // Out-of-bounds + await expect(extractAI('some text', { minConfidence: -0.5, cacheAdapter: customAdapter })) + .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "-0.5"', 400)); + + await expect(extractAI('some text', { minConfidence: 1.2, cacheAdapter: customAdapter })) + .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "1.2"', 400)); + + expect(customAdapter.get).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should throw TempoAiError(422) when extracted confidence is below minConfidence', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + events: [{ label: 'Vague Meeting', start: '2026-08-11T10:00:00', type: 'point', confidence: 0.5 }], + confidence: 0.5, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + await expect(extractAI('maybe meet sometime next week', { minConfidence: 0.8 })) + .rejects.toThrow(/extractAI confidence \(0.5\) is below the required threshold of 0.8/i); + }); + + it('should throw TempoAiError(400) when no providers are configured', async () => { + resetAI(); + await expect(extractAI('Meeting tomorrow at 10am')) + .rejects.toThrow(new TempoAiError('No AI providers configured. Please call initAI().', 400)); + }); + + it('should support multi-provider race execution mode', async () => { + let slowWasAborted = false; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockImplementation(async (_url, init) => { + const body = JSON.parse(init?.body as string); + const signal = init?.signal as AbortSignal | undefined; + if (body.model === 'fast-model') { + return new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + events: [{ label: 'Fast Event', start: '2026-08-12T10:00:00', type: 'point', confidence: 0.95 }], + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + + return new Promise((_resolve, reject) => { + if (signal?.aborted) { + slowWasAborted = true; + reject(new DOMException('Aborted', 'AbortError')); + return; + } + signal?.addEventListener('abort', () => { + slowWasAborted = true; + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + }); + + const result = await extractAI('Team sync on Wednesday at 10am', { + mode: 'race', + anchor: new Tempo('2026-08-10T00:00:00Z'), + timeZone: 'UTC', + providers: [ + { id: 'slow-provider', key: 'key-slow', url: 'https://api.openai.com/v1/chat/completions', model: 'slow-model' }, + { id: 'fast-provider', key: 'key-fast', url: 'https://api.groq.com/v1/chat/completions', model: 'fast-model' }, + ], + }); + + expect(result.provider).toBe('fast-provider'); + expect(result.events[0].label).toBe('Fast Event'); + expect(slowWasAborted).toBe(true); + }); + + it('should support batch array processing with softErrors', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + events: [{ label: 'Event 1', start: '2026-08-11T09:00:00', type: 'point', confidence: 0.95 }], + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + .mockResolvedValueOnce(new Response('Internal Error', { status: 500 })); + + const inputs = ['Meeting tomorrow at 9am', 'Another event']; + const results = await extractAI(inputs, { + softErrors: true, + anchor: new Tempo('2026-08-10T00:00:00Z'), + timeZone: 'UTC', + }); + + expect(Array.isArray(results)).toBe(true); + expect(results).toHaveLength(2); + + const successResult = results[0] as TempoAiExtractResult; + expect(successResult.events).toHaveLength(1); + expect(successResult.events[0].label).toBe('Event 1'); + + const errorResult = results[1] as TempoAiError; + expect(errorResult).toBeInstanceOf(TempoAiError); + expect(errorResult.status).toBe(500); + }); + + it('should return a secure() protected immutable object supporting .toJSON() clone', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + events: [{ label: 'Conference', start: '2026-08-15T09:00:00', type: 'point', confidence: 0.95 }], + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const result = await extractAI('Conference on August 15 at 9am'); + expect(() => { + (result as any).confidence = 0.5; + }).toThrow(TypeError); + + const clone = (result as any).toJSON(); + expect(clone.confidence).toBe(0.95); + clone.confidence = 0.5; + expect(clone.confidence).toBe(0.5); + }); +}); diff --git a/packages/plugins/ai/test/format.test.ts b/packages/plugins/ai/test/format.test.ts index d4829355..87d2a1ca 100644 --- a/packages/plugins/ai/test/format.test.ts +++ b/packages/plugins/ai/test/format.test.ts @@ -1,8 +1,10 @@ import { Tempo } from '@magmacomputing/tempo'; -import { formatAI, initAI, TempoAiError, type TempoAiFormatResult, type AiCacheAdapter } from '../src/index.js'; +import { formatAI, initAI, resetAI, TempoAiError, type TempoAiFormatResult, type AiCacheAdapter } from '../src/index.js'; describe('AI Format Plugin (formatAI)', () => { beforeEach(async () => { + resetAI(); + Tempo.cache.clear(); vi.spyOn(console, 'warn').mockImplementation(() => { }); vi.spyOn(console, 'error').mockImplementation(() => { }); vi.spyOn(console, 'log').mockImplementation(() => { }); @@ -10,6 +12,8 @@ describe('AI Format Plugin (formatAI)', () => { }); afterEach(() => { + resetAI(); + Tempo.cache.clear(); vi.restoreAllMocks(); }); @@ -98,6 +102,31 @@ describe('AI Format Plugin (formatAI)', () => { expect(promptContext).toContain('Regional Context: FR-IDF'); }); + it('should normalize empty array locale to system default or en-US without stringifying undefined', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: 'Formatted with default locale', + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const target = '2026-08-07T17:00:00Z'; + const result = await formatAI(target, 'test prompt', { + locale: [], + }); + + expect(result.formatted).toBe('Formatted with default locale'); + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + const promptContext = requestBody.messages[0].content; + expect(promptContext).toMatch(/Target Locale: [a-zA-Z-]+/); + expect(promptContext).not.toContain('undefined'); + }); + it('should check cache and skip network fetch on cache hits', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ @@ -187,6 +216,46 @@ describe('AI Format Plugin (formatAI)', () => { .rejects.toThrow(/Invalid anchor date provided to formatAI/i); }); + it('should reject non-finite and out-of-range minConfidence values before cache read or provider calls', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const customAdapter: AiCacheAdapter = { + get: vi.fn(async () => undefined), + set: vi.fn(async () => {}), + }; + + // Non-finite values + await expect(formatAI('2026-08-07', 'test', { minConfidence: NaN, cacheAdapter: customAdapter })) + .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "NaN"', 400)); + + await expect(formatAI('2026-08-07', 'test', { minConfidence: Infinity, cacheAdapter: customAdapter })) + .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "Infinity"', 400)); + + await expect(formatAI('2026-08-07', 'test', { minConfidence: -Infinity, cacheAdapter: customAdapter })) + .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "-Infinity"', 400)); + + // Out-of-range values + await expect(formatAI('2026-08-07', 'test', { minConfidence: -0.1, cacheAdapter: customAdapter })) + .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "-0.1"', 400)); + + await expect(formatAI('2026-08-07', 'test', { minConfidence: 1.05, cacheAdapter: customAdapter })) + .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "1.05"', 400)); + + // Verify neither cache nor provider fetch was called + expect(customAdapter.get).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should reject invalid configured default minConfidence from initAI', async () => { + await initAI({ + remoteConfigUrl: false, + minConfidence: 1.5, + providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }], + }); + + await expect(formatAI('2026-08-07', 'test')) + .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "1.5"', 400)); + }); + it('should support multi-provider race execution mode', async () => { let slowWasAborted = false; const fetchSpy = vi.spyOn(globalThis, 'fetch'); @@ -257,6 +326,37 @@ describe('AI Format Plugin (formatAI)', () => { expect(results[1]).toBeInstanceOf(TempoAiError); }); + it('should reject with TempoAiError on batch failure when softErrors is false', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: 'Item 1 formatted', + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + .mockResolvedValueOnce(new Response('Server Error', { status: 500 })); + + const items = [ + { date: '2026-08-03', prompt: 'item 1' }, + { date: '2026-08-05', prompt: 'item 2' }, + ]; + + await expect(formatAI(items, { softErrors: false })) + .rejects.toThrow(TempoAiError); + }); + + it('should return an empty array for an empty batch input without provider requests', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const results = await formatAI([]); + expect(results).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it('should honor force: true, cache: false, and ttl override options', async () => { const cacheStore = new Map(); const customAdapter: AiCacheAdapter = { @@ -297,4 +397,29 @@ describe('AI Format Plugin (formatAI)', () => { expect(res3.formatted).toBe('Fresh result'); expect(customAdapter.set).not.toHaveBeenCalled(); }); + + it('should return a secure() protected immutable object supporting .toJSON() clone', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: 'Tomorrow at 5pm', + confidence: 0.95, + reasoning: 'Target is tomorrow.', + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const result = await formatAI('2026-08-03T17:00:00Z', undefined, { anchor: '2026-08-02T17:00:00Z' }); + expect(() => { + (result as any).formatted = 'hacked'; + }).toThrow(TypeError); + + const clone = (result as any).toJSON(); + expect(clone.formatted).toBe('Tomorrow at 5pm'); + clone.formatted = 'modified'; + expect(clone.formatted).toBe('modified'); + }); }); diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json index 14f68039..f5d89227 100644 --- a/packages/tempo/.vitepress/theme/data/catalog.json +++ b/packages/tempo/.vitepress/theme/data/catalog.json @@ -50,8 +50,8 @@ "description": "Tempo community plugin for LLM-powered natural language processing and parsing.", "packageName": "@magmacomputing/tempo-plugin-ai", "plan": "community", - "status": "experimental", - "version": "4.0.0" + "status": "active", + "version": "1.0.0" }, { "id": "ticker", From 10b8207109488d91ebc521dc3350b88cb527029c Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sat, 15 Aug 2026 11:58:59 +1000 Subject: [PATCH 4/7] PR extractAI 1st review --- packages/plugins/ai/CHANGELOG.md | 6 +- packages/plugins/ai/doc/architecture.md | 7 +- packages/plugins/ai/doc/context.md | 7 +- packages/plugins/ai/doc/grounding.md | 18 +- packages/plugins/ai/doc/index.md | 18 +- packages/plugins/ai/doc/init.md | 8 +- packages/plugins/ai/doc/modes.md | 14 +- packages/plugins/ai/doc/parse.md | 2 +- packages/plugins/ai/doc/rate-limits.md | 23 +- packages/plugins/ai/doc/schedule.md | 15 +- packages/plugins/ai/doc/security.md | 150 ++++++++ packages/plugins/ai/src/core/cache.ts | 219 ++++++++++++ packages/plugins/ai/src/core/dispatch.ts | 33 +- packages/plugins/ai/src/core/init.ts | 113 ++---- packages/plugins/ai/src/core/logger.ts | 182 ++++++++++ packages/plugins/ai/src/core/support.ts | 90 +---- packages/plugins/ai/src/functions/context.ts | 35 +- packages/plugins/ai/src/functions/diff.ts | 39 +- packages/plugins/ai/src/functions/extract.ts | 114 ++++-- packages/plugins/ai/src/functions/format.ts | 31 +- packages/plugins/ai/src/functions/parse.ts | 12 +- .../plugins/ai/src/functions/recurrence.ts | 20 +- packages/plugins/ai/src/functions/schedule.ts | 56 ++- packages/plugins/ai/src/index.ts | 5 +- packages/plugins/ai/src/types/extract.type.ts | 2 + .../plugins/ai/src/types/recurrence.type.ts | 2 +- packages/plugins/ai/test/benchmark.spec.ts | 2 +- packages/plugins/ai/test/cache.test.ts | 64 +++- packages/plugins/ai/test/debug.test.ts | 334 ++++++++++++++++++ packages/plugins/ai/test/extract.test.ts | 125 +++++-- packages/plugins/ai/test/format.test.ts | 38 +- packages/plugins/ai/test/parse.test.ts | 8 +- 32 files changed, 1483 insertions(+), 309 deletions(-) create mode 100644 packages/plugins/ai/doc/security.md create mode 100644 packages/plugins/ai/src/core/cache.ts create mode 100644 packages/plugins/ai/src/core/logger.ts create mode 100644 packages/plugins/ai/test/debug.test.ts diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index 1f1864ca..2bdb3c44 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -35,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Cascading Cache TTL Hierarchy**: Implemented strict 4-tier TTL resolution (`options.ttl` > `provider.ttl` > `global config.ttl` > default 1 hour / 3,600,000 ms for `parseAI` or 24 hours / 86,400,000 ms for context/difference handlers) for fine-grained cache entry expiration control on stores enforcing TTL. - **Fail-Open Storage Resilience**: Custom cache adapter errors during `get()` or `set()` operations fail open silently to direct LLM resolution, preserving application request uptime. - **Dynamic Remote Provider Manifest (`loadRemoteManifest`)**: Lazily fetches remote provider defaults (`providers.v1.json`) on initialization with a 1500ms timeout and automatic air-gapped fallback to compiled `DEFAULT_PROVIDERS`. -- **Cache Eviction Synchronization**: Enhanced `clearAiCache()` to flush matching keys and prefixes across both `Tempo.cache` and custom `AiCacheAdapter` storage engines, returning `Promise` to await async adapter eviction. +- **Cache Eviction Synchronization**: Enhanced `aiCache.clear()` to flush matching keys and prefixes across both `Tempo.cache` and custom `AiCacheAdapter` storage engines, returning `Promise` to await async adapter eviction. ### Changed & Hardened - **Consensus Mode TTL Resolution**: Fixed a runtime bug where standard provider TTL lookups failed in Consensus mode due to the synthetic sentinel provider ID (`'consensus'`), which caused lookups on the winning provider array to return undefined. Now reduces over all participating provider configs to select the minimum (most conservative) TTL. @@ -65,11 +65,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Non-Destructive Glossary Appending**: Custom glossaries provided via `initAI({ cache })` are safely appended to `Tempo.cache` as static immortal terms without destructive overrides. - **Silent Native Pre-Parsing & Cache Controls**: `parseAI` attempts fast, zero-latency native `Tempo` resolution and checks `Tempo.cache` before initiating LLM network calls. Supports `cache: false` to bypass cache lookups and `force: true` to force a fresh LLM API request. - **Anchor Instance Reuse & Cache Salting**: Reuses anchor `Tempo` instances to minimize memory allocations and salts cache keys with the anchor's date and system context (`timeZone`, `calendar`, `locale`, `sphere`), preventing stale cache hits across midnight boundaries or context shifts. -- **Resilient Cache Invalidation**: Normalized cache key input (whitespace trimming and case insensitivity) for `clearAiCache` and internal lookups. +- **Resilient Cache Invalidation**: Normalized cache key input (whitespace trimming and case insensitivity) for `aiCache.clear()` and internal lookups. ## [0.1.0] - 2026-07-26 ### Added - Initial scaffolding of the AI natural language parsing plugin. -- Functional exports for `parseAI`, `initAI`, and `clearAiCache`. +- Functional exports for `parseAI`, `initAI`, and `aiCache`. - Initial provider fallback-routing engine supporting HTTP requests to configured LLM provider endpoints. diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md index 5f1a7059..9e7ed782 100644 --- a/packages/plugins/ai/doc/architecture.md +++ b/packages/plugins/ai/doc/architecture.md @@ -197,6 +197,9 @@ export async function POST(req: Request, env?: { GROQ_API_KEY?: string }) { ## 🔒 Security & Privacy Guarantees +> [!TIP] +> For an in-depth breakdown of our automated PII redaction, Smart Debug infrastructure, and tamper-resistant Proxy introspection, see the dedicated **[Security & Privacy Architecture Guide (`security.md`)](./security.md)**. + Whether running directly on backend servers (Node.js, Deno, Bun, Edge Workers) or through a client-side browser proxy, `@magmacomputing/tempo-plugin-ai` enforces strict security and privacy standards: ### 1. Transport Security (HTTPS / TLS) @@ -207,10 +210,10 @@ Temporal processing payloads (dates, times, context snippets, prompts) are proce ### 3. In-Memory Credential Redaction & Immutability * **Automated Key Redaction**: Calling `getAiConfig()` returns a sanitized, deeply read-only snapshot of active configurations with all provider `key` values replaced with `[REDACTED]`, preventing accidental exposure in log files, APM traces, or crash dumps. -* **Frozen Metadata**: All diagnostic metadata attached to `Tempo` instances via `.ai` is deeply frozen with `Object.freeze()` and guarded via runtime `Proxy` wrappers, protecting against direct runtime mutation of the `.ai` metadata. +* **Frozen Metadata**: All diagnostic metadata attached to `Tempo` instances via `.ai` and all structured AI result objects (`TempoAiFormatResult`, `TempoAiExtractResult`, `TempoAiDiffResult`, `TempoScheduleResult`, `TempoRecurrenceResult`, `TempoContext`) are deeply frozen with `Object.freeze()` and guarded via runtime `Proxy` wrappers, protecting against direct runtime mutation. ### 4. Deterministic Schema Guardrails & Confidence Range Verification -All LLM prompts are paired with rigid, machine-verifiable JSON schemas. Responses undergo strict boundary validation, regex parsing, confidence range verification (`0.0` to `1.0`), and ISO verification before any native `Tempo` date object or result payload is instantiated. If an LLM returns malformed, out-of-range, or non-chronological data, the plugin throws a typed `TempoAiError` or triggers automatic fallback rather than silently returning an invalid date. +All LLM prompts are paired with rigid, machine-verifiable JSON schemas. Responses undergo strict boundary validation, regex parsing, confidence range verification (`0.0` to `1.0`), and schema verification before any native `Tempo` date object or structured result payload is instantiated. If an LLM returns malformed, out-of-range, or unparseable data, the plugin throws a typed `TempoAiError` or triggers automatic provider fallback rather than silently propagating corrupt data. ### 5. Partitioned Caching & Fail-Open Storage Resilience * **Strict Cache Key Partitioning**: Caches are namespaced and hashed (`ai:::...`) with timezone, locale, calendar, and anchor date isolation to prevent cross-tenant or cross-regional cache poisoning. diff --git a/packages/plugins/ai/doc/context.md b/packages/plugins/ai/doc/context.md index 6d6175e1..f80773e2 100644 --- a/packages/plugins/ai/doc/context.md +++ b/packages/plugins/ai/doc/context.md @@ -2,6 +2,9 @@ `contextAI()` is designed to analyze unstructured or ambiguous text (e.g. user biographies, locations, or email bodies) and infer their regional configuration settings. It resolves these properties to a standard configuration object containing timezone, locale, calendar system, and hemisphere. +> [!TIP] +> **Smart Debug Telemetry**: Enabling `debug: true` activates operational logs. In production environments (`NODE_ENV === 'production'`), PII (emails, phone numbers, auth tokens) is automatically sanitized and masked in console output and terminal inspections. See the [Security & Privacy Architecture Guide](./security.md). + This is highly useful for user onboarding settings, automatic context mapping for calendar syncs, and geolocating inputs dynamically without maintaining static geographic mapping tables. --- @@ -28,7 +31,7 @@ console.log(context.timeZone); // "Australia/Sydney" console.log(context.locale); // "en-AU" console.log(context.calendar); // "gregory" console.log(context.sphere); // "south" -console.log(context.confidence); // 0.98 +console.log(context.confidence);// 0.98 ``` --- @@ -46,7 +49,7 @@ console.log(context.confidence); // 0.98 | **`providers`** | `AiProvider[]` | Per-request provider configuration overrides. | | **`timeout`** | `number` | Per-request timeout in milliseconds (overrides provider and global timeouts). | | **`hedgeDelay`** | `number` | Delay in milliseconds before initiating speculative hedging in `AiMode.Hedged` (default: `800ms`). | -| **`debug`** | `boolean` | If true, logs prompt context and LLM payloads to console. | +| **`debug`** | `boolean` | If true, logs prompt context and cache operations to console (automatically PII-sanitized in production). | | **`softErrors`** | `boolean` | If true, returns `TempoAiError` into array index position instead of rejecting the entire batch query. | --- diff --git a/packages/plugins/ai/doc/grounding.md b/packages/plugins/ai/doc/grounding.md index 72e44063..87cb554a 100644 --- a/packages/plugins/ai/doc/grounding.md +++ b/packages/plugins/ai/doc/grounding.md @@ -37,9 +37,13 @@ Passing the `Locale` and `TimeZone` is critical for the LLM to know whether `"11 ## The Decoupled Output Bridge -To ensure deterministic behavior, the LLM is instructed to *only* return strict ISO 8601 strings. +To ensure deterministic, type-safe behavior, the plugin enforces a strict decoupled bridge between AI text generation and JavaScript object hydration: -The plugin executes the network request, the LLM returns a local ISO string without a timezone offset or 'Z' suffix (like `"2026-11-26T00:00:00"`), and the plugin immediately passes that string back into the native `new Tempo()` constructor. The provider response must omit timezone suffixes to match the local ISO contract enforced by Tempo AI functions. The developer seamlessly receives a valid, native `Tempo` instance. This eliminates AST-construction ambiguity, creating a decoupled bridge between AI text generation and native Tempo conversion. +* **For Point-in-Time Parsing (`parseAI`)**: The LLM is instructed to return a strict local ISO 8601 string without a timezone offset or 'Z' suffix (e.g. `"2026-11-26T00:00:00"`). The plugin immediately constructs a native `new Tempo()` instance with caller-defined timezone and calendar context. +* **For Structured Functions (`formatAI`, `extractAI`, `diffAI`, `contextAI`)**: The LLM completes rigid JSON schemas validated against strict boundary rules, instantiating typed result objects (`TempoAiFormatResult`, `TempoAiExtractResult`, `TempoAiDiffResult`, `TempoContext`). +* **For Intervals & Generators (`scheduleAI`, `recurrenceAI`)**: The plugin hydrates interval boundaries into a proxied `Interval` or exposes an iterable generator yielding sequential `Tempo` instances. + +This eliminates AST-construction ambiguity and provides clean runtime contracts for every operation. ### Relative Date Ambiguity Tie-Breakers @@ -48,11 +52,13 @@ To eliminate model variance on idioms like "Next Friday" or "Last Tuesday", the * `"last [weekday/unit]"` / `"previous [weekday/unit]"`: Evaluated as the most recent past occurrence prior to the grounding anchor. * `"this [weekday]"`: Evaluated as the occurrence within the current calendar week containing the grounding anchor. -### Confidence Thresholds & Metadata (`.ai`) +### Confidence Thresholds & Metadata Handling -When `minConfidence` is supplied in options (e.g. `parseAI("...", { minConfidence: 0.8 })`), any LLM response returning a confidence score below that threshold produces a `Tempo` instance with `isValid === false`. +When `minConfidence` is supplied in options (e.g. `{ minConfidence: 0.85 }`): +* **`parseAI`**: Any LLM response returning a confidence score below the threshold produces a `Tempo` instance with `isValid === false` (when using `softErrors: true`) or throws a `TempoAiError(422)`. +* **Structured Functions (`formatAI`, `extractAI`, `diffAI`, `contextAI`, `scheduleAI`, `recurrenceAI`)**: Low-confidence completions immediately throw a `TempoAiError(422)` (or return a `TempoAiError` in batch arrays when `softErrors: true` is enabled). -Every resolved `Tempo` instance returned by `parseAI` (and other AI functions) has a non-writable, frozen `.ai` metadata descriptor containing execution audit data: +Every resolved `Tempo` instance returned by `parseAI` has a non-writable, frozen `.ai` metadata descriptor containing execution audit data: ```typescript const dt = await parseAI("Christmas 2026", { debug: true }); console.log(dt.ai); @@ -67,3 +73,5 @@ console.log(dt.ai); // normalizedPrompt: 'christmas 2026' // Present when debug is enabled // } ``` + +*(For other AI functions like `extractAI` or `diffAI`, diagnostic metadata including `confidence`, `reasoning`, and `provider` is attached directly to the returned result object.)* diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md index 9aaf11ff..f25cba95 100644 --- a/packages/plugins/ai/doc/index.md +++ b/packages/plugins/ai/doc/index.md @@ -40,18 +40,29 @@ All AI functions return a standard ES Promise wrapped object. | :--- | :--- | :--- | :--- | :---: | | **`parseAI`** | Natural language text string(s) | `Tempo` \| `Tempo[]` \| `(Tempo \| TempoAiError)[]` | Single point-in-time `Tempo` instance (or batch array) | | | **`formatAI`** | Date-time + prompt / style | `TempoAiFormatResult` \| `TempoAiFormatResult[]` \| `(TempoAiFormatResult \| TempoAiError)[]` | **Contextual narrative date formatting** (`formatted`, `confidence`, `provider`, `reasoning`) | | -| **`extractAI`** | Unstructured text string(s) | `TempoAiExtractResult` \| `(TempoAiExtractResult \| TempoAiError)[]` | **Extracted temporal entities & calendar events** (`events: TempoExtractedEvent[]`, `confidence`, `reasoning`) | | +| **`extractAI`** | Unstructured text string(s) | `TempoAiExtractResult` \| `TempoAiExtractResult[]` \| `(TempoAiExtractResult \| TempoAiError)[]` | **Extracted temporal entities & calendar events** (`events: TempoExtractedEvent[]`, `confidence`, `reasoning`) | | | **`recurrenceAI`** | Natural language pattern or RRULE string | `TempoRecurrenceResult` | **Iterable series of `Tempo` dates** (with `.take(n)`, `[Symbol.iterator]`, & RRULE string) | | | **`scheduleAI`** | Booking prompt + busy constraints | `TempoScheduleResult` | **Resolved appointment slot** (`start`, `end`, `slot`, `alternatives`, `ai.conflictBumped`) | | | **`diffAI`** | Start & End dates + prompt | `TempoAiDiffResult` \| `TempoAiDiffResult[]` \| `(TempoAiDiffResult \| TempoAiError)[]` | **Narrative time delta & business days** (`formatted`, `businessDays`, `days`, `hours`, `holidays`) | | | **`contextAI`** | Context text string(s) | `TempoContext` \| `TempoContext[]` \| `(TempoContext \| TempoAiError)[]` | **Inferred regional context** (`timeZone`, `locale`, `calendar`, `sphere`) | | | **`initAI`** | Provider config & API keys | `void` | Configured AI provider farm | | +### Summary of Distinct Return Contracts + +To streamline error handling and data consumption, return shapes across the AI plugin follow three distinct contracts: + +| Category | Functions | Return Type | Single Query Low-Confidence / Failure | Batch Array `softErrors: true` Contract | +| :--- | :--- | :--- | :--- | :--- | +| **Point-in-Time Date** | `parseAI` | `Tempo` (with `.ai`) | Throws `TempoAiError` (or returns invalid `Tempo` if `minConfidence` threshold unmet) | Returns invalid `Tempo` (`isValid === false`) in array position | +| **Structured AI Objects** | `formatAI`
`extractAI`
`diffAI`
`contextAI` | `TempoAiFormatResult`
`TempoAiExtractResult`
`TempoAiDiffResult`
`TempoContext` | Throws `TempoAiError` (422 for low confidence, 429 for quota, 500 for network) | Returns typed `TempoAiError` object directly in array position | +| **Intervals & Generators** | `scheduleAI`
`recurrenceAI` | `TempoScheduleResult` (Proxied `Interval`)
`TempoRecurrenceResult` (`.take(n)`) | Throws `TempoAiError` (Single item query only) | N/A (Single query operations) | + ## Architecture & Infrastructure Guides > [!IMPORTANT] -> **Production Recommendation**: Due to the complexities of LLM APIs, including caching gotchas, context injection, rate limits, and calendar math hallucinations, we politely but firmly recommend reading the dedicated guides below before deploying this plugin in a production environment. +> **Production Recommendation**: Due to the complexities of LLM APIs, including caching gotchas, context injection, rate limits, and calendar math hallucinations, we politely but strongly recommend reading the dedicated guides below before deploying this plugin in a production environment. +- [Security & Privacy Architecture](./security.md) (Smart Debug Telemetry, PII Masking, HTTPS & Proxy Introspection) - [Multi-Provider Execution Modes](./modes.md) (Hedged, RoundRobin, Adaptive, Race, Consensus, Fallback) - [Provider Architecture & Security](./architecture.md) (BYOK vs Proxy patterns, Browser Security, TLS 1.3 & Privacy Guarantees) - [Grounding & Natural Language Parsing](./grounding.md) (How Timezone and Locale are injected) @@ -62,8 +73,7 @@ All AI functions return a standard ES Promise wrapped object. > [!NOTE] > **Community Feedback & Prompt Engineering** > While `@magmacomputing/tempo-plugin-ai` utilizes deterministic grounding, schema enforcement, and confidence validation, LLM outputs can vary across models and prompt styles. We actively welcome community feedback and prompt optimizations—please report any edge cases or suggestions on the [Magma GitHub Issue Tracker](https://github.com/magmacomputing/magma/issues/new?template=bug_report_ai.yml). - -> [!CAUTION] +> > **Production Notice & "As-Is" Disclaimer**: Magma Computing Solutions and the Tempo core maintainers provide `@magmacomputing/tempo-plugin-ai` "as-is" without warranty of any kind. Large Language Models operate probabilistically; developers and system architects are responsible for validating AI-generated temporal outputs before committing them to financial, legal, medical, or life-critical applications. ## Licensing diff --git a/packages/plugins/ai/doc/init.md b/packages/plugins/ai/doc/init.md index 49c93810..3e518eaf 100644 --- a/packages/plugins/ai/doc/init.md +++ b/packages/plugins/ai/doc/init.md @@ -14,7 +14,7 @@ await initAI({ { id: 'openai', key: process.env.OPENAI_API_KEY, model: 'gpt-4o' } ], timeout: 5000, // 5-second global SLA default - debug: true // Enable operational trace logging (development-only) + debug: true // Enable operational trace logging (automatically PII-sanitized in production) }); ``` @@ -80,10 +80,10 @@ const dt = await parseAI("Next Tuesday at 4pm", { timeout: 3000 }); **Operational Trace Logging** Passing `debug: true` into `initAI` (or setting `{ debug: true }` on a per-request options object) outputs concise operational trace logs (prefixed with `[tempo-plugin-ai]`) to the developer console for monitoring provider fallback decisions and execution timing. -Detailed diagnostic context—including `rawPrompt`, `normalizedPrompt`, `reasoning`, confidence scores, and rate limit snapshots—is attached directly to the returned `Tempo` instance via the `.ai` property when `debug: true` is enabled. +Detailed diagnostic context—including `rawPrompt`, `normalizedPrompt`, `reasoning`, confidence scores, and rate limit snapshots—is attached directly to the returned `Tempo` instance via the `.ai` property for `parseAI`, or surfaced directly on the typed result object (`res.reasoning`, `res.confidence`, `res.ai`) for other AI functions when `debug: true` is enabled. -> [!WARNING] -> **Diagnostic Security Notice**: Inspecting or exposing the `.ai` metadata property (such as `rawPrompt` or `reasoning`) in public UI components or client-side telemetry may expose raw user inputs. Ensure sensitive diagnostic fields on `Tempo.ai` are sanitized before forwarding instances to external monitoring tools. +> [!TIP] +> **Smart Debug & Proxy Introspection**: In production environments (`NODE_ENV === 'production'`), terminal logging via `console.log(date.ai)` or `console.log(result)` automatically sanitizes and masks PII (emails, phones, bearer tokens) while preserving 100% in-memory data integrity for application code. Refer to the [Security & Privacy Architecture Guide](./security.md). ## Configuration Options Reference diff --git a/packages/plugins/ai/doc/modes.md b/packages/plugins/ai/doc/modes.md index 52a88c94..b1a599af 100644 --- a/packages/plugins/ai/doc/modes.md +++ b/packages/plugins/ai/doc/modes.md @@ -52,10 +52,10 @@ flowchart LR ### Proactive Cooldown Avoidance Before dispatching any request: -1. **Cooldown Detection**: The orchestrator checks if any configured provider has exhausted its request quota (`remainingRequests === 0`) and is within an active reset window (`resetAt > now`). +1. **Cooldown Detection**: The orchestrator checks if any configured provider has exhausted its request or token quota (`remainingRequests === 0` or `remainingTokens === 0`) and is within an active reset window (`resetAt > now`, derived from response reset timestamps or `retry-after` metadata). 2. **Pre-Dispatch Filtering**: In `Fallback`, `Race`, `Hedged`, and `RoundRobin` modes, exhausted providers are automatically removed from the active candidate pool for that request. - **`Fallback` & `Hedged`**: Avoids stalling on primary providers that are guaranteed to reject with HTTP 429. - - **`Race`**: Saves network bandwidth and avoid firing wasted requests to rate-limited models. + - **`Race`**: Saves network bandwidth and avoids firing wasted requests to rate-limited models. - **`RoundRobin`**: Skips over cooling-down keys without breaking the cyclic load-balancing progression. 3. **Fail-Open Resilience**: If *all* providers in the farm are currently in a cooldown window, the orchestrator keeps all providers available rather than failing prematurely, allowing the request to cascade or surface accurate rate-limit errors. @@ -172,17 +172,23 @@ const dt = await parseAI('tomorrow at noon', { ### 6. `AiMode.Consensus` — Multi-LLM Cross-Validation -Dispatches requests concurrently across all providers and compares the normalized ISO timestamps or RRULE strings. If all responding providers agree, confidence is elevated to `1.0` (unanimous). If providers disagree, the highest-confidence candidate is returned and flagged with `dt.ai.ambiguous = true`. +Dispatches requests concurrently across all providers and compares the normalized outputs (e.g. ISO timestamps for `parseAI`, RRULE strings for `recurrenceAI`, formatted strings for `diffAI`/`formatAI`, or structured entity counts for `extractAI`). If all responding providers agree, confidence is elevated to `1.0` (unanimous). If providers disagree, the highest-confidence candidate is returned and flagged with `ai.ambiguous = true` (attached to `Tempo.ai` on `parseAI` or returned on structured result objects). **Best for:** High-stakes legal, financial, and scheduling — contract dates, event conflict resolution, or auditing where hallucination prevention requires unanimous LLM agreement. ```typescript +// 1. Point-in-time cross validation const dt = await parseAI('contract renewal date', { mode: AiMode.Consensus }); if (dt.ai?.ambiguous) { - console.warn('Providers disagreed — treat this result with caution.'); + console.warn('Providers disagreed — treat this date with caution.'); } + +// 2. High-precision duration calculation across multiple providers +const diff = await diffAI(startDate, endDate, 'in business days excluding UK bank holidays', { + mode: AiMode.Consensus +}); ``` diff --git a/packages/plugins/ai/doc/parse.md b/packages/plugins/ai/doc/parse.md index 5189c5bb..537ddd6b 100644 --- a/packages/plugins/ai/doc/parse.md +++ b/packages/plugins/ai/doc/parse.md @@ -31,7 +31,7 @@ const dt = await parseAI("Third Friday of October", { timeZone: 'Australia/Sydney', // Context timezone locale: 'en-AU', // Context locale minConfidence: 0.85, // Require at least 0.85 confidence score - timeout: 3000, // 3-second SLA call-site timeout + timeout: 3000, // 3-second request timeout (throws TempoAiError(504) if exceeded) force: true, // Skip native pre-parsing & cache lookup debug: true // Enable operational trace logging & .ai metadata }); diff --git a/packages/plugins/ai/doc/rate-limits.md b/packages/plugins/ai/doc/rate-limits.md index d8d10562..187017e0 100644 --- a/packages/plugins/ai/doc/rate-limits.md +++ b/packages/plugins/ai/doc/rate-limits.md @@ -9,7 +9,7 @@ The plugin automatically tracks these limits by reading the standard `x-ratelimi Quota and rate-limit metadata can be inspected in two convenient ways: ### 1. Request-Locked Instance Metadata (`dt.ai.limits`) -Every `Tempo` instance returned by `parseAI` includes a frozen `.ai.limits` snapshot representing the rate limit state returned by provider HTTP headers for *that specific request*. Note that `.ai.limits` is guaranteed only for provider-backed network requests where rate-limit headers are returned by the selected provider; results resolved via native parsing (`provider: 'native'`) or cache hits (`provider: 'cache'`) may omit `.ai.limits` (`undefined`). +For `parseAI`, every resolved `Tempo` instance includes a frozen `.ai.limits` snapshot representing the rate limit state returned by provider HTTP headers for *that specific request*. Note that `.ai.limits` is guaranteed only for provider-backed network requests where rate-limit headers are returned by the selected provider; results resolved via native parsing (`provider: 'native'`) or cache hits (`provider: 'cache'`) may omit `.ai.limits` (`undefined`). ```typescript const dt = await parseAI("The third Friday of next month"); @@ -74,12 +74,24 @@ This is by design for three critical reasons: ### Soft Errors in Array Batches -When processing arrays of inputs, an unparseable input or provider failure on one item will throw an error by default, stopping execution. Passing `softErrors: true` allows AI functions to return invalid `Tempo` instances (`isValid === false`) for failing items while completing the rest of the array: +When processing arrays of inputs, an unparseable input or provider failure on one item will throw an error by default, halting execution of the entire batch. Passing `softErrors: true` allows batch operations to gracefully complete the rest of the array: + +* **For `parseAI`**: Failed array items return an invalid `Tempo` instance (`isValid === false`). +* **For Structured Functions (`formatAI`, `extractAI`, `diffAI`, `contextAI`)**: Failed array items return the typed `TempoAiError` object directly in that array position. ```typescript +// 1. parseAI with softErrors returns invalid Tempo instances const dates = await parseAI(["Thanksgiving 2026", "INVALID_PROMPT_STRING"], { softErrors: true }); console.log(dates[0].isValid); // true console.log(dates[1].isValid); // false + +// 2. Structured functions return TempoAiError objects into the array +import { formatAI, TempoAiError } from '@magmacomputing/tempo-plugin-ai'; + +const formatted = await formatAI([validDate, invalidDate], 'casual tone', { softErrors: true }); +if (formatted[1] instanceof TempoAiError) { + console.warn(`Format failed with code: ${formatted[1].code}`); +} ``` ### Static Glossary Seeding @@ -112,10 +124,13 @@ const dt = await parseAI("The last Friday before Christmas", { force: true, cach If the LLM hallucinates or returns an incorrect absolute date, you can explicitly purge the string from the cache: ```typescript -import { clearAiCache } from '@magmacomputing/tempo-plugin-ai'; +import { aiCache } from '@magmacomputing/tempo-plugin-ai'; // Evict a single string -clearAiCache("2nd tuesday in nov"); +await aiCache.clear("2nd tuesday in nov"); + +// Or purge all AI cached entries +await aiCache.clear(); ``` ### Forcing a Refresh diff --git a/packages/plugins/ai/doc/schedule.md b/packages/plugins/ai/doc/schedule.md index 3efe8b98..98e3b401 100644 --- a/packages/plugins/ai/doc/schedule.md +++ b/packages/plugins/ai/doc/schedule.md @@ -37,7 +37,7 @@ console.log(booking.ai?.conflictBumped); // true (pushed | Option | Type | Description | | :--- | :--- | :--- | | **`anchor`** | `TempoDateInput` | Anchor reference time to evaluate relative dates from. Defaults to workstation/browser current time. | -| **`events`** | `TempoInterval[]` | A list of existing busy calendar intervals that the meeting must not overlap with. | +| **`events`** | `Array<{ start: any; end: any; title?: string } \| TempoInterval \| Interval>` | A list of existing busy calendar intervals that the meeting must not overlap with. | | **`workingHours`** | `TempoWorkingHours` | Daily time window constraint (HH:MM formats) inside which slots must fit. | | **`timeZone`** | `string` | Target IANA timezone to calculate the booking slot and boundaries within. | | **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required to return a valid slot. | @@ -45,11 +45,20 @@ console.log(booking.ai?.conflictBumped); // true (pushed ### `TempoInterval` Interface ```typescript -interface TempoInterval { +export interface TempoInterval { + start: Tempo; + end: Tempo; +} +``` + +### Event Input Shape (`TempoScheduleOptions.events`) +The `events` option accepts raw event objects, continuous `TempoInterval` pairs, or native `Interval` instances: +```typescript +type ScheduleEventInput = { start: TempoDateInput; end: TempoDateInput; title?: string; -} +} | TempoInterval | Interval; ``` --- diff --git a/packages/plugins/ai/doc/security.md b/packages/plugins/ai/doc/security.md new file mode 100644 index 00000000..fd4c11e0 --- /dev/null +++ b/packages/plugins/ai/doc/security.md @@ -0,0 +1,150 @@ +# Security & Privacy Architecture + +The `@magmacomputing/tempo-plugin-ai` plugin is engineered with a **"Privacy and Security by Default"** philosophy. Because date parsing and calendar scheduling frequently interact with Personally Identifiable Information (PII)—such as meeting attendees, emails, phone numbers, and sensitive notes—the plugin incorporates multi-layered security controls to protect user data across transit, runtime memory, log output, and caching tiers. + +```mermaid +flowchart TD + subgraph Input ["1. Ingress & Transport"] + User["User Prompt / Event Data"] -->|"HTTPS / TLS 1.3 Enforcement"| Transport["Secure Transport Layer"] + end + + subgraph Memory ["2. In-Memory Processing & Storage"] + Transport --> Schema["Rigid Schema Validation & Grounding"] + Schema --> Runtime["In-Memory Execution
(Full Fidelity Access)"] + Runtime --> Cache["Partitioned Multi-Tier Cache
(Namespaced by Tenant/TZ/Locale)"] + end + + subgraph Egress ["3. Egress & Smart Debugging"] + Runtime --> ProxyMeta["Proxy-Wrapped Result Objects
(attachCustomInspect)"] + ProxyMeta --> Logic["Application Business Logic
(100% Raw Data Access)"] + ProxyMeta -->|"console.log() / util.inspect"| Logger["Smart Logger (logDebug)
• NODE_ENV=production: Auto-Masked PII
• NODE_ENV=development: Full Diagnostic Logs"] + end +``` + +--- + +## 1. Smart Debug Telemetry & PII Hardening + +Debugging LLM integrations traditionally presents a major security dilemma: enabling debug logs often inadvertently dumps raw prompts containing sensitive user emails, phone numbers, and auth tokens into centralized log aggregators (e.g. Datadog, CloudWatch, Sentry). + +`@magmacomputing/tempo-plugin-ai` eliminates this risk through **Smart Debug Infrastructure**: + +### Universal Environment Detection & Zero-Config Safety +* **Single Flag Experience**: Developers simply pass `{ debug: true }` (or configure `initAI({ debug: true })`). There are no confusing secondary flags to memorize. +* **Environment-Aware Sanitization**: The runtime automatically inspects `NODE_ENV`. In production environments (`NODE_ENV === 'production'`), all debug logs and terminal outputs automatically sanitize sensitive data before printing to `console.log` or `console.warn`. +* **Development Fidelity**: In non-production environments (local development, testing), full diagnostic strings are preserved for seamless prompt debugging. + +### Automatic PII Redaction +In production mode, all debug telemetry is scrubbed through automated regex sanitizers: +* **Email Addresses**: Masked to initial and domain (e.g., `john.doe@enterprise.com` $\rightarrow$ `j***@enterprise.com`). +* **Phone Numbers**: Masked to last four digits (e.g., `+1-555-867-5309` $\rightarrow$ `***-***-5309`). +* **Bearer & API Tokens**: Redacted with prefix/suffix preservation (e.g., `Bearer sk-proj-1234...` $\rightarrow$ `Bearer sk-p...1234`). +* **Length Bounds**: Exceptionally long strings (> 256 characters) are safely truncated with character count annotations to prevent log bloat and denial-of-service attacks. + +```typescript +import { parseAI, initAI } from '@magmacomputing/tempo-plugin-ai'; + +await initAI({ + providers: [{ id: 'groq', key: process.env.GROQ_API_KEY }], + debug: true // Safe in all environments +}); + +// Input containing sensitive attendee data +const date = await parseAI("Meeting with john.smith@company.org (call 555-123-4567) next Friday"); + +// In Production, console.log(date.ai) outputs: +// { +// provider: 'groq', +// confidence: 0.98, +// rawPrompt: 'Meeting with j***@company.org (call ***-***-4567) next Friday', +// reasoning: 'Parsed meeting for next Friday with j***@company.org' +// } +``` + +--- + +## 2. Tamper-Resistant Proxy Introspection + +All AI return objects (`Tempo.ai`, `TempoAiFormatResult`, `TempoAiExtractResult`, `TempoAiDiffResult`, `TempoScheduleResult`, `TempoRecurrenceResult`) utilize JavaScript `Proxy` wrappers and Node.js custom inspection hooks (`Symbol.for('nodejs.util.inspect.custom')` and `.toJSON()`): + +1. **Terminal & Log Safety**: When an AI result object is logged via `console.log()`, `util.inspect()`, or serialized for telemetry, the custom inspection hook intercepts the call and outputs the PII-masked view. +2. **100% In-Memory Code Integrity**: In-memory property access within your application code (`date.ai?.rawPrompt`, `result.events[0].rawText`, `res.reasoning`) retains full, unmodified data fidelity. +3. **Deep Immutability**: Metadata properties attached to `Tempo` instances are frozen using `Object.freeze()`, preventing runtime tampering or prototype pollution by downstream code or dependencies. + +```typescript +const result = await formatAI(targetDate, 'Notify alice.cooper@domain.com'); + +// 1. Terminal / Log Aggregators see sanitized PII in production: +console.log(result); +// => { formatted: '...', reasoning: '... client a***@domain.com ...' } + +// 2. Your application code receives full raw fidelity: +const rawReasoning = result.reasoning; +// => "Formatted for client alice.cooper@domain.com" +``` + +--- + +## 3. Transport Security & Network Hardening + +### Enforced HTTPS / TLS +* **Strict HTTPS Requirement**: All network communication with upstream LLM APIs and remote configuration servers must use HTTPS with modern TLS (TLS 1.2 or TLS 1.3). +* **Plaintext HTTP Disallowed**: Unencrypted HTTP endpoints are rejected at runtime, with an exception allowed exclusively for `localhost` origins during local development or unit testing with mock servers. + +### Dynamic Manifest Host Verification +* **Trusted Remote Endpoints**: When `loadRemoteManifest` resolves provider manifests, it enforces trusted origin allowlists. +* **Provider URL Sanitization**: Any dynamic endpoint received via remote manifests or the `fetchDefaults` hook is verified before runtime merging. Disallowed hosts are rejected and stripped to prevent server-side request forgery (SSRF). + +--- + +## 4. Credential Isolation & BYOK Architecture + +### Automated In-Memory Key Redaction +* Calling `getAiConfig()` returns a sanitized, read-only configuration snapshot. +* All provider `key` values, authorization tokens, and shared secrets are permanently replaced with `[REDACTED]`, ensuring secrets cannot be leaked via diagnostic endpoints or error monitors. + +### Frontend Zero-Storage Principle +* **No Client-Side Secrets**: LLM API keys must **never** be bundled into client-side single-page applications (React, Vue, Svelte) or stored in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`). +* **Proxy Architecture**: Public frontend web applications must route requests through a self-hosted backend proxy or secure AI Gateway (Cloudflare Worker, Next.js API Route) where private API keys are kept server-side. + +--- + +## 5. Ephemeral Processing & Partitioned Caching + +### Zero Data Retention Policy +* The plugin does not transmit telemetry, analytics, or prompt logs to external tracking servers. +* Prompts and temporal calculations exist ephemerally in memory during execution. + +### Tenant-Isolated Partitioned Caching +* **Namespaced Cache Keys**: Cache keys are generated with multi-factor hashing (`ai:::`) incorporating the user prompt, target timezone, locale, calendar system, and anchor date. +* **Zero Cross-Contamination**: Isolated cache keys prevent cross-tenant and cross-regional data leakage. +* **Granular Bypass Controls**: Operations requiring zero cache persistence can supply `cache: false` or `force: true` on any individual request. + +--- + +## 6. Schema Enforcement & Hallucination Defense + +Large Language Models can occasionally hallucinate dates or output non-deterministic formats. `@magmacomputing/tempo-plugin-ai` prevents invalid data propagation through strict input/output boundaries: + +1. **Rigid Schema Validation**: All provider completions are validated against deterministic schemas and regex patterns prior to object construction. +2. **Confidence Threshold Gating**: The plugin enforces configurable `minConfidence` thresholds (e.g. `minConfidence: 0.85`). Results falling below the threshold throw a typed `TempoAiError` or trigger automatic fallback. +3. **Deterministic Grounding Fallbacks**: Grounding metrics (such as business days, calendar day offsets, and duration calculations) are verified using deterministic `Tempo` calculations rather than unverified LLM assumptions. + +--- + +## 7. Residual Risks & Threat Model Matrix + +> [!IMPORTANT] +> **Primary Production Strategy**: The primary recommendation for production environments is to keep **`debug: false`** (the default). Smart Debug is designed as an automated safety net to prevent catastrophic PII leaks when developers troubleshoot live issues, but no automated sanitization layer can eliminate 100% of risk when raw diagnostic telemetry is captured. + +The following matrix documents residual threat vectors and recommended mitigations: + +| Threat Vector | Source | Risk Level | Architectural Behavior | Recommended Mitigation | +| :--- | :--- | :---: | :--- | :--- | +| **Direct Primitive Logging** | Developer `console.log(res.reasoning)` | Medium | Evaluates to the raw in-memory string and bypasses object inspection hooks. | Log entire result objects (`console.log(res)`) or leave `debug: false`. | +| **Object Spread Logging** | `console.log({ ...res })` | Low | Spreading copies raw enumerable keys into a plain object without non-enumerable inspect symbols. | Log the object directly (`console.log(res)`) rather than shallow spreading. | +| **Network-Layer APM Tracing** | Datadog, OpenTelemetry, Sentry HTTP capture | High | APM agents monkey-patching `fetch` capture raw outbound HTTP payloads in transit. | Disable full HTTP body capture on LLM routes in your APM configuration. | +| **Semantic PII** | Unstructured names, physical addresses, health info | Medium | Regexes catch structured PII (emails, phones, tokens) but not unstructured names/addresses. | Rely on payload truncation limits (< 256 chars) and avoid `debug: true` on sensitive workflows. | +| **Environment Variable Drift** | `NODE_ENV` not set or misconfigured | Low | Logger checks `NODE_ENV` (`production`, `prod`, `live`) and `PROD=true`. If unset, defaults to dev mode. | Verify deployment manifests explicitly export `NODE_ENV=production`. | +| **External Cache Driver Logs** | Third-party Redis/DB client debug logs | Low | Distributed cache adapters store raw JSON required to rehydrate `Tempo` instances. | Ensure production Redis/database clients have debug logging disabled. | + diff --git a/packages/plugins/ai/src/core/cache.ts b/packages/plugins/ai/src/core/cache.ts new file mode 100644 index 00000000..38a293f8 --- /dev/null +++ b/packages/plugins/ai/src/core/cache.ts @@ -0,0 +1,219 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { secure } from '@magmacomputing/tempo/library'; +import { _state } from './init.js'; +import { logDebug, warnDebug } from './logger.js'; +import type { AiCacheAdapter } from '../types/index.js'; + +export const AI_CACHE_NAMESPACE_PREFIX = 'ai:'; + +/** + * Normalizes input string for deterministic cache lookups by trimming excess whitespace and lowercasing. + */ +export function normalizeCacheInput(input: string): string { + return input.trim().toLowerCase().replace(/\s+/g, ' '); +} + +/** + * Generates a namespaced cache key for domain-specific AI functions. + */ +export function getNamespacedCacheKey(namespace: string, key: string): string { + return `${AI_CACHE_NAMESPACE_PREFIX}${namespace}::${key}`; +} + +/** + * Reads from multi-tier cache (Tier 2 external async adapter first, Tier 1 local in-memory Tempo.cache fallback). + */ +export async function readMultiTierCache( + cacheKey: string, + options: { + force?: boolean | undefined; + cache?: boolean | undefined; + cacheAdapter?: AiCacheAdapter | undefined; + debug?: boolean | undefined; + tag?: string | undefined; + }, +): Promise { + if (options.force) return undefined; + if (options.cache === false || _state.config.cache === false) return undefined; + + const tag = options.tag ?? 'tempo-plugin-ai'; + const adapter = options.cacheAdapter || _state.config.cacheAdapter; + if (adapter) { + try { + const val = await adapter.get(cacheKey); + if (val !== undefined && val !== null) { + logDebug(tag, `Cache hit (adapter): ${cacheKey}`, undefined, { debug: options.debug }); + return val; + } + } catch (err: any) { + warnDebug(tag, `Cache adapter get failed for ${cacheKey}`, err, { debug: options.debug }); + } + } + + const localVal = Tempo.cache.get(cacheKey); + if (localVal) { + logDebug(tag, `Cache hit (local): ${cacheKey}`, undefined, { debug: options.debug }); + return localVal; + } + + return undefined; +} + +/** + * Writes to multi-tier cache (Tier 1 local in-memory Tempo.cache and Tier 2 external async adapter). + */ +export async function writeMultiTierCache( + cacheKey: string, + value: string, + ttl: number, + options: { + cache?: boolean | undefined; + cacheAdapter?: AiCacheAdapter | undefined; + debug?: boolean | undefined; + tag?: string | undefined; + }, +): Promise { + if (options.cache === false || _state.config.cache === false) return; + + const tag = options.tag ?? 'tempo-plugin-ai'; + Tempo.cache.set(cacheKey, value); + + const adapter = options.cacheAdapter || _state.config.cacheAdapter; + if (adapter) { + try { + await adapter.set(cacheKey, value, ttl); + } catch (err: any) { + warnDebug(tag, `Cache adapter set failed for ${cacheKey}`, err, { debug: options.debug }); + } + } +} + +/** + * ## aiCache + * Unified, secure multi-tier cache manager for the Tempo AI plugin suite. + * Manages both local in-memory `Tempo.cache` (Tier 1) and external distributed storage adapters (Tier 2). + * + * Protected with `secure()` proxy to prevent direct external manipulation while providing a full store interface. + */ +export const aiCache = secure({ + /** + * Clears AI entries from the in-memory cache and any external storage adapters. + * If specific input strings or keys are provided, selectively purges only those entries and prefix trees. + * + * @param input - Optional string key, input prompt, or array of keys to purge + * @returns Promise that resolves once cache eviction is complete + */ + async clear(input?: string | string[]): Promise { + const adapter = _state.config.cacheAdapter; + + if (!input) { + Tempo.cache.clear(); + if (adapter?.clear) { + try { + await Promise.resolve(adapter.clear()).catch(() => { }); + } catch { } + } + return; + } + + const inputs = Array.isArray(input) ? input : [input]; + for (const i of inputs) { + const normalized = normalizeCacheInput(i); + const prefix = `${normalized}::`; + Tempo.cache.delete(normalized); + Tempo.cache.delete(i); + Tempo.cache.deletePrefix(prefix); + + if (adapter) { + try { + if (adapter.delete) { + await Promise.resolve(adapter.delete(normalized)).catch(() => { }); + await Promise.resolve(adapter.delete(i)).catch(() => { }); + } + if (adapter.clear) { + await Promise.resolve(adapter.clear(prefix)).catch(() => { }); + } + } catch { } + } + } + }, + + /** + * Deletes a specific key from both Tier 1 in-memory cache and Tier 2 storage adapter. + * + * @param key - The cache key to delete + * @returns True if the key was present in the in-memory cache, false otherwise + */ + async delete(key: string): Promise { + const normalized = normalizeCacheInput(key); + const deletedLocal = Tempo.cache.delete(normalized) || Tempo.cache.delete(key); + const adapter = _state.config.cacheAdapter; + if (adapter?.delete) { + try { + await Promise.resolve(adapter.delete(normalized)).catch(() => { }); + await Promise.resolve(adapter.delete(key)).catch(() => { }); + } catch { } + } + return deletedLocal; + }, + + /** + * Retrieves a cached string by key across multi-tier storage. + * + * @param key - The cache key to fetch + * @returns The cached string value, or undefined if not found + */ + async get(key: string): Promise { + const adapter = _state.config.cacheAdapter; + if (adapter?.get) { + try { + const val = await adapter.get(key); + if (val !== undefined && val !== null) return val; + } catch { } + } + return Tempo.cache.get(key); + }, + + /** + * Checks if a key exists in either Tier 1 in-memory cache or Tier 2 storage adapter. + * + * @param key - The cache key to check + * @returns True if the key exists, false otherwise + */ + async has(key: string): Promise { + const val = await this.get(key); + return val !== undefined; + }, + + /** + * Sets a string value into multi-tier cache with an optional TTL. + * + * @param key - The cache key + * @param value - The serialized string value to cache + * @param ttl - Optional TTL in milliseconds + */ + async set(key: string, value: string, ttl?: number): Promise { + Tempo.cache.set(key, value); + const adapter = _state.config.cacheAdapter; + if (adapter?.set) { + try { + await adapter.set(key, value, ttl); + } catch { } + } + }, + + /** + * Returns an iterator over active in-memory cache entries. + */ + entries(): IterableIterator<[string, string]> { + return Tempo.cache.entries(); + }, + + /** + * Returns a plain object snapshot of active in-memory cache entries. + */ + toJSON(): Record { + return Tempo.cache.toJSON(); + }, +}); + diff --git a/packages/plugins/ai/src/core/dispatch.ts b/packages/plugins/ai/src/core/dispatch.ts index 17122473..1f0808da 100644 --- a/packages/plugins/ai/src/core/dispatch.ts +++ b/packages/plugins/ai/src/core/dispatch.ts @@ -1,6 +1,7 @@ import { TempoAiError } from './error.js'; import { AiMode } from './config.js'; import { _state } from './init.js'; +import { logDebug, warnDebug } from './logger.js'; import type { AiProvider } from '../types/index.js'; /** @@ -102,14 +103,22 @@ async function executeFallbackMode( if (options?.minConfidence === undefined || confidence >= options.minConfidence) return candidate; - if (options?.debug) - console.log(`[${options.tag || 'tempo-plugin-ai'}] Provider '${candidate.providerId}' confidence (${confidence}) below minConfidence (${options.minConfidence}). Cascading to next provider...`); + logDebug( + options?.tag || 'tempo-plugin-ai', + `Provider '${candidate.providerId}' confidence (${confidence}) below minConfidence (${options.minConfidence}). Cascading to next provider...`, + undefined, + { debug: options?.debug }, + ); } catch (err: any) { lastError = err; if (err instanceof TempoAiError && err.code === 422 && options?.minConfidence === undefined) break; - if (options?.debug) - console.warn(`[${options.tag || 'tempo-plugin-ai'}] Provider '${provider.id}' failed:`, err); + warnDebug( + options?.tag || 'tempo-plugin-ai', + `Provider '${provider.id}' failed`, + err, + { debug: options?.debug }, + ); } } @@ -363,7 +372,7 @@ async function executeAdaptiveMode( if (limits) { const resetMs = limits.resetAt?.epoch?.ms ?? now; - isExhausted = limits.remainingRequests === 0 && resetMs > now; + isExhausted = (limits.remainingRequests === 0 || limits.remainingTokens === 0) && resetMs > now; } return { @@ -396,7 +405,8 @@ export function isProviderInCooldown(provider: AiProvider, now = Date.now()): bo const limits = _state.providerLimits.get(provider.id); if (!limits) return false; const resetMs = limits.resetAt?.epoch?.ms ?? now; - return limits.remainingRequests === 0 && resetMs > now; + const isExhausted = limits.remainingRequests === 0 || limits.remainingTokens === 0; + return isExhausted && resetMs > now; } /** @@ -414,10 +424,13 @@ export function filterCooldownProviders( const now = Date.now(); const available = providers.filter(p => !isProviderInCooldown(p, now)); if (available.length > 0 && available.length < providers.length) { - if (options?.debug) { - const skipped = providers.filter(p => isProviderInCooldown(p, now)).map(p => p.id); - console.log(`[${options?.tag || 'tempo-plugin-ai'}] Proactively filtered ${skipped.length} provider(s) in active 429 cooldown: ${skipped.join(', ')}`); - } + const skipped = providers.filter(p => isProviderInCooldown(p, now)).map(p => p.id); + logDebug( + options?.tag || 'tempo-plugin-ai', + `Proactively filtered ${skipped.length} provider(s) in active 429 cooldown: ${skipped.join(', ')}`, + undefined, + { debug: options?.debug }, + ); return available; } return providers; diff --git a/packages/plugins/ai/src/core/init.ts b/packages/plugins/ai/src/core/init.ts index 1ee7a9d1..ef4cf38a 100644 --- a/packages/plugins/ai/src/core/init.ts +++ b/packages/plugins/ai/src/core/init.ts @@ -1,7 +1,7 @@ import { Tempo } from '@magmacomputing/tempo'; import { getResolvedProviderDefaults, loadRemoteManifest, resetManifestCache } from './manifest.js'; -import { normalizeCacheInput, assertNoReservedProviderId } from './support.js'; +import { assertNoReservedProviderId } from './support.js'; import type { AiConfig, AiRateLimits, AiProvider } from '../types/index.js'; /** @@ -71,39 +71,39 @@ export function initAI(config: AiConfig): Promise { Tempo.init({ cache: config.cache, silent: true }); } - return (async () => { - if (remoteUrl !== false) { - try { - await loadRemoteManifest(remoteUrl, undefined, config.debug ?? _state.config.debug); - } catch { } - } - - if (_state.revision !== currentRevision) return; - - const fetchDefaults = config.fetchDefaults ?? _state.config.fetchDefaults; - const currentProviders = callerProviders; + return (async () => { + if (remoteUrl !== false) { + try { + await loadRemoteManifest(remoteUrl, undefined, config.debug ?? _state.config.debug); + } catch { } + } - if (fetchDefaults && currentProviders) { - const asyncProviders = await Promise.all(currentProviders.map(async p => { - const normalizedId = p.id?.toLowerCase() ?? ''; - const defaults = getResolvedProviderDefaults(normalizedId, remoteUrl, config.debug ?? _state.config.debug); - let hookOptions: Partial | null = null; - try { - hookOptions = await fetchDefaults(normalizedId); - } catch { } - return { - ...defaults, - ...(hookOptions ?? {}), - ...p, - } as AiProvider; - })); - if (_state.revision === currentRevision) - _state.config.providers = asyncProviders; - } else if (currentProviders) { - if (_state.revision === currentRevision) - _state.config.providers = resolveSyncProviders(currentProviders); - } - })(); + if (_state.revision !== currentRevision) return; + + const fetchDefaults = config.fetchDefaults ?? _state.config.fetchDefaults; + const currentProviders = callerProviders; + + if (fetchDefaults && currentProviders) { + const asyncProviders = await Promise.all(currentProviders.map(async p => { + const normalizedId = p.id?.toLowerCase() ?? ''; + const defaults = getResolvedProviderDefaults(normalizedId, remoteUrl, config.debug ?? _state.config.debug); + let hookOptions: Partial | null = null; + try { + hookOptions = await fetchDefaults(normalizedId); + } catch { } + return { + ...defaults, + ...(hookOptions ?? {}), + ...p, + } as AiProvider; + })); + if (_state.revision === currentRevision) + _state.config.providers = asyncProviders; + } else if (currentProviders) { + if (_state.revision === currentRevision) + _state.config.providers = resolveSyncProviders(currentProviders); + } + })(); } /** @@ -119,53 +119,6 @@ export function resetAI(): void { resetManifestCache(); } -/** - * Clears AI parsing results from the in-memory cache and any external storage adapters. - * If specific input strings or keys are provided, selectively purges only those entries. - * - * @param input - Optional string key, date string, or array of strings to purge from the cache - * @returns A Promise that resolves once cache eviction is completed - * @example - * ```ts - * await clearAiCache('next tuesday'); - * await clearAiCache(); // Clears all cached AI entries - * ``` - */ -export async function clearAiCache(input?: string | string[]): Promise { - const adapter = _state.config.cacheAdapter; - - if (!input) { - Tempo.cache.clear(); - if (adapter?.clear) { - try { - await Promise.resolve(adapter.clear()).catch(() => { }); - } catch { } - } - return; - } - - const inputs = Array.isArray(input) ? input : [input]; - for (const i of inputs) { - const normalized = normalizeCacheInput(i); - const prefix = `${normalized}::`; - Tempo.cache.delete(normalized); - Tempo.cache.delete(i); - Tempo.cache.deletePrefix(prefix); - - if (adapter) { - try { - if (adapter.delete) { - await Promise.resolve(adapter.delete(normalized)).catch(() => { }); - await Promise.resolve(adapter.delete(i)).catch(() => { }); - } - if (adapter.clear) { - await Promise.resolve(adapter.clear(prefix)).catch(() => { }); - } - } catch { } - } - } -} - /** * Retrieves the latest observed rate limits across all provider responses. * diff --git a/packages/plugins/ai/src/core/logger.ts b/packages/plugins/ai/src/core/logger.ts new file mode 100644 index 00000000..342b7f0d --- /dev/null +++ b/packages/plugins/ai/src/core/logger.ts @@ -0,0 +1,182 @@ +import { _state } from './init.js'; + +export const CUSTOM_INSPECT_SYMBOL = Symbol.for('nodejs.util.inspect.custom'); + +const RE_EMAIL = /[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g; +const RE_PHONE = /(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?(\d{4})/g; +const RE_BEARER = /Bearer\s+[A-Za-z0-9_\-\.]+/gi; +const RE_API_KEY = /\b(?:sk-[a-zA-Z0-9_\-]{6,}|gsk_[a-zA-Z0-9_\-]{6,}|key-[a-zA-Z0-9_\-]{6,})\b/gi; + +/** + * Universal runtime environment detector. + * Safely checks if the execution context is in production mode. + */ +export function isProductionEnvironment(): boolean { + try { + if (typeof process === 'undefined' || !process?.env) return false; + const nodeEnv = (process.env.NODE_ENV ?? '').toLowerCase(); + if (nodeEnv === 'production' || nodeEnv === 'prod' || nodeEnv === 'live') return true; + if (process.env.PROD === 'true' || process.env.PRODUCTION === 'true') return true; + return false; + } catch { + return false; + } +} + +/** + * Sanitizes and masks potential PII from strings for terminal/log output. + * + * In production mode: + * - Emails are masked (e.g. `j***@example.com`) + * - Phone numbers are masked (e.g. `***-***-5309`) + * - Bearer tokens and API keys are redacted + * + * In development mode: + * - Full fidelity text is preserved for local debugging. + */ +export function maskPii(input: string, isProd: boolean = isProductionEnvironment()): string { + if (typeof input !== 'string') return String(input); + if (!isProd) return input; + + return input + .replace(RE_BEARER, match => { + const token = match.replace(/Bearer\s+/i, ''); + if (token.length <= 8) return 'Bearer [REDACTED]'; + return `Bearer ${token.slice(0, 4)}...${token.slice(-4)}`; + }) + .replace(RE_API_KEY, match => { + if (match.length <= 8) return '[REDACTED_KEY]'; + return `${match.slice(0, 5)}...${match.slice(-4)}`; + }) + .replace(RE_EMAIL, match => { + const parts = match.split('@'); + const name = parts[0] || ''; + const domain = parts[1] || ''; + return `${name.slice(0, 1)}***@${domain}`; + }) + .replace(RE_PHONE, '***-***-$1'); +} + +/** + * Sanitizes arbitrary objects, arrays, or primitives for safe log printing. + */ +export function sanitizeForLog(data: any, isProd: boolean = isProductionEnvironment()): any { + if (data === null || data === undefined) return data; + if (!isProd) return data; + + if (typeof data === 'string') { + const masked = maskPii(data, true); + if (masked.length > 256) { + return `${masked.slice(0, 200)}... [truncated, length: ${data.length}]`; + } + return masked; + } + if (typeof data === 'number' || typeof data === 'boolean') return data; + + if (Array.isArray(data)) + return data.map(item => sanitizeForLog(item, isProd)); + + if (typeof data === 'object') { + const result: Record = {}; + for (const [key, val] of Object.entries(data)) { + const lowerKey = key.toLowerCase(); + if (lowerKey.includes('key') || lowerKey.includes('secret') || lowerKey.includes('token') || lowerKey.includes('password') || lowerKey.includes('auth')) { + result[key] = '[REDACTED]'; + } else if (key === 'rawPrompt' || key === 'normalizedPrompt' || key === 'prompt' || key === 'reasoning') { + result[key] = typeof val === 'string' ? maskPii(val, isProd) : val; + } else { + result[key] = sanitizeForLog(val, isProd); + } + } + return result; + } + + return String(data); +} + +/** + * Emits a sanitized debug log line if debugging is active. + * + * @param tag - Logging namespace / tag (e.g. 'tempo-plugin-ai:parse') + * @param message - Descriptive log message (automatically PII-masked) + * @param payload - Optional diagnostic metadata or payload + * @param options - Explicit debug override + */ +export function logDebug( + tag: string, + message: string, + payload?: any, + options?: { debug?: boolean | undefined }, +): void { + const shouldLog = options?.debug ?? _state.config.debug ?? false; + if (!shouldLog) return; + + const isProd = isProductionEnvironment(); + const sanitizedMsg = maskPii(message, isProd); + const prefix = tag.startsWith('[') ? tag : `[${tag}]`; + + if (payload !== undefined) { + const sanitizedPayload = sanitizeForLog(payload, isProd); + console.log(`${prefix} ${sanitizedMsg}`, sanitizedPayload); + } else { + console.log(`${prefix} ${sanitizedMsg}`); + } +} + +/** + * Emits a sanitized debug warning if debugging is active. + */ +export function warnDebug( + tag: string, + message: string, + error?: any, + options?: { debug?: boolean | undefined }, +): void { + const shouldLog = options?.debug ?? _state.config.debug ?? false; + if (!shouldLog) return; + + const isProd = isProductionEnvironment(); + const sanitizedMsg = maskPii(message, isProd); + const prefix = tag.startsWith('[') ? tag : `[${tag}]`; + + if (error !== undefined) { + const sanitizedError = error instanceof Error ? error : (typeof error === 'string' ? maskPii(error, isProd) : sanitizeForLog(error, isProd)); + console.warn(`${prefix} ${sanitizedMsg}:`, sanitizedError); + } else { + console.warn(`${prefix} ${sanitizedMsg}`); + } +} + +/** + * Attaches custom inspection (`util.inspect.custom` and `toJSON`) hooks to an object + * to ensure that `console.log()` outputs a PII-sanitized summary in terminal/log aggregators + * without altering in-memory property access. + */ +export function attachCustomInspect( + target: T, + getInspectView: (obj: T, isProd: boolean) => Record, +): T { + try { + Object.defineProperty(target, CUSTOM_INSPECT_SYMBOL, { + value: function () { + const isProd = isProductionEnvironment(); + return getInspectView(target, isProd); + }, + configurable: true, + enumerable: false, + writable: true, + }); + + Object.defineProperty(target, 'toJSON', { + value: function () { + const isProd = isProductionEnvironment(); + return getInspectView(target, isProd); + }, + configurable: true, + enumerable: false, + writable: true, + }); + } catch { } + + return target; +} diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index 7d24d5f7..a513689e 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -2,7 +2,8 @@ import { Tempo } from '@magmacomputing/tempo'; import { TempoAiError } from './error.js'; import { RESERVED_PROVIDER_IDS } from './config.js'; import { updateRateLimitsFromResponse, _state } from './init.js'; -import type { AiCacheAdapter, AiProvider, TempoParseAiMeta } from '../types/index.js'; +import { logDebug, attachCustomInspect, maskPii } from './logger.js'; +import type { AiProvider, TempoParseAiMeta } from '../types/index.js'; export function assertNoReservedProviderId(providers: Partial[]): void { for (const p of providers) { @@ -11,14 +12,6 @@ export function assertNoReservedProviderId(providers: Partial[]): vo } } -export function normalizeCacheInput(input: string): string { - return input.trim().toLowerCase().replace(/\s+/g, ' '); -} - -export function getNamespacedCacheKey(namespace: string, key: string): string { - return `ai:${namespace}::${key}`; -} - export function resolveProviderTtl( providerId: string, availableProviders: AiProvider[], @@ -47,68 +40,20 @@ export function resolveTzAndLocale( return { tz, loc }; } -export async function readMultiTierCache( - cacheKey: string, - options: { - force?: boolean | undefined; - cache?: boolean | undefined; - cacheAdapter?: AiCacheAdapter | undefined; - debug?: boolean | undefined; - tag?: string | undefined; - }, -): Promise { - if (options.force) return undefined; - if (options.cache === false || _state.config.cache === false) return undefined; - - const adapter = options.cacheAdapter || _state.config.cacheAdapter; - if (adapter) { - try { - const val = await adapter.get(cacheKey); - if (val !== undefined && val !== null) { - if (options.debug) console.log(`[${options.tag ?? 'tempo-plugin-ai'}] Cache hit (adapter): ${cacheKey}`); - return val; - } - } catch (err: any) { - if (options.debug) console.warn(`[${options.tag ?? 'tempo-plugin-ai'}] Cache adapter get failed for ${cacheKey}:`, err?.message ?? err); - } - } - - const localVal = Tempo.cache.get(cacheKey); - if (localVal) { - if (options.debug) console.log(`[${options.tag ?? 'tempo-plugin-ai'}] Cache hit (local): ${cacheKey}`); - return localVal; - } - - return undefined; -} - -export async function writeMultiTierCache( - cacheKey: string, - value: string, - ttl: number, - options: { - cache?: boolean | undefined; - cacheAdapter?: AiCacheAdapter | undefined; - debug?: boolean | undefined; - tag?: string | undefined; - }, -): Promise { - if (options.cache === false || _state.config.cache === false) return; - - Tempo.cache.set(cacheKey, value); - - const adapter = options.cacheAdapter || _state.config.cacheAdapter; - if (adapter) { - try { - await adapter.set(cacheKey, value, ttl); - } catch (err: any) { - if (options.debug) console.warn(`[${options.tag ?? 'tempo-plugin-ai'}] Cache adapter set failed for ${cacheKey}:`, err?.message ?? err); - } - } -} - export function attachAiMeta(instance: Tempo, meta: TempoParseAiMeta): Tempo { - const frozenMeta = Object.freeze(meta); + const inspectableMeta = attachCustomInspect({ ...meta }, (obj, isProd) => ({ + provider: obj.provider, + cached: obj.cached, + confidence: obj.confidence, + ambiguous: obj.ambiguous, + granularity: obj.granularity, + rawIso: obj.rawIso, + ...(obj.rawPrompt !== undefined ? { rawPrompt: maskPii(obj.rawPrompt, isProd) } : {}), + ...(obj.normalizedPrompt !== undefined ? { normalizedPrompt: maskPii(obj.normalizedPrompt, isProd) } : {}), + ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}), + ...(obj.limits ? { limits: obj.limits } : {}), + })); + const frozenMeta = Object.freeze(inspectableMeta); const boundMethodCache = new Map(); return new Proxy(instance, { @@ -202,8 +147,7 @@ Do not include markdown blocks or any text outside the JSON.`; const systemPrompt = customSystemPrompt ?? defaultSystemPrompt; - if (isDebug) - console.log(`[tempo-plugin-ai] Querying provider '${provider.id}' (model: ${model})...`); + logDebug('tempo-plugin-ai', `Querying provider '${provider.id}' (model: ${model})...`, undefined, { debug: isDebug }); const tokenParam = provider.tokenParam || (provider.options?.max_completion_tokens !== undefined ? 'max_completion_tokens' : undefined) @@ -265,7 +209,7 @@ Do not include markdown blocks or any text outside the JSON.`; if (isDebug) { const elapsed = Math.round(performance.now() - startTime); - console.log(`[tempo-plugin-ai] Received response from '${provider.id}' in ${elapsed}ms`); + logDebug('tempo-plugin-ai', `Received response from '${provider.id}' in ${elapsed}ms`, undefined, { debug: isDebug }); } return { rawContent: rawContent.trim(), providerId: provider.id, rateLimits: limits }; diff --git a/packages/plugins/ai/src/functions/context.ts b/packages/plugins/ai/src/functions/context.ts index ac76cd29..1053fbfe 100644 --- a/packages/plugins/ai/src/functions/context.ts +++ b/packages/plugins/ai/src/functions/context.ts @@ -5,14 +5,17 @@ import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; import { executeWithMode } from '../core/dispatch.js'; import { - assertNoReservedProviderId, - fetchFromProvider, getNamespacedCacheKey, normalizeCacheInput, readMultiTierCache, - resolveProviderTtl, writeMultiTierCache, +} from '../core/cache.js'; +import { + assertNoReservedProviderId, + fetchFromProvider, + resolveProviderTtl, } from '../core/support.js'; +import { logDebug, warnDebug, attachCustomInspect, maskPii } from '../core/logger.js'; import { RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX } from '../core/patterns.js'; import type { TempoContext, AiContextOptions } from '../types/index.js'; @@ -49,8 +52,8 @@ async function contextSingleInput(text: string, options?: AiContextOptions): Pro ? parsedCache.confidence : 1.0; if (effectiveMinConfidence === undefined || cachedConfidence >= effectiveMinConfidence) { - if (isDebug) console.log(`[tempo-plugin-ai:context] Cache hit: "${text}" -> ${cachedVal}`); - return secure({ + logDebug('tempo-plugin-ai:context', `Cache hit: "${text}" -> ${cachedVal}`, undefined, { debug: isDebug }); + const cachedResult: TempoContext = { timeZone: parsedCache.timeZone, locale: parsedCache.locale, calendar: parsedCache.calendar, @@ -58,7 +61,17 @@ async function contextSingleInput(text: string, options?: AiContextOptions): Pro confidence: cachedConfidence, provider: 'cache', reasoning: parsedCache.reasoning, - }); + }; + attachCustomInspect(cachedResult, (obj, isProd) => ({ + timeZone: obj.timeZone, + locale: obj.locale, + calendar: obj.calendar, + sphere: obj.sphere, + confidence: obj.confidence, + provider: obj.provider, + ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}), + })); + return secure(cachedResult); } } } catch { @@ -183,6 +196,16 @@ Do not include markdown blocks or text outside the JSON.`; tag: 'tempo-plugin-ai:context', }); + attachCustomInspect(finalResult, (obj, isProd) => ({ + timeZone: obj.timeZone, + locale: obj.locale, + calendar: obj.calendar, + sphere: obj.sphere, + confidence: obj.confidence, + provider: obj.provider, + ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}), + })); + return secure(finalResult); } diff --git a/packages/plugins/ai/src/functions/diff.ts b/packages/plugins/ai/src/functions/diff.ts index 0cc0b880..080be3c3 100644 --- a/packages/plugins/ai/src/functions/diff.ts +++ b/packages/plugins/ai/src/functions/diff.ts @@ -5,14 +5,17 @@ import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; import { executeWithMode } from '../core/dispatch.js'; import { - assertNoReservedProviderId, - fetchFromProvider, getNamespacedCacheKey, normalizeCacheInput, readMultiTierCache, - resolveProviderTtl, writeMultiTierCache, +} from '../core/cache.js'; +import { + assertNoReservedProviderId, + fetchFromProvider, + resolveProviderTtl, } from '../core/support.js'; +import { logDebug, warnDebug, attachCustomInspect, maskPii } from '../core/logger.js'; import { RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX } from '../core/patterns.js'; import type { TempoAiDiffResult, AiDiffOptions, DiffPair } from '../types/index.js'; @@ -109,8 +112,8 @@ async function diffSingleInput( ? parsedCache.confidence : 1.0; if (effectiveMinConfidence === undefined || cachedConfidence >= effectiveMinConfidence) { - if (isDebug) console.log(`[tempo-plugin-ai:diff] Cache hit: "${cacheKey}" -> ${cachedVal}`); - return secure({ + logDebug('tempo-plugin-ai:diff', `Cache hit: "${cacheKey}" -> ${cachedVal}`, undefined, { debug: isDebug }); + const cachedResult: TempoAiDiffResult = { formatted: parsedCache.formatted, days: parsedCache.days ?? grounding.calendarDays, hours: parsedCache.hours ?? grounding.elapsedHours, @@ -119,7 +122,18 @@ async function diffSingleInput( confidence: cachedConfidence, provider: 'cache', reasoning: parsedCache.reasoning, - }); + }; + attachCustomInspect(cachedResult, (obj, isProd) => ({ + formatted: obj.formatted, + days: obj.days, + hours: obj.hours, + businessDays: obj.businessDays, + ...(obj.holidays ? { holidays: obj.holidays } : {}), + confidence: obj.confidence, + provider: obj.provider, + ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}), + })); + return secure(cachedResult); } } } catch { @@ -207,7 +221,7 @@ Do not include markdown blocks or text outside the JSON.`; rateLimits, confidence, consensusKey: `${formatted}::${businessDays}`, - }; + } }, { minConfidence: effectiveMinConfidence, debug: isDebug, tag: 'tempo-plugin-ai:diff', hedgeDelay: effectiveHedgeDelay }, ); @@ -249,6 +263,17 @@ Do not include markdown blocks or text outside the JSON.`; tag: 'tempo-plugin-ai:diff', }); + attachCustomInspect(finalResult, (obj, isProd) => ({ + formatted: obj.formatted, + days: obj.days, + hours: obj.hours, + businessDays: obj.businessDays, + ...(obj.holidays ? { holidays: obj.holidays } : {}), + confidence: obj.confidence, + provider: obj.provider, + ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}), + })); + return secure(finalResult); } diff --git a/packages/plugins/ai/src/functions/extract.ts b/packages/plugins/ai/src/functions/extract.ts index 83011298..26b705ec 100644 --- a/packages/plugins/ai/src/functions/extract.ts +++ b/packages/plugins/ai/src/functions/extract.ts @@ -5,14 +5,17 @@ import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; import { executeWithMode } from '../core/dispatch.js'; import { - assertNoReservedProviderId, - fetchFromProvider, normalizeCacheInput, readMultiTierCache, + writeMultiTierCache, +} from '../core/cache.js'; +import { + assertNoReservedProviderId, + fetchFromProvider, resolveProviderTtl, resolveTzAndLocale, - writeMultiTierCache, } from '../core/support.js'; +import { logDebug, warnDebug, attachCustomInspect, maskPii } from '../core/logger.js'; import type { AiExtractOptions, TempoAiExtractResult, @@ -47,7 +50,7 @@ async function extractSingleInput( : new Tempo(anchor as any, { timeZone: tz })) : new Tempo(Math.floor(Date.now() / 60_000) * 60_000, { timeZone: tz }); } catch (err: any) { - throw new TempoAiError(`Invalid anchor date provided to extractAI: "${String(anchor)}"`, 400); + throw new TempoAiError(`Invalid anchor date provided to extractAI: "${String(anchor)}"`, 400, undefined, { cause: err }); } if (!anchorTempo.isValid) { @@ -105,38 +108,59 @@ async function extractSingleInput( : 1.0; if (effectiveMinConfidence !== undefined && cachedConfidence < effectiveMinConfidence) { - if (isDebug) console.log(`[tempo-plugin-ai:extract] Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`); + logDebug('tempo-plugin-ai:extract', `Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`, undefined, { debug: isDebug }); } else { const rehydratedEvents: TempoExtractedEvent[] = []; + const allowedTypes: TempoEventType[] = ['point', 'interval', 'deadline', 'recurrence', 'tentative']; for (const ev of parsedCache.events) { try { const start = new Tempo(ev.start, { timeZone: tz, locale: loc, calendar: cal }); if (!start.isValid) continue; const end = ev.end ? new Tempo(ev.end, { timeZone: tz, locale: loc, calendar: cal }) : undefined; if (end && !end.isValid) continue; + const type: TempoEventType = allowedTypes.includes(ev.type) ? ev.type : 'point'; rehydratedEvents.push({ label: String(ev.label || 'Event'), start, end, - type: ev.type || 'point', + type, rawText: ev.rawText ? String(ev.rawText) : undefined, confidence: typeof ev.confidence === 'number' && Number.isFinite(ev.confidence) ? Math.max(0.0, Math.min(1.0, ev.confidence)) : 1.0, }); - } catch { } + } catch (err: any) { + warnDebug('tempo-plugin-ai:extract', 'Failed to rehydrate cached event', err, { debug: isDebug }); + } } - return secure({ + const reasoning = typeof parsedCache.reasoning === 'string' ? parsedCache.reasoning : undefined; + const cachedResult: TempoAiExtractResult = { events: rehydratedEvents, confidence: cachedConfidence, provider: 'cache', - reasoning: parsedCache.reasoning, - }); + reasoning, + } + + attachCustomInspect(cachedResult, (obj, isProd) => ({ + events: obj.events.map(e => ({ + label: maskPii(e.label, isProd), + start: e.start?.toString(), + ...(e.end ? { end: e.end?.toString() } : {}), + type: e.type, + ...(e.rawText ? { rawText: maskPii(e.rawText, isProd) } : {}), + confidence: e.confidence, + })), + confidence: obj.confidence, + provider: obj.provider, + ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}), + })); + + return secure(cachedResult); } } } catch (err: any) { - if (isDebug) console.warn(`[tempo-plugin-ai:extract] Failed to parse cached payload:`, err?.message ?? err); + warnDebug('tempo-plugin-ai:extract', 'Failed to parse cached payload', err, { debug: isDebug }); } } @@ -261,7 +285,9 @@ ${region ? `- Region Context: ${region}\n` : ''}${categories.length > 0 ? `- Fil rawText, confidence: itemConf, }); - } catch { } + } catch (err: any) { + warnDebug('tempo-plugin-ai:extract', `Failed to parse event from provider '${providerId}'`, err, { debug: isDebug }); + } } return { @@ -313,6 +339,20 @@ ${region ? `- Region Context: ${region}\n` : ''}${categories.length > 0 ? `- Fil tag: 'tempo-plugin-ai:extract', }); + attachCustomInspect(finalResult, (obj, isProd) => ({ + events: obj.events.map(e => ({ + label: maskPii(e.label, isProd), + start: e.start?.toString(), + ...(e.end ? { end: e.end?.toString() } : {}), + type: e.type, + ...(e.rawText ? { rawText: maskPii(e.rawText, isProd) } : {}), + confidence: e.confidence, + })), + confidence: obj.confidence, + provider: obj.provider, + ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}), + })); + return secure(finalResult); } @@ -341,25 +381,47 @@ export async function extractAI( options?: AiExtractOptions, ): Promise { if (Array.isArray(textOrTexts)) { + if (textOrTexts.length === 0) return []; const opts = options || {}; const softErrors = opts.softErrors ?? false; + const concurrencyLimit = Math.max(1, Math.min(opts.concurrency ?? 4, textOrTexts.length)); - if (softErrors) { - const settled = await Promise.allSettled( - textOrTexts.map(t => extractSingleInput(t, opts)), - ); - return settled.map((res, index) => { - if (res.status === 'fulfilled') return res.value; - const rawReason = res.reason; - if (rawReason instanceof TempoAiError) return rawReason; - return new TempoAiError( - rawReason?.message || `Failed to extract events at index ${index}`, - typeof rawReason?.status === 'number' ? rawReason.status : 500, - ); - }); + const results: (TempoAiExtractResult | TempoAiError)[] = new Array(textOrTexts.length); + let nextIdx = 0; + let firstError: any = null; + + const worker = async () => { + while (nextIdx < textOrTexts.length) { + if (!softErrors && firstError) break; + const currentIndex = nextIdx++; + const item = textOrTexts[currentIndex]; + try { + const res = await extractSingleInput(item, opts); + results[currentIndex] = res; + } catch (err: any) { + if (softErrors) { + results[currentIndex] = err instanceof TempoAiError + ? err + : new TempoAiError( + err?.message || `Failed to extract events at index ${currentIndex}`, + typeof err?.status === 'number' ? err.status : 500, + ); + } else { + if (!firstError) firstError = err; + break; + } + } + } + }; + + const workers = Array.from({ length: concurrencyLimit }, () => worker()); + await Promise.all(workers); + + if (!softErrors && firstError) { + throw firstError; } - return Promise.all(textOrTexts.map(t => extractSingleInput(t, opts))); + return results; } return extractSingleInput(textOrTexts, options); diff --git a/packages/plugins/ai/src/functions/format.ts b/packages/plugins/ai/src/functions/format.ts index c4505441..24bb1096 100644 --- a/packages/plugins/ai/src/functions/format.ts +++ b/packages/plugins/ai/src/functions/format.ts @@ -5,14 +5,17 @@ import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; import { executeWithMode } from '../core/dispatch.js'; import { - assertNoReservedProviderId, - fetchFromProvider, normalizeCacheInput, readMultiTierCache, + writeMultiTierCache, +} from '../core/cache.js'; +import { + assertNoReservedProviderId, + fetchFromProvider, resolveProviderTtl, resolveTzAndLocale, - writeMultiTierCache, } from '../core/support.js'; +import { logDebug, warnDebug, attachCustomInspect, maskPii } from '../core/logger.js'; import type { AiFormatOptions, FormatItem, TempoAiFormatResult, TempoDateInput } from '../types/format.type.js'; export type { AiFormatOptions, FormatItem, TempoAiFormatResult, TempoDateInput }; @@ -148,19 +151,26 @@ async function formatSingleInput( : 1.0; if (effectiveMinConfidence !== undefined && cachedConfidence < effectiveMinConfidence) { - if (isDebug) console.log(`[tempo-plugin-ai:format] Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`); + logDebug('tempo-plugin-ai:format', `Cached confidence (${cachedConfidence}) is below minConfidence (${effectiveMinConfidence}), ignoring cache.`, undefined, { debug: isDebug }); } else { const reasoning = typeof parsedCache?.reasoning === 'string' ? parsedCache.reasoning : undefined; - return secure({ + const cachedResult: TempoAiFormatResult = { formatted: parsedCache.formatted, confidence: cachedConfidence, provider: 'cache', reasoning, - }); + } + attachCustomInspect(cachedResult, (obj, isProd) => ({ + formatted: obj.formatted, + confidence: obj.confidence, + provider: obj.provider, + ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}), + })); + return secure(cachedResult); } } } catch (err: any) { - if (isDebug) console.warn(`[tempo-plugin-ai:format] Failed to parse cached payload:`, err?.message ?? err); + warnDebug('tempo-plugin-ai:format', 'Failed to parse cached payload', err, { debug: isDebug }); } } @@ -279,6 +289,13 @@ Output JSON Schema: tag: 'tempo-plugin-ai:format', }); + attachCustomInspect(finalResult, (obj, isProd) => ({ + formatted: obj.formatted, + confidence: obj.confidence, + provider: obj.provider, + ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}), + })); + return secure(finalResult); } diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts index b0473ea8..4cff2b60 100644 --- a/packages/plugins/ai/src/functions/parse.ts +++ b/packages/plugins/ai/src/functions/parse.ts @@ -3,7 +3,9 @@ import { TempoAiError } from '../core/error.js'; import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; import { executeWithMode } from '../core/dispatch.js'; -import { normalizeCacheInput, attachAiMeta, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; +import { normalizeCacheInput } from '../core/cache.js'; +import { attachAiMeta, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; +import { logDebug, warnDebug } from '../core/logger.js'; import { RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX, RE_ISO_DATE_PREFIX, RE_ISO_Z_SUFFIX } from '../core/patterns.js'; import type { AiParseOptions } from '../types/index.js'; @@ -65,7 +67,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< cachedIso = val; } } catch (err: any) { - if (isDebug) console.log('[tempo-plugin-ai] Cache adapter read error:', err?.message); + warnDebug('tempo-plugin-ai:parse', 'Cache adapter read error', err?.message, { debug: isDebug }); } } @@ -73,7 +75,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< } if (cachedIso) { - if (isDebug) console.log(`[tempo-plugin-ai] Cache hit: "${str}" -> ${cachedIso}`); + logDebug('tempo-plugin-ai:parse', `Cache hit: "${str}" -> ${cachedIso}`, undefined, { debug: isDebug }); const cachedInstance = new Tempo(cachedIso, tempoConfig); return attachAiMeta(cachedInstance, { provider: 'cache', @@ -96,7 +98,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< || native.isValid; if (native.isValid && hasNativeMatches) { - if (isDebug) console.log(`[tempo-plugin-ai] Resolved natively: "${str}"`); + logDebug('tempo-plugin-ai:parse', `Resolved natively: "${str}"`, undefined, { debug: isDebug }); return attachAiMeta(native, { provider: 'native', cached: false, @@ -195,7 +197,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< const res = adapter.set(cacheKey, parsedIso, resolvedTtl); if (res instanceof Promise) await res; } catch (err: any) { - if (isDebug) console.log('[tempo-plugin-ai] Cache adapter write error:', err?.message); + warnDebug('tempo-plugin-ai:parse', 'Cache adapter write error', err?.message, { debug: isDebug }); } } Tempo.cache.set(cacheKey, parsedIso); diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts index 1bfb84a8..4664c62d 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -5,6 +5,7 @@ import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; import { executeWithMode } from '../core/dispatch.js'; import { fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; +import { logDebug, attachCustomInspect, maskPii } from '../core/logger.js'; import { RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX, RE_RRULE_PREFIX } from '../core/patterns.js'; import type { TempoRecurrenceOptions, TempoRecurrenceResult } from '../types/index.js'; @@ -89,7 +90,7 @@ function createRecurrenceResult( } } - return { + const result: TempoRecurrenceResult = { rrule: rruleStr, summary: summaryText, isFinite, @@ -99,7 +100,19 @@ function createRecurrenceResult( confidence, provider: providerId, reasoning - }; + } + + attachCustomInspect(result, (obj, isProd) => ({ + rrule: obj.rrule, + summary: maskPii(obj.summary, isProd), + isFinite: obj.isFinite, + size: obj.size, + confidence: obj.confidence, + provider: obj.provider, + ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}), + })); + + return result; } /** @@ -128,8 +141,7 @@ export async function recurrenceAI( if (isRRule) { const cleanRRule = input.trim().replace(RE_RRULE_PREFIX, ''); - if (isDebug) - console.log(`[tempo-plugin-ai:recurrence] Detected raw RRULE string: "${cleanRRule}"`); + logDebug('tempo-plugin-ai:recurrence', `Detected raw RRULE string: "${cleanRRule}"`, undefined, { debug: isDebug }); return createRecurrenceResult( cleanRRule, diff --git a/packages/plugins/ai/src/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts index aed395c0..9a3ee76d 100644 --- a/packages/plugins/ai/src/functions/schedule.ts +++ b/packages/plugins/ai/src/functions/schedule.ts @@ -5,6 +5,7 @@ import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; import { executeWithMode } from '../core/dispatch.js'; import { fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; +import { CUSTOM_INSPECT_SYMBOL, isProductionEnvironment, maskPii, attachCustomInspect } from '../core/logger.js'; import { RE_DURATION_MINUTES, RE_DURATION_HOURS, RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX, RE_ISO_WEEKDAY_DIGIT } from '../core/patterns.js'; import type { TempoScheduleOptions, TempoScheduleResult, TempoWorkingHours, TempoInterval, TempoScheduleMeta, AiProvider } from '../types/index.js'; @@ -110,36 +111,67 @@ Instructions: "alternatives": array of secondary { "start": "...", "end": "..." } options if available`; function wrapScheduleInterval(interval: Interval, meta: TempoScheduleMeta): TempoScheduleResult { - const frozenMeta = Object.freeze(meta); + const inspectableMeta = attachCustomInspect({ ...meta }, (obj, isProd) => ({ + start: interval.start?.toString(), + end: interval.end?.toString(), + durationMinutes: obj.durationMinutes, + summary: maskPii(obj.summary, isProd), + confidence: obj.confidence, + provider: obj.provider, + ...(obj.reasoning !== undefined ? { reasoning: maskPii(obj.reasoning, isProd) } : {}), + ...(obj.ai ? { + ai: { + provider: obj.ai.provider, + confidence: obj.ai.confidence, + cached: obj.ai.cached, + conflictBumped: obj.ai.conflictBumped, + ...(obj.ai.reasoning !== undefined ? { reasoning: maskPii(obj.ai.reasoning, isProd) } : {}), + }, + } : {}), + })); + const boundMethodCache = new Map(); + const carrier = Object.create(interval); + Object.assign(carrier, inspectableMeta); + attachCustomInspect(carrier, (_obj, isProd) => { + const inspectFn = (inspectableMeta as any)[CUSTOM_INSPECT_SYMBOL]; + return typeof inspectFn === 'function' ? inspectFn() : inspectableMeta; + }); - return new Proxy(interval, { + return new Proxy(carrier, { get(target, prop) { - if (Object.hasOwn(frozenMeta, prop)) - return (frozenMeta as any)[prop]; + if (prop === CUSTOM_INSPECT_SYMBOL) + return (inspectableMeta as any)[CUSTOM_INSPECT_SYMBOL]; + + if (prop === 'toJSON') + return (inspectableMeta as any).toJSON; + + if (Object.hasOwn(inspectableMeta, prop)) + return (inspectableMeta as any)[prop]; if (prop === 'constructor') - return Reflect.get(target, prop, target); + return Interval; if (boundMethodCache.has(prop)) return boundMethodCache.get(prop); - const val = Reflect.get(target, prop, target); + const val = Reflect.get(interval, prop, interval); if (isFunction(val)) { - const bound = val.bind(target); + const bound = val.bind(interval); boundMethodCache.set(prop, bound); return bound; } return val; }, has(target, prop) { - if (Object.hasOwn(frozenMeta, prop)) return true; - return Reflect.has(target, prop); + if (prop === CUSTOM_INSPECT_SYMBOL || prop === 'toJSON') return true; + if (Object.hasOwn(inspectableMeta, prop)) return true; + return Reflect.has(interval, prop); }, getOwnPropertyDescriptor(target, prop) { - if (Object.hasOwn(frozenMeta, prop)) { + if (Object.hasOwn(inspectableMeta, prop)) { return { - value: (frozenMeta as any)[prop], + value: (inspectableMeta as any)[prop], writable: false, configurable: true, enumerable: true, @@ -149,7 +181,7 @@ function wrapScheduleInterval(interval: Interval, meta: TempoScheduleMeta }, ownKeys(target) { const keys = Reflect.ownKeys(target); - for (const k of Object.keys(frozenMeta)) { + for (const k of Object.keys(inspectableMeta)) { if (!keys.includes(k)) keys.push(k); } return keys; diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts index 1a42b67a..b8d79ed8 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -6,8 +6,11 @@ export * from './core/config.js'; // AI Manifest Support export { loadRemoteManifest, resetManifestCache, DEFAULT_REMOTE_MANIFEST_URL } from './core/manifest.js'; +// AI Cache Manager +export { aiCache } from './core/cache.js'; + // AI Core Functions -export { initAI, resetAI, clearAiCache, getAiRateLimits, getAiProviderRateLimits, getAiConfig } from './core/init.js'; +export { initAI, resetAI, getAiRateLimits, getAiProviderRateLimits, getAiConfig } from './core/init.js'; // AI Function Handlers export { parseAI } from './functions/parse.js'; diff --git a/packages/plugins/ai/src/types/extract.type.ts b/packages/plugins/ai/src/types/extract.type.ts index 0547c22f..5d0df35b 100644 --- a/packages/plugins/ai/src/types/extract.type.ts +++ b/packages/plugins/ai/src/types/extract.type.ts @@ -41,4 +41,6 @@ export interface TempoAiExtractResult extends TempoBaseAiResult { export interface AiExtractOptions extends AiDateContextOptions { /** Optional category filters to guide event identification (e.g. ['meeting', 'deadline']) */ categories?: string[] | undefined; + /** Optional maximum number of concurrent extraction requests when processing arrays (default: 4) */ + concurrency?: number | undefined; } diff --git a/packages/plugins/ai/src/types/recurrence.type.ts b/packages/plugins/ai/src/types/recurrence.type.ts index e53ad281..d6c0d428 100644 --- a/packages/plugins/ai/src/types/recurrence.type.ts +++ b/packages/plugins/ai/src/types/recurrence.type.ts @@ -14,7 +14,7 @@ export interface TempoRecurrenceOptions extends AiParseOptions { /** Number of occurrences to pull per batch (default: 5) */ count?: number | undefined; /** Preferred BCP 47 locale tag for summary output (e.g. 'en-US', 'fr-FR', 'es-ES') */ - locale?: string | undefined; + locale?: string | string[] | undefined; } /** diff --git a/packages/plugins/ai/test/benchmark.spec.ts b/packages/plugins/ai/test/benchmark.spec.ts index 4f1d1e40..75a8273c 100644 --- a/packages/plugins/ai/test/benchmark.spec.ts +++ b/packages/plugins/ai/test/benchmark.spec.ts @@ -1,4 +1,4 @@ -import { normalizeCacheInput, getNamespacedCacheKey } from '../src/core/support.js'; +import { normalizeCacheInput, getNamespacedCacheKey } from '../src/core/cache.js'; describe('AI Support Helpers Benchmark & Integrity', () => { it('should normalize cache input string whitespace and case', () => { diff --git a/packages/plugins/ai/test/cache.test.ts b/packages/plugins/ai/test/cache.test.ts index 0790fdb1..ab9daa7f 100644 --- a/packages/plugins/ai/test/cache.test.ts +++ b/packages/plugins/ai/test/cache.test.ts @@ -1,4 +1,4 @@ -import { parseAI, initAI, clearAiCache, type AiCacheAdapter } from '../src/index.js'; +import { parseAI, initAI, aiCache, type AiCacheAdapter } from '../src/index.js'; import { Tempo } from '@magmacomputing/tempo'; describe('Advanced Cache TTL & Async Storage Adapters', () => { @@ -122,7 +122,7 @@ describe('Advanced Cache TTL & Async Storage Adapters', () => { expect(result.ai?.provider).toBe('groq'); }); - it('should clear custom cacheAdapter entries when clearAiCache is invoked', async () => { + it('should clear custom cacheAdapter entries when aiCache.clear is invoked', async () => { const mockAdapter: AiCacheAdapter = { get: vi.fn(), set: vi.fn(), @@ -135,12 +135,68 @@ describe('Advanced Cache TTL & Async Storage Adapters', () => { cacheAdapter: mockAdapter, }); - await clearAiCache('Easter 2026'); + await aiCache.clear('Easter 2026'); expect(mockAdapter.delete).toHaveBeenCalledWith('easter 2026'); expect(mockAdapter.delete).toHaveBeenCalledWith('Easter 2026'); expect(mockAdapter.clear).toHaveBeenCalledWith('easter 2026::'); - await clearAiCache(); + await aiCache.clear(); expect(mockAdapter.clear).toHaveBeenCalledTimes(2); }); + + it('should protect aiCache object from direct property mutation via secure()', () => { + expect(() => { + (aiCache as any).clear = null; + }).toThrow(); + + expect(() => { + (aiCache as any).newProp = 'tampered'; + }).toThrow(); + + expect(() => { + delete (aiCache as any).clear; + }).toThrow(); + }); + + it('should support store methods on aiCache (set, get, has, delete, clear, entries, toJSON)', async () => { + const store = new Map(); + const mockAdapter: AiCacheAdapter = { + get: vi.fn(async (key: string) => store.get(key)), + set: vi.fn(async (key: string, val: string) => { store.set(key, val); }), + delete: vi.fn(async (key: string) => { store.delete(key); }), + clear: vi.fn(async () => { store.clear(); }), + }; + + await initAI({ + remoteConfigUrl: false, + cacheAdapter: mockAdapter, + }); + + await aiCache.set('custom-key', 'custom-value', 5000); + expect(mockAdapter.set).toHaveBeenCalledWith('custom-key', 'custom-value', 5000); + + const hasKey = await aiCache.has('custom-key'); + expect(hasKey).toBe(true); + + const val = await aiCache.get('custom-key'); + expect(val).toBe('custom-value'); + + const deleted = await aiCache.delete('custom-key'); + expect(deleted).toBe(true); + expect(mockAdapter.delete).toHaveBeenCalledWith('custom-key'); + + const hasAfterDelete = await aiCache.has('custom-key'); + expect(hasAfterDelete).toBe(false); + + await aiCache.set('key-a', 'val-a'); + const json = aiCache.toJSON(); + expect(json['key-a']).toBe('val-a'); + + const entries = Array.from(aiCache.entries()); + expect(entries.some(([k, v]) => k === 'key-a' && v === 'val-a')).toBe(true); + + await aiCache.clear(); + expect(mockAdapter.clear).toHaveBeenCalled(); + expect(await aiCache.has('key-a')).toBe(false); + }); }); diff --git a/packages/plugins/ai/test/debug.test.ts b/packages/plugins/ai/test/debug.test.ts new file mode 100644 index 00000000..ca39417c --- /dev/null +++ b/packages/plugins/ai/test/debug.test.ts @@ -0,0 +1,334 @@ +import util from 'node:util'; +import { Tempo } from '@magmacomputing/tempo'; +import { + initAI, + resetAI, + parseAI, + formatAI, + extractAI, + diffAI, + contextAI, + scheduleAI, + recurrenceAI, +} from '../src/index.js'; +import { maskPii, sanitizeForLog, logDebug, warnDebug, attachCustomInspect } from '../src/core/logger.js'; + +describe('Smart Debug & PII Protection Infrastructure', () => { + const originalEnv = process.env.NODE_ENV; + + beforeEach(async () => { + resetAI(); + Tempo.cache.clear(); + process.env.NODE_ENV = 'test'; + await initAI({ + remoteConfigUrl: false, + providers: [{ id: 'groq', key: 'gsk-1234567890abcdef1234567890' }], + }); + }); + + afterEach(() => { + process.env.NODE_ENV = originalEnv; + resetAI(); + Tempo.cache.clear(); + vi.restoreAllMocks(); + }); + + describe('maskPii utility', () => { + it('should preserve full text when isProd is false (development mode)', () => { + const raw = 'Contact user john.smith@company.org or call +1-555-867-5309 with Bearer sk-ant-secret12345'; + const masked = maskPii(raw, false); + expect(masked).toBe(raw); + }); + + it('should mask emails, phone numbers, and bearer tokens when isProd is true', () => { + const raw = 'Reach out to support@magma.com or sales.desk@domain.co.uk'; + const masked = maskPii(raw, true); + expect(masked).not.toContain('support@magma.com'); + expect(masked).not.toContain('sales.desk@domain.co.uk'); + expect(masked).toContain('s***@magma.com'); + expect(masked).toContain('s***@domain.co.uk'); + }); + + it('should mask phone numbers in production', () => { + const raw = 'Direct line: +1-555-867-5309 or 555-123-4567'; + const masked = maskPii(raw, true); + expect(masked).toContain('***-***-5309'); + expect(masked).toContain('***-***-4567'); + }); + + it('should mask API keys and bearer tokens in production', () => { + const raw = 'Authorization: Bearer gsk_99887766554433221100 and key sk-proj-1234567890abcdef1234'; + const masked = maskPii(raw, true); + expect(masked).toContain('Bearer gsk_...1100'); + expect(masked).toContain('sk-pr...1234'); + }); + + it('should recognize production environment aliases (prod, live, PROD=true)', () => { + process.env.NODE_ENV = 'prod'; + expect(maskPii('email test@corp.com')).toContain('t***@corp.com'); + + process.env.NODE_ENV = 'live'; + expect(maskPii('email test@corp.com')).toContain('t***@corp.com'); + + process.env.NODE_ENV = 'development'; + process.env.PROD = 'true'; + expect(maskPii('email test@corp.com')).toContain('t***@corp.com'); + delete process.env.PROD; + }); + }); + + describe('sanitizeForLog utility', () => { + it('should truncate strings exceeding 256 characters in production', () => { + const longStr = 'A'.repeat(400); + const sanitizedProd = sanitizeForLog(longStr, true); + expect(typeof sanitizedProd).toBe('string'); + expect((sanitizedProd as string).length).toBeLessThan(400); + expect((sanitizedProd as string)).toContain('... [truncated'); + + const sanitizedDev = sanitizeForLog(longStr, false); + expect(sanitizedDev).toBe(longStr); + }); + + it('should recursively sanitize objects and mask sensitive values in production', () => { + const payload = { + user: 'alice@example.com', + details: { + phone: '555-987-6543', + notes: 'Regular note', + }, + tags: ['confidential: Bearer secret-token-123456'], + }; + + const sanitized = sanitizeForLog(payload, true) as any; + expect(sanitized.user).toBe('a***@example.com'); + expect(sanitized.details.phone).toBe('***-***-6543'); + expect(sanitized.tags[0]).toContain('Bearer secr...3456'); + }); + }); + + describe('attachCustomInspect & Proxy Introspection', () => { + it('should redact inspect/JSON output while maintaining 100% in-memory data integrity', () => { + const rawMeta = { + rawPrompt: 'Meeting with ceo@acme.com on next Friday', + reasoning: 'Parsed meeting request for next Friday', + confidence: 0.95, + }; + + const inspectable = attachCustomInspect(rawMeta, (obj, isProd) => ({ + confidence: obj.confidence, + rawPrompt: maskPii(obj.rawPrompt, isProd), + reasoning: maskPii(obj.reasoning, isProd), + })); + + // In-memory data is completely unredacted + expect(inspectable.rawPrompt).toBe('Meeting with ceo@acme.com on next Friday'); + expect(inspectable.reasoning).toBe('Parsed meeting request for next Friday'); + + // Custom inspect in production + process.env.NODE_ENV = 'production'; + const inspectCustomSymbol = Symbol.for('nodejs.util.inspect.custom'); + const inspectFn = (inspectable as any)[inspectCustomSymbol]; + expect(typeof inspectFn).toBe('function'); + + const inspectedProd = inspectFn(); + expect(inspectedProd.rawPrompt).toContain('c***@acme.com'); + expect(inspectedProd.rawPrompt).not.toContain('ceo@acme.com'); + + const jsonProd = (inspectable as any).toJSON(); + expect(jsonProd.rawPrompt).toContain('c***@acme.com'); + + // In-memory data still untouched + expect(inspectable.rawPrompt).toBe('Meeting with ceo@acme.com on next Friday'); + + // Node.js util.inspect integration + const terminalOutput = util.inspect(inspectable); + expect(terminalOutput).toContain('c***@acme.com'); + expect(terminalOutput).not.toContain('ceo@acme.com'); + }); + }); + + describe('Smart Logger (logDebug / warnDebug)', () => { + it('should only log when debug flag is active', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + logDebug('test-tag', 'Message 1', undefined, { debug: false }); + expect(logSpy).not.toHaveBeenCalled(); + + logDebug('test-tag', 'Message 2', undefined, { debug: true }); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy.mock.calls[0][0]).toContain('[test-tag] Message 2'); + + warnDebug('test-tag', 'Warning 1', new Error('Err'), { debug: false }); + expect(warnSpy).not.toHaveBeenCalled(); + + warnDebug('test-tag', 'Warning 2', new Error('Err'), { debug: true }); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('should sanitize PII in console.log when in production environment', () => { + process.env.NODE_ENV = 'production'; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + logDebug('test-tag', 'User email is sensitive@corp.com with token Bearer sk-1234567890', undefined, { debug: true }); + expect(logSpy).toHaveBeenCalledTimes(1); + const loggedMsg = logSpy.mock.calls[0][0]; + expect(loggedMsg).not.toContain('sensitive@corp.com'); + expect(loggedMsg).toContain('s***@corp.com'); + expect(loggedMsg).toContain('Bearer sk-1...7890'); + }); + }); + + describe('End-to-End AI Function Inspect Hardening', () => { + it('should protect parseAI returned Tempo instance metadata', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + iso: '2026-08-15T10:00:00Z', + confidence: 0.99, + reasoning: 'User john.doe@example.com requested next Saturday', + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const target = await parseAI('Meeting with john.doe@example.com next Saturday', { debug: true }); + expect(target.isValid).toBe(true); + + // In-memory access is 100% full fidelity + expect(target.ai?.rawPrompt).toBe('Meeting with john.doe@example.com next Saturday'); + expect(target.ai?.reasoning).toBe('User john.doe@example.com requested next Saturday'); + + // In production, util.inspect output masks PII + process.env.NODE_ENV = 'production'; + const terminalLog = util.inspect(target.ai); + expect(terminalLog).toContain('j***@example.com'); + expect(terminalLog).not.toContain('john.doe@example.com'); + }); + + it('should protect formatAI result object inspection', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: 'Saturday morning at 10:00 AM', + confidence: 0.95, + reasoning: 'Formatted for client alice.smith@partner.org with note call 555-123-4567', + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const res = await formatAI(new Tempo('2026-08-15T10:00:00Z'), 'friendly tone'); + expect(res.formatted).toBe('Saturday morning at 10:00 AM'); + expect(res.reasoning).toContain('alice.smith@partner.org'); + + // Under production inspect + process.env.NODE_ENV = 'production'; + const inspected = util.inspect(res); + expect(inspected).toContain('a***@partner.org'); + expect(inspected).not.toContain('alice.smith@partner.org'); + expect(inspected).toContain('***-***-4567'); + }); + + it('should protect extractAI result object inspection', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + events: [{ + label: 'Interview with candidate bob.ross@art.com', + start: '2026-08-15T14:00:00Z', + type: 'point', + rawText: 'Interview bob.ross@art.com (555-888-9999) at 2pm', + }], + confidence: 0.98, + reasoning: 'Extracted single candidate event', + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const res = await extractAI('Interview bob.ross@art.com (555-888-9999) at 2pm'); + expect(res.events[0].label).toBe('Interview with candidate bob.ross@art.com'); + expect(res.events[0].rawText).toContain('555-888-9999'); + + // Under production inspect + process.env.NODE_ENV = 'production'; + const inspected = util.inspect(res); + expect(inspected).toContain('b***@art.com'); + expect(inspected).not.toContain('bob.ross@art.com'); + expect(inspected).toContain('***-***-9999'); + }); + + it('should protect diffAI result object inspection', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + formatted: '3 business days', + confidence: 0.95, + reasoning: 'Calculated for ticket user#42 (urgent contact 555-333-2222)', + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const res = await diffAI(new Tempo('2026-08-10T09:00:00Z'), new Tempo('2026-08-13T09:00:00Z'), 'in business days'); + expect(res.reasoning).toContain('555-333-2222'); + + process.env.NODE_ENV = 'production'; + const inspected = util.inspect(res); + expect(inspected).toContain('***-***-2222'); + expect(inspected).not.toContain('555-333-2222'); + }); + + it('should protect scheduleAI result object inspection', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + start: '2026-08-17T10:00:00Z', + end: '2026-08-17T11:00:00Z', + summary: 'Meeting with client client@enterprise.com', + reasoning: 'Found available 1hr slot for client@enterprise.com', + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const res = await scheduleAI('Schedule 1 hour with client@enterprise.com next Monday'); + expect(res.summary).toBe('Meeting with client client@enterprise.com'); + + process.env.NODE_ENV = 'production'; + const inspected = util.inspect(res); + expect(inspected).toContain('c***@enterprise.com'); + expect(inspected).not.toContain('client@enterprise.com'); + }); + + it('should protect recurrenceAI result object inspection', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + rrule: 'FREQ=WEEKLY;BYDAY=TU;BYHOUR=15', + summary: 'Weekly sync with dev-team@internal.org at 3pm', + reasoning: 'Configured recurring meeting for dev-team@internal.org', + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const res = await recurrenceAI('Every Tuesday at 3pm for dev-team@internal.org'); + expect(res.summary).toBe('Weekly sync with dev-team@internal.org at 3pm'); + + process.env.NODE_ENV = 'production'; + const inspected = util.inspect(res); + expect(inspected).toContain('d***@internal.org'); + expect(inspected).not.toContain('dev-team@internal.org'); + }); + }); +}); diff --git a/packages/plugins/ai/test/extract.test.ts b/packages/plugins/ai/test/extract.test.ts index cfa326bf..ef3afba7 100644 --- a/packages/plugins/ai/test/extract.test.ts +++ b/packages/plugins/ai/test/extract.test.ts @@ -12,6 +12,7 @@ import { describe('AI Extract Plugin (extractAI)', () => { beforeEach(async () => { resetAI(); + Tempo.cache.clear(); vi.spyOn(console, 'warn').mockImplementation(() => { }); vi.spyOn(console, 'error').mockImplementation(() => { }); vi.spyOn(console, 'log').mockImplementation(() => { }); @@ -20,6 +21,7 @@ describe('AI Extract Plugin (extractAI)', () => { afterEach(() => { resetAI(); + Tempo.cache.clear(); vi.restoreAllMocks(); }); @@ -175,7 +177,7 @@ describe('AI Extract Plugin (extractAI)', () => { set: vi.fn(async (key: string, val: string) => { cacheStore.set(key, val); }), - }; + } const text = 'Dentist appointment on August 15 from 9am to 10am.'; const anchor = new Tempo('2026-08-01T00:00:00Z'); @@ -208,6 +210,14 @@ describe('AI Extract Plugin (extractAI)', () => { }); it('should support force: true and cache: false bypass options', async () => { + const cacheStore = new Map(); + const customAdapter: AiCacheAdapter = { + get: vi.fn(async (key: string) => cacheStore.get(key)), + set: vi.fn(async (key: string, val: string) => { + cacheStore.set(key, val); + }), + } + const fetchSpy = vi.spyOn(globalThis, 'fetch'); const mockResponse = () => new Response(JSON.stringify({ choices: [{ @@ -227,54 +237,77 @@ describe('AI Extract Plugin (extractAI)', () => { }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - fetchSpy.mockResolvedValueOnce(mockResponse()).mockResolvedValueOnce(mockResponse()); + fetchSpy + .mockResolvedValueOnce(mockResponse()) + .mockResolvedValueOnce(mockResponse()) + .mockResolvedValueOnce(mockResponse()); const text = '1-on-1 catchup on Wednesday at 3pm.'; const anchor = new Tempo('2026-08-10T09:00:00Z'); - await extractAI(text, { anchor, timeZone: 'UTC' }); + await extractAI(text, { anchor, timeZone: 'UTC', cacheAdapter: customAdapter }); expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(customAdapter.set).toHaveBeenCalledTimes(1); // force: true should make a new fetch - const forcedResult = await extractAI(text, { anchor, timeZone: 'UTC', force: true }); + const forcedResult = await extractAI(text, { anchor, timeZone: 'UTC', force: true, cacheAdapter: customAdapter }); expect(forcedResult.provider).toBe('groq'); expect(fetchSpy).toHaveBeenCalledTimes(2); + + // cache: false should skip writing to cache + customAdapter.set = vi.fn(); + const uncachedResult = await extractAI('Another catchup on Thursday at 4pm.', { + anchor, + timeZone: 'UTC', + cache: false, + cacheAdapter: customAdapter, + }); + expect(uncachedResult.provider).toBe('groq'); + expect(customAdapter.set).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(3); }); - it('should reject invalid text and anchor inputs with TempoAiError(400)', async () => { + it('should reject invalid text and anchor inputs with TempoAiError(400) and preserve error cause', async () => { await expect(extractAI('')) - .rejects.toThrow(new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400)); + .rejects.toMatchObject({ message: 'Invalid text input provided to extractAI: text must be a non-empty string.', status: 400 }); await expect(extractAI(' ')) - .rejects.toThrow(new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400)); + .rejects.toMatchObject({ message: 'Invalid text input provided to extractAI: text must be a non-empty string.', status: 400 }); await expect(extractAI(null as any)) - .rejects.toThrow(new TempoAiError('Invalid text input provided to extractAI: text must be a non-empty string.', 400)); - - await expect(extractAI('some text', { anchor: 'invalid-anchor-date' })) - .rejects.toThrow(/Invalid anchor date provided to extractAI/i); + .rejects.toMatchObject({ message: 'Invalid text input provided to extractAI: text must be a non-empty string.', status: 400 }); + + let caughtErr: any; + try { + await extractAI('some text', { anchor: 'invalid-anchor-date' }); + } catch (err: any) { + caughtErr = err; + } + expect(caughtErr).toBeInstanceOf(TempoAiError); + expect(caughtErr.status).toBe(400); + expect(caughtErr.cause).toBeDefined(); }); it('should validate minConfidence and reject non-finite and out-of-range thresholds before cache read or provider calls', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); const customAdapter: AiCacheAdapter = { get: vi.fn(async () => undefined), - set: vi.fn(async () => {}), - }; + set: vi.fn(async () => { }), + } // Non-finite await expect(extractAI('some text', { minConfidence: NaN, cacheAdapter: customAdapter })) - .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "NaN"', 400)); + .rejects.toMatchObject({ message: 'Invalid minConfidence provided to extractAI: "NaN"', status: 400 }); await expect(extractAI('some text', { minConfidence: Infinity, cacheAdapter: customAdapter })) - .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "Infinity"', 400)); + .rejects.toMatchObject({ message: 'Invalid minConfidence provided to extractAI: "Infinity"', status: 400 }); // Out-of-bounds await expect(extractAI('some text', { minConfidence: -0.5, cacheAdapter: customAdapter })) - .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "-0.5"', 400)); + .rejects.toMatchObject({ message: 'Invalid minConfidence provided to extractAI: "-0.5"', status: 400 }); await expect(extractAI('some text', { minConfidence: 1.2, cacheAdapter: customAdapter })) - .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to extractAI: "1.2"', 400)); + .rejects.toMatchObject({ message: 'Invalid minConfidence provided to extractAI: "1.2"', status: 400 }); expect(customAdapter.get).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled(); @@ -300,7 +333,7 @@ describe('AI Extract Plugin (extractAI)', () => { it('should throw TempoAiError(400) when no providers are configured', async () => { resetAI(); await expect(extractAI('Meeting tomorrow at 10am')) - .rejects.toThrow(new TempoAiError('No AI providers configured. Please call initAI().', 400)); + .rejects.toMatchObject({ message: 'No AI providers configured. Please call initAI().', status: 400 }); }); it('should support multi-provider race execution mode', async () => { @@ -352,8 +385,13 @@ describe('AI Extract Plugin (extractAI)', () => { it('should support batch array processing with softErrors', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); - fetchSpy - .mockResolvedValueOnce(new Response(JSON.stringify({ + fetchSpy.mockImplementation(async (_url, init) => { + const body = JSON.parse(init?.body as string); + const hasFailedPrompt = body.messages?.some((m: any) => m.content?.includes('Another event')); + if (hasFailedPrompt) { + return new Response('Internal Error', { status: 500 }); + } + return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({ @@ -362,8 +400,8 @@ describe('AI Extract Plugin (extractAI)', () => { }), }, }], - }), { status: 200, headers: { 'Content-Type': 'application/json' } })) - .mockResolvedValueOnce(new Response('Internal Error', { status: 500 })); + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); const inputs = ['Meeting tomorrow at 9am', 'Another event']; const results = await extractAI(inputs, { @@ -407,4 +445,47 @@ describe('AI Extract Plugin (extractAI)', () => { clone.confidence = 0.5; expect(clone.confidence).toBe(0.5); }); + + it('should log isDebug warnings for malformed cache items or provider events without changing control flow', async () => { + const warnSpy = vi.spyOn(console, 'warn'); + const customAdapter: AiCacheAdapter = { + get: vi.fn(async () => JSON.stringify({ + events: [{ label: 'Malformed', start: null }], + confidence: 0.9, + })), + set: vi.fn(async () => { }), + } + + const result = await extractAI('Dentist appointment tomorrow', { + debug: true, + cacheAdapter: customAdapter, + }); + expect(result.events).toHaveLength(0); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[tempo-plugin-ai:extract] Failed to rehydrate cached event:'), + expect.anything(), + ); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + events: [{ label: 'Malformed Provider Event', start: null }], + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const providerResult = await extractAI('Sync meeting', { + debug: true, + force: true, + }); + expect(providerResult.events).toHaveLength(0); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("[tempo-plugin-ai:extract] Failed to parse event from provider 'groq':"), + expect.anything(), + ); + }); }); diff --git a/packages/plugins/ai/test/format.test.ts b/packages/plugins/ai/test/format.test.ts index 87d2a1ca..8dfdbb3c 100644 --- a/packages/plugins/ai/test/format.test.ts +++ b/packages/plugins/ai/test/format.test.ts @@ -225,20 +225,20 @@ describe('AI Format Plugin (formatAI)', () => { // Non-finite values await expect(formatAI('2026-08-07', 'test', { minConfidence: NaN, cacheAdapter: customAdapter })) - .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "NaN"', 400)); + .rejects.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "NaN"', status: 400 }); await expect(formatAI('2026-08-07', 'test', { minConfidence: Infinity, cacheAdapter: customAdapter })) - .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "Infinity"', 400)); + .rejects.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "Infinity"', status: 400 }); await expect(formatAI('2026-08-07', 'test', { minConfidence: -Infinity, cacheAdapter: customAdapter })) - .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "-Infinity"', 400)); + .rejects.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "-Infinity"', status: 400 }); // Out-of-range values await expect(formatAI('2026-08-07', 'test', { minConfidence: -0.1, cacheAdapter: customAdapter })) - .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "-0.1"', 400)); + .rejects.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "-0.1"', status: 400 }); await expect(formatAI('2026-08-07', 'test', { minConfidence: 1.05, cacheAdapter: customAdapter })) - .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "1.05"', 400)); + .rejects.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "1.05"', status: 400 }); // Verify neither cache nor provider fetch was called expect(customAdapter.get).not.toHaveBeenCalled(); @@ -253,7 +253,7 @@ describe('AI Format Plugin (formatAI)', () => { }); await expect(formatAI('2026-08-07', 'test')) - .rejects.toThrow(new TempoAiError('Invalid minConfidence provided to formatAI: "1.5"', 400)); + .rejects.toMatchObject({ message: 'Invalid minConfidence provided to formatAI: "1.5"', status: 400 }); }); it('should support multi-provider race execution mode', async () => { @@ -302,8 +302,13 @@ describe('AI Format Plugin (formatAI)', () => { it('should support batch array processing with softErrors', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); - fetchSpy - .mockResolvedValueOnce(new Response(JSON.stringify({ + fetchSpy.mockImplementation(async (_url, init) => { + const body = JSON.parse(init?.body as string); + const hasFailedPrompt = body.messages?.some((m: any) => m.content?.includes('item 2')); + if (hasFailedPrompt) { + return new Response('Server Error', { status: 500 }); + } + return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({ @@ -312,8 +317,8 @@ describe('AI Format Plugin (formatAI)', () => { }), }, }], - }), { status: 200, headers: { 'Content-Type': 'application/json' } })) - .mockResolvedValueOnce(new Response('Server Error', { status: 500 })); + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); const items = [ { date: '2026-08-03', prompt: 'item 1' }, @@ -328,8 +333,13 @@ describe('AI Format Plugin (formatAI)', () => { it('should reject with TempoAiError on batch failure when softErrors is false', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); - fetchSpy - .mockResolvedValueOnce(new Response(JSON.stringify({ + fetchSpy.mockImplementation(async (_url, init) => { + const body = JSON.parse(init?.body as string); + const hasFailedPrompt = body.messages?.some((m: any) => m.content?.includes('item 2')); + if (hasFailedPrompt) { + return new Response('Server Error', { status: 500 }); + } + return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({ @@ -338,8 +348,8 @@ describe('AI Format Plugin (formatAI)', () => { }), }, }], - }), { status: 200, headers: { 'Content-Type': 'application/json' } })) - .mockResolvedValueOnce(new Response('Server Error', { status: 500 })); + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); const items = [ { date: '2026-08-03', prompt: 'item 1' }, diff --git a/packages/plugins/ai/test/parse.test.ts b/packages/plugins/ai/test/parse.test.ts index a72d8dd3..1d20c875 100644 --- a/packages/plugins/ai/test/parse.test.ts +++ b/packages/plugins/ai/test/parse.test.ts @@ -1,4 +1,4 @@ -import { parseAI, initAI, clearAiCache, getAiRateLimits, getAiConfig, TempoAiError, AiMode } from '../src/index.js'; +import { parseAI, initAI, aiCache, getAiRateLimits, getAiConfig, TempoAiError, AiMode } from '../src/index.js'; import { BoundedCache } from '@magmacomputing/tempo/support'; import { Tempo } from '@magmacomputing/tempo'; @@ -156,7 +156,7 @@ describe('AI Parsing Plugin (parseAI)', () => { it('should cache the result and mark provider as "cache"', async () => { const anchorDate = '2026-05-10T12:00:00Z'; - clearAiCache('The Friday after Thanksgiving'); + await aiCache.clear('The Friday after Thanksgiving'); const fetchSpy = vi.spyOn(globalThis, 'fetch'); if (!isLiveTest) { @@ -463,13 +463,13 @@ describe('AI Parsing Plugin (parseAI)', () => { expect(Array.from(cache.keys())).not.toContain('tempKey'); }); - it('should preserve clearAiCache functionality', async () => { + it('should preserve aiCache.clear functionality', async () => { const cache = new BoundedCache(100); cache.set('Thanksgiving::2026-05-10', '2026-11-26T00:00:00Z'); cache.set('Christmas::2026-05-10', '2026-12-25T00:00:00Z'); await initAI({ remoteConfigUrl: false, cache }); - clearAiCache('Thanksgiving'); + await aiCache.clear('Thanksgiving'); expect(cache.has('Thanksgiving::2026-05-10')).toBe(false); expect(cache.has('Christmas::2026-05-10')).toBe(true); From a093d58548facaa64a57b8ae68c60aec62c57dd4 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sat, 15 Aug 2026 14:52:47 +1000 Subject: [PATCH 5/7] PR extractAI 2nd review --- packages/plugins/.std/src/term.quarter.ts | 2 +- packages/plugins/.std/src/term.season.ts | 2 +- packages/plugins/ai/CHANGELOG.md | 4 +- packages/plugins/ai/doc/init.md | 2 +- packages/plugins/ai/doc/rate-limits.md | 4 +- packages/plugins/ai/doc/schedule.md | 17 ++-- packages/plugins/ai/doc/security.md | 12 +-- packages/plugins/ai/src/core/cache.ts | 49 ++++++++++- packages/plugins/ai/src/core/dispatch.ts | 15 +++- packages/plugins/ai/src/core/init.ts | 5 +- packages/plugins/ai/src/core/logger.ts | 44 ++++++---- packages/plugins/ai/src/core/support.ts | 10 +-- packages/plugins/ai/src/functions/diff.ts | 8 +- packages/plugins/ai/src/functions/extract.ts | 9 +- packages/plugins/ai/src/functions/parse.ts | 4 +- .../plugins/ai/src/functions/recurrence.ts | 4 +- packages/plugins/ai/src/functions/schedule.ts | 13 ++- .../plugins/ai/src/types/schedule.type.ts | 15 +++- packages/plugins/ai/test/debug.test.ts | 36 +++++++- packages/plugins/ai/test/recurrence.test.ts | 4 +- packages/plugins/astro/CHANGELOG.md | 5 ++ packages/plugins/astro/package.json | 2 +- packages/plugins/astro/src/index.ts | 2 +- .../doc/2-core-concepts/tempo.getters.md | 4 +- .../tempo/doc/2-core-concepts/tempo.parse.md | 2 +- .../tempo/doc/3-extending-tempo/tempo.term.md | 2 +- .../community-traction-and-stars-strategy.md | 87 +++++++++++++++++++ .../tempo/src/engine/engine.normalizer.ts | 3 +- packages/tempo/src/module/module.mutate.ts | 2 +- packages/tempo/src/plugin/term/term.util.ts | 8 +- packages/tempo/src/support/support.enum.ts | 2 +- packages/tempo/src/tempo.class.ts | 5 +- packages/tempo/src/tempo.type.ts | 3 +- packages/tempo/test/core/accessors.test.ts | 10 ++- .../tempo/test/core/static.getters.test.ts | 29 +++++++ packages/tempo/test/core/static.test.ts | 2 +- 36 files changed, 337 insertions(+), 90 deletions(-) create mode 100644 packages/tempo/plan/community-traction-and-stars-strategy.md diff --git a/packages/plugins/.std/src/term.quarter.ts b/packages/plugins/.std/src/term.quarter.ts index 8440f6c9..f22b5ab7 100644 --- a/packages/plugins/.std/src/term.quarter.ts +++ b/packages/plugins/.std/src/term.quarter.ts @@ -18,7 +18,7 @@ const groups = defineRange([ /** resolve the full candidate list for the current context */ function resolve(t: Tempo, anchor?: any): any[] { - if (t.config.sphere === undefined && anchor?.sphere === undefined) { + if (t.sphere === undefined && anchor?.sphere === undefined) { logWarn(`[tempo] QuarterTerm requires 'sphere' configuration (e.g. Tempo.init({ sphere: 'north' }) or { sphere: 'south' }).`, t.config); return []; } diff --git a/packages/plugins/.std/src/term.season.ts b/packages/plugins/.std/src/term.season.ts index 222ec400..441e8b01 100644 --- a/packages/plugins/.std/src/term.season.ts +++ b/packages/plugins/.std/src/term.season.ts @@ -18,7 +18,7 @@ const groups = defineRange([ /** resolve the full candidate list for the current context */ function resolve(t: Tempo, anchor?: any) { - if (t.config.sphere === undefined && anchor?.sphere === undefined) { + if (t.sphere === undefined && anchor?.sphere === undefined) { logWarn(`[tempo] SeasonTerm requires 'sphere' configuration (e.g. Tempo.init({ sphere: 'north' }) or { sphere: 'south' }).`, t.config); return []; } diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index 2bdb3c44..664e1e13 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -5,9 +5,10 @@ All notable changes to the `@magmacomputing/tempo-plugin-ai` project will be doc The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.3.0] - 2026-08-10 +## [1.0.0] - 2026-08-15 ### Added +- **Cache Eviction Synchronization**: Enhanced `aiCache.clear()` to flush matching keys and prefixes across both `Tempo.cache` and custom `AiCacheAdapter` storage engines, returning `Promise` to await async adapter eviction. - **Temporal Difference & Relative Grounding (`diffAI`)**: Added natural language temporal difference calculation and narrative summarization between two `Tempo` points, dates, or timestamps. - Pre-computes mathematical grounding metrics (`calendarDays`, `elapsedHours`, `businessDays` with weekend and holiday exclusion) to provide strict arithmetic backing for LLM narrative formatting. - Supports domain-specific delta formatting (e.g. accounting terms, working days, human relative explanations, or business SLAs). @@ -35,7 +36,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Cascading Cache TTL Hierarchy**: Implemented strict 4-tier TTL resolution (`options.ttl` > `provider.ttl` > `global config.ttl` > default 1 hour / 3,600,000 ms for `parseAI` or 24 hours / 86,400,000 ms for context/difference handlers) for fine-grained cache entry expiration control on stores enforcing TTL. - **Fail-Open Storage Resilience**: Custom cache adapter errors during `get()` or `set()` operations fail open silently to direct LLM resolution, preserving application request uptime. - **Dynamic Remote Provider Manifest (`loadRemoteManifest`)**: Lazily fetches remote provider defaults (`providers.v1.json`) on initialization with a 1500ms timeout and automatic air-gapped fallback to compiled `DEFAULT_PROVIDERS`. -- **Cache Eviction Synchronization**: Enhanced `aiCache.clear()` to flush matching keys and prefixes across both `Tempo.cache` and custom `AiCacheAdapter` storage engines, returning `Promise` to await async adapter eviction. ### Changed & Hardened - **Consensus Mode TTL Resolution**: Fixed a runtime bug where standard provider TTL lookups failed in Consensus mode due to the synthetic sentinel provider ID (`'consensus'`), which caused lookups on the winning provider array to return undefined. Now reduces over all participating provider configs to select the minimum (most conservative) TTL. diff --git a/packages/plugins/ai/doc/init.md b/packages/plugins/ai/doc/init.md index 3e518eaf..84265d11 100644 --- a/packages/plugins/ai/doc/init.md +++ b/packages/plugins/ai/doc/init.md @@ -80,7 +80,7 @@ const dt = await parseAI("Next Tuesday at 4pm", { timeout: 3000 }); **Operational Trace Logging** Passing `debug: true` into `initAI` (or setting `{ debug: true }` on a per-request options object) outputs concise operational trace logs (prefixed with `[tempo-plugin-ai]`) to the developer console for monitoring provider fallback decisions and execution timing. -Detailed diagnostic context—including `rawPrompt`, `normalizedPrompt`, `reasoning`, confidence scores, and rate limit snapshots—is attached directly to the returned `Tempo` instance via the `.ai` property for `parseAI`, or surfaced directly on the typed result object (`res.reasoning`, `res.confidence`, `res.ai`) for other AI functions when `debug: true` is enabled. +Detailed diagnostic context—including provider resolution, execution lineage, confidence scores, and when `debug: true` is active, `rawPrompt`, `normalizedPrompt`, and rate-limit snapshots—is attached directly to the returned `Tempo` instance via the `.ai` property for `parseAI`. Structured functions (`formatAI`, `diffAI`, `extractAI`, `contextAI`, `scheduleAI`) surface their respective typed properties directly on the result object (such as `res.confidence`, `res.provider`, and optional `res.reasoning`). > [!TIP] > **Smart Debug & Proxy Introspection**: In production environments (`NODE_ENV === 'production'`), terminal logging via `console.log(date.ai)` or `console.log(result)` automatically sanitizes and masks PII (emails, phones, bearer tokens) while preserving 100% in-memory data integrity for application code. Refer to the [Security & Privacy Architecture Guide](./security.md). diff --git a/packages/plugins/ai/doc/rate-limits.md b/packages/plugins/ai/doc/rate-limits.md index 187017e0..a6c944fd 100644 --- a/packages/plugins/ai/doc/rate-limits.md +++ b/packages/plugins/ai/doc/rate-limits.md @@ -67,14 +67,14 @@ When you pass an array of inputs to AI functions (such as `parseAI`), the plugin This is by design for three critical reasons: 1. **Cache Efficiency**: Individual processing allows AI functions to instantly resolve duplicate strings against `Tempo.cache`, saving massive amounts of API tokens. If you pass an array of 10,000 dates, but only 1,000 are unique, only 1,000 network requests are made. 2. **Token Economics**: A single request consumes ~100 tokens (System Prompt + User String + Output ISO). Given that frontier models cost cents per million tokens, the risk of array-misalignment bugs (see below) far outweighs the negligible savings of batching system prompts. -3. **Deterministic Safety**: LLMs are language models, not arrays. If you pass 50 strings, smaller models often hallucinate and return 49 strings, completely breaking your array indexing. By querying sequentially, we guarantee a strict 1:1 mapping and ensure one invalid string doesn't crash the entire batch. +3. **Deterministic Safety**: LLMs are language models, not arrays. If you pass 50 strings in a single prompt, smaller models often hallucinate and return 49 strings, completely breaking your array indexing. By dispatching items individually, we guarantee a strict 1:1 index alignment, prevent hallucinations from corrupting sibling entries, and allow granular per-item error isolation when `softErrors: true` is enabled. > [!WARNING] > **Granular Time Gotcha**: The cache key is automatically salted with the **calendar date** (`yyyy-mm-dd`) of the execution anchor. By default this uses the system execution date, but when `options.anchor` is explicitly set, it uses the caller-provided anchor date. Note that keeping a fixed anchor date retains the same cache key across midnight boundaries, so an automatic midnight cache miss is not guaranteed. ### Soft Errors in Array Batches -When processing arrays of inputs, an unparseable input or provider failure on one item will throw an error by default, halting execution of the entire batch. Passing `softErrors: true` allows batch operations to gracefully complete the rest of the array: +When processing arrays of inputs, an unparseable input or provider failure on one item will throw an error and reject the entire batch operation by default. Passing `softErrors: true` allows batch operations to continue processing all items and return per-item failure representations: * **For `parseAI`**: Failed array items return an invalid `Tempo` instance (`isValid === false`). * **For Structured Functions (`formatAI`, `extractAI`, `diffAI`, `contextAI`)**: Failed array items return the typed `TempoAiError` object directly in that array position. diff --git a/packages/plugins/ai/doc/schedule.md b/packages/plugins/ai/doc/schedule.md index 98e3b401..3a05ff6a 100644 --- a/packages/plugins/ai/doc/schedule.md +++ b/packages/plugins/ai/doc/schedule.md @@ -37,7 +37,7 @@ console.log(booking.ai?.conflictBumped); // true (pushed | Option | Type | Description | | :--- | :--- | :--- | | **`anchor`** | `TempoDateInput` | Anchor reference time to evaluate relative dates from. Defaults to workstation/browser current time. | -| **`events`** | `Array<{ start: any; end: any; title?: string } \| TempoInterval \| Interval>` | A list of existing busy calendar intervals that the meeting must not overlap with. | +| **`events`** | `Array` | A list of existing busy calendar intervals or booked events that the meeting must not overlap with. | | **`workingHours`** | `TempoWorkingHours` | Daily time window constraint (HH:MM formats) inside which slots must fit. | | **`timeZone`** | `string` | Target IANA timezone to calculate the booking slot and boundaries within. | | **`minConfidence`**| `number` | Minimum confidence score threshold (0.0 to 1.0) required to return a valid slot. | @@ -51,14 +51,15 @@ export interface TempoInterval { } ``` -### Event Input Shape (`TempoScheduleOptions.events`) -The `events` option accepts raw event objects, continuous `TempoInterval` pairs, or native `Interval` instances: +### Event Input Shape (`ScheduleEventInput`) +The `events` (or `intervals`) option accepts raw event objects, continuous `TempoInterval` pairs, native `Interval` instances, or `[start, end]` tuples: ```typescript -type ScheduleEventInput = { - start: TempoDateInput; - end: TempoDateInput; - title?: string; -} | TempoInterval | Interval; +export type ScheduleEventInput = + | { start: TempoDateInput; end: TempoDateInput; title?: string; label?: string } + | TempoInterval + | Interval + | [TempoDateInput, TempoDateInput] + | TempoDateInput; ``` --- diff --git a/packages/plugins/ai/doc/security.md b/packages/plugins/ai/doc/security.md index fd4c11e0..b0a00da6 100644 --- a/packages/plugins/ai/doc/security.md +++ b/packages/plugins/ai/doc/security.md @@ -111,14 +111,14 @@ const rawReasoning = result.reasoning; ## 5. Ephemeral Processing & Partitioned Caching -### Zero Data Retention Policy +### Zero External Telemetry Policy * The plugin does not transmit telemetry, analytics, or prompt logs to external tracking servers. -* Prompts and temporal calculations exist ephemerally in memory during execution. +* Prompt processing and temporal computations occur ephemerally during request execution. -### Tenant-Isolated Partitioned Caching -* **Namespaced Cache Keys**: Cache keys are generated with multi-factor hashing (`ai:::`) incorporating the user prompt, target timezone, locale, calendar system, and anchor date. -* **Zero Cross-Contamination**: Isolated cache keys prevent cross-tenant and cross-regional data leakage. -* **Granular Bypass Controls**: Operations requiring zero cache persistence can supply `cache: false` or `force: true` on any individual request. +### Partitioned Multi-Tier Caching +* **Namespaced Cache Keys**: Cache keys are generated with multi-factor domain partitioning (e.g., `diff::`, `format::`, `extract::`) incorporating the prompt text, anchor epoch, target timezone, locale, calendar system, and regional parameters to prevent contextual collision. +* **Storage Lifecycle**: Cached entries persist in the local `Tempo.cache` (`BoundedCache`) or caller-provided `AiCacheAdapter` (e.g. Redis, KV) strictly until TTL expiration or LRU capacity eviction. +* **Granular Bypass Controls**: Operations requiring zero cache persistence can supply `cache: false` or `force: true` on any individual request, or programmatically flush entries using `await aiCache.clear()`. --- diff --git a/packages/plugins/ai/src/core/cache.ts b/packages/plugins/ai/src/core/cache.ts index 38a293f8..dce3283e 100644 --- a/packages/plugins/ai/src/core/cache.ts +++ b/packages/plugins/ai/src/core/cache.ts @@ -6,6 +6,8 @@ import type { AiCacheAdapter } from '../types/index.js'; export const AI_CACHE_NAMESPACE_PREFIX = 'ai:'; +const _entryExpiries = new Map(); + /** * Normalizes input string for deterministic cache lookups by trimming excess whitespace and lowercasing. */ @@ -50,8 +52,14 @@ export async function readMultiTierCache( } } + if (_entryExpiries.has(cacheKey) && Date.now() > _entryExpiries.get(cacheKey)!) { + _entryExpiries.delete(cacheKey); + Tempo.cache.delete(cacheKey); + return undefined; + } + const localVal = Tempo.cache.get(cacheKey); - if (localVal) { + if (localVal !== undefined) { logDebug(tag, `Cache hit (local): ${cacheKey}`, undefined, { debug: options.debug }); return localVal; } @@ -77,6 +85,11 @@ export async function writeMultiTierCache( const tag = options.tag ?? 'tempo-plugin-ai'; Tempo.cache.set(cacheKey, value); + if (typeof ttl === 'number' && Number.isFinite(ttl) && ttl > 0) { + _entryExpiries.set(cacheKey, Date.now() + ttl); + } else { + _entryExpiries.delete(cacheKey); + } const adapter = options.cacheAdapter || _state.config.cacheAdapter; if (adapter) { @@ -108,6 +121,7 @@ export const aiCache = secure({ if (!input) { Tempo.cache.clear(); + _entryExpiries.clear(); if (adapter?.clear) { try { await Promise.resolve(adapter.clear()).catch(() => { }); @@ -123,12 +137,28 @@ export const aiCache = secure({ Tempo.cache.delete(normalized); Tempo.cache.delete(i); Tempo.cache.deletePrefix(prefix); + _entryExpiries.delete(normalized); + _entryExpiries.delete(i); + + const keysToDelete: string[] = []; + for (const [key] of Tempo.cache.entries()) { + if (key.includes(normalized) || key.includes(i)) { + keysToDelete.push(key); + } + } + for (const k of keysToDelete) { + Tempo.cache.delete(k); + _entryExpiries.delete(k); + } if (adapter) { try { if (adapter.delete) { await Promise.resolve(adapter.delete(normalized)).catch(() => { }); await Promise.resolve(adapter.delete(i)).catch(() => { }); + for (const k of keysToDelete) { + await Promise.resolve(adapter.delete(k)).catch(() => { }); + } } if (adapter.clear) { await Promise.resolve(adapter.clear(prefix)).catch(() => { }); @@ -145,12 +175,11 @@ export const aiCache = secure({ * @returns True if the key was present in the in-memory cache, false otherwise */ async delete(key: string): Promise { - const normalized = normalizeCacheInput(key); - const deletedLocal = Tempo.cache.delete(normalized) || Tempo.cache.delete(key); + _entryExpiries.delete(key); + const deletedLocal = Tempo.cache.delete(key); const adapter = _state.config.cacheAdapter; if (adapter?.delete) { try { - await Promise.resolve(adapter.delete(normalized)).catch(() => { }); await Promise.resolve(adapter.delete(key)).catch(() => { }); } catch { } } @@ -164,6 +193,12 @@ export const aiCache = secure({ * @returns The cached string value, or undefined if not found */ async get(key: string): Promise { + if (_entryExpiries.has(key) && Date.now() > _entryExpiries.get(key)!) { + _entryExpiries.delete(key); + Tempo.cache.delete(key); + return undefined; + } + const adapter = _state.config.cacheAdapter; if (adapter?.get) { try { @@ -194,6 +229,12 @@ export const aiCache = secure({ */ async set(key: string, value: string, ttl?: number): Promise { Tempo.cache.set(key, value); + if (typeof ttl === 'number' && Number.isFinite(ttl) && ttl > 0) { + _entryExpiries.set(key, Date.now() + ttl); + } else { + _entryExpiries.delete(key); + } + const adapter = _state.config.cacheAdapter; if (adapter?.set) { try { diff --git a/packages/plugins/ai/src/core/dispatch.ts b/packages/plugins/ai/src/core/dispatch.ts index 1f0808da..9b6c5a8c 100644 --- a/packages/plugins/ai/src/core/dispatch.ts +++ b/packages/plugins/ai/src/core/dispatch.ts @@ -422,9 +422,18 @@ export function filterCooldownProviders( ): AiProvider[] { if (providers.length <= 1) return providers; const now = Date.now(); - const available = providers.filter(p => !isProviderInCooldown(p, now)); - if (available.length > 0 && available.length < providers.length) { - const skipped = providers.filter(p => isProviderInCooldown(p, now)).map(p => p.id); + const available: AiProvider[] = []; + const skipped: string[] = []; + + for (const p of providers) { + if (isProviderInCooldown(p, now)) { + skipped.push(p.id); + } else { + available.push(p); + } + } + + if (available.length > 0 && skipped.length > 0) { logDebug( options?.tag || 'tempo-plugin-ai', `Proactively filtered ${skipped.length} provider(s) in active 429 cooldown: ${skipped.join(', ')}`, diff --git a/packages/plugins/ai/src/core/init.ts b/packages/plugins/ai/src/core/init.ts index ef4cf38a..94ffa31a 100644 --- a/packages/plugins/ai/src/core/init.ts +++ b/packages/plugins/ai/src/core/init.ts @@ -2,6 +2,7 @@ import { Tempo } from '@magmacomputing/tempo'; import { getResolvedProviderDefaults, loadRemoteManifest, resetManifestCache } from './manifest.js'; import { assertNoReservedProviderId } from './support.js'; +import { warnDebug } from './logger.js'; import type { AiConfig, AiRateLimits, AiProvider } from '../types/index.js'; /** @@ -90,7 +91,9 @@ export function initAI(config: AiConfig): Promise { let hookOptions: Partial | null = null; try { hookOptions = await fetchDefaults(normalizedId); - } catch { } + } catch (err: any) { + warnDebug('tempo-plugin-ai:init', `fetchDefaults hook failed for provider '${normalizedId}'`, err, { debug: config.debug ?? _state.config.debug }); + } return { ...defaults, ...(hookOptions ?? {}), diff --git a/packages/plugins/ai/src/core/logger.ts b/packages/plugins/ai/src/core/logger.ts index 342b7f0d..ae7a676c 100644 --- a/packages/plugins/ai/src/core/logger.ts +++ b/packages/plugins/ai/src/core/logger.ts @@ -3,7 +3,7 @@ import { _state } from './init.js'; export const CUSTOM_INSPECT_SYMBOL = Symbol.for('nodejs.util.inspect.custom'); const RE_EMAIL = /[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g; -const RE_PHONE = /(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?(\d{4})/g; +const RE_PHONE = /(?:\+?\d{1,3}[-.\s])?\(?\d{3}\)?[-.\s]\d{3}[-.\s](\d{4})\b/g; const RE_BEARER = /Bearer\s+[A-Za-z0-9_\-\.]+/gi; const RE_API_KEY = /\b(?:sk-[a-zA-Z0-9_\-]{6,}|gsk_[a-zA-Z0-9_\-]{6,}|key-[a-zA-Z0-9_\-]{6,})\b/gi; @@ -59,8 +59,13 @@ export function maskPii(input: string, isProd: boolean = isProductionEnvironment /** * Sanitizes arbitrary objects, arrays, or primitives for safe log printing. + * Protects against circular object references via a visited tracker. */ -export function sanitizeForLog(data: any, isProd: boolean = isProductionEnvironment()): any { +export function sanitizeForLog( + data: any, + isProd: boolean = isProductionEnvironment(), + visited: WeakSet = new WeakSet(), +): any { if (data === null || data === undefined) return data; if (!isProd) return data; @@ -71,21 +76,24 @@ export function sanitizeForLog(data: any, isProd: boolean = isProductionEnvironm } return masked; } - if (typeof data === 'number' || typeof data === 'boolean') return data; - - if (Array.isArray(data)) - return data.map(item => sanitizeForLog(item, isProd)); + if (typeof data === '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) : val; + result[key] = typeof val === 'string' ? maskPii(val, isProd) : sanitizeForLog(val, isProd, visited); } else { - result[key] = sanitizeForLog(val, isProd); + result[key] = sanitizeForLog(val, isProd, visited); } } return result; @@ -167,15 +175,17 @@ export function attachCustomInspect( writable: true, }); - Object.defineProperty(target, 'toJSON', { - 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 a513689e..d2faa1bb 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -32,8 +32,8 @@ export function resolveTzAndLocale( 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?.loc !== undefined && (Array.isArray(fallbackTempo.loc) ? fallbackTempo.loc.length > 0 : Boolean(fallbackTempo.loc))) - ? fallbackTempo.loc + : (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'; @@ -207,10 +207,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); - logDebug('tempo-plugin-ai', `Received response from '${provider.id}' in ${elapsed}ms`, undefined, { debug: isDebug }); - } + 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/diff.ts b/packages/plugins/ai/src/functions/diff.ts index 080be3c3..fccf8b4b 100644 --- a/packages/plugins/ai/src/functions/diff.ts +++ b/packages/plugins/ai/src/functions/diff.ts @@ -14,6 +14,7 @@ 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'; @@ -68,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 }); @@ -112,7 +112,7 @@ async function diffSingleInput( ? parsedCache.confidence : 1.0; if (effectiveMinConfidence === undefined || cachedConfidence >= effectiveMinConfidence) { - logDebug('tempo-plugin-ai:diff', `Cache hit: "${cacheKey}" -> ${cachedVal}`, undefined, { debug: isDebug }); + logDebug('tempo-plugin-ai:diff', `Cache hit: "${cacheKey}"`, cachedVal, { debug: isDebug }); const cachedResult: TempoAiDiffResult = { formatted: parsedCache.formatted, days: parsedCache.days ?? grounding.calendarDays, diff --git a/packages/plugins/ai/src/functions/extract.ts b/packages/plugins/ai/src/functions/extract.ts index 26b705ec..30a2a625 100644 --- a/packages/plugins/ai/src/functions/extract.ts +++ b/packages/plugins/ai/src/functions/extract.ts @@ -75,7 +75,7 @@ async function extractSingleInput( } = options || {}; const normalizedText = normalizeCacheInput(text); - const cacheKey = `extract::${normalizedText}::${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}')}::${tz}::${loc}::${cal}::${region}::${categoriesStr}`; + 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; @@ -384,7 +384,12 @@ export async function extractAI( if (textOrTexts.length === 0) return []; const opts = options || {}; const softErrors = opts.softErrors ?? false; - const concurrencyLimit = Math.max(1, Math.min(opts.concurrency ?? 4, textOrTexts.length)); + 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; diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts index 4cff2b60..caad5aeb 100644 --- a/packages/plugins/ai/src/functions/parse.ts +++ b/packages/plugins/ai/src/functions/parse.ts @@ -38,9 +38,9 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< if (Tempo.isTempo(options?.anchor)) { tz = String(options!.timeZone || options!.anchor.tz); cal = String(options!.calendar || options!.anchor.cal); - const rawLoc = options!.locale || options!.anchor.loc; + const rawLoc = options!.locale || options!.anchor.locale; loc = String(Array.isArray(rawLoc) ? rawLoc[0] : rawLoc); - sph = String(options!.sphere || options!.anchor.config.sphere || 'north'); + sph = String(options!.sphere || options!.anchor.sphere || 'north'); anchorStr = options!.anchor.toString(); } else { const resolvedOptions = Tempo.options; diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts index 4664c62d..24dcdb76 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -131,9 +131,9 @@ export async function recurrenceAI( // Resolve full Tempo context hierarchy const tz = options?.timeZone || (options?.anchor instanceof Tempo ? options.anchor.tz : undefined) || Tempo.options.timeZone; const cal = options?.calendar || (options?.anchor instanceof Tempo ? options.anchor.cal : undefined) || Tempo.options.calendar; - const loc = options?.locale || (options?.anchor instanceof Tempo ? options.anchor.loc : undefined) || Tempo.options.locale; + const 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.config.sphere : undefined) || Tempo.options.sphere; + 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 as any, contextConfig); diff --git a/packages/plugins/ai/src/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts index 9a3ee76d..7ce8d3a1 100644 --- a/packages/plugins/ai/src/functions/schedule.ts +++ b/packages/plugins/ai/src/functions/schedule.ts @@ -169,6 +169,14 @@ function wrapScheduleInterval(interval: Interval, meta: TempoScheduleMeta return Reflect.has(interval, prop); }, getOwnPropertyDescriptor(target, 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: (inspectableMeta as any)[prop], @@ -180,9 +188,10 @@ function wrapScheduleInterval(interval: Interval, meta: TempoScheduleMeta return Reflect.getOwnPropertyDescriptor(target, prop); }, ownKeys(target) { - const keys = Reflect.ownKeys(target); + const keys = Reflect.ownKeys(target).filter(k => k !== CUSTOM_INSPECT_SYMBOL && k !== 'toJSON'); for (const k of Object.keys(inspectableMeta)) { - if (!keys.includes(k)) keys.push(k); + if (k !== 'toJSON' && !keys.includes(k)) + keys.push(k); } return keys; }, diff --git a/packages/plugins/ai/src/types/schedule.type.ts b/packages/plugins/ai/src/types/schedule.type.ts index 727289a5..028985a7 100644 --- a/packages/plugins/ai/src/types/schedule.type.ts +++ b/packages/plugins/ai/src/types/schedule.type.ts @@ -29,6 +29,17 @@ 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)`. @@ -39,9 +50,9 @@ export interface TempoScheduleOptions extends AiParseOptions { /** Working hours configuration for slot resolution */ workingHours?: TempoWorkingHours | undefined; /** Existing booked events or busy intervals to avoid */ - events?: Array<{ start: any; end: any; title?: string }> | Array> | undefined; + events?: Array | undefined; /** Alias for events */ - intervals?: Array<{ start: any; end: any; title?: string }> | Array> | undefined; + intervals?: Array | undefined; /** Search window start constraint */ after?: TempoDateInput | undefined; /** Search window end constraint */ diff --git a/packages/plugins/ai/test/debug.test.ts b/packages/plugins/ai/test/debug.test.ts index ca39417c..0d393962 100644 --- a/packages/plugins/ai/test/debug.test.ts +++ b/packages/plugins/ai/test/debug.test.ts @@ -15,6 +15,7 @@ import { maskPii, sanitizeForLog, logDebug, warnDebug, attachCustomInspect } fro describe('Smart Debug & PII Protection Infrastructure', () => { const originalEnv = process.env.NODE_ENV; + const originalProd = process.env.PROD; beforeEach(async () => { resetAI(); @@ -27,7 +28,16 @@ describe('Smart Debug & PII Protection Infrastructure', () => { }); afterEach(() => { - process.env.NODE_ENV = originalEnv; + 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(); @@ -56,6 +66,12 @@ describe('Smart Debug & PII Protection Infrastructure', () => { 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); @@ -73,7 +89,6 @@ describe('Smart Debug & PII Protection Infrastructure', () => { process.env.NODE_ENV = 'development'; process.env.PROD = 'true'; expect(maskPii('email test@corp.com')).toContain('t***@corp.com'); - delete process.env.PROD; }); }); @@ -89,6 +104,23 @@ describe('Smart Debug & PII Protection Infrastructure', () => { 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', diff --git a/packages/plugins/ai/test/recurrence.test.ts b/packages/plugins/ai/test/recurrence.test.ts index aa819722..b91db655 100644 --- a/packages/plugins/ai/test/recurrence.test.ts +++ b/packages/plugins/ai/test/recurrence.test.ts @@ -260,7 +260,7 @@ describe('AI Recurrence Plugin (recurrenceAI)', () => { expect(items).toHaveLength(3); expect(items[0].tz).toBe('Australia/Sydney'); expect(items[0].cal).toBe('iso8601'); - expect(items[0].loc).toBe('en-AU'); - expect(items[0].config.sphere).toBe('south'); + 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/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..d98161a4 --- /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 (crossing the initial 10-star threshold to unlock automated CodeRabbit CI reviews), and drive organic adoption for `@magmacomputing/tempo` and `@magmacomputing/tempo-plugin-ai`. + +--- + +## 1. Immediate Target: Crossing 10 Stars (Unlocking CodeRabbit) + +CodeRabbit requires a minimum of **10 GitHub stars** on open-source repositories to enable automatic PR reviews on every push/PR without manual triggers. + +### 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** | **10 Stars** | CodeRabbit automated PR reviews restored permanently | +| **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 162bf25a..6758f800 100644 --- a/packages/tempo/src/engine/engine.normalizer.ts +++ b/packages/tempo/src/engine/engine.normalizer.ts @@ -83,7 +83,8 @@ export function getAliasContext(ctx: NormalizerContext, dateTime: Temporal.Zoned get ss() { return dateTime.second }, get tz() { return tz }, get cal() { return cal }, - get loc() { return state.config.locale ?? Default.locale }, + 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 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 e9ca0e07..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,7 +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 loc() { return (this.#local.config.locale ?? (this as any)[$Internal]().config.locale ?? Default.locale) as string | string[] } + /** 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 83a5456d..23f6a43f 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -71,7 +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 loc: string | 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 21ca13a0..7a99cb65 100644 --- a/packages/tempo/test/core/accessors.test.ts +++ b/packages/tempo/test/core/accessors.test.ts @@ -18,11 +18,13 @@ describe(`${label}`, () => { expect(tempo.dd).toBe(date.getDate()) }) - test(`${label} get instance locale via loc getters`, () => { + test(`${label} get instance locale and sphere getters`, () => { const tDefault = new Tempo('2024-05-20'); - expect(tDefault.loc).toBeDefined(); + expect(tDefault.locale).toBeDefined(); + expect(tDefault.sphere).toBeDefined(); - const tCustom = new Tempo('2024-05-20', { locale: 'fr-FR' }); - expect(tCustom.loc).toBe('fr-FR'); + 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..a7ab161f 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).toBeDefined(); + }) + + 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 1bfa7586..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', 'loc', '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`, () => { From 4c68463542bdd6bc5daa6ed237462543aa354eda Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 16 Aug 2026 06:09:44 +1000 Subject: [PATCH 6/7] PR ready for publish --- packages/plugins/ai/doc/architecture.md | 2 +- packages/plugins/ai/doc/context.md | 2 +- packages/plugins/ai/doc/diff.md | 2 +- packages/plugins/ai/doc/extract.md | 2 +- packages/plugins/ai/doc/format.md | 2 +- packages/plugins/ai/doc/index.md | 14 +- packages/plugins/ai/doc/security.md | 10 +- .../ai/plan/provider-farm-auto-discovery.md | 200 ++++++++++++++++++ packages/plugins/ai/src/core/config.ts | 2 +- packages/plugins/ai/src/core/support.ts | 16 -- packages/plugins/ai/test/debug.test.ts | 1 + packages/plugins/ai/test/manifest.test.ts | 2 +- packages/plugins/ai/test/parse.test.ts | 13 +- packages/tempo/.vitepress/config.ts | 174 +++++++-------- .../tempo/.vitepress/theme/data/catalog.json | 2 +- .../theme/data/plugins-sidebar.json | 107 ++++++++++ packages/tempo/bin/harvest-plugins.mjs | 121 +++++++++++ .../community-traction-and-stars-strategy.md | 8 +- .../tempo/test/core/static.getters.test.ts | 2 +- 19 files changed, 552 insertions(+), 130 deletions(-) create mode 100644 packages/plugins/ai/plan/provider-farm-auto-discovery.md create mode 100644 packages/tempo/.vitepress/theme/data/plugins-sidebar.json diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md index 9e7ed782..f6691dad 100644 --- a/packages/plugins/ai/doc/architecture.md +++ b/packages/plugins/ai/doc/architecture.md @@ -163,7 +163,7 @@ export async function POST(req: Request, env?: { GROQ_API_KEY?: string }) { 'Authorization': `Bearer ${apiKey}` }, body: JSON.stringify({ - model: 'llama-3.3-70b-versatile', + model: 'openai/gpt-oss-120b', messages: body.messages, temperature: 0.1, }), diff --git a/packages/plugins/ai/doc/context.md b/packages/plugins/ai/doc/context.md index f80773e2..663d4560 100644 --- a/packages/plugins/ai/doc/context.md +++ b/packages/plugins/ai/doc/context.md @@ -20,7 +20,7 @@ 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' } + { id: 'groq', key: process.env.GROQ_API_KEY } ] }); diff --git a/packages/plugins/ai/doc/diff.md b/packages/plugins/ai/doc/diff.md index fc8bd5aa..fba1eb85 100644 --- a/packages/plugins/ai/doc/diff.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 index 38abee37..c5602ada 100644 --- a/packages/plugins/ai/doc/extract.md +++ b/packages/plugins/ai/doc/extract.md @@ -15,7 +15,7 @@ import { initAI, extractAI } from '@magmacomputing/tempo-plugin-ai'; // 1. Initialize AI providers await initAI({ providers: [ - { id: 'groq', key: process.env.GROQ_API_KEY, model: 'llama-3.3-70b-versatile' } + { id: 'groq', key: process.env.GROQ_API_KEY } ] }); diff --git a/packages/plugins/ai/doc/format.md b/packages/plugins/ai/doc/format.md index c242f510..ec83f5c4 100644 --- a/packages/plugins/ai/doc/format.md +++ b/packages/plugins/ai/doc/format.md @@ -15,7 +15,7 @@ import { initAI, formatAI } from '@magmacomputing/tempo-plugin-ai'; // 1. Initialize AI providers await initAI({ providers: [ - { id: 'groq', key: process.env.GROQ_API_KEY, model: 'llama-3.3-70b-versatile' } + { id: 'groq', key: process.env.GROQ_API_KEY } ] }); diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md index f25cba95..9d265930 100644 --- a/packages/plugins/ai/doc/index.md +++ b/packages/plugins/ai/doc/index.md @@ -38,13 +38,13 @@ 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`) | | +| **`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 | | ### Summary of Distinct Return Contracts diff --git a/packages/plugins/ai/doc/security.md b/packages/plugins/ai/doc/security.md index b0a00da6..5b2dc8ac 100644 --- a/packages/plugins/ai/doc/security.md +++ b/packages/plugins/ai/doc/security.md @@ -27,18 +27,18 @@ flowchart TD Debugging LLM integrations traditionally presents a major security dilemma: enabling debug logs often inadvertently dumps raw prompts containing sensitive user emails, phone numbers, and auth tokens into centralized log aggregators (e.g. Datadog, CloudWatch, Sentry). -`@magmacomputing/tempo-plugin-ai` eliminates this risk through **Smart Debug Infrastructure**: +`@magmacomputing/tempo-plugin-ai` significantly mitigates this risk through **Smart Debug Infrastructure**: ### Universal Environment Detection & Zero-Config Safety -* **Single Flag Experience**: Developers simply pass `{ debug: true }` (or configure `initAI({ debug: true })`). There are no confusing secondary flags to memorize. +* **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` $\rightarrow$ `j***@enterprise.com`). -* **Phone Numbers**: Masked to last four digits (e.g., `+1-555-867-5309` $\rightarrow$ `***-***-5309`). -* **Bearer & API Tokens**: Redacted with prefix/suffix preservation (e.g., `Bearer sk-proj-1234...` $\rightarrow$ `Bearer sk-p...1234`). +* **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 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/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/support.ts b/packages/plugins/ai/src/core/support.ts index d2faa1bb..5ac4da8a 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -81,22 +81,6 @@ export function attachAiMeta(instance: Tempo, meta: TempoParseAiMeta): Tempo { 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; - } }); } diff --git a/packages/plugins/ai/test/debug.test.ts b/packages/plugins/ai/test/debug.test.ts index 0d393962..e384237e 100644 --- a/packages/plugins/ai/test/debug.test.ts +++ b/packages/plugins/ai/test/debug.test.ts @@ -213,6 +213,7 @@ describe('Smart Debug & PII Protection Infrastructure', () => { 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: { 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 1d20c875..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, aiCache, 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 () => { diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index bcb5b197..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' } ], diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json index f5d89227..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", 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/plan/community-traction-and-stars-strategy.md b/packages/tempo/plan/community-traction-and-stars-strategy.md index d98161a4..f0203c85 100644 --- a/packages/tempo/plan/community-traction-and-stars-strategy.md +++ b/packages/tempo/plan/community-traction-and-stars-strategy.md @@ -1,13 +1,13 @@ # Tempo Community Traction & GitHub Stars Strategy ## Objective -Establish high-visibility developer traction, increase GitHub star count (crossing the initial 10-star threshold to unlock automated CodeRabbit CI reviews), and drive organic adoption for `@magmacomputing/tempo` and `@magmacomputing/tempo-plugin-ai`. +Establish high-visibility developer traction, increase GitHub star count, and drive organic adoption for `@magmacomputing/tempo` and `@magmacomputing/tempo-plugin-ai`. --- -## 1. Immediate Target: Crossing 10 Stars (Unlocking CodeRabbit) +## 1. Initial Milestone: Community Growth & Early Stargazers -CodeRabbit requires a minimum of **10 GitHub stars** on open-source repositories to enable automatic PR reviews on every push/PR without manual triggers. +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). @@ -82,6 +82,6 @@ Submit pull requests to high-visibility curated repositories: | Milestone | Target | Key Benefit | | :--- | :--- | :--- | -| **Tier 1** | **10 Stars** | CodeRabbit automated PR reviews restored permanently | +| **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/test/core/static.getters.test.ts b/packages/tempo/test/core/static.getters.test.ts index a7ab161f..c8faf91c 100644 --- a/packages/tempo/test/core/static.getters.test.ts +++ b/packages/tempo/test/core/static.getters.test.ts @@ -201,7 +201,7 @@ describe(`${label} instance context getters`, () => { expect(typeof t.tz).toBe('string'); expect(typeof t.cal).toBe('string'); expect(t.locale).toBeDefined(); - expect(t.sphere).toBeDefined(); + expect(t.sphere).toBe(Tempo.config.sphere); }) test('resolves custom context options on instance', () => { From 8ba39e03384280c836e170b375ce87f85842397e Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 16 Aug 2026 19:14:24 +1000 Subject: [PATCH 7/7] PR tidy for publish --- .github/workflows/sync-providers.yml | 72 ++++ bin/sync-providers.mjs | 320 ++++++++++++++++++ package.json | 3 +- packages/functions/vitest.config.ts | 1 - packages/library/CHANGELOG.md | 1 + .../library/src/common/serialize.library.ts | 84 +++++ .../library/test/common/serialize.test.ts | 52 ++- packages/library/vitest.config.ts | 1 - packages/plugins/ai/CHANGELOG.md | 3 + packages/plugins/ai/doc/architecture.md | 6 +- packages/plugins/ai/doc/index.md | 28 +- packages/plugins/ai/doc/init.md | 47 ++- .../ai/plan/provider-farm-auto-discovery.md | 200 ----------- packages/plugins/ai/src/core/config.ts | 24 +- packages/plugins/ai/src/core/discovery.ts | 201 +++++++++++ packages/plugins/ai/src/core/init.ts | 164 ++++++--- packages/plugins/ai/src/core/manifest.ts | 7 +- packages/plugins/ai/src/core/models.ts | 134 ++++++++ packages/plugins/ai/src/core/support.ts | 184 +++++++++- packages/plugins/ai/src/functions/context.ts | 50 +-- packages/plugins/ai/src/functions/diff.ts | 48 +-- packages/plugins/ai/src/functions/extract.ts | 83 +---- packages/plugins/ai/src/functions/format.ts | 84 +---- packages/plugins/ai/src/functions/parse.ts | 75 ++-- .../plugins/ai/src/functions/recurrence.ts | 42 ++- packages/plugins/ai/src/functions/schedule.ts | 42 +-- packages/plugins/ai/src/index.ts | 6 + packages/plugins/ai/src/types/base.type.ts | 27 ++ packages/plugins/ai/test/discovery.test.ts | 214 ++++++++++++ packages/plugins/ai/test/extract.test.ts | 4 +- packages/plugins/ai/test/manifest.test.ts | 108 +++++- packages/plugins/ai/test/models.test.ts | 140 ++++++++ packages/plugins/ai/test/parse.test.ts | 40 ++- packages/plugins/vitest.shared.ts | 1 - packages/tempo/CHANGELOG.md | 4 + .../8-project-and-support/releases/v3.x.md | 85 +++++ packages/tempo/package.json | 11 + packages/tempo/public/providers.v1.json | 28 +- packages/tempo/public/providers.v1.jsonc | 49 +++ .../{defineConfig.ts => config.define.ts} | 0 packages/tempo/src/config/config.index.ts | 2 + .../{resolveConfig.ts => config.resolve.ts} | 7 +- packages/tempo/src/library.index.ts | 3 +- packages/tempo/src/support/support.init.ts | 5 + packages/tempo/src/tempo.class.ts | 7 +- packages/tempo/src/tempo.index.ts | 2 +- packages/tempo/src/tempo.type.ts | 4 +- packages/tempo/src/tsconfig.json | 2 + packages/tempo/test/README.md | 2 +- packages/tempo/test/core/config.test.ts | 38 +++ .../test/engine/parse.prefilter.flag.test.ts | 4 +- .../tempo/test/support/ci.prefilter.setup.ts | 13 - packages/tempo/test/tsconfig.json | 2 + packages/tempo/vitest.config.ts | 7 +- tsconfig.base.json | 47 ++- vitest.config.ts | 7 +- 56 files changed, 2191 insertions(+), 634 deletions(-) create mode 100644 .github/workflows/sync-providers.yml create mode 100644 bin/sync-providers.mjs delete mode 100644 packages/plugins/ai/plan/provider-farm-auto-discovery.md create mode 100644 packages/plugins/ai/src/core/discovery.ts create mode 100644 packages/plugins/ai/src/core/models.ts create mode 100644 packages/plugins/ai/test/discovery.test.ts create mode 100644 packages/plugins/ai/test/models.test.ts create mode 100644 packages/tempo/public/providers.v1.jsonc rename packages/tempo/src/config/{defineConfig.ts => config.define.ts} (100%) create mode 100644 packages/tempo/src/config/config.index.ts rename packages/tempo/src/config/{resolveConfig.ts => config.resolve.ts} (92%) delete mode 100644 packages/tempo/test/support/ci.prefilter.setup.ts diff --git a/.github/workflows/sync-providers.yml b/.github/workflows/sync-providers.yml new file mode 100644 index 00000000..479c2f1f --- /dev/null +++ b/.github/workflows/sync-providers.yml @@ -0,0 +1,72 @@ +name: Sync AI Provider Manifest + +on: + schedule: + # Run weekly on Mondays at 00:00 UTC + - cron: '0 0 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync-providers: + name: Scan & Sync AI Models + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Query Provider Endpoints & Synchronize Manifests + env: + GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + run: npm run providers:sync + + - name: Verify Plugin Test Suite + env: + TEMPO_LICENSE_KEY: "" + run: npm test --workspace=@magmacomputing/tempo-plugin-ai + + - name: Check for Manifest or Config Changes + id: git_status + run: | + if [ -n "$(git status --porcelain packages/tempo/public packages/plugins/ai/src/core/config.ts)" ]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create Pull Request for Updated Provider Manifest + if: steps.git_status.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore(ai): sync remote provider manifest and default models' + title: 'chore(ai): automated weekly provider model sync' + body: | + ## Automated AI Provider Manifest Sync + + This pull request was automatically generated by the weekly **Sync AI Provider Manifest** GitHub Action workflow. + + ### Updated Files: + * `packages/tempo/public/providers.v1.jsonc` (Canonical JSONC manifest) + * `packages/tempo/public/providers.v1.json` (Compiled CDN manifest) + * `packages/plugins/ai/src/core/config.ts` (Synchronized `DEFAULT_PROVIDERS` for offline fallback) + + All unit tests and provider contract assertions have been validated automatically. + branch: automated/sync-ai-providers + delete-branch: true diff --git a/bin/sync-providers.mjs b/bin/sync-providers.mjs new file mode 100644 index 00000000..ca4b99c2 --- /dev/null +++ b/bin/sync-providers.mjs @@ -0,0 +1,320 @@ +#!/usr/bin/env node + +/** + * ## sync-providers.mjs + * Automated CLI utility to query AI providers for available models, + * generate/sync public manifest files (providers.v1.jsonc / .json), + * and keep DEFAULT_PROVIDERS in packages/plugins/ai in lockstep. + * + * Usage: + * node bin/sync-providers.mjs [--dry-run] [--deploy] [--help] + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execSync } from 'node:child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const ROOT_DIR = resolve(__dirname, '..'); + +const MANIFEST_JSONC_PATH = resolve(ROOT_DIR, 'packages/tempo/public/providers.v1.jsonc'); +const MANIFEST_JSON_PATH = resolve(ROOT_DIR, 'packages/tempo/public/providers.v1.json'); +const REGISTRY_UI_PUBLIC_PATH = resolve(ROOT_DIR, '../tempo-workspace/apps/registry-ui/public'); +const AI_CONFIG_PATH = resolve(ROOT_DIR, 'packages/plugins/ai/src/core/config.ts'); + +const args = process.argv.slice(2); +const isDryRun = args.includes('--dry-run'); +const isDeploy = args.includes('--deploy'); + +if (args.includes('--help') || args.includes('-h')) { + console.log(` +Tempo AI Provider Sync Utility + +Options: + --dry-run Query providers and preview changes without writing to disk + --deploy Deploy updated manifests to Firebase Hosting after sync + --help Show this help menu +`); + process.exit(0); +} + +// Load local .env if present +const envPath = resolve(ROOT_DIR, '.env'); +if (existsSync(envPath)) { + const envLines = readFileSync(envPath, 'utf8').split('\n'); + for (const line of envLines) { + const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/); + if (match) { + const key = match[1]; + let value = match[2] || ''; + if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1); + if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1); + if (!process.env[key]) process.env[key] = value; + } + } +} + +/** + * Zero-dependency JSONC parser + */ +function parseJsonc(text) { + let inString = false; + let stringChar = ''; + let escaped = false; + let result = ''; + const len = text.length; + + for (let i = 0; i < len; i++) { + const char = text[i]; + const next = text[i + 1]; + + if (inString) { + result += char; + if (escaped) { + escaped = false; + } else if (char === '\\') { + escaped = true; + } else if (char === stringChar) { + inString = false; + } + continue; + } + + if (char === '"' || char === "'") { + inString = true; + stringChar = char; + result += char; + continue; + } + + if (char === '/' && next === '/') { + i += 2; + while (i < len && text[i] !== '\n' && text[i] !== '\r') i++; + if (i < len) result += text[i]; + continue; + } + + if (char === '/' && next === '*') { + i += 2; + while (i < len && !(text[i] === '*' && text[i + 1] === '/')) i++; + i++; + continue; + } + + result += char; + } + + return JSON.parse(result.replace(/,(\s*[}\]])/g, '$1')); +} + +const PROVIDER_REGISTRY = { + groq: { + name: 'Groq', + env: 'GROQ_API_KEY', + url: 'https://api.groq.com/openai/v1/models', + extract: data => (data.data || []).map(m => m.id), + selectRecommended: (models, current) => + models.find(m => m === 'openai/gpt-oss-120b') + ?? models.find(m => m.includes('qwen3.6-27b')) + ?? models.find(m => m.includes('llama-3.3-70b-versatile')) + ?? current + }, + gemini: { + name: 'Google Gemini', + env: 'GEMINI_API_KEY', + envAlt: 'GOOGLE_API_KEY', + url: 'https://generativelanguage.googleapis.com/v1beta/models', + headerType: 'goog', + extract: data => (data.models || []).map(m => (m.name || '').replace(/^models\//, '')), + selectRecommended: (models, current) => + models.find(m => m === 'gemini-3.7-flash') + ?? models.find(m => m === 'gemini-3.6-flash') + ?? models.find(m => m === 'gemini-2.5-flash') + ?? current + }, + openai: { + name: 'OpenAI', + env: 'OPENAI_API_KEY', + url: 'https://api.openai.com/v1/models', + extract: data => (data.data || []).map(m => m.id), + selectRecommended: (models, current) => + models.find(m => m === 'gpt-5.4-mini') + ?? models.find(m => m === 'gpt-5.4') + ?? current + }, + mistral: { + name: 'Mistral', + env: 'MISTRAL_API_KEY', + url: 'https://api.mistral.ai/v1/models', + extract: data => (data.data || []).map(m => m.id), + selectRecommended: (models, current) => + models.find(m => m === 'mistral-small-latest') + ?? current + } +}; + +/** + * Unified helper to query model discovery endpoints with error & expiration detection. + */ +async function fetchProviderModels(def, apiKey) { + const headers = def.headerType === 'goog' + ? { 'x-goog-api-key': apiKey, Accept: 'application/json' } + : { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' }; + + const res = await fetch(def.url, { headers }); + + if (res.status === 401 || res.status === 403 || (def.headerType === 'goog' && res.status === 400)) { + const msg = `${def.env} appears invalid, revoked or expired (HTTP ${res.status})`; + if (process.env.GITHUB_ACTIONS) console.log(`::warning title=${def.name} API Key Issue::${msg}`); + throw new Error(msg); + } + + if (!res.ok) throw new Error(`${def.name} models query returned ${res.status}: ${res.statusText}`); + + const data = await res.json(); + return def.extract(data); +} + +async function main() { + console.log('🔄 Starting Tempo AI Provider Model Sync...\n'); + + const existingManifest = existsSync(MANIFEST_JSONC_PATH) + ? parseJsonc(readFileSync(MANIFEST_JSONC_PATH, 'utf8')) + : existsSync(MANIFEST_JSON_PATH) + ? JSON.parse(readFileSync(MANIFEST_JSON_PATH, 'utf8')) + : { version: '1.1', providers: {} }; + + const providers = existingManifest.providers || {}; + let changesDetected = false; + + // Scan all configured providers in table + for (const [id, def] of Object.entries(PROVIDER_REGISTRY)) { + const key = process.env[def.env] || (def.envAlt ? process.env[def.envAlt] : undefined); + if (!key) { + console.log(`ℹ️ ${def.env} not set - keeping existing ${def.name} defaults.`); + continue; + } + + try { + console.log(`📡 Querying ${def.name} models endpoint...`); + const models = await fetchProviderModels(def, key); + console.log(` Found ${models.length} active models on ${def.name}.`); + + if (!providers[id]) providers[id] = {}; + if (!providers[id].models) providers[id].models = {}; + + const current = providers[id].models.default || providers[id].model; + const recommended = def.selectRecommended(models, current); + + // Clean up retired root model field + if ('model' in providers[id]) delete providers[id].model; + + if (recommended && current !== recommended) { + console.log(` ✨ ${def.name} model change: ${current} -> ${recommended}`); + providers[id].models.default = recommended; + if (id === 'gemini') providers[id].models.fast = recommended; + changesDetected = true; + } else if (!providers[id].models.default && recommended) { + providers[id].models.default = recommended; + } + } catch (err) { + console.warn(` ⚠️ ${def.name} query skipped: ${err.message}`); + } + } + + // Clean up any remaining legacy root model fields across all providers + for (const prov of Object.values(providers)) { + if ('model' in prov) delete prov.model; + } + + console.log('\n📊 Summary of Current Provider Defaults:'); + for (const [id, prov] of Object.entries(providers)) { + const defaultModel = prov.models?.default || '(none)'; + console.log(` • ${id.padEnd(8)}: default=${defaultModel} (tokenParam=${prov.tokenParam || 'max_tokens'})`); + } + + const updatedManifest = { + version: '1.1', + updatedAt: new Date().toISOString().split('T')[0] + 'T00:00:00Z', + providers + }; + + if (isDryRun) { + console.log(`\n[Dry Run] Changes detected: ${changesDetected ? 'YES (would update manifests on disk)' : 'NO (manifests are currently up-to-date)'}`); + console.log('[Dry Run] No files modified.'); + return; + } + + if (changesDetected) { + console.log('\n✨ Model updates detected: New provider recommendations were discovered and applied.'); + } else { + console.log('\n✨ All provider defaults are already up-to-date (no model changes detected).'); + } + + // Write clean JSON manifest + const jsonContent = JSON.stringify(updatedManifest, null, 2) + '\n'; + writeFileSync(MANIFEST_JSON_PATH, jsonContent, 'utf8'); + console.log(`\n💾 Saved: ${MANIFEST_JSON_PATH}`); + + // Write commented JSONC manifest + const jsoncContent = `{\n // Tempo AI Plugin - Dynamic Remote Provider Manifest v1.1\n // Hosted at: https://tempo.magmacomputing.com.au/providers.v1.jsonc (and .json)\n // Consumed automatically by @magmacomputing/tempo-plugin-ai during initAI()\n "version": "1.1",\n "updatedAt": "${updatedManifest.updatedAt}",\n "providers": {\n // Groq: High-speed open weights inference\n "groq": ${JSON.stringify(providers.groq, null, 6).replace(/^/gm, ' ').trim()},\n // OpenAI: Modern GPT series\n "openai": ${JSON.stringify(providers.openai, null, 6).replace(/^/gm, ' ').trim()},\n // Google Gemini: Multimodal flash & reasoning\n "gemini": ${JSON.stringify(providers.gemini, null, 6).replace(/^/gm, ' ').trim()},\n // Mistral AI: European low-latency models\n "mistral": ${JSON.stringify(providers.mistral, null, 6).replace(/^/gm, ' ').trim()}\n }\n}\n`; + + writeFileSync(MANIFEST_JSONC_PATH, jsoncContent, 'utf8'); + console.log(`💾 Saved: ${MANIFEST_JSONC_PATH}`); + + // Also copy to tempo-workspace/apps/registry-ui/public if workspace exists + if (existsSync(REGISTRY_UI_PUBLIC_PATH)) { + const targetJson = resolve(REGISTRY_UI_PUBLIC_PATH, 'providers.v1.json'); + const targetJsonc = resolve(REGISTRY_UI_PUBLIC_PATH, 'providers.v1.jsonc'); + writeFileSync(targetJson, jsonContent, 'utf8'); + writeFileSync(targetJsonc, jsoncContent, 'utf8'); + console.log(`💾 Synced to Registry UI: ${targetJson}`); + console.log(`💾 Synced to Registry UI: ${targetJsonc}`); + } + + // Synchronize DEFAULT_PROVIDERS in packages/plugins/ai/src/core/config.ts + if (existsSync(AI_CONFIG_PATH)) { + let configSrc = readFileSync(AI_CONFIG_PATH, 'utf8'); + const groqModel = providers.groq?.models?.default || 'openai/gpt-oss-120b'; + const openAiModel = providers.openai?.models?.default || 'gpt-5.4-mini'; + const geminiModel = providers.gemini?.models?.default || 'gemini-3.7-flash'; + const mistralModel = providers.mistral?.models?.default || 'mistral-small-latest'; + + configSrc = configSrc + .replace(/(groq:\s*\{[\s\S]*?default:\s*')[^']+(')/, `$1${groqModel}$2`) + .replace(/(openai:\s*\{[\s\S]*?default:\s*')[^']+(')/, `$1${openAiModel}$2`) + .replace(/(gemini:\s*\{[\s\S]*?default:\s*')[^']+(')/, `$1${geminiModel}$2`) + .replace(/(mistral:\s*\{[\s\S]*?default:\s*')[^']+(')/, `$1${mistralModel}$2`); + + writeFileSync(AI_CONFIG_PATH, configSrc, 'utf8'); + console.log(`💾 Synchronized: ${AI_CONFIG_PATH}`); + } + + if (isDeploy) { + console.log('\n🚀 Triggering deployment to Firebase Hosting...'); + try { + const workspaceRegistryPath = resolve(ROOT_DIR, '../tempo-workspace'); + if (existsSync(workspaceRegistryPath)) { + execSync('npm --prefix apps/registry-ui run build && firebase deploy --only hosting', { + cwd: workspaceRegistryPath, + stdio: 'inherit' + }); + console.log('\n✅ Deployment successful!'); + console.log('🌐 Verify CDN via: curl -sI https://tempo.magmacomputing.com.au/providers.v1.json'); + } else { + console.warn('⚠️ tempo-workspace not found at expected sibling directory.'); + } + } catch (err) { + console.error(`❌ Deployment failed: ${err.message}`); + } + } + + console.log('\n✅ Provider sync completed successfully!'); +} + +main().catch(err => { + console.error('\n❌ Fatal error in sync-providers:', err); + process.exit(1); +}); diff --git a/package.json b/package.json index 2361dc82..3e19d11a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "version:bump": "node bin/version-bump.mjs", "version:sync": "node bin/version-sync.mjs", "catalog:sync": "node packages/plugins/.bin/catalog-sync.mjs", + "providers:sync": "node bin/sync-providers.mjs", "repl": "npm run repl --workspace=@magmacomputing/tempo", "repl:plugins": "tsx --import ./packages/plugins/.bin/temporal-polyfill.mts ./packages/plugins/.bin/repl.mts", "repl:dist": "npm run repl:dist --workspace=@magmacomputing/tempo", @@ -70,4 +71,4 @@ "magic-string": "^1.1.1", "typescript-7": "npm:typescript@^7.0.2" } -} +} \ No newline at end of file diff --git a/packages/functions/vitest.config.ts b/packages/functions/vitest.config.ts index 35f8815a..5be6ce11 100644 --- a/packages/functions/vitest.config.ts +++ b/packages/functions/vitest.config.ts @@ -8,7 +8,6 @@ const polyfill = resolve(__dirname, './test/setup.ts'); export default defineConfig({ esbuild: false, - oxc: false, plugins: [ swc.vite({ jsc: { diff --git a/packages/library/CHANGELOG.md b/packages/library/CHANGELOG.md index b3ac0840..c21c20a2 100644 --- a/packages/library/CHANGELOG.md +++ b/packages/library/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.11.1] - 2026-08-06 ### Added +- **JSONC Support (`serialize.library`)**: Added standalone, zero-dependency `parseJSONC` and `stripJSONC` utilities to `#library/serialize.library.js` for parsing JSON configuration and manifest files containing single-line (`//`) and multi-line (`/* ... */`) comments and trailing commas. - **Calendar & Time Math (`calendar.library`)**: Added standalone date/calendar constants and helpers (`ISO_WEEKDAY_NAMES`, `DAY_MAP`, `MONTH_MAP`, `getDaysInMonth`, `getUtcParts`, `DayKey`, `MonthKey`, `IsoWeekdayNames`) in `#library/calendar.library.js`. - **Recurrence Engine (`recurrence.library`)**: Added standalone zero-dependency RFC 5545 recurrence rule utilities (`isRRuleString`, `isFiniteRRule`, `parseRRule`, `getNextRRuleEpoch`, `expandRRuleEpochs`, `ParsedRRule`) to `#library/recurrence.library.js`. diff --git a/packages/library/src/common/serialize.library.ts b/packages/library/src/common/serialize.library.ts index 178d80a9..6ad24126 100644 --- a/packages/library/src/common/serialize.library.ts +++ b/packages/library/src/common/serialize.library.ts @@ -391,3 +391,87 @@ function typeify(json: any, sentinel?: Function) { return Reflect.construct(cls, [value]) // create new Class instance } } + +/** + * Strips single-line (`//`) and multi-line (`/* ... *\/`) comments and trailing commas from a JSONC string. + * Preserves URLs and slashes within quoted strings. + * + * @param text - The JSONC formatted string to strip + * @returns Cleaned JSON string ready for `JSON.parse` + * @example + * ```ts + * const cleanJson = stripJSONC('{\n // comment\n "key": "value",\n}'); + * ``` + */ +export function stripJSONC(text: string): string { + if (!isString(text)) + throw new TypeError('Expected string input to stripJSONC'); + + let inString = false; + let stringChar = ''; + let escaped = false; + let result = ''; + const len = text.length; + + for (let i = 0; i < len; i++) { + const char = text[i]; + const next = text[i + 1]; + + if (inString) { + result += char; + if (escaped) + escaped = false; + else if (char === '\\') + escaped = true; + else if (char === stringChar) + inString = false; + continue; + } + + if (char === '"' || char === "'") { + inString = true; + stringChar = char; + result += char; + continue; + } + + // Single-line comment: // ... + if (char === '/' && next === '/') { + i += 2; + while (i < len && text[i] !== '\n' && text[i] !== '\r') + i++; + if (i < len) result += text[i]; + continue; + } + + // Multi-line comment: /* ... */ + if (char === '/' && next === '*') { + i += 2; + while (i < len && !(text[i] === '*' && text[i + 1] === '/')) + i++; + i++; // skip closing '/' + continue; + } + + result += char; + } + + // Remove trailing commas before } or ] + return result.replace(/,(\s*[}\]])/g, '$1'); +} + +/** + * Zero-dependency parser for JSON and JSONC (JSON with comments & trailing commas). + * + * @param text - The JSON or JSONC string to parse + * @param reviver - Optional transformation function applied to parsed key-value pairs + * @returns The parsed JavaScript object or value + * @example + * ```ts + * const config = parseJSONC<{ mode: string }>('{\n // Mode setting\n "mode": "fallback",\n}'); + * ``` + */ +export function parseJSONC(text: string, reviver?: (this: any, key: string, value: any) => any): T { + return JSON.parse(stripJSONC(text), reviver); +} + diff --git a/packages/library/test/common/serialize.test.ts b/packages/library/test/common/serialize.test.ts index 7259cf3f..1f7d4e1a 100644 --- a/packages/library/test/common/serialize.test.ts +++ b/packages/library/test/common/serialize.test.ts @@ -1,4 +1,4 @@ -import { stringify, objectify, cloneify } from '#library/serialize.library.js'; +import { stringify, objectify, cloneify, parseJSONC } from '#library/serialize.library.js'; describe('Serializer Library', () => { @@ -115,4 +115,54 @@ describe('Serializer Library', () => { }); }); + describe('parseJSONC & stripJSONC', () => { + it('should parse clean JSON without comments', () => { + const json = '{"name": "tempo", "version": 1}'; + expect(parseJSONC(json)).toEqual({ name: 'tempo', version: 1 }); + }); + + it('should strip single-line comments and trailing commas', () => { + const jsonc = ` + { + // Project configuration + "name": "tempo", // inline comment + "enabled": true, + } + `; + expect(parseJSONC(jsonc)).toEqual({ name: 'tempo', enabled: true }); + }); + + it('should strip multi-line block comments', () => { + const jsonc = ` + { + /* Multi-line + description block */ + "mode": "fallback", + "tiers": ["fast", "reasoning", /* trailing */] + } + `; + expect(parseJSONC(jsonc)).toEqual({ mode: 'fallback', tiers: ['fast', 'reasoning'] }); + }); + + it('should preserve URLs with slashes inside quoted strings', () => { + const jsonc = ` + { + // Remote API endpoint + "url": "https://api.groq.com/openai/v1/models", + "regex": "/*not-a-comment*/" + } + `; + expect(parseJSONC(jsonc)).toEqual({ + url: 'https://api.groq.com/openai/v1/models', + regex: '/*not-a-comment*/', + }); + }); + + it('should preserve escaped characters inside strings', () => { + const jsonc = '{"msg": "Hello \\"world\\" // not a comment"}'; + expect(parseJSONC(jsonc)).toEqual({ msg: 'Hello "world" // not a comment' }); + }); + }); + }); + diff --git a/packages/library/vitest.config.ts b/packages/library/vitest.config.ts index 0fa3711a..5f4c091a 100644 --- a/packages/library/vitest.config.ts +++ b/packages/library/vitest.config.ts @@ -8,7 +8,6 @@ const isDist = process.env.TEST_DIST === 'true'; export default defineConfig({ esbuild: false, - oxc: false, plugins: [ swc.vite({ jsc: { diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index 664e1e13..b303f3f6 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0] - 2026-08-15 ### Added +- **Zero-Configuration Auto-Discovery (`discovery.ts`)**: Enabled zero-boilerplate AI initialization. The plugin automatically scans environment variables (`GROQ_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `MISTRAL_API_KEY`) and resolves `tempo.config.*` files via `@magmacomputing/tempo/config`, making explicit `initAI()` calls completely optional in server and CLI environments. +- **Environment Template Interpolation**: Added support for `${VAR_NAME}`, `${env:VAR_NAME}`, and `$env:VAR_NAME` variable templates within AI configuration files and manifests, featuring case-insensitive environment matching and safe empty-string fallbacks. +- **Lazy AI State Initialization**: Standardized `_state.config` to trigger auto-discovery on first AI function invocation (`parseAI`, `formatAI`, `diffAI`, `extractAI`, `recurrenceAI`, `scheduleAI`, `contextAI`), eliminating repetitive initialization boilerplate across all AI handlers. - **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. diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md index f6691dad..6502ff88 100644 --- a/packages/plugins/ai/doc/architecture.md +++ b/packages/plugins/ai/doc/architecture.md @@ -2,9 +2,11 @@ The `@magmacomputing/tempo-plugin-ai` plugin is designed to be highly flexible, supporting both direct Bring Your Own Key (BYOK) integrations for backend systems, and Proxied integrations for frontend clients. -## Bring Your Own Key (BYOK) +## Bring Your Own Key (BYOK) & Zero-Config Discovery -For Node.js backends and Edge Workers, the simplest approach is to supply your raw API keys directly to the `initAI` function. +For Node.js, Deno, and Bun backends, `@magmacomputing/tempo-plugin-ai` supports **Zero-Config Auto-Discovery**. If standard environment variables (`GROQ_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `MISTRAL_API_KEY`) are present, calling `initAI()` is optional—the plugin will automatically discover credentials and wire up provider defaults lazily on first function call. + +Alternatively, you can supply your API keys and execution options explicitly via `initAI`: ```typescript import { initAI } from '@magmacomputing/tempo-plugin-ai'; diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md index 9d265930..2f05a862 100644 --- a/packages/plugins/ai/doc/index.md +++ b/packages/plugins/ai/doc/index.md @@ -15,20 +15,38 @@ Raw LLM API keys must **never** be exposed in client-side browser bundles or sto ::: ## Installation & Quickstart - + ```bash npm install @magmacomputing/tempo-plugin-ai ``` +### 1. Zero-Config Mode (Instant Execution) +If you have standard provider keys in your environment (`GROQ_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `MISTRAL_API_KEY`), simply call any AI function directly with zero boilerplate: + +```typescript +import { parseAI } from '@magmacomputing/tempo-plugin-ai'; + +// Automatically discovers GROQ_API_KEY / OPENAI_API_KEY from the environment +const dt = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); +console.log(dt.format('{yyyy}-{mm}-{dd}')); // 2026-11-17 +``` + +### 2. Explicit Provider Farm Configuration +For custom models, custom SLAs, or multi-provider execution strategies: + ```typescript -import { parseAI, initAI } from '@magmacomputing/tempo-plugin-ai'; +import { parseAI, initAI, AiMode } from '@magmacomputing/tempo-plugin-ai'; -// Initialize provider farm (Node/SSR backend) +// Explicitly configure provider farm & fallback strategy await initAI({ - providers: [{ id: 'groq', key: process.env.GROQ_API_KEY }] + mode: AiMode.Fallback, + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY }, + { id: 'openai', key: process.env.OPENAI_API_KEY, model: 'gpt-4o' } + ], + timeout: 5000 }); -// Parse natural language temporal expressions const dt = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); console.log(dt.format('{yyyy}-{mm}-{dd}')); // 2026-11-17 ``` diff --git a/packages/plugins/ai/doc/init.md b/packages/plugins/ai/doc/init.md index 84265d11..df628e50 100644 --- a/packages/plugins/ai/doc/init.md +++ b/packages/plugins/ai/doc/init.md @@ -2,12 +2,55 @@ `initAI()` sets up the global configuration for `@magmacomputing/tempo-plugin-ai`, managing provider authentication, multi-provider execution modes, global SLAs/timeouts, and caching strategies. -## Basic Usage +## Zero-Config Auto-Discovery + +`@magmacomputing/tempo-plugin-ai` features a zero-boilerplate auto-discovery architecture. In server environments (Node.js, Deno, Bun), calling `initAI()` is **completely optional** when standard environment variables (`GROQ_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `MISTRAL_API_KEY`) or a `tempo.config.json` file are present. + +```typescript +import { parseAI } from '@magmacomputing/tempo-plugin-ai'; + +// When GROQ_API_KEY is present in the environment: +// Zero setup required — providers and SLAs are auto-discovered lazily on first call! +const dt = await parseAI("next Friday at 4pm"); +``` + +### Configuration Resolution Order + +Configuration is automatically discovered and resolved in the following priority: +1. **Call-site explicit overrides** (`options.providers`, `options.mode`). +2. **Explicit `initAI(config)` parameters**. +3. **Active `Tempo.config.plugins.ai`** (in-memory or loaded via `Tempo.bootstrap()`). +4. **Filesystem `tempo.config.*` files** (JSON, JSONC, JS, TS). +5. **Runtime Environment Variables** (`GROQ_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `MISTRAL_API_KEY`). + +### `tempo.config.json` Example + +You can declare AI provider configurations directly within your project's `tempo.config.json` using template variable interpolation: + +```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": "$env:OPENAI_API_KEY", "model": "gpt-4o-mini" } + ] + } + } +} +``` + +## Basic Usage (Explicit Configuration) ```typescript import { initAI, parseAI } from '@magmacomputing/tempo-plugin-ai'; -// Initialize with BYOK (Bring Your Own Key) provider credentials +// Initialize with custom provider credentials and execution options await initAI({ providers: [ { id: 'groq', key: process.env.GROQ_API_KEY }, diff --git a/packages/plugins/ai/plan/provider-farm-auto-discovery.md b/packages/plugins/ai/plan/provider-farm-auto-discovery.md deleted file mode 100644 index 1469ea93..00000000 --- a/packages/plugins/ai/plan/provider-farm-auto-discovery.md +++ /dev/null @@ -1,200 +0,0 @@ -# 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/src/core/config.ts b/packages/plugins/ai/src/core/config.ts index 29e901bf..454768fe 100644 --- a/packages/plugins/ai/src/core/config.ts +++ b/packages/plugins/ai/src/core/config.ts @@ -33,22 +33,38 @@ 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: 'openai/gpt-oss-120b', + models: { + default: 'openai/gpt-oss-120b', + fast: 'qwen/qwen3.6-27b', + large: 'openai/gpt-oss-120b' + }, tokenParam: 'max_tokens' }, openai: { url: 'https://api.openai.com/v1/chat/completions', - model: 'gpt-5.4-mini', + models: { + default: 'gpt-5.4-mini', + fast: 'gpt-5.4-mini', + reasoning: 'o3-mini' + }, tokenParam: 'max_completion_tokens' }, gemini: { url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', - model: 'gemini-3.6-flash', + models: { + default: 'gemini-3.7-flash', + fast: 'gemini-3.7-flash', + reasoning: 'gemini-2.5-pro' + }, tokenParam: 'max_tokens' }, mistral: { url: 'https://api.mistral.ai/v1/chat/completions', - model: 'mistral-small-latest', + models: { + default: 'mistral-small-latest', + fast: 'mistral-small-latest', + large: 'mistral-large-latest' + }, tokenParam: 'max_tokens' } }); diff --git a/packages/plugins/ai/src/core/discovery.ts b/packages/plugins/ai/src/core/discovery.ts new file mode 100644 index 00000000..f47188f3 --- /dev/null +++ b/packages/plugins/ai/src/core/discovery.ts @@ -0,0 +1,201 @@ +import { getContext, CONTEXT, isObject, isString, isArray } from '@magmacomputing/tempo/library'; +import { Tempo } from '@magmacomputing/tempo'; + +import { DEFAULT_PROVIDERS } from './config.js'; +import type { AiConfig, AiProvider } from '../types/index.js'; + +/** + * Checks if the current execution context is a server-side JavaScript runtime (Node.js, Deno, Bun). + */ +export function isServerRuntime(): boolean { + const { type } = getContext(); + return type === CONTEXT.NodeJS || type === CONTEXT.Deno; +} + +/** + * Checks if the current execution context is a browser environment. + */ +export function isBrowserRuntime(): boolean { + const { type } = getContext(); + return type === CONTEXT.Browser; +} + +/** + * Safely accesses the runtime environment variables without crashing non-server runtimes. + */ +export function getRuntimeEnv(): Record { + if (!isServerRuntime()) + return {}; + try { + if (typeof process !== 'undefined' && process?.env) + return process.env as Record; + } catch { } + return {}; +} + +function getEnvValue(env: Record, name: string): string | undefined { + if (env[name] !== undefined) return env[name]; + const upper = name.toUpperCase(); + if (env[upper] !== undefined) return env[upper]; + const lower = name.toLowerCase(); + if (env[lower] !== undefined) return env[lower]; + const match = Object.keys(env).find(k => k.toLowerCase() === lower); + return match ? env[match] : undefined; +} + +/** + * Interpolates environment variable expressions (${VAR_NAME}, ${env:VAR_NAME}, and $env:VAR_NAME) + * in a string, substituting missing variables with an empty string. + */ +export function interpolateEnvValue(value: string, env: Record = getRuntimeEnv()): string { + return value.replace(/(?:\$\{(?:env:)?([A-Z0-9_]+)\}|\$env:([A-Z0-9_]+))/gi, (_, g1, g2) => { + const varName = g1 || g2; + return getEnvValue(env, varName) ?? ''; + }); +} + +/** + * Recursively traverses and interpolates environment variable expressions in strings, arrays, and objects. + */ +export function interpolateEnv(obj: T, env: Record = getRuntimeEnv()): T { + if (isString(obj)) + return interpolateEnvValue(obj, env) as unknown as T; + + if (isArray(obj)) + return obj.map(item => interpolateEnv(item, env)) as unknown as T; + + if (isObject(obj)) { + const result: Record = {}; + for (const [key, val] of Object.entries(obj)) { + result[key] = interpolateEnv(val, env); + } + return result as T; + } + + return obj; +} + +/** + * Well-known AI provider environment variable mappings. + */ +export const WELL_KNOWN_ENV_MAP: Record = { + groq: ['GROQ_API_KEY'], + openai: ['OPENAI_API_KEY'], + gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'], + mistral: ['MISTRAL_API_KEY'], +}; + +/** + * Scans the active environment variables for well-known provider tokens and constructs + * matching AiProvider configurations using built-in defaults. + */ +export function scanWellKnownEnvProviders(env: Record = getRuntimeEnv()): AiProvider[] { + const providers: AiProvider[] = []; + + for (const [providerId, envVars] of Object.entries(WELL_KNOWN_ENV_MAP)) { + for (const envVar of envVars) { + const rawVal = env[envVar]; + if (rawVal && typeof rawVal === 'string' && rawVal.trim().length > 0) { + const defaultTemplate = DEFAULT_PROVIDERS[providerId as keyof typeof DEFAULT_PROVIDERS]; + providers.push({ + ...(defaultTemplate || {}), + id: providerId, + key: rawVal.trim(), + }); + break; + } + } + } + + return providers; +} + +/** + * Inspects active Tempo runtime state and static class configuration for AI plugin settings. + */ +export function getActiveTempoConfigAi(): AiConfig | undefined { + try { + const rt = (globalThis as any)[Symbol.for('magmacomputing/tempo/runtime')]; + const stateConfig = rt?.state?.config; + if (stateConfig?.plugins?.ai) + return stateConfig.plugins.ai; + if (stateConfig?.ai) + return stateConfig.ai; + + const tempoClassConfig = rt?.modules?.['Tempo']?.config || (Tempo as any)?.config; + if (tempoClassConfig?.plugins?.ai) + return tempoClassConfig.plugins.ai; + if (tempoClassConfig?.ai) + return tempoClassConfig.ai; + } catch { } + return undefined; +} + +/** + * Dynamically resolves AI configuration from tempo.config.* files on disk if running in server environments. + */ +export async function resolveTempoConfigFileAi(): Promise { + if (!isServerRuntime()) + return undefined; + try { + const { resolveConfig } = await import('@magmacomputing/tempo/config'); + const resolved = await resolveConfig(); + if (resolved?.plugins && isObject(resolved.plugins) && (resolved.plugins as any).ai) + return (resolved.plugins as any).ai as AiConfig; + if ((resolved as any)?.ai) + return (resolved as any).ai as AiConfig; + } catch { } + return undefined; +} + +/** + * Resolves full AI configuration through layered auto-discovery: + * 1. Active Tempo in-memory configuration (`Tempo.config.plugins.ai`) + * 2. Filesystem configuration (`tempo.config.*` via `resolveConfig()`) + * 3. Caller explicit configuration overrides + * 4. Recursive environment variable template string interpolation (`${VAR_NAME}`, `$env:VAR_NAME`) + * 5. Environment variable token scanning for well-known provider keys (`GROQ_API_KEY`, etc.) + */ +export async function resolveAutoDiscoveredConfig(explicitConfig?: AiConfig): Promise { + const env = getRuntimeEnv(); + + // 1. Active in-memory Tempo.config + let activeConfig = getActiveTempoConfigAi(); + + // 2. If no in-memory AI config, resolve from filesystem if in server runtime + if (!activeConfig) + activeConfig = await resolveTempoConfigFileAi(); + + // Merge with any explicit caller-supplied configuration + const mergedConfig: AiConfig = { + ...(activeConfig || {}), + ...(explicitConfig || {}), + }; + + // 3. Interpolate environment variables in configuration strings + const interpolated = interpolateEnv(mergedConfig, env); + + // 4. If no providers defined in configuration, scan environment for well-known keys + if (!interpolated.providers || interpolated.providers.length === 0) { + const envProviders = scanWellKnownEnvProviders(env); + if (envProviders.length > 0) + interpolated.providers = envProviders; + } else { + // Resolve any missing keys for explicitly configured providers + interpolated.providers = interpolated.providers.map(p => { + if (p.key && p.key.trim().length > 0) + return p; + const envVars = WELL_KNOWN_ENV_MAP[p.id?.toLowerCase() ?? '']; + if (envVars) { + for (const envVar of envVars) { + const val = env[envVar]; + if (val && typeof val === 'string' && val.trim().length > 0) + return { ...p, key: val.trim() }; + } + } + return p; + }); + } + + return interpolated; +} diff --git a/packages/plugins/ai/src/core/init.ts b/packages/plugins/ai/src/core/init.ts index 94ffa31a..6b244cd9 100644 --- a/packages/plugins/ai/src/core/init.ts +++ b/packages/plugins/ai/src/core/init.ts @@ -3,24 +3,70 @@ import { Tempo } from '@magmacomputing/tempo'; import { getResolvedProviderDefaults, loadRemoteManifest, resetManifestCache } from './manifest.js'; import { assertNoReservedProviderId } from './support.js'; import { warnDebug } from './logger.js'; +import { + getActiveTempoConfigAi, + resolveAutoDiscoveredConfig, + scanWellKnownEnvProviders, + interpolateEnv, + getRuntimeEnv, + WELL_KNOWN_ENV_MAP, +} from './discovery.js'; import type { AiConfig, AiRateLimits, AiProvider } from '../types/index.js'; +let _config: AiConfig = {}; + /** * Internal singleton state container for the AI plugin. + * Automatically resolves configuration from Tempo runtime on first access. * @internal */ -export const _state: { - config: AiConfig; - rawProviders?: AiProvider[] | undefined; - limits: AiRateLimits | null; - providerLimits: Map; - revision: number; -} = { - config: {}, - rawProviders: undefined, - limits: null, - providerLimits: new Map(), - revision: 0, +export const _state = { + get config(): AiConfig { + if (_state.rawProviders === undefined && (!_config.providers || _config.providers.length === 0)) { + const tempoAiConfig = getActiveTempoConfigAi(); + if (tempoAiConfig) { + initAI(tempoAiConfig); + } else { + const envProviders = scanWellKnownEnvProviders(); + if (envProviders.length > 0) + initAI({ providers: envProviders }); + } + } + return _config; + }, + set config(val: AiConfig) { + _config = val; + }, + rawProviders: undefined as AiProvider[] | undefined, + limits: null as AiRateLimits | null, + providerLimits: new Map(), + revision: 0, +}; + +/** + * Ensures AI configuration is automatically initialized from Tempo.config, + * filesystem tempo.config.*, or runtime environment variables if providers are not yet configured. + */ +export async function ensureAiInitialized(): Promise { + if (_state.config.providers && _state.config.providers.length > 0) + return; + + const discovered = await resolveAutoDiscoveredConfig(); + if (discovered.providers && discovered.providers.length > 0) { + await initAI(discovered); + } +} + +function resolveProviderApiKey(id: string, explicitKey?: string, env: Record = getRuntimeEnv()): string | undefined { + if (explicitKey) return explicitKey; + const envVars = WELL_KNOWN_ENV_MAP[id.toLowerCase()]; + if (!envVars) return undefined; + for (const envVar of envVars) { + const val = env[envVar]; + if (val && typeof val === 'string' && val.trim().length > 0) + return val.trim(); + } + return undefined; } /** @@ -28,7 +74,7 @@ export const _state: { * Configures AI provider credentials, models, timeouts, caching options, * and asynchronously resolves provider defaults against remote manifests. * - * @param config - Global AI plugin configuration object + * @param config - Optional AI plugin configuration object (inherits from Tempo.config.plugins.ai or Tempo.config.ai if omitted) * @returns A Promise that resolves once initial configuration and background manifest synchronization is scheduled * @example * ```ts @@ -38,66 +84,88 @@ export const _state: { * }); * ``` */ -export function initAI(config: AiConfig): Promise { - if (config.providers) - assertNoReservedProviderId(config.providers); - - if (config.providers) - _state.rawProviders = config.providers; - - const currentRevision = ++_state.revision; - const remoteUrl = config.remoteConfigUrl ?? _state.config.remoteConfigUrl; - const callerProviders = config.providers ?? _state.rawProviders; - - const resolveSyncProviders = (providers?: AiProvider[]) => { - if (!providers) return _state.config.providers; - return providers.map(p => { - const normalizedId = p.id?.toLowerCase() ?? ''; - const defaults = getResolvedProviderDefaults(normalizedId, remoteUrl, config.debug ?? _state.config.debug); - return { - ...defaults, - ...p - } as AiProvider; - }); - } +export function initAI(config?: AiConfig): Promise { + const tempoAiConfig = getActiveTempoConfigAi(); + const env = getRuntimeEnv(); - // Synchronously update _state.config for immediate availability - _state.config = { - ..._state.config, - ...config, - providers: resolveSyncProviders(callerProviders) || [] - }; + const hasExplicitProviders = config?.providers !== undefined || tempoAiConfig?.providers !== undefined; - if (config.cache) { - Tempo.init({ cache: config.cache, silent: true }); - } + const mergedRaw: AiConfig = { + ...(tempoAiConfig || {}), + ...(config || {}), + }; + + const mergedConfig = interpolateEnv(mergedRaw, env); + + if (!hasExplicitProviders && (!mergedConfig.providers || mergedConfig.providers.length === 0)) { + const envProviders = scanWellKnownEnvProviders(env); + if (envProviders.length > 0) + mergedConfig.providers = envProviders; + } + + if (mergedConfig.providers) + assertNoReservedProviderId(mergedConfig.providers); + + if (mergedConfig.providers !== undefined) + _state.rawProviders = mergedConfig.providers; + + const currentRevision = ++_state.revision; + const remoteUrl = mergedConfig.remoteConfigUrl ?? _state.config.remoteConfigUrl; + const callerProviders = mergedConfig.providers ?? _state.rawProviders; + + const resolveSyncProviders = (providers?: AiProvider[]) => { + if (!providers) return _state.config.providers; + return providers.map(p => { + const normalizedId = p.id?.toLowerCase() ?? ''; + const defaults = getResolvedProviderDefaults(normalizedId, remoteUrl, mergedConfig.debug ?? _state.config.debug); + const resolvedKey = (p.key && p.key.trim().length > 0) ? p.key : resolveProviderApiKey(normalizedId, undefined, env); + return { + ...defaults, + ...p, + ...(resolvedKey ? { key: resolvedKey } : {}), + } as AiProvider; + }); + }; + + // Synchronously update _state.config for immediate availability + _state.config = { + ..._state.config, + ...mergedConfig, + providers: resolveSyncProviders(callerProviders) || [], + }; + + if (mergedConfig.cache) { + Tempo.init({ cache: mergedConfig.cache, silent: true }); + } return (async () => { if (remoteUrl !== false) { try { - await loadRemoteManifest(remoteUrl, undefined, config.debug ?? _state.config.debug); + await loadRemoteManifest(remoteUrl, undefined, mergedConfig.debug ?? _state.config.debug); } catch { } } if (_state.revision !== currentRevision) return; - const fetchDefaults = config.fetchDefaults ?? _state.config.fetchDefaults; + const fetchDefaults = mergedConfig.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); + const defaults = getResolvedProviderDefaults(normalizedId, remoteUrl, mergedConfig.debug ?? _state.config.debug); + const resolvedKey = p.key ?? resolveProviderApiKey(normalizedId); 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 }); + warnDebug('tempo-plugin-ai:init', `fetchDefaults hook failed for provider '${normalizedId}'`, err, { debug: mergedConfig.debug ?? _state.config.debug }); } return { ...defaults, ...(hookOptions ?? {}), ...p, + ...(resolvedKey ? { key: resolvedKey } : {}) } as AiProvider; })); if (_state.revision === currentRevision) diff --git a/packages/plugins/ai/src/core/manifest.ts b/packages/plugins/ai/src/core/manifest.ts index f47b8506..e5ad087c 100644 --- a/packages/plugins/ai/src/core/manifest.ts +++ b/packages/plugins/ai/src/core/manifest.ts @@ -1,3 +1,4 @@ +import { parseJSONC } from '@magmacomputing/tempo/library'; import { DEFAULT_PROVIDERS } from './config.js'; import type { AiProvider } from '../types/index.js'; @@ -29,6 +30,7 @@ function isValidManifestUrl(urlStr: string): boolean { /** * Fetches the remote AI provider manifest. Remote defaults are loaded during initialization. + * Supports both standard JSON and JSONC (JSON with comments & trailing commas). * Fail-open: if network fails or times out, returns null and allows fallback to local DEFAULT_PROVIDERS. */ export async function loadRemoteManifest( @@ -63,7 +65,7 @@ export async function loadRemoteManifest( const response = await fetch(targetUrl, { signal: controller.signal, - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json, text/plain, */*' }, redirect: 'error', }); @@ -75,7 +77,8 @@ export async function loadRemoteManifest( return null; } - const data = await response.json(); + const rawText = await response.text(); + const data = parseJSONC(rawText); if (data && typeof data === 'object' && data.providers && typeof data.providers === 'object') { const manifest = data.providers as Record>; _cachedManifestMap.set(targetUrl, manifest); diff --git a/packages/plugins/ai/src/core/models.ts b/packages/plugins/ai/src/core/models.ts new file mode 100644 index 00000000..b3fc2322 --- /dev/null +++ b/packages/plugins/ai/src/core/models.ts @@ -0,0 +1,134 @@ +import { TempoAiError } from './error.js'; +import { parseJSONC } from '@magmacomputing/tempo/library'; + +export interface ProviderModelInfo { + id: string; + name?: string | undefined; + ownedBy?: string | undefined; + created?: number | undefined; + description?: string | undefined; + contextWindow?: number | undefined; + supportedGenerationMethods?: string[] | undefined; +} + +export interface ListProviderModelsOptions { + url?: string | undefined; + timeout?: number | undefined; +} + +const DEFAULT_MODELS_TIMEOUT_MS = 10_000; + +/** + * Queries an AI provider's models endpoint to retrieve available models. + * Supports Groq, OpenAI, Google Gemini, Mistral, and OpenAI-compatible gateways. + * + * @param providerId - Provider identifier ('groq', 'openai', 'gemini', 'mistral', etc.) + * @param apiKey - Private API authorization key + * @param options - Optional endpoint URL override and request timeout + * @returns Array of discovered model descriptors + */ +export async function listProviderModels( + providerId: string, + apiKey: string, + options: ListProviderModelsOptions = {} +): Promise { + const normalizedId = providerId?.toLowerCase()?.trim() ?? ''; + if (!normalizedId) + throw new TempoAiError('Provider ID is required to query models', 400); + + if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) + throw new TempoAiError(`API key is required to query models for provider '${providerId}'`, 401); + + const timeoutMs = options.timeout ?? DEFAULT_MODELS_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + let endpointUrl: string; + const headers: Record = { + Accept: 'application/json, text/plain, */*' + }; + + switch (normalizedId) { + case 'gemini': + endpointUrl = options.url ?? 'https://generativelanguage.googleapis.com/v1beta/models'; + headers['x-goog-api-key'] = apiKey.trim(); + break; + case 'groq': + endpointUrl = options.url ?? 'https://api.groq.com/openai/v1/models'; + headers.Authorization = `Bearer ${apiKey.trim()}`; + break; + case 'openai': + endpointUrl = options.url ?? 'https://api.openai.com/v1/models'; + headers.Authorization = `Bearer ${apiKey.trim()}`; + break; + case 'mistral': + endpointUrl = options.url ?? 'https://api.mistral.ai/v1/models'; + headers.Authorization = `Bearer ${apiKey.trim()}`; + break; + default: + endpointUrl = options.url ?? `https://api.${normalizedId}.com/v1/models`; + headers.Authorization = `Bearer ${apiKey.trim()}`; + break; + } + + const response = await fetch(endpointUrl, { + signal: controller.signal, + headers, + redirect: 'error' + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new TempoAiError( + `Failed to query models from ${providerId} (${response.status}): ${errorText || response.statusText}`, + response.status + ); + } + + const rawText = await response.text(); + const data = parseJSONC(rawText); + + // Format 1: Google Gemini { models: [{ name: "models/gemini-3.7-flash", ... }] } + if (data && Array.isArray(data.models)) { + return data.models.map((item: any) => ({ + id: String(item.name || '').replace(/^models\//, ''), + name: item.displayName || undefined, + description: item.description || undefined, + contextWindow: typeof item.inputTokenLimit === 'number' ? item.inputTokenLimit : undefined, + supportedGenerationMethods: Array.isArray(item.supportedGenerationMethods) ? item.supportedGenerationMethods : undefined + })).filter((m: ProviderModelInfo) => m.id.length > 0); + } + + // Format 2: OpenAI / Groq / Mistral { data: [{ id: "gpt-5.4-mini", ... }] } + if (data && Array.isArray(data.data)) { + return data.data.map((item: any) => ({ + id: String(item.id || ''), + name: item.name || undefined, + ownedBy: item.owned_by || undefined, + created: typeof item.created === 'number' ? item.created : undefined, + description: item.description || undefined, + contextWindow: typeof item.context_window === 'number' ? item.context_window : undefined + })).filter((m: ProviderModelInfo) => m.id.length > 0); + } + + // Format 3: Direct Array [ { id: "model-1" }, ... ] + if (Array.isArray(data)) { + return data.map((item: any) => ({ + id: typeof item === 'string' ? item : String(item?.id || ''), + name: item?.name || undefined, + description: item?.description || undefined + })).filter((m: ProviderModelInfo) => m.id.length > 0); + } + + return []; + } catch (err: any) { + if (err instanceof TempoAiError) + throw err; + if (err.name === 'AbortError' || controller.signal.aborted) + throw new TempoAiError(`Timeout querying models for provider '${providerId}' after ${timeoutMs}ms`, 504); + throw new TempoAiError(`Network error querying models for '${providerId}': ${err?.message || err}`, 500); + } finally { + clearTimeout(timer); + } +} diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index 5ac4da8a..9cd2389a 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -3,6 +3,7 @@ import { TempoAiError } from './error.js'; import { RESERVED_PROVIDER_IDS } from './config.js'; import { updateRateLimitsFromResponse, _state } from './init.js'; import { logDebug, attachCustomInspect, maskPii } from './logger.js'; +import { RE_MARKDOWN_JSON_PREFIX, RE_MARKDOWN_JSON_SUFFIX } from './patterns.js'; import type { AiProvider, TempoParseAiMeta } from '../types/index.js'; export function assertNoReservedProviderId(providers: Partial[]): void { @@ -12,6 +13,39 @@ export function assertNoReservedProviderId(providers: Partial[]): vo } } +/** + * Resolves available AI providers from options or global state, asserts validity, and ensures no reserved IDs. + * + * @param options - Function options containing an optional providers override array + * @returns Array of validated AiProvider configurations + * @throws TempoAiError(400) if no providers are available or if a reserved provider ID is used + */ +export function getAvailableProviders(options?: { providers?: Partial[] | undefined } | undefined): AiProvider[] { + const availableProviders = (options?.providers ?? _state.config.providers) as AiProvider[]; + if (!availableProviders || availableProviders.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); + + assertNoReservedProviderId(availableProviders); + return availableProviders; +} + +/** + * Strips markdown JSON fences and parses JSON payload from provider response. + * + * @param rawContent - Raw content string returned from LLM + * @param providerId - ID of the provider for error reporting + * @returns Parsed JSON object + * @throws TempoAiError(422) if JSON parsing fails + */ +export function parseJsonPayload(rawContent: string, providerId: string): T { + const cleanContent = rawContent.replace(RE_MARKDOWN_JSON_PREFIX, '').replace(RE_MARKDOWN_JSON_SUFFIX, '').trim(); + try { + return JSON.parse(cleanContent); + } catch (err: any) { + throw new TempoAiError(`Provider ${providerId} returned invalid JSON payload.`, 422, undefined, { cause: err }); + } +} + export function resolveProviderTtl( providerId: string, availableProviders: AiProvider[], @@ -24,10 +58,25 @@ export function resolveProviderTtl( return callTtl ?? providerTtl ?? _state.config.ttl ?? defaultTtl; } -export function resolveTzAndLocale( - options?: { timeZone?: string | undefined; locale?: string | string[] | undefined } | undefined, +export interface ResolvedAiContext { + tz: string; + loc: string; + cal: string; + sph: string; + contextConfig: { timeZone: string; locale: string; calendar: string; sphere: string }; +} + +/** + * Resolves complete regional, timezone, and calendar context hierarchy from options and/or anchor instance. + * + * @param options - Function options with potential context overrides + * @param fallbackTempo - Anchor or fallback Tempo instance + * @returns Resolved context fields and context configuration object + */ +export function resolveFullContext( + options?: { timeZone?: string | undefined; locale?: string | string[] | undefined; calendar?: string | undefined; sphere?: 'north' | 'south' | string | undefined; [key: string]: any } | undefined, fallbackTempo?: Tempo | null, -): { tz: string; loc: string } { +): ResolvedAiContext { 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))) @@ -37,9 +86,116 @@ export function resolveTzAndLocale( : 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'; + const cal = String(options?.calendar || fallbackTempo?.cal || resolvedOptions.calendar || _state.config.calendar || 'iso8601'); + const sph = String(options?.sphere || fallbackTempo?.sphere || resolvedOptions.sphere || _state.config.sphere || 'north'); + const contextConfig = { timeZone: tz, locale: loc, calendar: cal, sphere: sph }; + + return { tz, loc, cal, sph, contextConfig }; +} + +export function resolveTzAndLocale( + options?: { timeZone?: string | undefined; locale?: string | string[] | undefined } | undefined, + fallbackTempo?: Tempo | null, +): { tz: string; loc: string } { + const { tz, loc } = resolveFullContext(options, fallbackTempo); return { tz, loc }; } +/** + * Validates that minConfidence is a finite number between 0.0 and 1.0. + * + * @param minConfidence - Optional confidence threshold to validate + * @param targetFnName - Optional function name for descriptive error messaging + * @returns Validated minConfidence number or undefined + * @throws TempoAiError(400) if minConfidence is invalid + */ +export function validateMinConfidence(minConfidence?: number, targetFnName?: string): number | undefined { + const effective = minConfidence ?? _state.config.minConfidence; + if ( + effective !== undefined && + (typeof effective !== 'number' || + !Number.isFinite(effective) || + effective < 0.0 || + effective > 1.0) + ) { + const target = targetFnName ? ` to ${targetFnName}` : ''; + throw new TempoAiError(`Invalid minConfidence provided${target}: "${String(effective)}"`, 400); + } + return effective; +} + +/** + * Concurrently processes an array of items with bounded concurrency and optional soft error normalization. + * + * @param items - Array of input items to process + * @param workerFn - Asynchronous transformation function for each item + * @param options - Batch options containing softErrors and concurrency limits + * @returns Array of results or TempoAiErrors + */ +export async function executeBatch( + items: TIn[], + workerFn: (item: TIn, index: number) => Promise, + options?: { softErrors?: boolean | undefined; concurrency?: number | undefined } | undefined, +): Promise<(TOut | TempoAiError)[]> { + if (items.length === 0) return []; + const softErrors = Boolean(options?.softErrors); + const concurrencyLimit = Math.max(1, Math.min(16, options?.concurrency ?? (softErrors ? 4 : items.length))); + + if (concurrencyLimit >= items.length && !options?.concurrency) { + if (softErrors) { + const settled = await Promise.allSettled(items.map((item, idx) => workerFn(item, idx))); + return settled.map((s, idx) => { + if (s.status === 'fulfilled') return s.value; + return s.reason instanceof TempoAiError + ? s.reason + : new TempoAiError( + s.reason?.message || `Failed to process item at index ${idx}`, + typeof s.reason?.status === 'number' ? s.reason.status : 500, + undefined, + { cause: s.reason }, + ); + }); + } + return Promise.all(items.map((item, idx) => workerFn(item, idx))); + } + + const results: (TOut | TempoAiError)[] = new Array(items.length); + let nextIdx = 0; + let firstError: Error | null = null; + + const worker = async () => { + while (nextIdx < items.length) { + if (!softErrors && firstError) break; + const currentIndex = nextIdx++; + const item = items[currentIndex]; + try { + const res = await workerFn(item, currentIndex); + results[currentIndex] = res; + } catch (err: any) { + if (softErrors) { + results[currentIndex] = err instanceof TempoAiError + ? err + : new TempoAiError( + err?.message || `Failed to process item at index ${currentIndex}`, + typeof err?.status === 'number' ? err.status : 500, + undefined, + { cause: err }, + ); + } else { + if (!firstError) firstError = err; + break; + } + } + } + }; + + const workers = Array.from({ length: Math.min(concurrencyLimit, items.length) }, () => worker()); + await Promise.all(workers); + + if (!softErrors && firstError) throw firstError; + return results; +} + export function attachAiMeta(instance: Tempo, meta: TempoParseAiMeta): Tempo { const inspectableMeta = attachCustomInspect({ ...meta }, (obj, isProd) => ({ provider: obj.provider, @@ -84,6 +240,26 @@ export function attachAiMeta(instance: Tempo, meta: TempoParseAiMeta): Tempo { }); } +export function resolveProviderModel(provider: AiProvider, requestedTier?: string): string | undefined { + if (provider.model && typeof provider.model === 'string' && provider.model.trim().length > 0) + return provider.model.trim(); + + const models = provider.models; + if (!models) return undefined; + + if (Array.isArray(models)) + return typeof models[0] === 'string' ? models[0] : undefined; + + if (typeof models === 'object') { + const targetTier = requestedTier || provider.tier || 'default'; + return (models as Record)[targetTier] + || (models as Record).default + || Object.values(models)[0]; + } + + return undefined; +} + export async function fetchFromProvider( provider: AiProvider, str: string, @@ -94,7 +270,7 @@ export async function fetchFromProvider( customSystemPrompt?: string ): Promise<{ rawContent: string; providerId: string; rateLimits: ReturnType }> { const url = provider.url!; - const model = provider.model!; + const model = resolveProviderModel(provider, provider.tier); if (!url || typeof url !== 'string') throw new TempoAiError(`Provider ${provider.id} missing valid endpoint URL.`, 400); diff --git a/packages/plugins/ai/src/functions/context.ts b/packages/plugins/ai/src/functions/context.ts index 1053fbfe..0989965b 100644 --- a/packages/plugins/ai/src/functions/context.ts +++ b/packages/plugins/ai/src/functions/context.ts @@ -1,4 +1,3 @@ -import { Tempo } from '@magmacomputing/tempo'; import { secure } from '@magmacomputing/tempo/library'; import { TempoAiError } from '../core/error.js'; import { AiMode } from '../core/config.js'; @@ -11,26 +10,25 @@ import { writeMultiTierCache, } from '../core/cache.js'; import { - assertNoReservedProviderId, + getAvailableProviders, + parseJsonPayload, + resolveFullContext, + validateMinConfidence, + executeBatch, 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 { logDebug, attachCustomInspect, maskPii } from '../core/logger.js'; import type { TempoContext, AiContextOptions } from '../types/index.js'; async function contextSingleInput(text: string, options?: AiContextOptions): Promise { const isDebug = options?.debug ?? _state.config.debug ?? false; const normalizedStr = normalizeCacheInput(text); - const { force, debug, mode: aiMode, providers, minConfidence, cache: aiCacheOption, timeout: callTimeout, ttl, cacheAdapter, hedgeDelay } = options || {}; - const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence; + const { force, mode: aiMode, minConfidence, cache: aiCacheOption, timeout: callTimeout, ttl, cacheAdapter, hedgeDelay } = options || {}; + const effectiveMinConfidence = validateMinConfidence(minConfidence, 'contextAI'); - const resolvedOptions = Tempo.options; - const tz = String(options?.timeZone || resolvedOptions.timeZone); - const cal = String(options?.calendar || resolvedOptions.calendar); - const loc = String(Array.isArray(options?.locale) ? options?.locale[0] : (options?.locale || resolvedOptions.locale)); - const sph = String(options?.sphere || resolvedOptions.sphere || 'north'); + const { tz, loc, cal, sph } = resolveFullContext(options); const cacheKey = getNamespacedCacheKey('context', `${normalizedStr}::${tz}::${loc}::${cal}::${sph}`); const adapter = cacheAdapter ?? _state.config.cacheAdapter; @@ -81,12 +79,7 @@ async function contextSingleInput(text: string, options?: AiContextOptions): Pro const contextString = `Workstation baseline context - Timezone: ${tz}, Locale: ${loc}, Calendar: ${cal}, Hemisphere: ${sph}. Use these baseline settings as the default if the input text contains no geographic or regional clues.`; - 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 availableProviders = getAvailableProviders(options); const mode = aiMode || _state.config.mode || AiMode.Fallback; const effectiveHedgeDelay = hedgeDelay ?? _state.config.hedgeDelay; @@ -118,13 +111,7 @@ Do not include markdown blocks or text outside the JSON.`; callTimeout, systemPrompt, ); - const cleanContent = rawContent.replace(RE_MARKDOWN_JSON_PREFIX, '').replace(RE_MARKDOWN_JSON_SUFFIX, ''); - let parsedData: any; - try { - parsedData = JSON.parse(cleanContent); - } catch { - throw new TempoAiError(`Provider ${providerId} returned invalid JSON payload.`, 422); - } + const parsedData = parseJsonPayload(rawContent, providerId); const timeZone = typeof parsedData?.timeZone === 'string' ? parsedData.timeZone.trim() : ''; const locale = typeof parsedData?.locale === 'string' ? parsedData.locale.trim() : ''; @@ -148,14 +135,14 @@ Do not include markdown blocks or text outside the JSON.`; rateLimits, confidence, consensusKey: `${timeZone}::${locale}::${calendar}::${sphere}`, - } + }; }, { minConfidence: effectiveMinConfidence, debug: isDebug, tag: 'tempo-plugin-ai:context', hedgeDelay: effectiveHedgeDelay }, ); _state.limits = winningCandidate.rateLimits ?? null; - const { data: parsedData, providerId, rateLimits } = winningCandidate; + const { data: parsedData, providerId } = winningCandidate; const confidence = typeof winningCandidate.confidence === 'number' ? winningCandidate.confidence : 1.0; const reasoning = parsedData.reasoning; @@ -177,7 +164,7 @@ Do not include markdown blocks or text outside the JSON.`; confidence, provider: providerId, reasoning, - } + }; const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000); const cacheVal = JSON.stringify({ @@ -234,13 +221,8 @@ export async function contextAI( input: string | string[], options?: AiContextOptions, ): Promise { - if (Array.isArray(input)) { - if (options?.softErrors) { - const settled = await Promise.allSettled(input.map(str => contextSingleInput(str, options))); - return settled.map(s => s.status === 'fulfilled' ? s.value : (s.reason as TempoAiError)); - } - return Promise.all(input.map(str => contextSingleInput(str, options))); - } + if (Array.isArray(input)) + return executeBatch(input, str => contextSingleInput(str, options), options); return contextSingleInput(input, options); } diff --git a/packages/plugins/ai/src/functions/diff.ts b/packages/plugins/ai/src/functions/diff.ts index fccf8b4b..351f43f3 100644 --- a/packages/plugins/ai/src/functions/diff.ts +++ b/packages/plugins/ai/src/functions/diff.ts @@ -11,13 +11,15 @@ import { writeMultiTierCache, } from '../core/cache.js'; import { - assertNoReservedProviderId, + getAvailableProviders, + parseJsonPayload, + validateMinConfidence, + executeBatch, 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 { logDebug, attachCustomInspect, maskPii } from '../core/logger.js'; import type { TempoAiDiffResult, AiDiffOptions, DiffPair } from '../types/index.js'; /** @@ -87,13 +89,13 @@ async function diffSingleInput( const promptText = prompt?.trim() || 'Provide a natural summary of the temporal difference between these two dates.'; const normalizedPrompt = normalizeCacheInput(promptText); - const { force, mode: aiMode, providers, minConfidence, cache: aiCacheOption, timeout: callTimeout, ttl, cacheAdapter, hedgeDelay } = options || {}; + const { force, mode: aiMode, minConfidence, cache: aiCacheOption, timeout: callTimeout, ttl, cacheAdapter, hedgeDelay } = options || {}; const sortedHolidays = holidays ? [...holidays].sort().join(',') : ''; 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 effectiveMinConfidence = validateMinConfidence(minConfidence, 'diffAI'); const effectiveHedgeDelay = hedgeDelay ?? _state.config.hedgeDelay; const cachedVal = await readMultiTierCache(cacheKey, { @@ -141,12 +143,7 @@ async function diffSingleInput( } } - 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 availableProviders = getAvailableProviders(options); const mode = aiMode || _state.config.mode || AiMode.Fallback; const contextString = `Grounding Context: @@ -191,13 +188,7 @@ Do not include markdown blocks or text outside the JSON.`; callTimeout, systemPrompt, ); - const cleanContent = rawContent.replace(RE_MARKDOWN_JSON_PREFIX, '').replace(RE_MARKDOWN_JSON_SUFFIX, ''); - let parsedData: any; - try { - parsedData = JSON.parse(cleanContent); - } catch { - throw new TempoAiError(`Provider ${providerId} returned invalid JSON payload.`, 422); - } + const parsedData = parseJsonPayload(rawContent, providerId); const formatted = typeof parsedData?.formatted === 'string' ? parsedData.formatted.trim() : ''; if (!formatted) @@ -221,7 +212,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 }, ); @@ -243,7 +234,7 @@ Do not include markdown blocks or text outside the JSON.`; confidence, provider: providerId, reasoning: parsedData.reasoning, - } + }; const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000); const cacheVal = JSON.stringify({ @@ -311,19 +302,10 @@ export async function diffAI( ): Promise { if (Array.isArray(startOrPairs)) { const resolvedOptions = (endOrOptions as AiDiffOptions) || {}; - if (resolvedOptions.softErrors) { - const settled = await Promise.allSettled( - startOrPairs.map(async pair => diffSingleInput(pair?.start, pair?.end, pair?.prompt, resolvedOptions)) - ); - return settled.map(s => { - if (s.status === 'fulfilled') return s.value; - return s.reason instanceof TempoAiError - ? s.reason - : new TempoAiError(s.reason?.message || String(s.reason), 500); - }); - } - return Promise.all( - startOrPairs.map(async pair => diffSingleInput(pair?.start, pair?.end, pair?.prompt, resolvedOptions)) + return executeBatch( + startOrPairs, + pair => diffSingleInput(pair?.start, pair?.end, pair?.prompt, resolvedOptions), + resolvedOptions, ); } diff --git a/packages/plugins/ai/src/functions/extract.ts b/packages/plugins/ai/src/functions/extract.ts index 30a2a625..0589bcc1 100644 --- a/packages/plugins/ai/src/functions/extract.ts +++ b/packages/plugins/ai/src/functions/extract.ts @@ -10,7 +10,10 @@ import { writeMultiTierCache, } from '../core/cache.js'; import { - assertNoReservedProviderId, + getAvailableProviders, + parseJsonPayload, + validateMinConfidence, + executeBatch, fetchFromProvider, resolveProviderTtl, resolveTzAndLocale, @@ -65,7 +68,6 @@ async function extractSingleInput( const { force, mode: aiMode, - providers, minConfidence, cache: aiCacheOption, timeout: callTimeout, @@ -78,17 +80,7 @@ async function extractSingleInput( 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 effectiveMinConfidence = validateMinConfidence(minConfidence, 'extractAI'); const effectiveHedgeDelay = hedgeDelay ?? _state.config.hedgeDelay; const cachedVal = await readMultiTierCache(cacheKey, { @@ -140,7 +132,7 @@ async function extractSingleInput( confidence: cachedConfidence, provider: 'cache', reasoning, - } + }; attachCustomInspect(cachedResult, (obj, isProd) => ({ events: obj.events.map(e => ({ @@ -164,12 +156,7 @@ async function extractSingleInput( } } - 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 availableProviders = getAvailableProviders(options); const weekdayNames = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; const anchorWeekday = weekdayNames[anchorTempo.dow] || anchorTempo.format('{www}'); @@ -227,12 +214,7 @@ ${region ? `- Region Context: ${region}\n` : ''}${categories.length > 0 ? `- Fil systemPrompt, ); - let parsedData: any; - try { - parsedData = JSON.parse(rawContent); - } catch (err: any) { - throw new TempoAiError(`Provider ${providerId} returned invalid JSON: ${err?.message}`, 422); - } + const parsedData = parseJsonPayload(rawContent, providerId); if (typeof parsedData !== 'object' || parsedData === null) throw new TempoAiError(`Provider ${providerId} returned non-object JSON payload.`, 422); @@ -322,7 +304,7 @@ ${region ? `- Region Context: ${region}\n` : ''}${categories.length > 0 ? `- Fil confidence, provider: providerId, reasoning: parsedData.reasoning, - } + }; const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000); const cacheVal = JSON.stringify({ @@ -381,52 +363,7 @@ export async function extractAI( options?: AiExtractOptions, ): Promise { 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 executeBatch(textOrTexts, str => extractSingleInput(str, options), options); } return extractSingleInput(textOrTexts, options); diff --git a/packages/plugins/ai/src/functions/format.ts b/packages/plugins/ai/src/functions/format.ts index 24bb1096..86a75342 100644 --- a/packages/plugins/ai/src/functions/format.ts +++ b/packages/plugins/ai/src/functions/format.ts @@ -10,7 +10,10 @@ import { writeMultiTierCache, } from '../core/cache.js'; import { - assertNoReservedProviderId, + getAvailableProviders, + parseJsonPayload, + validateMinConfidence, + executeBatch, fetchFromProvider, resolveProviderTtl, resolveTzAndLocale, @@ -109,7 +112,6 @@ async function formatSingleInput( const { force, mode: aiMode, - providers, minConfidence, cache: aiCacheOption, timeout: callTimeout, @@ -121,17 +123,7 @@ async function formatSingleInput( 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 effectiveMinConfidence = validateMinConfidence(minConfidence, 'formatAI'); const effectiveHedgeDelay = hedgeDelay ?? _state.config.hedgeDelay; const cachedVal = await readMultiTierCache(cacheKey, { @@ -159,7 +151,7 @@ async function formatSingleInput( confidence: cachedConfidence, provider: 'cache', reasoning, - } + }; attachCustomInspect(cachedResult, (obj, isProd) => ({ formatted: obj.formatted, confidence: obj.confidence, @@ -174,12 +166,7 @@ async function formatSingleInput( } } - 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 availableProviders = getAvailableProviders(options); 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. @@ -227,12 +214,7 @@ Output JSON Schema: systemPrompt, ); - let parsedData: any; - try { - parsedData = JSON.parse(rawContent); - } catch (err: any) { - throw new TempoAiError(`Provider ${providerId} returned invalid JSON: ${err?.message}`, 422); - } + const parsedData = parseJsonPayload(rawContent, providerId); if (typeof parsedData !== 'object' || parsedData === null) throw new TempoAiError(`Provider ${providerId} returned non-object JSON payload.`, 422); @@ -256,7 +238,7 @@ Output JSON Schema: rateLimits, confidence, consensusKey: formatted.toLowerCase(), - } + }; }, { minConfidence: effectiveMinConfidence, debug: isDebug, tag: 'tempo-plugin-ai:format', hedgeDelay: effectiveHedgeDelay }, ); @@ -278,7 +260,7 @@ Output JSON Schema: confidence, provider: providerId, reasoning: parsedData.reasoning, - } + }; const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 86_400_000); const cacheVal = JSON.stringify(finalResult); @@ -325,48 +307,12 @@ export async function formatAI( options?: AiFormatOptions, ): Promise { if (Array.isArray(dateOrItems)) { - if (dateOrItems.length === 0) return []; const opts = (typeof promptOrOptions === 'object' && promptOrOptions !== null ? promptOrOptions : options) || {}; - const softErrors = opts.softErrors ?? false; - const concurrencyLimit = Math.max(1, Math.min(opts.concurrency ?? 4, dateOrItems.length)); - - const results: (TempoAiFormatResult | TempoAiError)[] = new Array(dateOrItems.length); - let nextIdx = 0; - let firstError: any = null; - - const worker = async () => { - while (nextIdx < dateOrItems.length) { - if (!softErrors && firstError) break; - const currentIndex = nextIdx++; - const item = dateOrItems[currentIndex]; - const itemOpts = item.options ? { ...opts, ...item.options } : opts; - try { - const res = await formatSingleInput(item.date, item.prompt, itemOpts); - results[currentIndex] = res; - } catch (err: any) { - if (softErrors) { - results[currentIndex] = err instanceof TempoAiError - ? err - : new TempoAiError( - err?.message || `Failed to format date at index ${currentIndex}`, - typeof err?.status === 'number' ? err.status : 500, - ); - } else { - if (!firstError) firstError = err; - break; - } - } - } - }; - - const workers = Array.from({ length: concurrencyLimit }, () => worker()); - await Promise.all(workers); - - if (!softErrors && firstError) { - throw firstError; - } - - return results; + return executeBatch( + dateOrItems, + item => formatSingleInput(item.date, item.prompt, item.options ? { ...opts, ...item.options } : opts), + opts, + ); } const prompt = typeof promptOrOptions === 'string' ? promptOrOptions : undefined; diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts index caad5aeb..79aab8fc 100644 --- a/packages/plugins/ai/src/functions/parse.ts +++ b/packages/plugins/ai/src/functions/parse.ts @@ -4,9 +4,18 @@ import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; import { executeWithMode } from '../core/dispatch.js'; import { normalizeCacheInput } from '../core/cache.js'; -import { attachAiMeta, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; +import { + attachAiMeta, + fetchFromProvider, + getAvailableProviders, + parseJsonPayload, + resolveFullContext, + validateMinConfidence, + resolveProviderTtl, + executeBatch, +} 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 { RE_ISO_DATE_PREFIX, RE_ISO_Z_SUFFIX } from '../core/patterns.js'; import type { AiParseOptions } from '../types/index.js'; async function parseSingleInput(str: string, options?: AiParseOptions): Promise { @@ -34,26 +43,14 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< ...coreOptions } = options || {}; - let tz: string, cal: string, loc: string, sph: string, anchorStr: string; - if (Tempo.isTempo(options?.anchor)) { - 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); - 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 fallbackTempo = Tempo.isTempo(anchor) ? anchor : null; + const { tz, cal, loc, sph, contextConfig } = resolveFullContext(options, fallbackTempo); + const tempoConfig = { ...coreOptions, ...contextConfig, sphere: sph as any } as any; + + const anchorTempo = Tempo.isTempo(anchor) + ? (anchor.tz === tz ? anchor : anchor.set({ timeZone: tz })) + : new Tempo(anchor !== undefined ? (anchor as any) : undefined, tempoConfig); - 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; @@ -116,14 +113,9 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< const contextString = `Current Time: ${anchorTempo.format('{wkd}, {yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${loc}, Hemisphere: ${sph}.`; - 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 availableProviders = getAvailableProviders(options); const mode = aiMode || _state.config.mode || AiMode.Fallback; - const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence; + const effectiveMinConfidence = validateMinConfidence(minConfidence, 'parseAI'); const effectiveHedgeDelay = hedgeDelay ?? _state.config.hedgeDelay; const winningCandidate = await executeWithMode( @@ -131,13 +123,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< availableProviders, async (provider, signal) => { const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug, signal, callTimeout); - const cleanContent = rawContent.replace(RE_MARKDOWN_JSON_PREFIX, '').replace(RE_MARKDOWN_JSON_SUFFIX, ''); - let parsedData: any; - try { - parsedData = JSON.parse(cleanContent); - } catch { - throw new TempoAiError(`Provider ${providerId} returned invalid JSON payload.`, 422); - } + const parsedData = parseJsonPayload(rawContent, providerId); const confidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : (parsedData?.iso === 'INVALID' ? 0.0 : 1.0); return { @@ -181,15 +167,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< const parsedIso = `${rawIso.replace(RE_ISO_Z_SUFFIX, '')}[${tz}]`; - // Determine TTL hierarchy: options.ttl > provider.ttl > global config.ttl > 3600000 (1 hour) - // 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: 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; + const resolvedTtl = resolveProviderTtl(providerId, availableProviders, ttl, 3_600_000); if (aiCacheOption !== false) { if (adapter) { @@ -244,13 +222,8 @@ export async function parseAI( input: string | string[], options?: AiParseOptions ): Promise { - if (Array.isArray(input)) { - if (options?.softErrors) { - const settled = await Promise.allSettled(input.map(str => parseSingleInput(str, options))); - return settled.map(s => s.status === 'fulfilled' ? s.value : (s.reason as TempoAiError)); - } - return Promise.all(input.map(str => parseSingleInput(str, options))); - } + if (Array.isArray(input)) + return executeBatch(input, str => parseSingleInput(str, options), options); return parseSingleInput(input, options); } diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts index 24dcdb76..e0e20797 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -4,9 +4,15 @@ 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 { fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; +import { + fetchFromProvider, + getAvailableProviders, + parseJsonPayload, + resolveFullContext, + validateMinConfidence, +} 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 { RE_RRULE_PREFIX } from '../core/patterns.js'; import type { TempoRecurrenceOptions, TempoRecurrenceResult } from '../types/index.js'; function expandOccurrences(rrule: string, anchor: Tempo, options?: { count?: number; after?: any; before?: any }): Tempo[] { @@ -99,8 +105,8 @@ function createRecurrenceResult( [Symbol.iterator]: () => createIterator(), confidence, provider: providerId, - reasoning - } + reasoning, + }; attachCustomInspect(result, (obj, isProd) => ({ rrule: obj.rrule, @@ -128,15 +134,10 @@ export async function recurrenceAI( const isDebug = options?.debug ?? _state.config.debug ?? false; const isRRule = isRRuleString(input); - // Resolve full Tempo context hierarchy - 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 as any, contextConfig); + const { tz, cal, loc, sph, contextConfig } = resolveFullContext(options, Tempo.isTempo(options?.anchor) ? options.anchor : null); + const anchorTempo = Tempo.isTempo(options?.anchor) + ? (options.anchor.tz === tz ? options.anchor : options.anchor.set({ timeZone: tz })) + : new Tempo(options?.anchor as any, contextConfig); const defaultBatchSize = options?.count ?? 5; if (isRRule) { @@ -155,21 +156,17 @@ export async function recurrenceAI( ); } - const availableProviders = options?.providers || _state.config.providers; - if (!availableProviders || availableProviders.length === 0) - throw new TempoAiError('No AI providers configured. Please call initAI().', 400); - - assertNoReservedProviderId(availableProviders); + const availableProviders = getAvailableProviders(options); const mode = options?.mode || _state.config.mode || AiMode.Fallback; - const effectiveMinConfidence = options?.minConfidence ?? _state.config.minConfidence; + const effectiveMinConfidence = validateMinConfidence(options?.minConfidence, 'recurrenceAI'); const callTimeout = options?.timeout; - const contextString = `Current Time: ${anchorTempo.format('{wkd}, {yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${scalarLoc}, Hemisphere: ${sph}.`; + const contextString = `Current Time: ${anchorTempo.format('{wkd}, {yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${loc}, 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 '${scalarLoc}' (e.g., 'Every Tuesday at 15:00')", + "summary": "Clear, concise human-friendly description localized to locale '${loc}' (e.g., 'Every Tuesday at 15:00')", "reasoning": "Step-by-step calendar math explanation", "confidence": 0.95 } @@ -192,8 +189,7 @@ Do not include markdown blocks or text outside the JSON.`; callTimeout, systemPrompt, ); - const cleanContent = rawContent.replace(RE_MARKDOWN_JSON_PREFIX, '').replace(RE_MARKDOWN_JSON_SUFFIX, ''); - const parsedData = JSON.parse(cleanContent); + const parsedData = parseJsonPayload(rawContent, providerId); const confidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : 0.9; return { diff --git a/packages/plugins/ai/src/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts index 7ce8d3a1..6acd57e3 100644 --- a/packages/plugins/ai/src/functions/schedule.ts +++ b/packages/plugins/ai/src/functions/schedule.ts @@ -4,10 +4,15 @@ 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 { 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'; +import { + fetchFromProvider, + getAvailableProviders, + parseJsonPayload, + validateMinConfidence, +} from '../core/support.js'; +import { CUSTOM_INSPECT_SYMBOL, maskPii, attachCustomInspect } from '../core/logger.js'; +import { RE_DURATION_MINUTES, RE_DURATION_HOURS, RE_ISO_WEEKDAY_DIGIT } from '../core/patterns.js'; +import type { TempoScheduleOptions, TempoScheduleResult, TempoWorkingHours, TempoInterval, TempoScheduleMeta } from '../types/index.js'; function normalizeBusyEvents(rawEvents?: any[], timeZone = 'UTC'): Array<{ start: Tempo; end: Tempo; title?: string | undefined }> { if (!Array.isArray(rawEvents)) return []; @@ -16,7 +21,7 @@ function normalizeBusyEvents(rawEvents?: any[], timeZone = 'UTC'): Array<{ start if (!val) return new Tempo({ timeZone }); if (Tempo.isTempo(val)) return val; return new Tempo(val, { timeZone }); - } + }; return rawEvents.map(evt => { let start: Tempo; @@ -216,12 +221,7 @@ export async function scheduleAI( } const state = _state; - const availableProviders = options?.providers ?? state.config.providers; - - if (!availableProviders || availableProviders.length === 0) - throw new TempoAiError('No AI providers configured for scheduleAI. Call initAI() or supply providers in options.', 400); - - assertNoReservedProviderId(availableProviders); + const availableProviders = getAvailableProviders(options); const resolvedTz = options?.timeZone || (options?.anchor instanceof Tempo ? options.anchor.tz : undefined) @@ -234,7 +234,7 @@ export async function scheduleAI( end: options?.workingHours?.end ?? '17:00', days: options?.workingHours?.days ?? [1, 2, 3, 4, 5], timeZone: options?.workingHours?.timeZone ?? timeZone, - } + }; const rawBusy = options?.events ?? options?.intervals; const busyEvents = normalizeBusyEvents(rawBusy, timeZone); @@ -244,6 +244,7 @@ export async function scheduleAI( const isDebug = Boolean(options?.debug ?? state.config.debug); const mode = options?.mode || state.config.mode || AiMode.Fallback; const callTimeout = options?.timeout ?? state.config.timeout ?? 15000; + const effectiveMinConfidence = validateMinConfidence(options?.minConfidence, 'scheduleAI'); const winningCandidate = await executeWithMode( mode, @@ -259,13 +260,7 @@ export async function scheduleAI( SCHEDULE_SYSTEM_PROMPT, ); - const cleanContent = rawContent.replace(RE_MARKDOWN_JSON_PREFIX, '').replace(RE_MARKDOWN_JSON_SUFFIX, ''); - let parsed: any; - try { - parsed = JSON.parse(cleanContent); - } catch { - throw new TempoAiError(`Provider ${provider.id} returned invalid JSON payload.`, 422); - } + const parsed = parseJsonPayload(rawContent, providerId); if (!parsed.start || !parsed.end) throw new TempoAiError(`Provider ${provider.id} missing start or end ISO timestamp.`, 422); @@ -300,7 +295,7 @@ export async function scheduleAI( consensusKey: `${startKey}::${endKey}`, }; }, - { minConfidence: options?.minConfidence ?? state.config.minConfidence, debug: isDebug, tag: 'tempo-plugin-ai:schedule', hedgeDelay: options?.hedgeDelay ?? state.config.hedgeDelay }, + { minConfidence: effectiveMinConfidence, debug: isDebug, tag: 'tempo-plugin-ai:schedule', hedgeDelay: options?.hedgeDelay ?? state.config.hedgeDelay }, ); _state.limits = winningCandidate.rateLimits ?? null; @@ -308,9 +303,8 @@ export async function scheduleAI( const { data: scheduleData, providerId } = winningCandidate; const confidence = typeof winningCandidate.confidence === 'number' ? winningCandidate.confidence : 0.9; - const minConf = options?.minConfidence ?? state.config.minConfidence ?? 0.0; - if (confidence < minConf) - throw new TempoAiError(`scheduleAI confidence (${confidence}) is below the required threshold of ${minConf}`, 422); + if (effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence) + throw new TempoAiError(`scheduleAI confidence (${confidence}) is below the required threshold of ${effectiveMinConfidence}`, 422); let finalStart = scheduleData.startTempo; let finalEnd = scheduleData.endTempo; @@ -345,7 +339,7 @@ export async function scheduleAI( next = next.add({ days: 1 }); } return next; - } + }; // Deterministic Conflict & Working Hours Validation using core Interval.overlaps() const MAX_ADJUSTMENT_ITERATIONS = 50; diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts index b8d79ed8..c4b168be 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -12,6 +12,12 @@ export { aiCache } from './core/cache.js'; // AI Core Functions export { initAI, resetAI, getAiRateLimits, getAiProviderRateLimits, getAiConfig } from './core/init.js'; +// AI Model Discovery +export { listProviderModels, type ProviderModelInfo, type ListProviderModelsOptions } from './core/models.js'; + +// AI Auto-Discovery +export { isServerRuntime, isBrowserRuntime, interpolateEnv, interpolateEnvValue, scanWellKnownEnvProviders, resolveAutoDiscoveredConfig } from './core/discovery.js'; + // AI Function Handlers export { parseAI } from './functions/parse.js'; export { formatAI } from './functions/format.js'; diff --git a/packages/plugins/ai/src/types/base.type.ts b/packages/plugins/ai/src/types/base.type.ts index bce9747a..e4f01dcf 100644 --- a/packages/plugins/ai/src/types/base.type.ts +++ b/packages/plugins/ai/src/types/base.type.ts @@ -50,6 +50,8 @@ export interface AiDateContextOptions extends AiBaseOptions { locale?: string | string[] | undefined; /** Preferred calendar system (e.g. 'gregory', 'islamic', 'hebrew'). */ calendar?: string | undefined; + /** Hemisphere ('north' | 'south') for seasonal and environmental calculations. */ + sphere?: 'north' | 'south' | string | undefined; /** Custom regional context (e.g. 'AU-NSW', 'US-CA'). */ region?: string | undefined; } @@ -119,6 +121,23 @@ export interface AiRateLimits { resetAt: Tempo | null; } +/** + * ## AiModelTiers + * Tiered dictionary of model identifiers for adaptive routing. + */ +export interface AiModelTiers { + /** Default fallback model for standard inference */ + default?: string | undefined; + /** High-speed / low-latency model for rapid processing */ + fast?: string | undefined; + /** Extended reasoning / chain-of-thought model */ + reasoning?: string | undefined; + /** High-capacity / large context model */ + large?: string | undefined; + /** Custom named tier */ + [tier: string]: string | undefined; +} + /** * ## AiProvider * Represents an LLM provider and its respective BYOK API key and configuration options. @@ -132,6 +151,10 @@ export interface AiProvider { url?: string | undefined; /** Optional custom model identifier (e.g., to override the provider's default model) */ model?: string | undefined; + /** Tiered model dictionary (e.g. { default: '...', fast: '...', reasoning: '...' }) */ + models?: AiModelTiers | undefined; + /** Model tier preference ('default' | 'fast' | 'reasoning' | 'large' | string) */ + tier?: 'default' | 'fast' | 'reasoning' | 'large' | 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 */ @@ -167,6 +190,10 @@ export interface AiConfig { timeZone?: string | undefined; /** Optional default BCP 47 locale for AI operations */ locale?: string | string[] | undefined; + /** Optional default calendar system for AI operations (e.g. 'iso8601', 'gregory', 'islamic', 'hebrew') */ + calendar?: string | undefined; + /** Optional default hemisphere ('north' | 'south') for seasonal and environmental calculations */ + sphere?: 'north' | 'south' | string | undefined; /** Optional global cache TTL in milliseconds for AI parsing entries (default: 3600000ms / 1 hour) */ ttl?: number | undefined; /** Optional global timeout in milliseconds for AI requests (default: 15000ms) */ diff --git a/packages/plugins/ai/test/discovery.test.ts b/packages/plugins/ai/test/discovery.test.ts new file mode 100644 index 00000000..a707513c --- /dev/null +++ b/packages/plugins/ai/test/discovery.test.ts @@ -0,0 +1,214 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { + initAI, + resetAI, + getAiConfig, + parseAI, + TempoAiError, + isServerRuntime, + isBrowserRuntime, + interpolateEnvValue, + interpolateEnv, + scanWellKnownEnvProviders, + resolveAutoDiscoveredConfig, +} from '../src/index.js'; + +describe('AI Provider Farm Auto-Discovery & Zero-Config Subsystem', () => { + const savedEnv = { ...process.env }; + + beforeEach(() => { + resetAI(); + Tempo.cache.clear(); + // Clean out all AI provider env variables before each test + delete process.env.GROQ_API_KEY; + delete process.env.OPENAI_API_KEY; + delete process.env.GEMINI_API_KEY; + delete process.env.GOOGLE_API_KEY; + delete process.env.MISTRAL_API_KEY; + }); + + afterEach(() => { + resetAI(); + Tempo.cache.clear(); + vi.restoreAllMocks(); + process.env = { ...savedEnv }; + }); + + describe('Runtime Environment Detection', () => { + it('should detect server runtime in Node.js test environment', () => { + expect(isServerRuntime()).toBe(true); + expect(isBrowserRuntime()).toBe(false); + }); + }); + + describe('Environment Variable Interpolation', () => { + it('should interpolate ${VAR_NAME} syntax', () => { + const env = { TEST_KEY: 'secret-123' }; + expect(interpolateEnvValue('Bearer ${TEST_KEY}', env)).toBe('Bearer secret-123'); + }); + + it('should interpolate ${env:VAR_NAME} syntax', () => { + const env = { API_SECRET: 'alpha-beta' }; + expect(interpolateEnvValue('Token: ${env:API_SECRET}', env)).toBe('Token: alpha-beta'); + }); + + it('should interpolate $env:VAR_NAME syntax', () => { + const env = { MY_TOKEN: 'token-xyz' }; + expect(interpolateEnvValue('Key $env:MY_TOKEN', env)).toBe('Key token-xyz'); + }); + + it('should replace missing environment variables with empty string', () => { + const env = {}; + expect(interpolateEnvValue('prefix-${MISSING_VAR}-suffix', env)).toBe('prefix--suffix'); + expect(interpolateEnvValue('prefix-$env:MISSING_VAR-suffix', env)).toBe('prefix--suffix'); + expect(interpolateEnvValue('prefix-${env:MISSING_VAR}-suffix', env)).toBe('prefix--suffix'); + }); + + it('should handle case-insensitive variable names in template patterns', () => { + const env = { GROQ_API_KEY: 'gsk_test' }; + expect(interpolateEnvValue('${groq_api_key}', env)).toBe('gsk_test'); + expect(interpolateEnvValue('$env:groq_api_key', env)).toBe('gsk_test'); + }); + + it('should recursively interpolate objects and arrays', () => { + const env = { + API_KEY: 'my-secret', + API_MODEL: 'llama-3.3-70b-versatile', + ENDPOINT: 'https://api.example.com', + }; + + const config = { + remoteConfigUrl: false, + model: '${API_MODEL}', + providers: [ + { + id: 'custom', + key: '${API_KEY}', + endpoint: '${ENDPOINT}/v1', + aliases: ['${API_MODEL}', 'backup-model'], + }, + ], + }; + + const interpolated = interpolateEnv(config, env); + + expect(interpolated.model).toBe('llama-3.3-70b-versatile'); + expect(interpolated.providers[0].key).toBe('my-secret'); + expect(interpolated.providers[0].endpoint).toBe('https://api.example.com/v1'); + expect(interpolated.providers[0].aliases).toEqual(['llama-3.3-70b-versatile', 'backup-model']); + }); + }); + + describe('Well-Known Provider Environment Variable Scanning', () => { + it('should discover single provider from environment variable', () => { + const env = { GROQ_API_KEY: 'gsk_discovered_key' }; + const providers = scanWellKnownEnvProviders(env); + + expect(providers).toHaveLength(1); + expect(providers[0].id).toBe('groq'); + expect(providers[0].key).toBe('gsk_discovered_key'); + }); + + it('should discover multiple providers across well-known keys', () => { + const env = { + GROQ_API_KEY: 'gsk_groq', + OPENAI_API_KEY: 'sk_openai', + GEMINI_API_KEY: 'gemini_key', + MISTRAL_API_KEY: 'mistral_key', + }; + const providers = scanWellKnownEnvProviders(env); + + expect(providers).toHaveLength(4); + const ids = providers.map(p => p.id); + expect(ids).toContain('groq'); + expect(ids).toContain('openai'); + expect(ids).toContain('gemini'); + expect(ids).toContain('mistral'); + }); + + it('should support GOOGLE_API_KEY alias for gemini', () => { + const env = { GOOGLE_API_KEY: 'google_cloud_gemini_key' }; + const providers = scanWellKnownEnvProviders(env); + + expect(providers).toHaveLength(1); + expect(providers[0].id).toBe('gemini'); + expect(providers[0].key).toBe('google_cloud_gemini_key'); + }); + + it('should return empty array if no well-known keys are in the environment', () => { + const providers = scanWellKnownEnvProviders({}); + expect(providers).toEqual([]); + }); + }); + + describe('Auto-Discovery Integration & Zero-Config Initialization', () => { + it('should auto-initialize providers when initAI() is called with no arguments', async () => { + process.env.GROQ_API_KEY = 'gsk_auto_init'; + + await initAI(); + + const activeConfig = getAiConfig(); + expect(activeConfig.providers).toBeDefined(); + expect(activeConfig.providers?.length).toBe(1); + expect(activeConfig.providers?.[0].id).toBe('groq'); + expect(activeConfig.providers?.[0].key).toBe('[REDACTED]'); + }); + + it('should lazily discover providers on first AI function call (zero-config parseAI)', async () => { + process.env.GROQ_API_KEY = 'gsk_lazy_zero_config'; + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url: any) => { + const urlStr = String(url); + if (urlStr.includes('manifest') || urlStr.includes('githubusercontent')) { + return new Response(JSON.stringify({ version: '1.0.0', providers: {} }), { status: 200 }); + } + return new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + iso: '2026-08-17T09:00:00Z', + confidence: 0.98, + reasoning: 'Parsed next Monday at 9am', + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + const result = await parseAI('next Monday at 9am', { force: true }); + expect(result.isValid).toBe(true); + expect(result.ai?.provider).toBe('groq'); + expect(result.ai?.cached).toBe(false); + }); + + it('should throw clear TempoAiError when no providers or environment keys are found', async () => { + await expect(parseAI('tomorrow at 5pm', { force: true })).rejects.toThrow(TempoAiError); + await expect(parseAI('tomorrow at 5pm', { force: true })).rejects.toThrow(/No AI providers configured/i); + }); + + it('should interpolate template keys inside explicit initAI() configuration', async () => { + process.env.MY_CUSTOM_GROQ_KEY = 'gsk_interpolated_custom'; + + await initAI({ + remoteConfigUrl: false, + providers: [ + { + id: 'groq', + key: '${MY_CUSTOM_GROQ_KEY}', + }, + ], + }); + + const config = getAiConfig(); + expect(config.providers?.[0].key).toBe('[REDACTED]'); + }); + + it('should resolve full config asynchronously via resolveAutoDiscoveredConfig', async () => { + process.env.OPENAI_API_KEY = 'sk-auto-discovered'; + + const resolved = await resolveAutoDiscoveredConfig(); + expect(resolved.providers).toBeDefined(); + expect(resolved.providers?.some(p => p.id === 'openai' && p.key === 'sk-auto-discovered')).toBe(true); + }); + }); +}); diff --git a/packages/plugins/ai/test/extract.test.ts b/packages/plugins/ai/test/extract.test.ts index ef3afba7..af15f947 100644 --- a/packages/plugins/ai/test/extract.test.ts +++ b/packages/plugins/ai/test/extract.test.ts @@ -331,9 +331,9 @@ describe('AI Extract Plugin (extractAI)', () => { }); it('should throw TempoAiError(400) when no providers are configured', async () => { - resetAI(); + await initAI({ remoteConfigUrl: false, providers: [] }); await expect(extractAI('Meeting tomorrow at 10am')) - .rejects.toMatchObject({ message: 'No AI providers configured. Please call initAI().', status: 400 }); + .rejects.toMatchObject({ message: expect.stringMatching(/No AI providers configured/i), status: 400 }); }); it('should support multi-provider race execution mode', async () => { diff --git a/packages/plugins/ai/test/manifest.test.ts b/packages/plugins/ai/test/manifest.test.ts index 3e8516ae..eecfe3dc 100644 --- a/packages/plugins/ai/test/manifest.test.ts +++ b/packages/plugins/ai/test/manifest.test.ts @@ -6,6 +6,7 @@ import { DEFAULT_REMOTE_MANIFEST_URL, DEFAULT_PROVIDERS } from '../src/index.js'; +import { parseJSONC } from '@magmacomputing/tempo/library'; describe('Remote Provider Manifest & Dynamic Defaults', () => { beforeEach(() => { @@ -35,7 +36,7 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledWith( DEFAULT_REMOTE_MANIFEST_URL, - expect.objectContaining({ headers: { Accept: 'application/json' }, redirect: 'error' }) + expect.objectContaining({ redirect: 'error' }) ); expect(result1).toEqual(mockManifest.providers); @@ -158,7 +159,7 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { }); const config = getAiConfig(); - expect(config.providers?.[0].model).toBe(DEFAULT_PROVIDERS.openai.model); + expect(config.providers?.[0].models?.default).toBe(DEFAULT_PROVIDERS.openai.models?.default); }); it('should return empty defaults for unrecognized provider IDs not in DEFAULT_PROVIDERS or manifest', async () => { @@ -170,6 +171,7 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { const config = getAiConfig(); expect(config.providers?.[0].id).toBe('custom-unrecognized-llm'); expect(config.providers?.[0].model).toBeUndefined(); + expect(config.providers?.[0].models).toBeUndefined(); expect(config.providers?.[0].url).toBeUndefined(); }); @@ -235,7 +237,7 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { providers: [{ id: 'groq', key: 'test-key' }] }); - expect(getAiConfig().providers?.[0].model).toBe(DEFAULT_PROVIDERS.groq.model); + expect(getAiConfig().providers?.[0].models?.default).toBe(DEFAULT_PROVIDERS.groq.models?.default); // Re-init with fetchDefaults hook and omitted providers await initAI({ @@ -277,4 +279,104 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { // Config should remain the fresh one, not overwritten by stale in-flight init expect(getAiConfig().providers?.[0].id).toBe('openai'); }); + + describe('JSONC Parser (parseJSONC)', () => { + it('should parse standard JSON objects and arrays', () => { + const json = '{"name": "tempo", "active": true, "count": 42, "items": [1, 2, 3]}'; + expect(parseJSONC(json)).toEqual({ + name: 'tempo', + active: true, + count: 42, + items: [1, 2, 3] + }); + }); + + it('should strip single-line comments without stripping URLs inside strings', () => { + const jsonc = ` + // Top-level comment + { + "provider": "groq", // provider ID + "url": "https://api.groq.com/openai/v1/chat/completions", // Endpoint URL with slashes + "model": "openai/gpt-oss-120b" // Active model + } + `; + const parsed = parseJSONC(jsonc); + expect(parsed.provider).toBe('groq'); + expect(parsed.url).toBe('https://api.groq.com/openai/v1/chat/completions'); + expect(parsed.model).toBe('openai/gpt-oss-120b'); + }); + + it('should strip multi-line comments', () => { + const jsonc = ` + /* + * Multi-line header comment + * Explaining model rollout + */ + { + "version": "1.1", + /* inline comment */ "providers": { + "gemini": { + "model": "gemini-3.7-flash" + } + } + } + `; + const parsed = parseJSONC(jsonc); + expect(parsed.version).toBe('1.1'); + expect(parsed.providers.gemini.model).toBe('gemini-3.7-flash'); + }); + + it('should handle trailing commas in objects and arrays gracefully', () => { + const jsonc = ` + { + "providers": { + "openai": { + "model": "gpt-5.4-mini", + "tokenParam": "max_completion_tokens", + }, + }, + "tags": [ + "fast", + "cost-effective", + ], + } + `; + const parsed = parseJSONC(jsonc); + expect(parsed.providers.openai.model).toBe('gpt-5.4-mini'); + expect(parsed.tags).toEqual(['fast', 'cost-effective']); + }); + + it('should parse remote manifest with comments seamlessly in loadRemoteManifest', async () => { + const mockJsoncManifest = ` + // Remote Manifest v1.1 + { + "version": "1.1", + "providers": { + // Groq default fast model + "groq": { + "url": "https://api.groq.com/openai/v1/chat/completions", + "model": "openai/gpt-oss-120b", + "tokenParam": "max_tokens", + }, + // Gemini Flash + "gemini": { + "url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + "model": "gemini-3.7-flash", + "tokenParam": "max_tokens", + }, + }, + } + `; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(mockJsoncManifest, { status: 200 }) + ); + + const result = await loadRemoteManifest('https://tempo.magmacomputing.com.au/providers.v1.jsonc'); + expect(result).toBeDefined(); + expect(result?.groq?.model).toBe('openai/gpt-oss-120b'); + expect(result?.groq?.url).toBe('https://api.groq.com/openai/v1/chat/completions'); + expect(result?.gemini?.model).toBe('gemini-3.7-flash'); + }); + }); }); diff --git a/packages/plugins/ai/test/models.test.ts b/packages/plugins/ai/test/models.test.ts new file mode 100644 index 00000000..f3c9f96b --- /dev/null +++ b/packages/plugins/ai/test/models.test.ts @@ -0,0 +1,140 @@ +import { listProviderModels, TempoAiError } from '../src/index.js'; + +describe('AI Provider Model Discovery (listProviderModels)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should query Groq models endpoint with Authorization Bearer header', async () => { + const mockGroqResponse = { + object: 'list', + data: [ + { id: 'openai/gpt-oss-120b', object: 'model', created: 1720000000, owned_by: 'openai', context_window: 131072 }, + { id: 'qwen/qwen3.6-27b', object: 'model', created: 1720000000, owned_by: 'qwen', context_window: 65536 } + ] + }; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockGroqResponse), { status: 200 }) + ); + + const models = await listProviderModels('groq', 'gsk-mock-key-123'); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.groq.com/openai/v1/models', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer gsk-mock-key-123' + }) + }) + ); + + expect(models).toHaveLength(2); + expect(models[0].id).toBe('openai/gpt-oss-120b'); + expect(models[0].ownedBy).toBe('openai'); + expect(models[0].contextWindow).toBe(131072); + expect(models[1].id).toBe('qwen/qwen3.6-27b'); + }); + + it('should query Google Gemini models endpoint and format model identifiers', async () => { + const mockGeminiResponse = { + models: [ + { + name: 'models/gemini-3.7-flash', + displayName: 'Gemini 3.7 Flash', + description: 'Next-generation multimodal model', + inputTokenLimit: 1048576, + outputTokenLimit: 8192, + supportedGenerationMethods: ['generateContent', 'countTokens'] + }, + { + name: 'models/gemini-2.5-pro', + displayName: 'Gemini 2.5 Pro', + description: 'Deep reasoning model', + inputTokenLimit: 2097152, + outputTokenLimit: 8192, + supportedGenerationMethods: ['generateContent'] + } + ] + }; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockGeminiResponse), { status: 200 }) + ); + + const models = await listProviderModels('gemini', 'gemini-mock-key-456'); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://generativelanguage.googleapis.com/v1beta/models', + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-goog-api-key': 'gemini-mock-key-456' + }) + }) + ); + + expect(models).toHaveLength(2); + expect(models[0].id).toBe('gemini-3.7-flash'); + expect(models[0].name).toBe('Gemini 3.7 Flash'); + expect(models[0].contextWindow).toBe(1048576); + expect(models[1].id).toBe('gemini-2.5-pro'); + }); + + it('should query OpenAI models endpoint', async () => { + const mockOpenAiResponse = { + object: 'list', + data: [ + { id: 'gpt-5.4-mini', object: 'model', created: 1725000000, owned_by: 'system' }, + { id: 'gpt-5.4', object: 'model', created: 1725000000, owned_by: 'system' } + ] + }; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockOpenAiResponse), { status: 200 }) + ); + + const models = await listProviderModels('openai', 'sk-mock-key-789'); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.openai.com/v1/models', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer sk-mock-key-789' + }) + }) + ); + + expect(models).toHaveLength(2); + expect(models[0].id).toBe('gpt-5.4-mini'); + expect(models[1].id).toBe('gpt-5.4'); + }); + + it('should throw 401 TempoAiError if API key is missing or blank', async () => { + await expect(listProviderModels('groq', '')).rejects.toThrow(TempoAiError); + await expect(listProviderModels('groq', ' ')).rejects.toThrow(/API key is required/); + }); + + it('should throw 400 TempoAiError if provider ID is missing', async () => { + await expect(listProviderModels('', 'key-123')).rejects.toThrow(TempoAiError); + }); + + it('should handle HTTP error responses from provider', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: 'Invalid API Key' } }), { status: 401 }) + ); + + await expect(listProviderModels('groq', 'invalid-key')).rejects.toThrow(TempoAiError); + }); + + it('should support custom endpoint URL override', async () => { + const customUrl = 'https://custom-gateway.corp.com/v1/models'; + const mockResponse = { + data: [{ id: 'custom-fine-tuned-model' }] + }; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockResponse), { status: 200 }) + ); + + const models = await listProviderModels('custom', 'token-123', { url: customUrl }); + expect(fetchSpy).toHaveBeenCalledWith(customUrl, expect.anything()); + expect(models[0].id).toBe('custom-fine-tuned-model'); + }); +}); diff --git a/packages/plugins/ai/test/parse.test.ts b/packages/plugins/ai/test/parse.test.ts index 43d94eac..54f1418d 100644 --- a/packages/plugins/ai/test/parse.test.ts +++ b/packages/plugins/ai/test/parse.test.ts @@ -48,7 +48,43 @@ 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(DEFAULT_PROVIDERS.groq.model); + expect(config.providers?.[0].models?.default).toBe(DEFAULT_PROVIDERS.groq.models?.default); + }); + + it('should automatically inherit configuration from Tempo.config.plugins.ai', async () => { + resetAI(); + Tempo.init({ + plugins: { + ai: { + mode: AiMode.Race, + timeout: 4500, + providers: [{ id: 'groq', key: 'tempo-config-plugins-key' }] + } + } + }); + + await initAI(); + const config = getAiConfig(); + expect(config.mode).toBe('race'); + expect(config.timeout).toBe(4500); + expect(config.providers?.[0].id).toBe('groq'); + }); + + it('should auto-initialize from Tempo.config.plugins.ai on parseAI if initAI was not called', async () => { + resetAI(); + Tempo.init({ + plugins: { + ai: { + mode: AiMode.Fallback, + providers: [{ id: 'groq', key: 'auto-init-key' }] + } + } + }); + + const result = await parseAI('2026-05-10'); + expect(result.isValid).toBe(true); + const config = getAiConfig(); + expect(config.providers?.[0].id).toBe('groq'); }); it('should fall back to native parsing first and attach .ai metadata', async () => { @@ -82,7 +118,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(DEFAULT_PROVIDERS.gemini.model); + expect(body.model).toBe(DEFAULT_PROVIDERS.gemini.models?.default); }); it('should throw TempoAiError if no key is configured and AI is needed', async () => { diff --git a/packages/plugins/vitest.shared.ts b/packages/plugins/vitest.shared.ts index 19c90850..9a3a534d 100644 --- a/packages/plugins/vitest.shared.ts +++ b/packages/plugins/vitest.shared.ts @@ -9,7 +9,6 @@ const spy = resolve(__dirname, '../tempo/test/support/setup.console-spy.ts'); export default defineConfig({ esbuild: false, - oxc: false, plugins: [ swc.vite({ jsc: { diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 2b6d9375..6aa68c5e 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.11.1] - 2026-08-10 ### Added +- **Dedicated Config Subpath (`@magmacomputing/tempo/config`)**: Added a dedicated `./config` subpath export in `package.json` exposing `defineConfig` and `resolveConfig` from `config.index.ts` with full TypeScript definitions. +- **JSONC Configuration Discovery (`config.resolve`)**: Upgraded filesystem `tempo.config.*` discovery in `resolveConfig` to parse `.json` and `.jsonc` files using `parseJSONC`, natively supporting single-line comments, block comments, and trailing commas. +- **Provider Manifest Sync Tooling (`bin/sync-providers.mjs`)**: Added automated JSONC manifest stripping and synchronization scripts (`providers:sync`) and GitHub Actions workflow (`sync-providers.yml`) to keep `providers.v1.jsonc` and `providers.v1.json` in sync. - **Timezone Abbreviation & Humanized Offset Parsing**: Upgraded `Token.tzd` snippet compilation and Master Guard scanning to natively support 3–4 letter timezone abbreviations (e.g. `AEST`, `PST`, `EST`, `CET`, `JST`) alongside `GMT`/`UTC` offset prefixes (e.g. `'Aug 6, 16:16 GMT+10'`, `'August 6, 16:16 AEST'`). Dynamically compiles `Token.tzd` from `DEFAULTS.TIMEZONE` and introduces `Match.offset` for clean structural offset matching with downstream `Temporal.ZonedDateTime` validation. - **Cache Serialization (`toJSON`)**: Added native `toJSON()` serialization support to `BoundedCache` and the `Tempo.cache` facade object. Calling `Tempo.cache.toJSON()` or `JSON.stringify(Tempo.cache)` now cleanly converts active, non-expired in-memory cache entries into a plain key-value JavaScript object. - **AI Context & IDE Integration (`llms.txt`)**: Published official standardized `llms.txt` and `llms-full.txt` context bundles at `https://tempo.magmacomputing.com.au` to provide full project context and enhance code-generation accuracy for IDE tools (Cursor, VS Code / GitHub Copilot, Antigravity) and web AI interfaces (ChatGPT, Claude, Gemini). @@ -16,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **AI Documentation Guide**: Added a dedicated `AI & IDE Integration` guide (`doc/1-getting-started/ai-integration.md`) featured directly in the primary VitePress navigation sidebar under Getting Started. ### Changed & Hardened +- **Config Module Symmetry**: Renamed internal config modules to `config.define.ts` and `config.resolve.ts` following Tempo file naming conventions. - **Timezone Offset Normalization (`engine.lexer.ts`)**: Upgraded `parseZone` to normalize signed hour-and-minute offsets (e.g. `+5:30`, `-8:30`, `+05:30`, `+530`) to canonical ISO-8601 `±HH:MM` format before calling `toZonedDateTime`, enabling seamless parsing for half-hour and quarter-hour timezones. - **Safe Timezone Configuration Mutation**: Hardened `parseZone` so that `config.timeZone` is updated only when `toZonedDateTime` completes successfully without throwing, preventing state mutation on invalid timezone identifiers. - **Remote Provider Manifest Defaults (`providers.v1.json`)**: Updated the Groq provider default model from the retiring `llama-3.3-70b-versatile` to `openai/gpt-oss-120b`. diff --git a/packages/tempo/doc/8-project-and-support/releases/v3.x.md b/packages/tempo/doc/8-project-and-support/releases/v3.x.md index d8d526f0..fcf60ebf 100644 --- a/packages/tempo/doc/8-project-and-support/releases/v3.x.md +++ b/packages/tempo/doc/8-project-and-support/releases/v3.x.md @@ -1,5 +1,90 @@ # 📜 Version 3.x History +## [v3.11.1] - 2026-08-10 + +### ✨ Dedicated Config Subpath (`@magmacomputing/tempo/config`) +Tempo configuration can now be authored with full IDE typing and autocomplete via the dedicated `@magmacomputing/tempo/config` entry point. It exports `defineConfig` for type-safe configuration authoring and `resolveConfig` for programmatic discovery: + +```typescript +import { defineConfig } from '@magmacomputing/tempo/config'; + +export default defineConfig({ + timeZone: 'Australia/Sydney', + locale: 'en-AU', + cache: { + ttl: 3600000, + maxSize: 500, + }, +}); +``` + +### ✨ JSONC Configuration Support +The filesystem configuration resolver now natively parses JSON with comments (`tempo.config.jsonc` and `tempo.config.json`), supporting single-line (`//`), multi-line (`/* */`) comments, and trailing commas for clean, human-readable config files without requiring build steps. + +### ✨ Timezone Abbreviations & Humanized Offsets +The natural-language and layout parsers now natively recognize common 3–4 letter timezone abbreviations (e.g. `AEST`, `PST`, `EST`, `CET`, `JST`) as well as explicit `GMT`/`UTC` offset prefixes: + +```typescript +new Tempo('Aug 6, 2026 16:16 GMT+10'); +new Tempo('August 6, 2026 16:16 AEST'); +new Tempo('2026-08-08 10:30 +5:30'); // Fractional and half-hour offsets supported +``` + +### ✨ SQL & Space-Delimited Timestamp Parsing +Tempo automatically detects and normalizes SQL and space-delimited timestamps (such as `2026-08-08 10:30:00 [America/New_York]`) into standard ISO 8601 strings during initialization, collapsing extraneous whitespace while safely preserving complex timezone identifiers. + +### ✨ Cache Inspection & Serialization (`toJSON`) +`Tempo.cache` and the underlying `BoundedCache` engine now support native `.toJSON()` serialization, allowing you to easily inspect active, non-expired cache entries or serialize them with `JSON.stringify(Tempo.cache)` for diagnostics. + +### 📚 AI Context & IDE Integration (`llms.txt`) +Published official, standardized `llms.txt` and `llms-full.txt` context bundles at [tempo.magmacomputing.com.au](https://tempo.magmacomputing.com.au) to provide rich, curated project context for modern AI coding tools (Cursor, Copilot, Antigravity, Claude, ChatGPT). Added a dedicated **AI & IDE Integration** guide to the documentation. + +--- + +## [v3.11.0] - 2026-07-31 + +### ✨ Centralized Cache Engine (`Tempo.cache`) +Introduced a high-performance `BoundedCache` singleton managing date resolution, layout compilation, and AI operations. It supports configurable LRU capacity eviction (`maxSize`) and time-to-live (`ttl`) expiration: + +```typescript +Tempo.init({ + cache: { + maxSize: 1000, + ttl: 60 * 60 * 1000, // 1 hour TTL + }, +}); +``` + +### ✨ Glossary Seeding +You can now seed domain-specific terms or pre-resolved glossary mappings into the cache during initialization. Seeded glossary items remain permanently cached as immortal entries exempt from LRU eviction and TTL expiration: + +```typescript +const glossary = new Map([ + ['fiscal_kickoff', '2026-07-01T00:00:00Z'], + ['release_v3', '2026-08-10T12:00:00Z'], +]); + +Tempo.init({ cache: glossary }); +``` + +### ⚡ Multi-Provider AI Farm & Batching +Upgraded `@magmacomputing/tempo-plugin-ai` to support multi-provider orchestration modes (`AiMode.Fallback`, `AiMode.Race`, `AiMode.Consensus`, `AiMode.Hedged`, `AiMode.RoundRobin`, `AiMode.Adaptive`), bounded concurrency batch processing, and `softErrors` error boundaries for robust resilience in production environments. + +### 📚 Documentation Enhancements +Scaffolded the **Cache Management** (`tempo.cache.md`) Core Concepts guide, detailing cache topology, glossary vs alias decision matrices, and integration across core and plugins. + +--- + +## [v3.10.3] - 2026-07-29 + +### ⚡ Zero-Overhead Instantiation & Lazy System Clock +Construction of `Tempo` instances has been further optimized to deliver true zero-overhead instantiation: +- **Deferred System Clock (`#now`)**: System clock acquisition (`Temporal.Instant.fromEpochNanoseconds`) is deferred until relative duration math or parsing fallbacks explicitly require the current time. Constructing instances from explicit date strings, numbers, or objects skips system clock calls entirely. +- **Lazy Delegators (`#fmt`, `#term`)**: Internal proxy delegator objects are constructed on-demand only when `.fmt` or `.term` properties are accessed. +- **High-Frequency Acceleration**: Substantially boosts throughput for loops, high-volume data transformation, and `Tempo.Interval` boundary operations (`overlaps`, `contains`, `intersection`, `union`). + +--- + ## [v3.10.2] - 2026-07-25 ### ✨ Experimental AI Parsing diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 8b40e042..48325872 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -113,6 +113,12 @@ "#tempo/license": { "default": "./dist/plugin/license/license.validator.js" }, + "#tempo/config": { + "default": "./dist/config/config.index.js" + }, + "#tempo/config/*.js": { + "default": "./dist/config/*.js" + }, "#tempo/*.js": { "default": "./dist/*.js" } @@ -123,6 +129,11 @@ "import": "./dist/tempo.index.js", "default": "./dist/tempo.index.js" }, + "./config": { + "types": "./dist/config/config.index.d.ts", + "import": "./dist/config/config.index.js", + "default": "./dist/config/config.index.js" + }, "./enums": { "types": "./dist/support/support.enum.d.ts", "import": "./dist/support/support.enum.js", diff --git a/packages/tempo/public/providers.v1.json b/packages/tempo/public/providers.v1.json index 73570985..8bba346b 100644 --- a/packages/tempo/public/providers.v1.json +++ b/packages/tempo/public/providers.v1.json @@ -1,25 +1,41 @@ { - "version": "1.0", - "updatedAt": "2026-08-05T00:00:00Z", + "version": "1.1", + "updatedAt": "2026-08-16T00:00:00Z", "providers": { "groq": { "url": "https://api.groq.com/openai/v1/chat/completions", - "model": "openai/gpt-oss-120b", + "models": { + "default": "openai/gpt-oss-120b", + "fast": "qwen/qwen3.6-27b", + "large": "openai/gpt-oss-120b" + }, "tokenParam": "max_tokens" }, "openai": { "url": "https://api.openai.com/v1/chat/completions", - "model": "gpt-5.4-mini", + "models": { + "default": "gpt-5.4-mini", + "fast": "gpt-5.4-mini", + "reasoning": "o3-mini" + }, "tokenParam": "max_completion_tokens" }, "gemini": { "url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", - "model": "gemini-3.6-flash", + "models": { + "default": "gemini-3.7-flash", + "fast": "gemini-3.7-flash", + "reasoning": "gemini-2.5-pro" + }, "tokenParam": "max_tokens" }, "mistral": { "url": "https://api.mistral.ai/v1/chat/completions", - "model": "mistral-small-latest", + "models": { + "default": "mistral-small-latest", + "fast": "mistral-small-latest", + "large": "mistral-large-latest" + }, "tokenParam": "max_tokens" } } diff --git a/packages/tempo/public/providers.v1.jsonc b/packages/tempo/public/providers.v1.jsonc new file mode 100644 index 00000000..af24f28a --- /dev/null +++ b/packages/tempo/public/providers.v1.jsonc @@ -0,0 +1,49 @@ +{ + // Tempo AI Plugin - Dynamic Remote Provider Manifest v1.1 + // Hosted at: https://tempo.magmacomputing.com.au/providers.v1.jsonc (and .json) + // Consumed automatically by @magmacomputing/tempo-plugin-ai during initAI() + "version": "1.1", + "updatedAt": "2026-08-16T00:00:00Z", + "providers": { + // Groq: High-speed open weights inference + "groq": { + "url": "https://api.groq.com/openai/v1/chat/completions", + "models": { + "default": "openai/gpt-oss-120b", + "fast": "qwen/qwen3.6-27b", + "large": "openai/gpt-oss-120b" + }, + "tokenParam": "max_tokens" + }, + // OpenAI: Modern GPT series + "openai": { + "url": "https://api.openai.com/v1/chat/completions", + "models": { + "default": "gpt-5.4-mini", + "fast": "gpt-5.4-mini", + "reasoning": "o3-mini" + }, + "tokenParam": "max_completion_tokens" + }, + // Google Gemini: Multimodal flash & reasoning + "gemini": { + "url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + "models": { + "default": "gemini-3.7-flash", + "fast": "gemini-3.7-flash", + "reasoning": "gemini-2.5-pro" + }, + "tokenParam": "max_tokens" + }, + // Mistral AI: European low-latency models + "mistral": { + "url": "https://api.mistral.ai/v1/chat/completions", + "models": { + "default": "mistral-small-latest", + "fast": "mistral-small-latest", + "large": "mistral-large-latest" + }, + "tokenParam": "max_tokens" + } + } +} diff --git a/packages/tempo/src/config/defineConfig.ts b/packages/tempo/src/config/config.define.ts similarity index 100% rename from packages/tempo/src/config/defineConfig.ts rename to packages/tempo/src/config/config.define.ts diff --git a/packages/tempo/src/config/config.index.ts b/packages/tempo/src/config/config.index.ts new file mode 100644 index 00000000..ed35bf73 --- /dev/null +++ b/packages/tempo/src/config/config.index.ts @@ -0,0 +1,2 @@ +export { defineConfig } from './config.define.js'; +export { resolveConfig } from './config.resolve.js'; diff --git a/packages/tempo/src/config/resolveConfig.ts b/packages/tempo/src/config/config.resolve.ts similarity index 92% rename from packages/tempo/src/config/resolveConfig.ts rename to packages/tempo/src/config/config.resolve.ts index 506cb422..e701ca3a 100644 --- a/packages/tempo/src/config/resolveConfig.ts +++ b/packages/tempo/src/config/config.resolve.ts @@ -1,4 +1,5 @@ import { isFunction } from '#library/assertion.library.js'; +import { parseJSONC } from '#library/serialize.library.js'; import type { Options } from '../tempo.type.js'; // Minimal declaration so TS doesn't complain in browser environments without @types/node @@ -31,9 +32,9 @@ export async function resolveConfig(options?: { cwd?: string, configFile?: strin let currentDir = options?.cwd || process.cwd(); const loadFile = async (configPath: string, ext: string) => { - if (ext === '.json') { + if (ext === '.json' || ext === '.jsonc') { const content = await fs.promises.readFile(configPath, 'utf8'); - return JSON.parse(content) as Options; + return parseJSONC(content) as Options; } else { // Use pathToFileURL to safely load absolute paths on Windows const { pathToFileURL } = await import(modUrl); @@ -61,7 +62,7 @@ export async function resolveConfig(options?: { cwd?: string, configFile?: strin while (currentDir !== rootPath) { const pkgJson = path.join(currentDir, 'package.json'); - const exts = ['.ts', '.js', '.mjs', '.cjs', '.json']; + const exts = ['.ts', '.js', '.mjs', '.cjs', '.jsonc', '.json']; for (const ext of exts) { const configPath = path.join(currentDir, `tempo.config${ext}`); diff --git a/packages/tempo/src/library.index.ts b/packages/tempo/src/library.index.ts index cc18a5f8..a81346f0 100644 --- a/packages/tempo/src/library.index.ts +++ b/packages/tempo/src/library.index.ts @@ -10,7 +10,8 @@ export * as webToken from '#library/webtoken.library.js'; export { enumify, type Enum } from '#library/enumerate.library.js'; export { fetchRequest, fetchHead, HttpError } from '#library/request.library.js'; export { asArray } from '#library/coercion.library.js'; -export { stringify, objectify, cloneify } from '#library/serialize.library.js'; +export { stringify, objectify, cloneify, parseJSONC, stripJSONC } from '#library/serialize.library.js'; +export { getContext, CONTEXT } from '#library/utility.library.js'; export * from '#library/proxy.library.js'; export * from '#library/assertion.library.js'; diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 569a13ec..f3edd7f0 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -411,6 +411,11 @@ export function extendState(state: t.Internal.State, options: t.Options): boolea } break; + case 'plugins': + if (isObject(arg.value) && !Array.isArray(arg.value) && !('name' in arg.value) && !('key' in arg.value)) + setProperty(state.config, 'plugins', arg.value); + break; + default: setProperty(state.config, optKey, arg.value); break; diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index a0dda3f3..bacc1775 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -817,7 +817,7 @@ export class Tempo { * before initializing the engine. */ static async bootstrap(options?: { cwd?: string, configFile?: string }): Promise { - const { resolveConfig } = await import('./config/resolveConfig.js'); + const { resolveConfig } = await import('./config/config.resolve.js'); const config = await resolveConfig(options); this.init(config || {}); await this.ready(); @@ -914,7 +914,10 @@ export class Tempo { setLogLevel(state.config.debug ?? options.debug ?? Default?.debug ?? LOG.Info); - if (options.plugins) this.extend(options.plugins); // ensure init-plugins are processed before 'ready' + if (options.plugins) { + if (Array.isArray(options.plugins) || isFunction(options.plugins) || (isObject(options.plugins) && ('name' in options.plugins || 'key' in options.plugins || 'install' in options.plugins))) + this.extend(options.plugins); + } if (Context.type === CONTEXT.Browser || state.config.debug === LOG.Debug) logDebug('Tempo:', this.config, state.config); diff --git a/packages/tempo/src/tempo.index.ts b/packages/tempo/src/tempo.index.ts index be2a3387..8c40335d 100644 --- a/packages/tempo/src/tempo.index.ts +++ b/packages/tempo/src/tempo.index.ts @@ -39,7 +39,6 @@ Tempo.extend(core); export { parse, format } from '#tempo/module'; export { enums }; -export { defineConfig } from './config/defineConfig.js'; // make the Tempo type augmentations available export type * from '#tempo/parse'; @@ -48,6 +47,7 @@ export type * from '#tempo/mutate'; export type * from '#tempo/duration'; export type * from '#tempo/term'; +export { defineConfig } from './config/config.define.js'; export { Interval } from './interval.class.js'; export * from './tempo.class.js'; export default Tempo; diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 23f6a43f..d3537d06 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -353,7 +353,7 @@ export namespace Internal { periods?: Period | RegistryOption; ignores?: Ignore; }; - /** plugins to be automatically extended */ plugins: (TempoPlugin | TermPlugin) | (TempoPlugin | TermPlugin)[]; + /** plugins to be automatically extended or plugin configurations */plugins?: (TempoPlugin | TermPlugin) | (TempoPlugin | TermPlugin)[] | Record; /** supplied value to parse */ value: DateTime; /** @internal temporary anchor used during parsing */ anchor: any; /** @internal accumulated parse results */ result?: Match[] | undefined; @@ -450,7 +450,7 @@ export namespace Internal { /** @deprecated Provide configuration inside `registry: { locales: ... }` */locales?: Record>; /** custom data augmentation registries */ registry?: { formats?: Property, locales?: Record>, modifiers?: Record, tokens?: Record }; /** noise words to ignore during parsing via Tempo.ignore() */ignore?: Ignore; - /** plugins to be automatically extended via Tempo.extend() */plugins?: (TempoPlugin | TermPlugin) | (TempoPlugin | TermPlugin)[]; + /** plugins to be automatically extended via Tempo.extend() or plugin-specific configuration dictionary */plugins?: (TempoPlugin | TermPlugin) | (TempoPlugin | TermPlugin)[] | Record; } export interface LicenseScope { diff --git a/packages/tempo/src/tsconfig.json b/packages/tempo/src/tsconfig.json index 4dff0e19..cc681801 100644 --- a/packages/tempo/src/tsconfig.json +++ b/packages/tempo/src/tsconfig.json @@ -25,6 +25,8 @@ "#tempo/mutate": [ "./module/module.mutate.ts" ], "#tempo/engine/*.js": [ "./engine/*.ts" ], "#tempo/module/*.js": [ "./module/*.ts" ], + "#tempo/config/*.js": [ "./config/*.ts" ], + "#tempo/config": [ "./config/config.index.ts" ], "#tempo/plugin/extend/*.js": [ "./plugin/extend/*.ts" ], "#tempo/plugin/term/*.js": [ "./plugin/term/*.ts" ], "#tempo/plugin/plugin.*.js": [ "./plugin/plugin.*.ts" ], diff --git a/packages/tempo/test/README.md b/packages/tempo/test/README.md index af8dd3e6..4c4d1b93 100644 --- a/packages/tempo/test/README.md +++ b/packages/tempo/test/README.md @@ -35,6 +35,6 @@ npm run test:ci ## Conventions -- **Isolation Assertions**: Files ending in `.core.test.ts` or `.lazy.test.ts` are designed to assert behavior *before* modules are fully loaded. These are automatically excluded during the `test:ci:prefilter` run to avoid side-effects. +- **Isolation Assertions**: Files ending in `.core.test.ts` or `.lazy.test.ts` are designed to assert behavior *before* modules are fully loaded. These are executed in isolation under the `Tempo: Core` test project to avoid cross-suite side-effects. - **Console Suppression**: By default, `console` output is suppressed in tests via `test/support/setup.console-spy.ts`. To debug, you can use `(console.log as any).mockRestore()` within your test or comment out the suppression in the setup file. - **Anchoring**: When testing relative dates, always provide a fixed anchor to ensure tests are deterministic. diff --git a/packages/tempo/test/core/config.test.ts b/packages/tempo/test/core/config.test.ts index de97b296..ad0b49c7 100644 --- a/packages/tempo/test/core/config.test.ts +++ b/packages/tempo/test/core/config.test.ts @@ -1,4 +1,5 @@ import { Tempo } from '#tempo'; +import { parseJSONC } from '@magmacomputing/tempo/library'; describe('#setConfig refactor verification', () => { @@ -108,4 +109,41 @@ describe('#setConfig refactor verification', () => { expect(config.value).toBeUndefined(); }) + test('should parse JSONC config with comments and trailing commas during bootstrap', async () => { + using _ = Tempo; + + const jsoncText = ` + { + // Default timezone + "timeZone": "Australia/Sydney", + "plugins": { + // AI Configuration + "ai": { + "mode": "fallback", + "providers": [ + { "id": "groq" }, + ], + }, + }, + } + `; + const parsed = parseJSONC(jsoncText); + expect(parsed.timeZone).toBe('Australia/Sydney'); + expect(parsed.plugins.ai.mode).toBe('fallback'); + expect(parsed.plugins.ai.providers).toEqual([{ id: 'groq' }]); + + await Tempo.init(parsed); + expect(Tempo.config.timeZone).toBe('Australia/Sydney'); + expect((Tempo.config as any).plugins?.ai?.mode).toBe('fallback'); + }) + + test('should export defineConfig and resolveConfig from config module', async () => { + const { defineConfig, resolveConfig } = await import('../../src/config/config.index.js'); + expect(typeof defineConfig).toBe('function'); + expect(typeof resolveConfig).toBe('function'); + const dummy = { timeZone: 'UTC' }; + expect(defineConfig(dummy)).toBe(dummy); + }) + }) + diff --git a/packages/tempo/test/engine/parse.prefilter.flag.test.ts b/packages/tempo/test/engine/parse.prefilter.flag.test.ts index 922a09d3..629b2023 100644 --- a/packages/tempo/test/engine/parse.prefilter.flag.test.ts +++ b/packages/tempo/test/engine/parse.prefilter.flag.test.ts @@ -5,7 +5,7 @@ describe('parse prefilter feature flag', () => { Tempo.init(); }); - test.skipIf(process.env.TEMPO_PREFILTER_CI === 'true')('defaults to enabled', () => { + test('defaults to enabled', () => { expect(Tempo.parse.planner.preFilter).toBe(true); }); @@ -18,7 +18,7 @@ describe('parse prefilter feature flag', () => { expect(t.parse.result?.[0]?.match).toBe('relativeOffset'); }); - test.skipIf(process.env.TEMPO_PREFILTER_CI === 'true')('can be disabled per-instance without changing global setting', () => { + test('can be disabled per-instance without changing global setting', () => { Tempo.init({ preFilter: true }); const t = new Tempo('monday', { timeZone: 'UTC', preFilter: false }); diff --git a/packages/tempo/test/support/ci.prefilter.setup.ts b/packages/tempo/test/support/ci.prefilter.setup.ts deleted file mode 100644 index ab803f73..00000000 --- a/packages/tempo/test/support/ci.prefilter.setup.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Tempo } from '#tempo'; - -// Enable patchability of Tempo.init only in test/CI by setting a global flag -(globalThis as any).TEMPO_TESTING = true; - -// Optionally, call Tempo.init with parsePrefilter=true for initial hydration -Tempo.init({ parsePrefilter: true }); - -if ((typeof process !== 'undefined' && (process.env.CI || process.env.TEMPO_PREFILTER_CI))) { - // eslint-disable-next-line no-console - console.log('[CI] parsePrefilter enabled for all tests (Tempo.init initial hydration)'); -} - diff --git a/packages/tempo/test/tsconfig.json b/packages/tempo/test/tsconfig.json index cde6fa0e..6ed6ded9 100644 --- a/packages/tempo/test/tsconfig.json +++ b/packages/tempo/test/tsconfig.json @@ -22,6 +22,8 @@ "#tempo/module": [ "../src/module/module.index.ts" ], "#tempo/engine/*.js": [ "../src/engine/*.ts" ], "#tempo/module/*.js": [ "../src/module/*.ts" ], + "#tempo/config/*.js": [ "../src/config/*.ts" ], + "#tempo/config": [ "../src/config/config.index.ts" ], "#tempo/plugin/extend/*.js": [ "../src/plugin/extend/*.ts" ], "#tempo/plugin/term/*.js": [ "../src/plugin/term/*.ts" ], "#tempo/term/*": [ "../src/plugin/term/term.*.ts" ], diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index a44fb150..22f770c1 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -33,7 +33,6 @@ const isPremiumAvailable = Boolean( export default defineConfig({ esbuild: false, - oxc: false, plugins: [ swc.vite({ jsc: { @@ -61,6 +60,8 @@ export default defineConfig({ alias: isDist ? [ { find: /^#tempo\/license$/, replacement: resolve(__dirname, './dist/plugin/license/license.validator.js') }, { find: /^#tempo\/core$/, replacement: resolve(__dirname, './dist/core.index.js') }, + { find: /^#tempo\/config\/(.*)\.js$/, replacement: resolve(__dirname, './dist/config/$1.js') }, + { find: /^#tempo\/config$/, replacement: resolve(__dirname, './dist/config/config.index.js') }, { find: /^#tempo\/term$/, replacement: resolve(__dirname, './dist/plugin/term/term.index.js') }, { find: /^#tempo\/(parse|format|mutate|duration)$/, replacement: resolve(__dirname, './dist/module/module.$1.js') }, { find: /^#tempo\/module$/, replacement: resolve(__dirname, './dist/module/module.index.js') }, @@ -82,6 +83,7 @@ export default defineConfig({ { find: /^@magmacomputing\/tempo\/term$/, replacement: resolve(__dirname, './dist/plugin/term/term.index.js') }, { find: /^@magmacomputing\/tempo\/term\/(.*)$/, replacement: resolve(__dirname, './dist/plugin/term/term.$1.js') }, { find: /^@magmacomputing\/tempo\/core$/, replacement: resolve(__dirname, './dist/core.index.js') }, + { find: /^@magmacomputing\/tempo\/config$/, replacement: resolve(__dirname, './dist/config/config.index.js') }, { find: /^@magmacomputing\/tempo\/library$/, replacement: resolve(__dirname, './dist/library.index.js') }, { find: /^@magmacomputing\/tempo$/, replacement: resolve(__dirname, './dist/tempo.index.js') }, ] : [ @@ -95,9 +97,12 @@ export default defineConfig({ { find: /^@magmacomputing\/tempo\/term$/, replacement: resolve(__dirname, './src/plugin/term/term.index.ts') }, { find: /^@magmacomputing\/tempo\/term\/(.*)$/, replacement: resolve(__dirname, './src/plugin/term/term.$1.ts') }, { find: /^@magmacomputing\/tempo\/core$/, replacement: resolve(__dirname, './src/core.index.ts') }, + { find: /^@magmacomputing\/tempo\/config$/, replacement: resolve(__dirname, './src/config/config.index.ts') }, { find: /^@magmacomputing\/tempo\/library$/, replacement: resolve(__dirname, './src/library.index.ts') }, { find: /^@magmacomputing\/tempo$/, replacement: resolve(__dirname, './src/tempo.index.ts') }, { find: /^#tempo\/core$/, replacement: resolve(__dirname, './src/core.index.ts') }, + { find: /^#tempo\/config\/(.*)\.js$/, replacement: resolve(__dirname, './src/config/$1.ts') }, + { find: /^#tempo\/config$/, replacement: resolve(__dirname, './src/config/config.index.ts') }, { find: /^#tempo\/term$/, replacement: resolve(__dirname, './src/plugin/term/term.index.ts') }, { find: /^#tempo\/term\/(.*)$/, replacement: resolve(__dirname, './src/plugin/term/$1') }, { find: /^#tempo\/(parse|format|mutate|duration)$/, replacement: resolve(__dirname, './src/module/module.$1.ts') }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 42bb5cc1..b2517d10 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -15,8 +15,7 @@ "ESNext.Temporal", "DOM" ], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - "types": [ - ], + "types": [], // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ @@ -32,15 +31,39 @@ "moduleResolution": "NodeNext", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ "paths": { - "#library": [ "./packages/library/src/common.index.ts" ], - "#library/*": [ "./packages/library/src/common/*" ], - "#browser": [ "./packages/library/src/browser.index.ts" ], - "#browser/*": [ "./packages/library/src/browser/*" ], - "#server": [ "./packages/library/src/server.index.ts" ], - "#server/*": [ "./packages/library/src/server/*" ], - "#tempo/license": [ "../tempo-plugin/packages/_core/src/index.ts" ], - "#tempo": [ "./packages/tempo/src/tempo.index.ts" ], - "#tempo/*": [ "./packages/tempo/src/*" ] + "#library": [ + "./packages/library/src/common.index.ts" + ], + "#library/*": [ + "./packages/library/src/common/*" + ], + "#browser": [ + "./packages/library/src/browser.index.ts" + ], + "#browser/*": [ + "./packages/library/src/browser/*" + ], + "#server": [ + "./packages/library/src/server.index.ts" + ], + "#server/*": [ + "./packages/library/src/server/*" + ], + "#tempo/config": [ + "./packages/tempo/src/config/config.index.ts" + ], + "#tempo/config/*": [ + "./packages/tempo/src/config/*" + ], + "#tempo/license": [ + "../tempo-plugin/packages/_core/src/index.ts" + ], + "#tempo": [ + "./packages/tempo/src/tempo.index.ts" + ], + "#tempo/*": [ + "./packages/tempo/src/*" + ] }, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ @@ -105,4 +128,4 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ } -} +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 1f1bf575..75ab4c23 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ esbuild: false, - oxc: false, plugins: [ swc.vite({ jsc: { @@ -27,9 +26,7 @@ export default defineConfig({ name: 'Tempo: Full', include: ['packages/tempo/test/**/*.{test,spec}.ts'], exclude: ['**/node_modules/**', '**/test/**/*.core.test.ts', '**/test/**/*.lazy.test.ts'], - setupFiles: process.env.TEMPO_PREFILTER_CI === 'true' - ? ['./packages/tempo/bin/temporal-polyfill.ts', './packages/tempo/test/support/setup.console-spy.ts', './packages/tempo/test/support/ci.prefilter.setup.ts'] - : ['./packages/tempo/bin/temporal-polyfill.ts', './packages/tempo/test/support/setup.console-spy.ts'], + setupFiles: ['./packages/tempo/bin/temporal-polyfill.ts', './packages/tempo/test/support/setup.console-spy.ts'], } }, { @@ -65,6 +62,8 @@ export default defineConfig({ { find: /^#tempo\/plugin\.(util|type)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/plugin.$1.ts') }, { find: /^#tempo\/plugin\.(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/plugin/extend/plugin.$1.ts') }, { find: /^#tempo\/core$/, replacement: path.resolve(__dirname, './packages/tempo/src/core.index.ts') }, + { find: /^#tempo\/config\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/tempo/src/config/$1.ts') }, + { find: /^#tempo\/config$/, replacement: path.resolve(__dirname, './packages/tempo/src/config/config.index.ts') }, { find: /^#tempo\/(parse|format|mutate|duration)$/, replacement: path.resolve(__dirname, './packages/tempo/src/module/module.$1.ts') }, { find: /^#tempo\/support$/, replacement: path.resolve(__dirname, './packages/tempo/src/support/support.index.ts') }, { find: /^#tempo\/module$/, replacement: path.resolve(__dirname, './packages/tempo/src/module/module.index.ts') },