diff --git a/.agent/rules/plan-execution.md b/.agent/rules/plan-execution.md new file mode 100644 index 00000000..6902cd6b --- /dev/null +++ b/.agent/rules/plan-execution.md @@ -0,0 +1,12 @@ +# Plan & Development Execution Rules + +## 1. Interactive Pair-Programming (Default Mode) +During standard interactive conversations, code discussions, and step-by-step refactoring: +- **ALWAYS use native file editing tools (`replace_file_content` and `write_to_file`)** for creating and modifying files. +- This provides visual line-by-line diff previews and interactive approval checkboxes directly in the user's IDE UI. + +## 2. Autonomous Background Execution (AFK / `/ok` Mode) +When the user explicitly approves an implementation plan for autonomous background execution (e.g. by typing `/ok` or clicking "Ok to proceed"): +- Validate approval state, verify the identity of the currently approved implementation plan, and validate command scope prior to enabling `SafeToAutoRun` or invoking `run_command`. +- Destructive commands or actions with external side effects must remain interactive unless explicitly authorized in the approved plan. +- For non-destructive operations covered by the approved plan, set `SafeToAutoRun: true` and use terminal shell operations (`run_command`) or file tools for unblocked execution. diff --git a/.agent/workflows/ok.md b/.agent/workflows/ok.md new file mode 100644 index 00000000..4f5aaa16 --- /dev/null +++ b/.agent/workflows/ok.md @@ -0,0 +1,6 @@ +--- +description: Ok to proceed with your Plan +--- +// turbo-all + +Verify active approval state and validate command scope against the approved plan before invoking execution. Ok to proceed with the approved plan. diff --git a/.github/ISSUE_TEMPLATE/bug_report_ai.yml b/.github/ISSUE_TEMPLATE/bug_report_ai.yml new file mode 100644 index 00000000..cf80c737 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report_ai.yml @@ -0,0 +1,74 @@ +name: "🐛 Bug Report: parseAI Plugin" +description: Report an issue or unexpected date parsing result with the parseAI plugin +title: "[parseAI]: " +labels: ["bug", "plugin:ai", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for helping refine `@magmacomputing/tempo-plugin-ai`! Since this plugin is currently experimental, detailed reproduction details help us stabilize context resolution and prompt engineering. + + - type: input + id: environment + attributes: + label: Version & Environment + placeholder: "e.g., tempo-plugin-ai v0.2.0, tempo v3.11.0, Node.js v20.11" + validations: + required: true + + - type: dropdown + id: provider + attributes: + label: AI Provider + options: + - OpenAI (gpt-4o / gpt-4o-mini / gpt-5.4-mini) + - Azure OpenAI + - Custom LLM Proxy + - Native Fallback + validations: + required: true + + - type: textarea + id: input_query + attributes: + label: Natural Language Input Phrase + description: What string was passed to parseAI()? + placeholder: 'e.g. "The penultimate Tuesday before Thanksgiving in 2026"' + validations: + required: true + + - type: textarea + id: context + attributes: + label: Context & Options + description: Provide relevant options (timeZone, locale, calendar, anchor date, etc.). + placeholder: '{ timeZone: "America/New_York", anchor: "2026-11-01" }' + validations: + required: false + + - type: textarea + id: behavior + attributes: + label: Expected vs. Actual Result + placeholder: | + Expected: 2026-11-17T00:00:00-05:00 + Actual: 2026-11-24T00:00:00-05:00 (or TempoAiError) + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Minimal Reproduction Code + description: Include a snippet showing initAI() and parseAI() invocations. DO NOT include real API keys or private endpoints! + render: typescript + placeholder: | + import { Tempo } from '@magmacomputing/tempo'; + import { parseAI, initAI } from '@magmacomputing/tempo-plugin-ai'; + + // DO NOT INCLUDE REAL API KEYS OR PRIVATE ENDPOINTS BELOW + initAI({ + providers: [{ id: 'openai', key: 'YOUR_API_KEY_HERE' }] + }); + + const res = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); diff --git a/package-lock.json b/package-lock.json index 07e70235..69b2f698 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "3.10.2", + "version": "3.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "3.10.2", + "version": "3.11.0", "workspaces": [ "packages/*", "packages/plugins/*" @@ -17,10 +17,10 @@ "devDependencies": { "@js-temporal/polyfill": "^0.5.1", "@rollup/plugin-node-resolve": "^16.0.3", - "@types/google.maps": "^3.65.2", + "@types/google.maps": "^3.65.3", "@types/hammerjs": "^2.0.46", "@types/jquery": "^4.0.0", - "@types/node": "^26.0.1", + "@types/node": "^26.1.2", "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/browser-webdriverio": "^4.1.8", @@ -1135,7 +1135,7 @@ "link": true }, "node_modules/@magmacomputing/tempo-plugin-ai": { - "resolved": "packages/plugins/parseAI", + "resolved": "packages/plugins/ai", "link": true }, "node_modules/@magmacomputing/tempo-plugin-astro": { @@ -2725,9 +2725,9 @@ "license": "MIT" }, "node_modules/@types/google.maps": { - "version": "3.65.2", - "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.65.2.tgz", - "integrity": "sha512-e52bmOhGCQSNabFpL48iQlwJybq6rfns8NUVJ20MR7CdPlHQ2RmSCnPbJfrUYJfogrE4OiHQTZ4LXpop+eer1w==", + "version": "3.65.3", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.65.3.tgz", + "integrity": "sha512-tqbbx7MUtoDk+RwpZMymPDj6Skez0FhqDZNhLhS5UDmCx4D1dgP+BJOHTebiO/rhjJLa1f8te2p6mJJeFKVFOQ==", "dev": true, "license": "MIT" }, @@ -2798,9 +2798,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", - "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "dev": true, "license": "MIT", "dependencies": { @@ -11045,7 +11045,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "3.10.2", + "version": "3.11.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -11061,6 +11061,17 @@ "magma-cli": "index.js" } }, + "packages/plugins/ai": { + "name": "@magmacomputing/tempo-plugin-ai", + "version": "0.2.0", + "license": "MIT", + "devDependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, + "peerDependencies": { + "@magmacomputing/tempo": "^3.10.3" + } + }, "packages/plugins/astro": { "name": "@magmacomputing/tempo-plugin-astro", "version": "2.1.3", @@ -11361,17 +11372,6 @@ } } }, - "packages/plugins/parseAI": { - "name": "@magmacomputing/tempo-plugin-ai", - "version": "0.1.0", - "license": "MIT", - "devDependencies": { - "@js-temporal/polyfill": "^0.5.1" - }, - "peerDependencies": { - "@magmacomputing/tempo": "^3.10.2" - } - }, "packages/plugins/snap": { "name": "@magmacomputing/tempo-plugin-snap", "version": "1.3.2", @@ -11392,7 +11392,7 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "3.10.2", + "version": "3.11.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" diff --git a/package.json b/package.json index 1b330e4a..a960cd8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.10.2", + "version": "3.11.0", "private": true, "engines": { "node": ">=20.0.0" @@ -36,10 +36,10 @@ "devDependencies": { "@js-temporal/polyfill": "^0.5.1", "@rollup/plugin-node-resolve": "^16.0.3", - "@types/google.maps": "^3.65.2", + "@types/google.maps": "^3.65.3", "@types/hammerjs": "^2.0.46", "@types/jquery": "^4.0.0", - "@types/node": "^26.0.1", + "@types/node": "^26.1.2", "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/browser-webdriverio": "^4.1.8", @@ -63,9 +63,9 @@ "allowScripts": { "edgedriver@6.3.0": true, "geckodriver@6.1.0": true, - "@swc/core@1.15.41": true, "esbuild@0.28.1": true, - "esbuild@0.21.5": true + "esbuild@0.21.5": true, + "@swc/core@1.15.43": true }, "dependencies": { "typescript-7": "npm:typescript@^7.0.2" diff --git a/packages/library/package.json b/packages/library/package.json index 045c08b1..312b3090 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.10.2", + "version": "3.11.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md new file mode 100644 index 00000000..2c8e1e22 --- /dev/null +++ b/packages/plugins/ai/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to the `@magmacomputing/tempo-plugin-ai` project will be documented in this file. + +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.2.0] - 2026-07-30 + +### Added +- **Parallel Array Batching & Index Locking**: Concurrently processes array prompt inputs via `Promise.all` (or `Promise.allSettled` when `softErrors: true`) with guaranteed index-locked alignment between inputs and output arrays. +- **Multi-Stream Provider Execution Modes (`AiMode`)**: Added `mode` options (`AiMode.Fallback`, `AiMode.Race`, `AiMode.Consensus`) for fine-grained control over provider routing, speculative racing, and multi-LLM consensus verification with confidence boosting. +- **Runtime Configuration Module (`parseAI.config.ts`)**: Extracted `AiMode`, `RESERVED_PROVIDER_IDS`, and `DEFAULT_PROVIDERS` into a runtime module adhering to Tempo's monorepo `as const` object map and `Object.freeze()` immutability standards. +- **Secured `.ai` Metadata Interceptor**: Proxy-based `.ai` metadata injection for frozen `Tempo` instances, preserving private class field access while exposing provider resolution lineage, ambiguity metrics, and PII-isolated debugging fields (`debug: true`). +- **Semantic LLM Failure & Soft Error Handling**: Opt-in `softErrors: true` for returning `TempoAiError` objects directly within batch output arrays without throwing, and graceful mapping of LLM `"INVALID"` responses to `isValid = false` `Tempo` instances. +- **Centralized Caching Integration**: Powered by core Tempo's centralized `BoundedCache` singleton (`Tempo.cache`) enforcing memory safety, capacity-bounded LRU eviction (`maxSize`), time-to-live expiration (`ttl`), and static immortal glossary isolation. +- **Multi-Provider Fallback Routing**: Robust failover loop across configured LLM providers (`groq`, `openai`, `gemini`, `mistral`, or custom endpoints). Supports custom `tokenParam` mappings (`max_tokens` vs `max_completion_tokens`) and request timeout control via `AbortController`. +- **Rate Limit Tracking (`getAiRateLimits`)**: Inspects provider HTTP response headers (`x-ratelimit-remaining-requests`, `x-ratelimit-remaining-tokens`, `x-ratelimit-reset-tokens`) and exposes real-time quota status via `getAiRateLimits()`, including a `resetAt` `Tempo` timestamp. +- **Structured Error Handling (`TempoAiError`)**: Custom error class providing HTTP status codes (`code`) and optional `retryAt` `Tempo` timestamps. Features a circuit-breaker that immediately stops provider failovers when an LLM returns an explicit `INVALID` parse result (422 status). +- **Documentation & Spec Suite**: Complete architectural guides (`architecture.md`, `context.md`, `rate-limits.md`, `index.md`) and a full Vitest test suite (`test/index.spec.ts`) covering live and mocked provider workflows. + +### Changed & Performance +- **Centralized Caching Architecture**: Delegated all cache capacity and TTL parameters directly to core `Tempo.init()`, allowing `initAI` to focus strictly on LLM provider registration. +- **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. + +## [0.1.0] - 2026-07-26 + +### Added +- Initial scaffolding of the AI natural language parsing plugin. +- Functional exports for `parseAI`, `initAI`, and `clearAiCache`. +- Initial provider fallback-routing engine supporting HTTP requests to configured LLM provider endpoints. diff --git a/packages/plugins/parseAI/LICENSE b/packages/plugins/ai/LICENSE similarity index 100% rename from packages/plugins/parseAI/LICENSE rename to packages/plugins/ai/LICENSE diff --git a/packages/plugins/ai/README.md b/packages/plugins/ai/README.md new file mode 100644 index 00000000..d3ad1e90 --- /dev/null +++ b/packages/plugins/ai/README.md @@ -0,0 +1,46 @@ +![Tempo Plugin](https://raw.githubusercontent.com/magmacomputing/magma/main/packages/tempo/public/plugin-logo.svg) + +# @magmacomputing/tempo-plugin-ai + +

+ npm version npm peer dependency version License TypeScript Ready Documentation +

+ +Tempo community plugin for LLM-powered natural language parsing. + +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 complex natural language expressions into `Tempo` instances. + +> **CRITICAL SECURITY WARNING**: Raw LLM API keys must **never** be exposed in a client-side browser bundle. BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, you must use a proxy service. +> +> **LLM Output Disclaimer**: Large Language Models are probabilistic text generators, not deterministic calculators. Magma Computing Solutions and Tempo core maintainers provide `@magmacomputing/tempo-plugin-ai` "as-is". Developers and organizations are solely responsible for validating AI-generated date and time outputs before relying on them in financial, legal, medical, or time-critical production systems. + +## Installation + +```bash +npm install @magmacomputing/tempo-plugin-ai +``` + +## Setup & Usage + +```typescript +import { parseAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; + +// Initialize with your BYOK API Key (ensuring non-undefined string key) +initAI({ + providers: [ + ...(process.env.GROQ_API_KEY ? [{ id: 'groq', key: process.env.GROQ_API_KEY }] : []), + ] +}); + +// Parse a complex natural language string! +const dt = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); + +// Evict bad parses from the cache +clearAiCache("The penultimate Tuesday before Thanksgiving in 2026"); +``` + +Full documentation is available at [https://magmacomputing.github.io/magma/doc/9-plugins/ai.index.html](https://magmacomputing.github.io/magma/doc/9-plugins/ai.index.html). + +## 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/architecture.md b/packages/plugins/ai/doc/architecture.md new file mode 100644 index 00000000..5ceea909 --- /dev/null +++ b/packages/plugins/ai/doc/architecture.md @@ -0,0 +1,90 @@ +# Provider Architecture & Security + +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) + +For Node.js backends and Edge Workers, the simplest approach is to supply your raw API keys directly to the `initAI` function. + +```typescript +import { initAI } from '@magmacomputing/tempo-plugin-ai'; + +initAI({ + providers: [ + ...(process.env.GROQ_API_KEY ? [{ id: 'groq', key: process.env.GROQ_API_KEY }] : []), + ...(process.env.GEMINI_API_KEY ? [{ id: 'gemini', key: process.env.GEMINI_API_KEY }] : []), + ...(process.env.OPENAI_API_KEY ? [{ id: 'openai', key: process.env.OPENAI_API_KEY }] : []) + ] +}); +``` + +### Advanced Configuration (Custom Models & LLM Options) +By default, standard providers automatically map to their optimal APIs and models (e.g., `'gemini'` maps to `gemini-1.5-flash`). +However, you can explicitly override URLs, models, and inject arbitrary LLM parameters (like `temperature`) for power-user control! + +```typescript +initAI({ + providers: [ + // 1. Enterprise Azure OpenAI (via Entra ID Bearer token or backend proxy wrapper) + // Note: BYOK requests send 'Authorization: Bearer '. When connecting to Azure OpenAI, + // supply an Entra ID bearer token as provider.key or route through an Azure API gateway. + ...(process.env.AZURE_ENTRA_BEARER_TOKEN ? [{ + id: 'openai', + key: process.env.AZURE_ENTRA_BEARER_TOKEN, + url: 'https://my-enterprise.openai.azure.com/v1/chat/completions', + model: 'gpt-4o', + options: { temperature: 0.2, seed: 42 } + }] : []), + // 2. Local Open-Source Models (e.g. Ollama) + { + id: 'local', + key: 'no-key-needed', + url: 'http://localhost:11434/v1/chat/completions', + model: 'llama3.1:8b' + } + ] +}); +``` + +### Frontend Security Warning +> [!CAUTION] +> **Never** expose a raw LLM API key in a client-side browser bundle (like React or Vue). If a developer hardcodes a BYOK key into a public website, anyone can extract it, spam it, and exhaust the developer's quota or get the account permanently banned. BYOK keys are *only* safe on backend servers. + +## The Proxy Architecture + +If you need to execute AI functions directly on a public frontend application, you must route requests through a secure backend proxy. + +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. + +Because LLM API calls typically take ~300-800ms, the ~20ms overhead of routing the request through your own backend proxy is negligible. + +## Fallback Loops & Execution Modes + +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. + +### 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. + +```typescript +const result = await parseAI("Thanksgiving 2026", { mode: 'race' }); +``` + +### 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. + +```typescript +const result = await parseAI("The penultimate Tuesday before Thanksgiving", { + mode: 'consensus', + minConfidence: 0.85 +}); +``` + +### 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/parseAI/doc/context.md b/packages/plugins/ai/doc/context.md similarity index 65% rename from packages/plugins/parseAI/doc/context.md rename to packages/plugins/ai/doc/context.md index 5615d50b..ddae1eee 100644 --- a/packages/plugins/parseAI/doc/context.md +++ b/packages/plugins/ai/doc/context.md @@ -2,7 +2,7 @@ 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. -The `parseAI` plugin solves this by automatically wrapping your input with rich environmental context before sending it to the LLM. +The Tempo AI plugin solves this by automatically wrapping your input with rich environmental context before sending it to the LLM. ## Geographic Context @@ -15,11 +15,11 @@ Along with your string, the plugin passes a hidden context payload to the LLM: 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: ```typescript -// Explicitly evaluate this relative query from the perspective of September 1st -const dt = await parseAI("Next Friday at 5pm", { anchor: '2026-09-01T00:00:00Z' }); +// 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 day after tomorrow", { locale: 'ja-JP', timeZone: 'Asia/Tokyo' }); +const tokyoDt = await parseAI("The second Sunday of May", { locale: 'ja-JP', timeZone: 'Asia/Tokyo' }); ``` ### Why Locale is Critical @@ -32,7 +32,7 @@ Passing the `Locale` is absolutely critical for the LLM to know whether "11/12" 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 `parseAI`. 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. +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 @@ -41,3 +41,23 @@ 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 `Current Time`. * `"this [weekday]"`: Evaluated as the occurrence within the current calendar week containing `Current Time`. +### 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 new file mode 100644 index 00000000..03689d44 --- /dev/null +++ b/packages/plugins/ai/doc/index.md @@ -0,0 +1,109 @@ +![Tempo Plugin](/plugin-logo.svg) + +# @magmacomputing/tempo-plugin-ai + +

+ 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 parsing. + +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. + +> **Note**: This plugin is **not** a silver-bullet replacement for all your parsing needs! `Tempo.parse()` natively handles structured dates and formats phenomenally well using its Aliases, Layouts, and Snippets. The Tempo AI plugin is specifically designed to be an alternative path for handling completely unstructured, conversational human language that would otherwise be impossible to Regex. +> +> **CRITICAL SECURITY WARNING**: Raw LLM API keys must **never** be exposed in a client-side browser bundle. BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, you must use a proxy service. + +## Ideal Use-Cases + +Good AI function candidates (such as `parseAI`) represent unstructured, conversational, or event-driven natural language expressions that are impossible to Regex or parse with standard relative offset rules: + +- **Holiday & Relative Calendar Math**: `"The Friday after Thanksgiving"`, `"The penultimate Tuesday before Christmas"` +- **Named Cultural / Event Terms**: `"Star Wars Day at 5pm"`, `"A fortnight after Labor Day"` +- **Conversational Relative Terms**: `"The last working day of Q3"`, `"Midday on the summer solstice"` + +> **Avoid Simple Offsets**: Phrases like `"in 5 minutes"`, `"tomorrow"`, or `"next Friday"` are natively intercepted and resolved by core `Tempo` without calling the LLM (unless `force: true` is passed). + +## Installation + +```bash +npm install @magmacomputing/tempo-plugin-ai +``` + +## Setup & Usage + +```typescript +import { parseAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; + +// Initialize with your BYOK API Key +initAI({ + providers: [ + { id: 'openai', key: process.env.OPENAI_API_KEY, model: 'gpt-5.4-mini' }, + ], + debug: true // (Development-only) Enable verbose console logging +}); +``` + +> **Tip**: `initAI` is fully re-callable! You can call it multiple times during your application's lifecycle to hot-swap API keys or update your fallback providers mid-stream without restarting your server. + +```typescript +// Parse a complex natural language string! +const dt1 = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); + +// Evict bad parses from the cache +clearAiCache("The penultimate Tuesday before Thanksgiving in 2026"); +``` + +## Execution Modes & Multi-Provider Options + +The AI plugin supports multi-provider execution strategies (`fallback`, `race`, `consensus`) and confidence filtering on per-request options: + +```typescript +// 1. Race mode: send concurrent requests to all providers, returning the fastest valid response +const fastest = await parseAI("Third Friday of October", { mode: 'race' }); + +// 2. Consensus mode: query providers concurrently and boost confidence when outputs agree +const agreed = await parseAI("The penultimate Tuesday before Thanksgiving", { + mode: 'consensus', + minConfidence: 0.85 // Require at least 0.85 confidence threshold +}); +``` + +## Debugging & Forced Evaluation + +When building your LLM queries, it is often useful to see exactly how AI functions route your data. + +**Global Debugging** +Passing `debug: true` into `initAI` is intended for **development environments only**. It will globally log system prompts, localized context, and raw LLM responses to the console. Because prompts, context, and responses may contain user-supplied or sensitive data, disable `debug: true` or redact sensitive logs in production. + +**Forced Evaluation** +If a relative phrase (like `"Next Friday"`) would normally be resolved by the native `Tempo` engine or read from existing cache, you can skip native pre-parsing and cache lookups by passing `force: true`. The resulting LLM response is still written to `Tempo.cache` for subsequent lookups: + +```typescript +const dt = await parseAI("Next Friday", { + anchor: '2026-09-01T00:00:00Z', + force: true, // Skips native pre-parsing & cache lookup; forces an LLM request (result is cached) + debug: true // Overrides the global debug flag for this specific request +}); +``` + +## Documentation Topics + +> [!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 three dedicated guides below before deploying this plugin in a production environment. + +To learn more about configuring and optimizing the AI Plugin, check out the dedicated guides: +- [Provider Architecture & Security](./architecture.md) (BYOK vs Proxy patterns, Frontend Security) +- [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) + +## 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/parseAI/doc/rate-limits.md b/packages/plugins/ai/doc/rate-limits.md similarity index 61% rename from packages/plugins/parseAI/doc/rate-limits.md rename to packages/plugins/ai/doc/rate-limits.md index 6a0815a1..26d66d68 100644 --- a/packages/plugins/parseAI/doc/rate-limits.md +++ b/packages/plugins/ai/doc/rate-limits.md @@ -5,7 +5,7 @@ When using third-party AI APIs, your application is subject to strict rate limit The plugin automatically tracks these limits by reading the standard `x-ratelimit-*` HTTP headers returned by providers like OpenAI and Groq. ## Tracking Quota Real-time -To expose this data without ruining the clean `Promise` return type of the parse method, the plugin provides a dedicated utility function: `getAiRateLimits()`. +To expose this data without ruining the clean return signatures of Tempo AI functions, the plugin provides a dedicated utility function: `getAiRateLimits()`. ```typescript import { getAiRateLimits } from '@magmacomputing/tempo-plugin-ai'; @@ -45,7 +45,7 @@ By default, the plugin maintains an internal `Map` of strings to their respectiv ### Array Processing & Token Economics -When you pass an array of strings to `parseAI`, the plugin intentionally does **not** batch them into a single massive LLM request. Instead, it iterates through the array and processes each string individually. +When you pass an array of inputs to AI functions (such as `parseAI`), the plugin intentionally does **not** batch them into a single massive LLM request. Instead, it iterates through the array and processes each item individually. This is by design for three critical reasons: 1. **Cache Efficiency**: Individual processing allows the plugin to instantly resolve duplicate strings against the local cache, saving massive amounts of API tokens. If you pass an array of 10,000 dates, but only 1,000 are unique, the plugin only makes 1,000 requests. @@ -53,14 +53,42 @@ This is by design for three critical reasons: 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. > [!WARNING] -> **Granular Time Gotcha**: The cache key is automatically salted with the **calendar date** (`yyyy-mm-dd`) of execution. This brilliantly protects relative day queries (like `"tomorrow"`) because the cache automatically misses as soon as midnight strikes! However, if you are parsing granular, time-relative phrases (like `"in 5 minutes"` or `"next hour"`), the calendar date salt is not enough to prevent staleness on a long-running server. +> **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. -### Bypassing Cache for Relative Times -If you are intentionally parsing highly granular relative times (like `"in 5 minutes"`) and your server is long-running, you should explicitly disable caching for that specific query to ensure it is evaluated against real-world time: +### 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: + +```typescript +const dates = await parseAI(["Thanksgiving 2026", "INVALID_PROMPT_STRING"], { softErrors: true }); +console.log(dates[0].isValid); // true +console.log(dates[1].isValid); // false +``` + +### Static Glossary Seeding + +In addition to dynamic cache lookups, `initAI` can be initialized with a pre-seeded `BoundedCache` or synchronous `Map` containing immortal static business terms (e.g. company glossaries). Static entries bypass TTL expiration and LLM network requests: + +```typescript +const glossary = new Map([ + ['fiscal_q3_start', '2026-07-01T00:00:00Z'], + ['annual_shutdown', '2026-12-24T00:00:00Z'] +]); + +initAI({ + providers: [{ id: 'openai', key: process.env.OPENAI_API_KEY }], + cache: glossary +}); + +const start = await parseAI('fiscal_q3_start'); // Resolves instantly from static cache without hitting network! +``` + +### Bypassing Cache & Forcing Network Requests +Passing `cache: false` disables reading and writing to the cache, but native pre-parsing may still resolve standard phrases. To guarantee an LLM provider request while disabling caching of the response, combine `force: true` with `cache: false`: ```typescript -// The LLM will ALWAYS be queried, and the result will NOT be cached -const dt = await parseAI("in 5 minutes", { cache: false }); +// Forces an LLM network request and prevents reading or writing to cache +const dt = await parseAI("The last Friday before Christmas", { force: true, cache: false }); ``` ### Evicting Bad Parses diff --git a/packages/plugins/parseAI/package.json b/packages/plugins/ai/package.json similarity index 92% rename from packages/plugins/parseAI/package.json rename to packages/plugins/ai/package.json index ef26a586..a580adec 100644 --- a/packages/plugins/parseAI/package.json +++ b/packages/plugins/ai/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo-plugin-ai", - "version": "0.1.0", + "version": "0.2.0", "description": "Tempo community plugin for LLM-powered natural language parsing.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -16,7 +16,7 @@ "access": "public" }, "scripts": { - "build": "tsup && tsc", + "build": "tsup", "test": "vitest run -c ../vitest.shared.ts", "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build" }, @@ -25,7 +25,7 @@ "plan": "community" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.10.2" + "@magmacomputing/tempo": "^3.11.0" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1" @@ -47,4 +47,4 @@ "import": "./dist/index.js" } } -} \ No newline at end of file +} diff --git a/packages/plugins/ai/plan/v0.3.0-roadmap.md b/packages/plugins/ai/plan/v0.3.0-roadmap.md new file mode 100644 index 00000000..60d2a291 --- /dev/null +++ b/packages/plugins/ai/plan/v0.3.0-roadmap.md @@ -0,0 +1,79 @@ +# @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. Remote Provider Manifest & Dynamic Defaults + +### Background & Objective +AI providers update model names (e.g. `gemini-2.5-flash`, `gpt-4o-mini`), endpoints, and parameters frequently. Hardcoding model defaults into the published NPM package requires frequent patch releases. v0.3.0 will introduce remote provider manifest fetching. + +### Requirements & Architecture +* **Hosted Manifest Endpoint**: Host a static `providers.v1.json` manifest on the Firebase-hosted Tempo Registry (`https://registry.tempo.dev/ai/providers.v1.json`). +* **Lifecycle & Caching**: + * Fetch occurs **once** per application lifecycle / module load (lazy-evaluated on first `initAI()` call). + * Manifest response is cached in module-scoped memory (`_remoteDefaults`). + * Re-calling `initAI()` reads from in-memory cache without triggering new network requests. +* **Fail-Open & Offline Support**: + * If the network request fails, times out, or the client is offline/air-gapped, `initAI()` synchronously falls back to compiled local `DEFAULT_PROVIDERS`. +* **Developer Override Options**: + ```typescript + initAI({ + providers: [{ id: 'openai', key: '...' }], + remoteConfigUrl: 'https://custom-registry.internal.net/ai/providers.json', // Custom endpoint + fetchDefaults: async (providerId) => { ... } // Custom resolver hook + }); + ``` + +--- + +## 2. Implementation of Scaffolded AI Function Handlers + +In v0.2.0, upcoming function handlers were scaffolded with `@internal` JSDoc tags and `not yet implemented` guards. v0.3.0 will implement the following functions: + +### 2.1 `formatAI(tempo: Tempo, prompt: string, options?: AiOptions): 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?: AiOptions): Promise` +* Scans unstructured text (emails, transcripts, task notes) to extract embedded temporal entities into structured `TempoEvent` records (`label`, `start`, `end`, `type`). + +### 2.3 `diffAI(start: Tempo, end: Tempo, prompt?: string, options?: AiOptions): Promise` +* Calculates and summarizes the delta between two `Tempo` instances in human, business, or operational terms (e.g., `"6 working business days (48 hours)"`). + +### 2.4 `scheduleAI(prompt: string, options?: AiScheduleOptions): Promise` +* Resolves natural language scheduling prompts against working hours, existing calendar events, and timezones into an optimal start/end `Tempo` interval. + +### 2.5 `recurrenceAI(prompt: string, options?: AiOptions): Promise` +* Translates complex natural language repeating schedule descriptions into standard RRULE strings and `Tempo` instance date generators (`rule.next(count)`). + +### 2.6 `contextAI(text: string, options?: AiOptions): Promise` +* Infers `timeZone`, `locale`, and preferred `calendar` system from ambiguous location descriptions or user bios. + +--- + +## 3. Telemetry & Token Usage Tracking + +Extend the `.ai` metadata property attached to returned `Tempo` instances to include token consumption metrics: +```typescript +interface AiMeta { + provider: string; + cached: boolean; + confidence: number; + ambiguous: boolean; + granularity: string; + usage?: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; +} +``` + +--- + +## 4. Advanced Cache TTL & Eviction Policies + +* Allow per-provider or per-request TTL configurations in `initAI({ ttl: 3600000 })`. +* Support optional async storage adapters (e.g., Redis / KV stores) via explicit async wrapper interfaces. diff --git a/packages/plugins/ai/src/core/config.ts b/packages/plugins/ai/src/core/config.ts new file mode 100644 index 00000000..947f42ec --- /dev/null +++ b/packages/plugins/ai/src/core/config.ts @@ -0,0 +1,47 @@ +import type { AiProvider } from './types.js'; + +/** + * ## AiMode + * Execution modes across the provider farm: + * - `Fallback` ('fallback'): Sequential provider rotation until one succeeds. + * - `Race` ('race'): Concurrent speculative requests; returns fastest success. + * - `Consensus` ('consensus'): Concurrent requests across all providers with confidence voting. + */ +export const AiMode = Object.freeze({ + Fallback: 'fallback', + Race: 'race', + Consensus: 'consensus' +} as const); + +export type AiMode = (typeof AiMode)[keyof typeof AiMode]; + +/** + * Keywords reserved by parseAI to avoid provider configuration collisions. + */ +export const RESERVED_PROVIDER_IDS: ReadonlySet = new Set(['native', 'cache']); + +/** + * Built-in default endpoint and model configurations for popular providers. + */ +export const DEFAULT_PROVIDERS: Readonly>>> = Object.freeze({ + groq: Object.freeze({ + url: 'https://api.groq.com/openai/v1/chat/completions', + model: 'llama-3.3-70b-versatile', + tokenParam: 'max_tokens' + }), + openai: Object.freeze({ + url: 'https://api.openai.com/v1/chat/completions', + model: 'gpt-5.4-mini', + tokenParam: 'max_completion_tokens' + }), + gemini: Object.freeze({ + url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', + model: 'gemini-3.6-flash', + tokenParam: 'max_tokens' + }), + mistral: Object.freeze({ + url: 'https://api.mistral.ai/v1/chat/completions', + model: 'mistral-small-latest', + tokenParam: 'max_tokens' + }) +}); diff --git a/packages/plugins/parseAI/src/error.ts b/packages/plugins/ai/src/core/error.ts similarity index 88% rename from packages/plugins/parseAI/src/error.ts rename to packages/plugins/ai/src/core/error.ts index fcaecabc..a9f6f32f 100644 --- a/packages/plugins/parseAI/src/error.ts +++ b/packages/plugins/ai/src/core/error.ts @@ -2,7 +2,7 @@ import type { Tempo } from '@magmacomputing/tempo'; /** * ## TempoAiError - * A specialized Error thrown during AI-driven parsing when network fetches fail, + * A specialized Error thrown during AI-driven operations when network fetches fail, * timeouts occur, or rate limits are exceeded. */ export class TempoAiError extends Error { diff --git a/packages/plugins/ai/src/core/init.ts b/packages/plugins/ai/src/core/init.ts new file mode 100644 index 00000000..4af961d0 --- /dev/null +++ b/packages/plugins/ai/src/core/init.ts @@ -0,0 +1,142 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { DEFAULT_PROVIDERS } from './config.js'; +import { normalizeCacheInput, assertNoReservedProviderId } from './support.js'; +import type { AiConfig, AiRateLimits, AiProvider } from './types.js'; + +export const _state: { + config: AiConfig; + limits: AiRateLimits | null; +} = { + config: {}, + limits: null, +} + +export function initAI(config: AiConfig): void { + if (config.providers) + assertNoReservedProviderId(config.providers); + + const resolvedProviders = config.providers ? config.providers.map(p => { + const normalizedId = p.id?.toLowerCase() ?? ''; + const defaults = DEFAULT_PROVIDERS[normalizedId] || DEFAULT_PROVIDERS.openai; + return { + ...defaults, + ...p + } as AiProvider; + }) : _state.config.providers; + + _state.config = { + ..._state.config, + ...config, + providers: resolvedProviders || [] + }; + + if (config.cache) { + Tempo.init({ cache: config.cache as any }); + } +} + +export function clearAiCache(input: string | string[]): void { + 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); + } +} + +export function getAiRateLimits(): AiRateLimits | null { + return _state.limits; +} + +export function parseResetHeaderToTempo(resetHeader: string): Tempo | null { + const trimmed = resetHeader.trim(); + if (!trimmed) return null; + + // Case 1: Simple numeric string (seconds or epoch) + if (/^\d+(\.\d+)?$/.test(trimmed)) { + const val = parseFloat(trimmed); + if (Number.isNaN(val) || val < 0) return null; + if (val > 1000000000) { + try { + const t = new Tempo(val * 1000); + return t.isValid ? t : null; + } catch { + return null; + } + } + try { + const t = new Tempo().add(`${val} seconds`); + return t.isValid ? t : null; + } catch { + return null; + } + } + + // Case 2: Compound duration like '4m12s', '1h30m20s', '2m30.5s', '500ms' + const compoundRegex = /^(?:(\d+(?:\.\d+)?)d)?(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?$/i; + if (compoundRegex.test(trimmed)) { + const match = trimmed.match(compoundRegex); + if (match) { + const d = match[1] ? parseFloat(match[1]) : 0; + const h = match[2] ? parseFloat(match[2]) : 0; + const m = match[3] ? parseFloat(match[3]) : 0; + const s = match[4] ? parseFloat(match[4]) : 0; + const ms = match[5] ? parseFloat(match[5]) : 0; + + const totalMs = (d * 86400 + h * 3600 + m * 60 + s) * 1000 + ms; + if (totalMs <= 0 || Number.isNaN(totalMs)) return null; + + try { + const t = new Tempo().add(`${totalMs} milliseconds`); + return t.isValid ? t : null; + } catch { + return null; + } + } + } + + // Case 3: HTTP-date string (e.g., 'Wed, 21 Oct 2026 07:28:00 GMT') + if (/GMT|UTC|\d{2}:\d{2}:\d{2}/i.test(trimmed)) { + try { + const t = new Tempo(trimmed, { catch: true }); + return t.isValid ? t : null; + } catch { + return null; + } + } + + return null; +} + +export function parseRateLimitsFromResponse(response: Response): AiRateLimits | null { + const remReqHeader = response.headers.get('x-ratelimit-remaining-requests'); + const remTokHeader = response.headers.get('x-ratelimit-remaining-tokens'); + const resetTokHeader = response.headers.get('x-ratelimit-reset-tokens') + || response.headers.get('x-ratelimit-reset-requests') + || response.headers.get('retry-after'); + + if (remReqHeader === null && remTokHeader === null && resetTokHeader === null) + return null; + + const reqNum = remReqHeader !== null ? parseInt(remReqHeader, 10) : NaN; + const tokNum = remTokHeader !== null ? parseInt(remTokHeader, 10) : NaN; + + const parsedReq = Number.isNaN(reqNum) ? null : reqNum; + const parsedTok = Number.isNaN(tokNum) ? null : tokNum; + const resetAtTempo = resetTokHeader ? parseResetHeaderToTempo(resetTokHeader) : null; + + if (parsedReq === null && parsedTok === null && resetAtTempo === null) + return null; + + return { + remainingRequests: parsedReq, + remainingTokens: parsedTok, + resetAt: resetAtTempo + } +} + +export function updateRateLimitsFromResponse(response: Response): AiRateLimits | null { + return parseRateLimitsFromResponse(response); +} diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts new file mode 100644 index 00000000..6a89c8c0 --- /dev/null +++ b/packages/plugins/ai/src/core/support.ts @@ -0,0 +1,151 @@ +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.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); + } + } +} + +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 attachAiMeta(instance: Tempo, meta: TempoAiMeta): Tempo { + const frozenMeta = Object.freeze(meta); + 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; + } + const val = Reflect.get(target, prop, target); + if (typeof val === 'function') return val.bind(target); + 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 +): Promise<{ rawContent: string; providerId: string; rateLimits: ReturnType }> { + const url = provider.url!; + const model = provider.model!; + + const systemPrompt = `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.", + "confidence": 0.95, + "ambiguous": false, + "granularity": "minute" +} + +Ambiguity Rules: +- "next [weekday/unit]": Immediate next chronological occurrence after Current Time. +- "last [weekday/unit]" / "previous [weekday/unit]": Most recent past occurrence prior to Current Time. +- "this [weekday]": Occurrence in the current calendar week containing Current Time. +- "confidence": Float score between 0.0 (gibberish/unparseable) and 1.0 (100% certain). +- "granularity": Primary time precision level ('year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'unknown'). + +Do not include markdown blocks or any text outside the JSON.`; + + if (isDebug) + console.log(`[tempo-plugin-ai] Sending to ${provider.id}:`, { system: `${systemPrompt}\n${contextString}`, user: str }); + + const tokenParam = provider.tokenParam + || (provider.options?.max_completion_tokens !== undefined ? 'max_completion_tokens' : undefined) + || (provider.options?.max_tokens !== undefined ? 'max_tokens' : undefined) + || 'max_tokens'; + const tokenLimit = { [tokenParam]: 250 }; + + const controller = new AbortController(); + const timeoutMs = provider.options?.timeout ?? 15000; + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const onParentAbort = () => controller.abort(); + if (parentSignal) { + if (parentSignal.aborted) controller.abort(); + else parentSignal.addEventListener('abort', onParentAbort); + } + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${provider.key}` + }, + body: JSON.stringify({ + model: model, + messages: [ + { role: 'system', content: `${systemPrompt}\n${contextString}` }, + { role: 'user', content: str } + ], + temperature: 0, + ...tokenLimit, + response_format: { type: 'json_object' }, + ...provider.options + }), + signal: controller.signal + }); + + const limits = updateRateLimitsFromResponse(response); + + if (!response.ok) { + const errorText = await response.text(); + const resetTime = limits?.resetAt ?? undefined; + _state.limits = limits; + throw new TempoAiError(`Provider ${provider.id} failed with status ${response.status}. Details: ${errorText}`, response.status, resetTime); + } + + const data = await response.json(); + const rawContent = data?.choices?.[0]?.message?.content; + if (typeof rawContent !== 'string') + throw new TempoAiError(`Provider ${provider.id} returned invalid response payload.`, 422); + + if (isDebug) + console.log(`[tempo-plugin-ai] Received from ${provider.id}:`, rawContent); + + return { rawContent: rawContent.trim(), providerId: provider.id, rateLimits: limits }; + } finally { + clearTimeout(timeoutId); + if (parentSignal) { + parentSignal.removeEventListener('abort', onParentAbort); + } + } +} diff --git a/packages/plugins/ai/src/core/types.ts b/packages/plugins/ai/src/core/types.ts new file mode 100644 index 00000000..83e5e1f6 --- /dev/null +++ b/packages/plugins/ai/src/core/types.ts @@ -0,0 +1,113 @@ +import type { Tempo } from '@magmacomputing/tempo'; +import type { AiMode } from './config.js'; + +/** + * ## TempoAiMeta + * Frozen metadata object attached to Tempo instances produced by `parseAI`. + */ +export interface TempoAiMeta { + /** Resolution source ('native', 'cache', or provider ID like 'groq', 'openai', 'ollama') */ + readonly provider: string; + /** Whether the result was retrieved from cache */ + readonly cached: boolean; + /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ + readonly confidence: number; + /** Whether the input prompt had multiple possible interpretations */ + readonly ambiguous: boolean; + /** Granularity level of the parsed date ('year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'unknown') */ + readonly granularity: string; + /** Raw un-augmented ISO 8601 string returned by the LLM (if applicable) */ + readonly rawIso?: string | undefined; + /** Step-by-step calendar math reasoning (included when debug: true or when provided by LLM) */ + readonly reasoning?: string | undefined; + /** Raw prompt input (only included when debug: true) */ + readonly rawPrompt?: string | undefined; + /** Normalized prompt input (only included when debug: true) */ + readonly normalizedPrompt?: string | undefined; +} + +declare module '@magmacomputing/tempo' { + interface Tempo { + /** Frozen AI resolution metadata attached when parsed via parseAI */ + ai?: TempoAiMeta | undefined; + } +} + +/** + * ## AiProvider + * Represents an LLM provider and its respective BYOK API key. + */ +export interface AiProvider { + /** The provider identifier (e.g., 'groq', 'gemini', 'openai', 'mistral', 'custom') */ + id: string; + /** The raw API key for the respective provider */ + key: string; + /** Optional custom API endpoint URL (e.g., for local Ollama or Azure OpenAI) */ + url?: string; + /** Optional custom model identifier (e.g., to override the provider's default model) */ + model?: string; + /** Optional parameter name for max token limit (e.g. 'max_tokens' or 'max_completion_tokens') */ + tokenParam?: string | undefined; + /** Optional LLM parameters (e.g. temperature, max_tokens, top_p) */ + options?: Record; +} + +/** + * ## AiParseOptions + * Options passed to `parseAI(input, options)`. + */ +export interface AiParseOptions { + /** Reference anchor date/time instance or string */ + anchor?: any; + /** Target timeZone override */ + timeZone?: string; + /** Target calendar override */ + calendar?: string; + /** Target locale override */ + locale?: string; + /** 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; + /** 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; + /** Allow extra options */ + [key: string]: any; +} + +/** + * ## AiConfig + * Configuration options for the AI parsing plugin. + */ +export interface AiConfig { + /** An array of fallback providers to use for routing */ + providers?: AiProvider[] | undefined; + /** Optional custom cache implementation for storing parsed strings */ + cache?: Map | undefined; + /** If true, logs the spoon-fed LLM context prompt and raw LLM response to the console */ + debug?: boolean | undefined; +} + +/** + * ## AiRateLimits + * Exposes the rate limit and billing statistics returned in the HTTP headers + * of the most recent LLM proxy request. + */ +export interface AiRateLimits { + /** Number of remaining requests allowed in the current time window, or null if unknown */ + remainingRequests: number | null; + /** Number of remaining tokens allowed in the current time window, or null if unknown */ + remainingTokens: number | null; + /** A Tempo instance representing the exact time the limits reset, or null if unknown */ + resetAt: Tempo | null; +} diff --git a/packages/plugins/ai/src/functions/context.ts b/packages/plugins/ai/src/functions/context.ts new file mode 100644 index 00000000..4a48ae87 --- /dev/null +++ b/packages/plugins/ai/src/functions/context.ts @@ -0,0 +1,25 @@ +export interface TempoContext { + timeZone: string; + locale: string; + calendar: string; + confidence?: number; +} + +/** + * @internal Draft implementation scaffolded for v0.3.0 roadmap. + * ## contextAI (Upcoming Export) + * Infers `timeZone`, `locale`, and preferred `calendar` system from ambiguous location strings, + * user bios, or context descriptions. + * + * ### Why it fits Tempo: + * Integrates directly into Tempo's core configuration parameters (`timeZone`, `locale`, `calendar`). + * + * ### Example Usage: + * ```ts + * const context = await contextAI('We are meeting near Shibuya Station during Golden Week'); + * // returns: { timeZone: 'Asia/Tokyo', locale: 'ja-JP', calendar: 'gregory' } + * ``` + */ +export async function contextAI(_text: string, _options?: Record): Promise { + throw new Error('contextAI is not yet implemented in tempo-plugin-ai.'); +} diff --git a/packages/plugins/ai/src/functions/diff.ts b/packages/plugins/ai/src/functions/diff.ts new file mode 100644 index 00000000..ba7f309c --- /dev/null +++ b/packages/plugins/ai/src/functions/diff.ts @@ -0,0 +1,32 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +export interface TempoAiDiffResult { + formatted: string; + days?: number; + hours?: number; + businessDays?: number; + reasoning?: string; +} + +/** + * @internal Draft implementation scaffolded for v0.3.0 roadmap. + * ## diffAI (Upcoming Export) + * Expresses the delta between two timestamps or `Tempo` instances in human, business, + * or operational terms. + * + * ### Why it fits Tempo: + * Fills the gap between raw numeric milliseconds/days calculations in `Tempo.diff()` + * and domain-specific human summaries (accounting, shipping SLAs, project planning). + * + * ### Example Usage: + * ```ts + * const start = new Tempo('2026-08-01T09:00:00'); + * const end = new Tempo('2026-08-10T17:00:00'); + * + * const diff = await diffAI(start, end, 'explain in terms of business working days excluding weekends'); + * // returns: { formatted: "6 business days (48 working hours)", businessDays: 6 } + * ``` + */ +export async function diffAI(_start: any, _end: any, _prompt?: string, _options?: Record): Promise { + throw new Error('diffAI is not yet implemented in tempo-plugin-ai.'); +} diff --git a/packages/plugins/ai/src/functions/extract.ts b/packages/plugins/ai/src/functions/extract.ts new file mode 100644 index 00000000..175ad093 --- /dev/null +++ b/packages/plugins/ai/src/functions/extract.ts @@ -0,0 +1,29 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +export interface TempoEvent { + label: string; + start: Tempo; + end?: Tempo | undefined; + type?: 'event' | 'deadline' | 'milestone' | 'reminder' | string; +} + +/** + * @internal Draft implementation scaffolded for v0.3.0 roadmap. + * ## extractAI (Upcoming Export) + * Scans unstructured text (emails, transcripts, task notes) and extracts all + * embedded temporal entities, deadlines, and events into structured `TempoEvent` records. + * + * ### Why it fits Tempo: + * Essential for calendar apps and document processing workflows where temporal references + * are buried inside unstructured text. + * + * ### Example Usage: + * ```ts + * const text = "Let's meet tomorrow at 10am. Final deliverables due next Friday EOD."; + * const events = await extractAI(text, { anchor: new Tempo() }); + * // returns array of TempoEvent records with parsed Tempo instances + * ``` + */ +export async function extractAI(_text: string, _options?: Record): Promise { + throw new Error('extractAI is not yet implemented in tempo-plugin-ai.'); +} diff --git a/packages/plugins/ai/src/functions/format.ts b/packages/plugins/ai/src/functions/format.ts new file mode 100644 index 00000000..a3eaea45 --- /dev/null +++ b/packages/plugins/ai/src/functions/format.ts @@ -0,0 +1,23 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +/** + * @internal Draft implementation scaffolded for v0.3.0 roadmap. + * ## formatAI (Upcoming Export) + * Formats a `Tempo` instance 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. + * + * ### Example Usage: + * ```ts + * const t = new Tempo('2026-08-07T17:00:00[America/New_York]'); + * + * // "this Friday at 5:00 PM EST (in 5 days)" + * const friendly = await formatAI(t, 'friendly reminder tone with relative countdown'); + * ``` + */ +export async function formatAI(_tempo: Tempo, _prompt: string, _options?: Record): Promise { + throw new Error('formatAI is not yet implemented in tempo-plugin-ai.'); +} diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts new file mode 100644 index 00000000..309c0b99 --- /dev/null +++ b/packages/plugins/ai/src/functions/parse.ts @@ -0,0 +1,257 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { TempoAiError } from '../core/error.js'; +import { AiMode } from '../core/config.js'; +import type { AiParseOptions } from '../core/types.js'; +import { _state } from '../core/init.js'; +import { normalizeCacheInput, attachAiMeta, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; + +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, ...coreOptions } = options || {}; + + let tz: string, cal: string, loc: string, sph: string, anchorStr: string; + if (Tempo.isTempo(options?.anchor)) { + tz = String(options!.timeZone || options!.anchor.config.timeZone); + cal = String(options!.calendar || options!.anchor.config.calendar); + loc = String(Array.isArray(options!.locale) ? options!.locale[0] : (options!.locale || options!.anchor.config.locale)); + sph = String(options!.sphere || options!.anchor.config.sphere || 'north'); + 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)); + sph = String(options?.sphere || resolvedOptions.sphere || 'north'); + anchorStr = String(options?.anchor || new Tempo().toString()); + } + + const anchorTempo = new Tempo(anchorStr, { ...coreOptions, timeZone: tz, calendar: cal, locale: loc, sphere: sph as any }); + const cacheSalt = anchorTempo.format('{yyyy}-{mm}-{dd}'); + const cacheKey = `${normalizedStr}::${cacheSalt}::${tz}::${cal}::${loc}::${sph}`; + + let cachedIso: string | undefined; + if (!force && aiCacheOption !== false) { + if (Tempo.cache.has(cacheKey)) { + cachedIso = Tempo.cache.get(cacheKey); + } else if (Tempo.cache.has(normalizedStr)) { + cachedIso = Tempo.cache.get(normalizedStr); + } else if (Tempo.cache.has(str)) { + cachedIso = Tempo.cache.get(str); + } + } + + if (cachedIso) { + if (isDebug) console.log(`[tempo-plugin-ai] Cache hit for "${str}":`, cachedIso); + const cachedInstance = new Tempo(cachedIso, coreOptions); + return attachAiMeta(cachedInstance, { + provider: 'cache', + cached: true, + confidence: 1.0, + ambiguous: false, + granularity: 'day', + rawIso: cachedIso, + rawPrompt: isDebug ? str : undefined, + normalizedPrompt: isDebug ? normalizedStr : undefined + }); + } + + if (!force) { + try { + const native = new Tempo(str, { ...coreOptions, silent: true }); + const hasNativeMatches = Tempo.cache.has(str) + || Tempo.cache.has(normalizedStr) + || /^\d{4}-\d{2}-\d{2}/.test(str.trim()) + || native.isValid; + + if (native.isValid && hasNativeMatches) { + if (isDebug) console.log(`[parseAI] Resolved natively: "${str}"`); + return attachAiMeta(native, { + provider: 'native', + cached: false, + confidence: 1.0, + ambiguous: false, + granularity: 'day', + rawPrompt: isDebug ? str : undefined, + normalizedPrompt: isDebug ? normalizedStr : undefined + }); + } + } catch { + // Fallback + } + } + + 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 mode = aiMode || AiMode.Fallback; + let successfulResult: { parsedData: any; providerId: string; rateLimits?: any } | null = null; + + if (mode === AiMode.Fallback) { + let lastError: any = null; + let bestCandidate: { parsedData: any; providerId: string; rateLimits?: any } | null = null; + + for (const provider of availableProviders) { + try { + const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug); + const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); + const parsedData = JSON.parse(cleanContent); + + const candidateConfidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : (parsedData?.iso === 'INVALID' ? 0.0 : 1.0); + + if (!bestCandidate || candidateConfidence > (bestCandidate.parsedData?.confidence ?? 0)) { + bestCandidate = { parsedData, providerId, rateLimits }; + } + + if (minConfidence === undefined || candidateConfidence >= minConfidence) { + successfulResult = { parsedData, providerId, rateLimits }; + break; + } + + if (isDebug) { + console.log(`[parseAI] Provider ${providerId} confidence (${candidateConfidence}) below minConfidence (${minConfidence}). Cascading to next provider...`); + } + } catch (err: any) { + lastError = err; + if (err instanceof TempoAiError && err.code === 422 && minConfidence === undefined) break; + } + } + + if (!successfulResult) { + if (bestCandidate) { + successfulResult = bestCandidate; + } else { + throw lastError || new TempoAiError('All configured AI providers failed.', 500); + } + } + + } else if (mode === AiMode.Race) { + const parentController = new AbortController(); + try { + const promises = availableProviders.map(async (provider) => { + const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug, parentController.signal); + const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); + return { parsedData: JSON.parse(cleanContent), providerId, rateLimits }; + }); + successfulResult = await Promise.race(promises); + parentController.abort(); + } catch (err: any) { + parentController.abort(); + throw err instanceof TempoAiError ? err : new TempoAiError(`Provider race failed: ${err.message}`, 500); + } + + } else if (mode === AiMode.Consensus) { + const promises = availableProviders.map(async (provider) => { + const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug); + const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); + return { parsedData: JSON.parse(cleanContent), providerId, rateLimits }; + }); + + const settled = await Promise.allSettled(promises); + const fulfilled = settled + .filter((s): s is PromiseFulfilledResult<{ parsedData: any; providerId: string; rateLimits: any }> => s.status === 'fulfilled') + .map(s => s.value); + + if (fulfilled.length === 0) { + const firstRejected = settled.find(s => s.status === 'rejected') as PromiseRejectedResult | undefined; + throw firstRejected?.reason || new TempoAiError('Consensus failed: all providers rejected.', 500); + } + + if (fulfilled.length === 1) { + successfulResult = fulfilled[0]; + } else { + const firstIso = fulfilled[0].parsedData?.iso; + const allMatch = fulfilled.every(f => f.parsedData?.iso === firstIso); + + if (allMatch) { + successfulResult = { + parsedData: { + ...fulfilled[0].parsedData, + confidence: 1.0, + ambiguous: false + }, + providerId: AiMode.Consensus, + rateLimits: fulfilled[0].rateLimits + }; + } else { + const sorted = [...fulfilled].sort((a, b) => (b.parsedData?.confidence ?? 0) - (a.parsedData?.confidence ?? 0)); + successfulResult = { + parsedData: { + ...sorted[0].parsedData, + ambiguous: true + }, + providerId: sorted[0].providerId, + rateLimits: sorted[0].rateLimits + }; + } + } + } + + _state.limits = successfulResult?.rateLimits ?? null; + + const { parsedData, providerId } = successfulResult!; + const rawIso = typeof parsedData?.iso === 'string' ? parsedData.iso : 'INVALID'; + const confidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : (rawIso === 'INVALID' ? 0.0 : 1.0); + const ambiguous = Boolean(parsedData?.ambiguous || rawIso === 'INVALID'); + const granularity = typeof parsedData?.granularity === 'string' ? parsedData.granularity : 'unknown'; + const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined; + + const isBelowMinConfidence = minConfidence !== undefined && confidence < minConfidence; + + if (rawIso === 'INVALID' || isBelowMinConfidence) { + const invalidInstance = new Tempo('INVALID', { ...coreOptions, catch: true }); + return attachAiMeta(invalidInstance, { + provider: providerId, + cached: false, + confidence, + ambiguous: true, + granularity, + rawIso: rawIso === 'INVALID' ? 'INVALID' : rawIso, + reasoning: isDebug ? reasoning : undefined, + rawPrompt: isDebug ? str : undefined, + normalizedPrompt: isDebug ? normalizedStr : undefined + }); + } + + const parsedIso = `${rawIso.replace(/Z$/i, '')}[${tz}]`; + + if (aiCacheOption !== false) { + Tempo.cache.set(cacheKey, parsedIso); + } + + const finalInstance = new Tempo(parsedIso, coreOptions); + return attachAiMeta(finalInstance, { + provider: providerId, + cached: false, + confidence, + ambiguous, + granularity, + rawIso, + reasoning: isDebug ? reasoning : undefined, + rawPrompt: isDebug ? str : undefined, + normalizedPrompt: isDebug ? normalizedStr : undefined + }); +} + +export async function parseAI(input: string, options?: AiParseOptions): Promise; +export async function parseAI(input: string[], options?: AiParseOptions): Promise<(Tempo | TempoAiError)[]>; +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))); + } + + return parseSingleInput(input, options); +} diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts new file mode 100644 index 00000000..52375aad --- /dev/null +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -0,0 +1,28 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +export interface TempoRecurrenceRule { + rrule: string; + next(count?: number): Tempo[]; +} + +/** + * @internal Draft implementation scaffolded for v0.3.0 roadmap. + * ## recurrenceAI (Upcoming Export) + * Translates natural language descriptions of complex repeating schedules into + * structured RRULE strings and `Tempo` date generators. + * + * ### Why it fits Tempo: + * RRULE strings are notoriously complex to craft manually. `recurrenceAI` turns plain text + * into deterministic `Tempo` date sequences. + * + * ### Example Usage: + * ```ts + * const rule = await recurrenceAI('every 2nd and 4th Thursday of the month except company holidays', { + * timeZone: 'Europe/London' + * }); + * const nextDates = rule.next(5); // Returns array of 5 upcoming Tempo instances + * ``` + */ +export async function recurrenceAI(_prompt: string, _options?: Record): Promise { + throw new Error('recurrenceAI is not yet implemented in tempo-plugin-ai.'); +} diff --git a/packages/plugins/ai/src/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts new file mode 100644 index 00000000..0e41e9c1 --- /dev/null +++ b/packages/plugins/ai/src/functions/schedule.ts @@ -0,0 +1,27 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +export interface TempoInterval { + start: Tempo; + end: Tempo; +} + +/** + * @internal Draft implementation scaffolded for v0.3.0 roadmap. + * ## scheduleAI (Upcoming Export) + * Resolves natural language scheduling prompts against working hours, existing calendar + * events, and timezones into an optimal start/end `Tempo` interval. + * + * ### Why it fits Tempo: + * Solves non-trivial calendar slot finding while producing strongly typed `Tempo` interval boundaries. + * + * ### Example Usage: + * ```ts + * const slot = await scheduleAI('Find 45 minutes next Tuesday afternoon after 2pm PST excluding lunch', { + * workingHours: { start: '09:00', end: '17:00', timeZone: 'America/Los_Angeles' } + * }); + * console.log(slot.start.toString()); // "2026-08-04T14:15:00[America/Los_Angeles]" + * ``` + */ +export async function scheduleAI(_prompt: string, _options?: Record): Promise { + throw new Error('scheduleAI is not yet implemented in tempo-plugin-ai.'); +} diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts new file mode 100644 index 00000000..fccea421 --- /dev/null +++ b/packages/plugins/ai/src/index.ts @@ -0,0 +1,34 @@ +// Core Infrastructure & Configuration +export { TempoAiError } from './core/error.js'; +export * from './core/types.js'; +export * from './core/config.js'; +export { initAI, clearAiCache, getAiRateLimits } from './core/init.js'; + +// AI Function Handlers +export { parseAI } from './functions/parse.js'; + +/* + * ============================================================================ + * Upcoming AI Function Exports (Scaffolded for Future Releases) + * ============================================================================ + * The following exports lay the groundwork for expanding tempo-plugin-ai. + * Uncomment these exports as their implementations are finalized. + */ + +// /** Formats a Tempo instance into human-friendly, contextual narrative text */ +// export { formatAI } from './functions/format.js'; + +// /** Scans unstructured text and extracts embedded temporal entities & events */ +// export { extractAI, type TempoEvent } from './functions/extract.js'; + +// /** Expresses the delta between two dates in human, business, or operational terms */ +// export { diffAI, type TempoAiDiffResult } from './functions/diff.js'; + +// /** Resolves natural language scheduling prompts into optimal Tempo intervals */ +// export { scheduleAI, type TempoInterval } from './functions/schedule.js'; + +// /** Translates natural language descriptions of repeating schedules into RRULEs */ +// export { recurrenceAI, type TempoRecurrenceRule } from './functions/recurrence.js'; + +// /** Infers timeZone, locale, and calendar from ambiguous location or text strings */ +// export { contextAI, inferContextAI, type TempoContext } from './functions/context.js'; diff --git a/packages/plugins/ai/test/benchmark.spec.ts b/packages/plugins/ai/test/benchmark.spec.ts new file mode 100644 index 00000000..4f1d1e40 --- /dev/null +++ b/packages/plugins/ai/test/benchmark.spec.ts @@ -0,0 +1,15 @@ +import { normalizeCacheInput, getNamespacedCacheKey } from '../src/core/support.js'; + +describe('AI Support Helpers Benchmark & Integrity', () => { + it('should normalize cache input string whitespace and case', () => { + const raw = ' NEXT Friday at 3PM '; + const normalized = normalizeCacheInput(raw); + expect(normalized).toBe('next friday at 3pm'); + }); + + it('should generate properly namespaced cache keys', () => { + const key = getNamespacedCacheKey('extractAI', 'user-event-prompt'); + expect(key).toBe('ai:extractAI::user-event-prompt'); + expect(key.startsWith('ai:')).toBe(true); + }); +}); diff --git a/packages/plugins/ai/test/index.spec.ts b/packages/plugins/ai/test/index.spec.ts new file mode 100644 index 00000000..21599b73 --- /dev/null +++ b/packages/plugins/ai/test/index.spec.ts @@ -0,0 +1,528 @@ +import { parseAI, initAI, clearAiCache, getAiRateLimits, TempoAiError, AiMode } from '../src/index.js'; +import { BoundedCache } from '@magmacomputing/tempo/support'; +import { Tempo } from '@magmacomputing/tempo'; + +describe('AI Parsing Plugin', () => { + const liveApiKey = process.env.GROQ_API_KEY ?? process.env.OPENAI_API_KEY; + const liveProviderId = process.env.GROQ_API_KEY ? 'groq' : 'openai'; + const isLiveTest = Boolean(process.env.LIVE_AI_TEST && liveApiKey); + + beforeEach(() => { + if (isLiveTest) { + initAI({ + providers: [{ id: liveProviderId, key: liveApiKey! }] + }); + } else { + initAI({ + providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] + }); + } + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fall back to native parsing first and attach .ai metadata', async () => { + const result = await parseAI('2026-05-10'); + expect(result.isValid).toBe(true); + expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-05-10'); + expect(result.ai).toBeDefined(); + expect(result.ai?.provider).toBe('native'); + expect(result.ai?.cached).toBe(false); + expect(result.ai?.confidence).toBe(1.0); + expect(Object.isFrozen(result.ai)).toBe(true); + }); + + it('should throw TempoAiError if reserved provider ID "native" or "cache" is used in initAI', () => { + expect(() => initAI({ providers: [{ id: 'native', key: '123' }] })).toThrow(TempoAiError); + expect(() => initAI({ providers: [{ id: 'cache', key: '123' }] })).toThrow(TempoAiError); + }); + + it('should canonicalize Gemini provider ID and use gemini-3.6-flash model by default', async () => { + initAI({ + providers: [{ id: 'Gemini', key: 'mock-gemini-key' }] + }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"iso":"2026-12-25T00:00:00"}' } }] + }), { status: 200 })); + + 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'); + }); + + it('should throw TempoAiError if no key is configured and AI is needed', async () => { + initAI({ providers: [] }); + await expect(parseAI('Next Thanksgiving')).rejects.toThrow(TempoAiError); + await expect(parseAI('Next Thanksgiving')).rejects.toThrow('No AI providers configured.'); + }); + + it('should parse natural language successfully and attach secured .ai metadata', async () => { + if (!isLiveTest) { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"The Friday after Thanksgiving", "iso":"2026-11-27T00:00:00", "confidence":0.98, "ambiguous":false, "granularity":"day"}' } }] + }), { + status: 200, + headers: new Headers({ + 'x-ratelimit-remaining-requests': '99', + 'x-ratelimit-remaining-tokens': '4950' + }) + })); + } + + const anchorDate = '2026-05-10T12:00:00Z'; + const result = await parseAI('The Friday after Thanksgiving', { anchor: anchorDate, timeZone: 'UTC', force: true }); + + expect(result).toBeInstanceOf(Tempo); + expect(result.isValid).toBe(true); + expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-27'); + + expect(result.ai).toBeDefined(); + expect(result.ai?.provider).toBe(isLiveTest ? liveProviderId : 'groq'); + expect(result.ai?.cached).toBe(false); + expect(result.ai?.confidence).toBe(isLiveTest ? 1.0 : 0.98); + expect(result.ai?.ambiguous).toBe(false); + expect(result.ai?.granularity).toBe(isLiveTest ? 'unknown' : 'day'); + expect(Object.isFrozen(result.ai)).toBe(true); + }); + + it('should attach rawPrompt and normalizedPrompt to .ai only when debug is true', async () => { + if (!isLiveTest) { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Debug test", "iso":"2026-12-25T00:00:00", "confidence":0.95}' } }] + }), { status: 200 })); + } + + const result = await parseAI(' Christmas 2026 ', { force: true, debug: true }); + expect(result.ai?.rawPrompt).toBe(' Christmas 2026 '); + expect(result.ai?.normalizedPrompt).toBe('christmas 2026'); + expect(result.ai?.reasoning).toBe('Debug test'); + + if (!isLiveTest) { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"No debug test", "iso":"2026-12-25T00:00:00", "confidence":0.95}' } }] + }), { status: 200 })); + } + + const noDebug = await parseAI(' Christmas 2026 ', { force: true, debug: false }); + expect(noDebug.ai?.rawPrompt).toBeUndefined(); + expect(noDebug.ai?.normalizedPrompt).toBeUndefined(); + expect(noDebug.ai?.reasoning).toBeUndefined(); + }); + + it('should cache the result and mark provider as "cache"', async () => { + const anchorDate = '2026-05-10T12:00:00Z'; + clearAiCache('The Friday after Thanksgiving'); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + if (!isLiveTest) { + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"The Friday after Thanksgiving", "iso":"2026-11-27T00:00:00", "confidence":0.99}' } }] + }), { status: 200 })); + } + + const dt1 = await parseAI('The Friday after Thanksgiving', { anchor: anchorDate, timeZone: 'UTC' }); + expect(dt1.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-27'); + expect(dt1.ai?.cached).toBe(false); + + const dt2 = await parseAI('The Friday after Thanksgiving', { anchor: anchorDate, timeZone: 'UTC' }); + expect(dt2.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-27'); + expect(dt2.ai?.provider).toBe('cache'); + expect(dt2.ai?.cached).toBe(true); + + if (!isLiveTest) { + expect(fetchSpy).toHaveBeenCalledTimes(1); + } + }); + + it('should return a Tempo instance with isValid = false when LLM returns INVALID', async () => { + if (!isLiveTest) { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Gibberish text", "iso":"INVALID", "confidence":0.0, "ambiguous":true}' } }] + }), { status: 200 })); + } + + const result = await parseAI('complete gibberish text', { force: true }); + expect(result).toBeInstanceOf(Tempo); + expect(result.isValid).toBe(false); + expect(result.ai?.confidence).toBe(0.0); + expect(result.ai?.ambiguous).toBe(true); + expect(result.ai?.rawIso).toBe('INVALID'); + }); + + it('should return an invalid Tempo instance (isValid = false) with metadata when minConfidence threshold is not met', async () => { + if (!isLiveTest) { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Uncertain date", "iso":"2026-05-10T00:00:00", "confidence":0.5}' } }] + }), { status: 200 })); + } + + const result = await parseAI('somewhat ambiguous date', { force: true, minConfidence: 0.7 }); + expect(result).toBeInstanceOf(Tempo); + expect(result.isValid).toBe(false); + expect(result.ai?.confidence).toBe(0.5); + expect(result.ai?.ambiguous).toBe(true); + }); + + it('should cascade from low-confidence local provider to high-confidence online provider in Fallback mode', async () => { + initAI({ + providers: [ + { id: 'local-llm', key: 'key1', url: 'https://api.openai.com/v1/chat', model: 'local' }, + { id: 'cloud-llm', key: 'key2', url: 'https://api.openai.com/v1/chat', model: 'cloud' } + ] + }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + // Provider 1 (local-llm): Low confidence (0.4) + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Uncertain local guess", "iso":"2026-11-26T00:00:00", "confidence":0.4}' } }] + }), { status: 200 })); + // Provider 2 (cloud-llm): High confidence (0.95) + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"High confidence cloud result", "iso":"2026-11-26T00:00:00", "confidence":0.95}' } }] + }), { status: 200 })); + + const result = await parseAI('Thanksgiving 2026', { force: true, minConfidence: 0.8, mode: AiMode.Fallback }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result.isValid).toBe(true); + expect(result.ai?.provider).toBe('cloud-llm'); + expect(result.ai?.confidence).toBe(0.95); + }); + + it('should short-circuit and stop looking to other providers when a provider meets minConfidence', async () => { + initAI({ + providers: [ + { id: 'local-llm', key: 'key1', url: 'https://api.openai.com/v1/chat', model: 'local' }, + { id: 'cloud-llm', key: 'key2', url: 'https://api.openai.com/v1/chat', model: 'cloud' } + ] + }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + // Provider 1 (local-llm): High confidence (0.90 >= 0.85) -> Short circuit! + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Confident local result", "iso":"2026-11-26T00:00:00", "confidence":0.90}' } }] + }), { status: 200 })); + + const result = await parseAI('Thanksgiving 2026', { force: true, minConfidence: 0.85, mode: AiMode.Fallback }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result.isValid).toBe(true); + expect(result.ai?.provider).toBe('local-llm'); + expect(result.ai?.confidence).toBe(0.90); + }); + + it('should execute parallel array batching with preserved index ordering', async () => { + if (!isLiveTest) { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Date 1", "iso":"2026-01-01T00:00:00", "confidence":0.99}' } }] + }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Date 2", "iso":"2026-02-02T00:00:00", "confidence":0.99}' } }] + }), { status: 200 })); + } + + const [res1, res2] = await parseAI(['New Years 2026', 'Groundhog Day 2026'], { force: true }); + + expect(Tempo.isTempo(res1)).toBe(true); + expect(Tempo.isTempo(res2)).toBe(true); + + if (Tempo.isTempo(res1) && Tempo.isTempo(res2)) { + expect(res1.format('{yyyy}-{mm}-{dd}')).toBe('2026-01-01'); + expect(res2.format('{yyyy}-{mm}-{dd}')).toBe('2026-02-02'); + } + }); + + it('should support softErrors in array batch processing', async () => { + initAI({ + providers: [{ id: 'groq', key: 'test-key' }] + }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + // Item 1 succeeds + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Date 1", "iso":"2026-01-01T00:00:00", "confidence":0.99}' } }] + }), { status: 200 })); + // Item 2 fails with 500 + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 500, statusText: 'Internal Error' })); + + const results = await parseAI(['Valid Date Prompt', 'Failing Prompt'], { force: true, softErrors: true }); + + expect(results).toHaveLength(2); + expect(results[0]).toBeInstanceOf(Tempo); + expect((results[0] as Tempo).format('{yyyy}-{mm}-{dd}')).toBe('2026-01-01'); + + expect(results[1]).toBeInstanceOf(TempoAiError); + expect((results[1] as TempoAiError).code).toBe(500); + }); + + describe('Execution Modes: Race & Consensus', () => { + it('should support mode: race and return the fastest resolving provider', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockImplementation(async (_url, opts) => { + const body = JSON.parse(opts?.body as string); + if (body.model === 'fast-model') { + return new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Fast", "iso":"2026-06-01T00:00:00", "confidence":0.95}' } }] + }), { status: 200 }); + } + // Slow model delays + await new Promise(resolve => setTimeout(resolve, 500)); + return new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Slow", "iso":"2026-06-01T00:00:00", "confidence":0.95}' } }] + }), { status: 200 }); + }); + + const result = await parseAI('June 1st 2026', { + force: true, + mode: AiMode.Race, + providers: [ + { id: 'slow-provider', key: 'key1', url: 'https://api.openai.com/v1/chat', model: 'slow-model' }, + { id: 'fast-provider', key: 'key2', url: 'https://api.openai.com/v1/chat', model: 'fast-model' } + ] + }); + + expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-06-01'); + expect(result.ai?.provider).toBe('fast-provider'); + }); + + it('should support mode: consensus and boost confidence when providers agree', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Model 1", "iso":"2026-07-04T00:00:00", "confidence":0.90}' } }] + }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Model 2", "iso":"2026-07-04T00:00:00", "confidence":0.85}' } }] + }), { status: 200 })); + + const result = await parseAI('4th of July 2026', { + force: true, + mode: AiMode.Consensus, + providers: [ + { id: 'p1', key: 'key1', url: 'https://api.openai.com/v1/chat', model: 'm1' }, + { id: 'p2', key: 'key2', url: 'https://api.openai.com/v1/chat', model: 'm2' } + ] + }); + + expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-07-04'); + expect(result.ai?.provider).toBe('consensus'); + expect(result.ai?.confidence).toBe(1.0); + expect(result.ai?.ambiguous).toBe(false); + }); + }); + + describe('Mocked Network Failures', () => { + it('should throw TempoAiError with 401 when API key is bad, expired, or revoked', async () => { + initAI({ providers: [{ id: 'openai', key: 'bad_key' }] }); + + vi.spyOn(global, 'fetch').mockResolvedValueOnce(new Response(null, { + status: 401, + statusText: 'Unauthorized' + })); + + try { + await parseAI('Thanksgiving', { force: true }); + expect.unreachable('Should have thrown an error'); + } catch (err: any) { + expect(err).toBeInstanceOf(TempoAiError); + expect(err.code).toBe(401); + expect(err.message).toContain('Provider openai failed with status 401'); + } + }); + + it('should seamlessly fallback to the next provider if the first hits a 429 Exhausted Key rate limit', async () => { + initAI({ + providers: [ + { id: 'openai', key: 'exhausted_key' }, + { id: 'openai', key: 'good_key' } + ] + }); + + const fetchSpy = vi.spyOn(global, 'fetch'); + + fetchSpy.mockResolvedValueOnce(new Response(null, { + status: 429, + statusText: 'Too Many Requests', + headers: new Headers({ + 'x-ratelimit-reset-tokens': '60' + }) + })); + + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"It is Thanksgiving.", "iso":"2026-11-26T00:00:00Z"}' } }] + }), { + status: 200, + headers: new Headers({ + 'x-ratelimit-remaining-tokens': '5000' + }) + })); + + const result = await parseAI('Thanksgiving', { force: true }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-26'); + + const limits = getAiRateLimits(); + expect(limits?.remainingTokens).toBe(5000); + }); + }); + + describe('BoundedCache & Eviction', () => { + it('should enforce maxCacheSize when inserting items beyond capacity', () => { + const cache = new BoundedCache(3); + cache.set('key1', 'val1'); + cache.set('key2', 'val2'); + cache.set('key3', 'val3'); + expect(cache.size).toBe(3); + + cache.set('key4', 'val4'); + expect(cache.size).toBe(3); + expect(cache.has('key1')).toBe(false); + expect(cache.has('key4')).toBe(true); + }); + + it('should evict expired items based on TTL', async () => { + const cache = new BoundedCache(100, 50); + cache.set('tempKey', 'tempVal'); + expect(cache.has('tempKey')).toBe(true); + + await new Promise(resolve => setTimeout(resolve, 60)); + + expect(cache.has('tempKey')).toBe(false); + expect(cache.get('tempKey')).toBeUndefined(); + expect(Array.from(cache.keys())).not.toContain('tempKey'); + }); + + it('should preserve clearAiCache functionality', () => { + 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'); + + initAI({ cache }); + clearAiCache('Thanksgiving'); + + expect(cache.has('Thanksgiving::2026-05-10')).toBe(false); + expect(cache.has('Christmas::2026-05-10')).toBe(true); + }); + + it('should resolve static un-salted user glossary terms without hitting network or expiring', async () => { + const glossary = new Map([ + ['my_custom_company_glossary_term', '2026-11-01T00:00:00Z'] + ]); + + initAI({ cache: glossary }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const result = await parseAI('my_custom_company_glossary_term'); + expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-01'); + expect(result.ai?.provider).toBe('cache'); + expect(result.ai?.cached).toBe(true); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Rate Limit & Reset Header Parsing Hardening', () => { + it('should correctly parse compound reset duration strings like 4m12s and 1h30m', async () => { + initAI({ providers: [{ id: 'openai', key: 'test-key' }] }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + // Response 1 with compound duration header '4m12s' + fetchSpy.mockResolvedValueOnce(new Response(null, { + status: 429, + statusText: 'Too Many Requests', + headers: new Headers({ + 'x-ratelimit-reset-tokens': '4m12s' + }) + })); + + try { + await parseAI('Thanksgiving', { force: true }); + expect.unreachable('Should have thrown TempoAiError'); + } catch (err: any) { + expect(err).toBeInstanceOf(TempoAiError); + expect(err.code).toBe(429); + expect(err.retryAt).toBeDefined(); + expect(err.retryAt).toBeInstanceOf(Tempo); + } + + const limits1 = getAiRateLimits(); + expect(limits1?.resetAt).toBeDefined(); + }); + + it('should replace rather than retain prior rate-limit state when subsequent response has no headers', async () => { + initAI({ providers: [{ id: 'openai', key: 'test-key' }] }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + // Request 1: Has rate limit headers + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"iso":"2026-11-26T00:00:00"}' } }] + }), { + status: 200, + headers: new Headers({ + 'x-ratelimit-remaining-tokens': '5000' + }) + })); + + await parseAI('Thanksgiving 2026', { force: true }); + expect(getAiRateLimits()?.remainingTokens).toBe(5000); + + // Request 2: Has NO rate limit headers + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"iso":"2026-12-25T00:00:00"}' } }] + }), { + status: 200 + })); + + await parseAI('Christmas 2026', { force: true }); + + // State should now be null (replaced, not retained!) + expect(getAiRateLimits()).toBeNull(); + }); + + it('should parse HTTP-date format Retry-After header strings into valid Tempo resetAt', async () => { + initAI({ providers: [{ id: 'openai', key: 'test-key' }] }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const httpDateStr = 'Wed, 21 Oct 2026 07:28:00 GMT'; + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"iso":"2026-11-26T00:00:00"}' } }] + }), { + status: 200, + headers: new Headers({ + 'retry-after': httpDateStr + }) + })); + + await parseAI('Thanksgiving 2026', { force: true }); + expect(getAiRateLimits()?.resetAt).toBeDefined(); + expect(getAiRateLimits()?.resetAt?.isValid).toBe(true); + }); + + it('should ignore invalid or malformed duration strings without throwing or crashing', async () => { + initAI({ providers: [{ id: 'openai', key: 'test-key' }] }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + fetchSpy.mockResolvedValueOnce(new Response(null, { + status: 429, + headers: new Headers({ + 'x-ratelimit-reset-tokens': 'invalid_compound_string_123' + }) + })); + + try { + await parseAI('Thanksgiving', { force: true }); + } catch (err: any) { + expect(err).toBeInstanceOf(TempoAiError); + expect(err.retryAt).toBeUndefined(); + } + + expect(getAiRateLimits()).toBeNull(); + }); + }); +}); diff --git a/packages/plugins/parseAI/test/tsconfig.json b/packages/plugins/ai/test/tsconfig.json similarity index 100% rename from packages/plugins/parseAI/test/tsconfig.json rename to packages/plugins/ai/test/tsconfig.json diff --git a/packages/plugins/parseAI/tsconfig.json b/packages/plugins/ai/tsconfig.json similarity index 79% rename from packages/plugins/parseAI/tsconfig.json rename to packages/plugins/ai/tsconfig.json index e14056b1..288e3451 100644 --- a/packages/plugins/parseAI/tsconfig.json +++ b/packages/plugins/ai/tsconfig.json @@ -5,7 +5,8 @@ "rootDir": "./src", "declaration": true, "emitDeclarationOnly": true, - "types": ["node"] + "types": ["node"], + "ignoreDeprecations": "6.0" }, "include": [ "src" diff --git a/packages/plugins/parseAI/tsup.config.ts b/packages/plugins/ai/tsup.config.ts similarity index 93% rename from packages/plugins/parseAI/tsup.config.ts rename to packages/plugins/ai/tsup.config.ts index 62bd4b2b..70376645 100644 --- a/packages/plugins/parseAI/tsup.config.ts +++ b/packages/plugins/ai/tsup.config.ts @@ -4,4 +4,5 @@ import { sharedConfig } from '../tsup.shared.js'; export default defineConfig({ ...sharedConfig, entry: ['src/index.ts'], + dts: true, }); diff --git a/packages/plugins/parseAI/CHANGELOG.md b/packages/plugins/parseAI/CHANGELOG.md deleted file mode 100644 index 30a2780b..00000000 --- a/packages/plugins/parseAI/CHANGELOG.md +++ /dev/null @@ -1,6 +0,0 @@ -# @magmacomputing/tempo-plugin-ai - -## 0.1.0 -- Initial scaffolding of the AI natural language parsing plugin. -- Added functional exports for `parseAI`, `initAI`, and `clearAiCache`. -- Drafted initial fallback-routing logic (mocked proxy). diff --git a/packages/plugins/parseAI/README.md b/packages/plugins/parseAI/README.md deleted file mode 100644 index 4b880d68..00000000 --- a/packages/plugins/parseAI/README.md +++ /dev/null @@ -1,44 +0,0 @@ -![Tempo Plugin](https://raw.githubusercontent.com/magmacomputing/magma/main/packages/tempo/public/plugin-logo.svg) - -# @magmacomputing/tempo-plugin-ai - -[![npm version](https://img.shields.io/npm/v/@magmacomputing/tempo-plugin-ai?style=flat-square)](https://www.npmjs.com/package/@magmacomputing/tempo-plugin-ai) -[![npm peer dependency version](https://img.shields.io/npm/dependency-version/@magmacomputing/tempo-plugin-ai/peer/@magmacomputing/tempo?style=flat-square)](https://www.npmjs.com/package/@magmacomputing/tempo) -[![License](https://img.shields.io/npm/l/@magmacomputing/tempo-plugin-ai?style=flat-square)](https://www.npmjs.com/package/@magmacomputing/tempo-plugin-ai) - -Tempo community plugin for LLM-powered natural language parsing. - -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 complex natural language expressions into `Tempo` instances. - -> **CRITICAL SECURITY WARNING**: Raw LLM API keys must **never** be exposed in a client-side browser bundle. BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, you must use a proxy service. - -## Installation - -```bash -npm install @magmacomputing/tempo-plugin-ai -``` - -## Setup & Usage - -```typescript -import { parseAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; - -// Initialize with your BYOK API Key -initAI({ - providers: [ - { id: 'groq', key: process.env.GROQ_API_KEY }, - ] -}); - -// Parse a complex natural language string! -const dt = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); - -// Evict bad parses from the cache -clearAiCache("The penultimate Tuesday before Thanksgiving in 2026"); -``` - -Full documentation is available at [https://magmacomputing.github.io/magma/doc/9-plugins/ai.index.html](https://magmacomputing.github.io/magma/doc/9-plugins/ai.index.html). - -## 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/parseAI/doc/architecture.md b/packages/plugins/parseAI/doc/architecture.md deleted file mode 100644 index d564cd48..00000000 --- a/packages/plugins/parseAI/doc/architecture.md +++ /dev/null @@ -1,67 +0,0 @@ -# Provider Architecture & Security - -The parseAI 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) - -For Node.js backends and Edge Workers, the simplest approach is to supply your raw API keys directly to the `initAI` function. - -```typescript -import { initAI } from '@magmacomputing/tempo-plugin-ai'; - -initAI({ - providers: [ - { id: 'groq', key: process.env.GROQ_API_KEY }, - { id: 'gemini', key: process.env.GEMINI_API_KEY }, - { id: 'openai', key: process.env.OPENAI_API_KEY } - ] -}); -``` - -### Advanced Configuration (Custom Models & LLM Options) -By default, standard providers automatically map to their optimal APIs and models (e.g., `'gemini'` maps to `gemini-1.5-flash`). -However, you can explicitly override URLs, models, and inject arbitrary LLM parameters (like `temperature`) for power-user control! - -```typescript -initAI({ - providers: [ - // 1. Enterprise Azure OpenAI - { - id: 'openai', - key: process.env.AZURE_API_KEY, - url: 'https://my-enterprise.openai.azure.com/v1/chat/completions', - model: 'gpt-4o', - options: { temperature: 0.2, seed: 42 } - }, - // 2. Local Open-Source Models (e.g. Ollama) - { - id: 'local', - key: 'no-key-needed', - url: 'http://localhost:11434/v1/chat/completions', - model: 'llama3.1:8b' - } - ] -}); -``` - -### Frontend Security Warning -> [!CAUTION] -> **Never** expose a raw LLM API key in a client-side browser bundle (like React or Vue). If a developer hardcodes a BYOK key into a public website, anyone can extract it, spam it, and exhaust the developer's quota or get the account permanently banned. BYOK keys are *only* safe on backend servers. - -## The Proxy Architecture - -If you need to parse natural language directly on a public frontend application, you must route requests through a secure backend proxy. - -A standard proxy architecture (e.g. using Cloudflare Workers or a custom Node/Express backend) involves: -1. **Frontend Request**: The browser sends the natural language string 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 `parseAI` command 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. - -Because LLM API calls typically take ~300-800ms, the ~20ms overhead of routing the request through your own backend proxy is negligible. - -## Fallback Loops - -Because third-party APIs can experience downtime or aggressive rate limiting, the plugin supports seamless fallback loops. - -When configuring `initAI()`, provide an array of providers. 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 list. This ensures maximum uptime for your users without complex retry logic in your application. diff --git a/packages/plugins/parseAI/doc/index.md b/packages/plugins/parseAI/doc/index.md deleted file mode 100644 index a61254f9..00000000 --- a/packages/plugins/parseAI/doc/index.md +++ /dev/null @@ -1,81 +0,0 @@ -![Tempo Plugin](/plugin-logo.svg) - -# @magmacomputing/tempo-plugin-ai - -[![npm version](https://img.shields.io/npm/v/@magmacomputing/tempo-plugin-ai?style=flat-square)](https://www.npmjs.com/package/@magmacomputing/tempo-plugin-ai) -[![npm peer dependency version](https://img.shields.io/npm/dependency-version/@magmacomputing/tempo-plugin-ai/peer/@magmacomputing/tempo?style=flat-square)](https://www.npmjs.com/package/@magmacomputing/tempo) -[![License](https://img.shields.io/npm/l/@magmacomputing/tempo-plugin-ai?style=flat-square)](https://www.npmjs.com/package/@magmacomputing/tempo-plugin-ai) - -> [!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 Issues](https://github.com/magmacomputing/magma/issues) page! - -Tempo community plugin for LLM-powered natural language parsing. - -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 complex natural language expressions into `Tempo` instances. - -> **Note**: This plugin is **not** a silver-bullet replacement for all your parsing needs! `Tempo.parse()` natively handles structured dates and formats phenomenally well using its Aliases, Layouts, and Snippets. The `parseAI` plugin is specifically designed to be an alternative path for handling completely unstructured, conversational human language that would otherwise be impossible to Regex. -> -> **CRITICAL SECURITY WARNING**: Raw LLM API keys must **never** be exposed in a client-side browser bundle. BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, you must use a proxy service. - -## Installation - -```bash -npm install @magmacomputing/tempo-plugin-ai -``` - -## Setup & Usage - -```typescript -import { parseAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; - -// Initialize with your BYOK API Key -initAI({ - providers: [ - { id: 'openai', key: process.env.OPENAI_API_KEY, model: 'gpt-5.4-mini' }, - ], - debug: true // (Development-only) Enable verbose console logging -}); -``` - -> **Tip**: `initAI` is fully re-callable! You can call it multiple times during your application's lifecycle to hot-swap API keys or update your fallback providers mid-stream without restarting your server. - -```typescript -// Parse a complex natural language string! -const dt1 = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); - -// Evict bad parses from the cache -clearAiCache("The penultimate Tuesday before Thanksgiving in 2026"); -``` - -## Debugging & Forced Evaluation - -When building your LLM queries, it is often useful to see exactly how `parseAI` is routing your data. - -**Global Debugging** -Passing `debug: true` into `initAI` is intended for **development environments only**. It will globally log system prompts, localized context, and raw LLM responses to the console. Because prompts, context, and responses may contain user-supplied or sensitive data, disable `debug: true` or redact sensitive logs in production. - -**Forced Evaluation** -If a relative query (like `"Next Friday"`) is perfectly intercepted by the native `Tempo` layout engine, but the anchor context inheritance is returning an undesired timezone, you can forcefully bypass the deterministic engine and the cache by passing `force: true`: - -```typescript -const dt = await parseAI("Next Friday at 5pm", { - anchor: '2026-09-01T00:00:00Z', - force: true, // Bypasses native parsers & cache; forces a network LLM request! - debug: true // Overrides the global debug flag for this specific request -}); -``` - -## Documentation Topics - -> [!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 three dedicated guides below before deploying this plugin in a production environment. - -To learn more about configuring and optimizing the AI Plugin, check out the dedicated guides: -- [Provider Architecture & Security](./architecture.md) (BYOK vs Proxy patterns, Frontend Security) -- [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) - -## 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/parseAI/src/cache.ts b/packages/plugins/parseAI/src/cache.ts deleted file mode 100644 index c5704c84..00000000 --- a/packages/plugins/parseAI/src/cache.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * ## BoundedCache - * Map implementation enforcing maximum capacity (LRU) and TTL eviction. - */ -export class BoundedCache extends Map { - #timestamps = new Map(); - maxSize: number; - ttl: number; - - constructor(maxSize = 1000, ttl = 24 * 60 * 60 * 1000) { - super(); - this.maxSize = maxSize; - this.ttl = ttl; - } - - #isExpired(key: K): boolean { - const time = this.#timestamps.get(key); - if (time === undefined) return false; - return Date.now() - time > this.ttl; - } - - evictExpired(): void { - const now = Date.now(); - for (const [key, time] of this.#timestamps.entries()) { - if (now - time > this.ttl) - this.delete(key); - } - } - - override get(key: K): V | undefined { - if (this.#isExpired(key)) { - this.delete(key); - return undefined; - } - if (super.has(key)) { - const val = super.get(key) as V; - super.delete(key); - super.set(key, val); - return val; - } - return undefined; - } - - override has(key: K): boolean { - if (this.#isExpired(key)) { - this.delete(key); - return false; - } - return super.has(key); - } - - override set(key: K, value: V): this { - this.evictExpired(); - - if (super.has(key)) - super.delete(key); - - super.set(key, value); - this.#timestamps.set(key, Date.now()); - - while (this.size > this.maxSize) { - const oldestKey = super.keys().next().value; - if (oldestKey !== undefined) { - this.delete(oldestKey); - } else { - break; - } - } - - return this; - } - - override delete(key: K): boolean { - this.#timestamps.delete(key); - return super.delete(key); - } - - override clear(): void { - this.#timestamps.clear(); - super.clear(); - } - - override keys(): MapIterator { - this.evictExpired(); - return super.keys(); - } - - override values(): MapIterator { - this.evictExpired(); - return super.values(); - } - - override entries(): MapIterator<[K, V]> { - this.evictExpired(); - return super.entries(); - } - - override[Symbol.iterator](): MapIterator<[K, V]> { - this.evictExpired(); - return super[Symbol.iterator](); - } -} diff --git a/packages/plugins/parseAI/src/index.ts b/packages/plugins/parseAI/src/index.ts deleted file mode 100644 index 279a1226..00000000 --- a/packages/plugins/parseAI/src/index.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { Tempo } from '@magmacomputing/tempo'; -import type * as t from '@magmacomputing/tempo'; - -import { TempoAiError } from './error.js'; -export { TempoAiError } from './error.js'; - -import { BoundedCache } from './cache.js'; -export { BoundedCache } from './cache.js'; - -export * from './parseAI.type.js'; -import type { AiConfig, AiRateLimits, AiProvider } from './parseAI.type.js'; - -// Global module state -const _state: { - config: AiConfig; - cache: Map; - limits: AiRateLimits | null; -} = { - config: {}, - cache: new BoundedCache(), - limits: null, -} - -const DEFAULT_PROVIDERS: Record> = { - groq: { - url: 'https://api.groq.com/openai/v1/chat/completions', - model: 'llama-3.3-70b-versatile', - tokenParam: 'max_tokens' - }, - openai: { - url: 'https://api.openai.com/v1/chat/completions', - model: 'gpt-5.4-mini', - tokenParam: 'max_completion_tokens' - }, - gemini: { - url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', - model: 'gemini-1.5-flash', - tokenParam: 'max_tokens' - }, - mistral: { - url: 'https://api.mistral.ai/v1/chat/completions', - model: 'mistral-small-latest', - tokenParam: 'max_tokens' - } -} - -/** - * ## initAI - * Initializes the global AI Plugin configuration. - * Must be called before executing `parseAI`. - * - * @param config - The plugin configuration (providers and optional cache) - */ -export function initAI(config: AiConfig): void { - const resolvedProviders = (config.providers || []).map(p => { - const defaults = DEFAULT_PROVIDERS[p.id] || DEFAULT_PROVIDERS.openai; - return { - ...defaults, - ...p - } as AiProvider; - }); - - _state.config = { - ..._state.config, - ...config, - providers: resolvedProviders - }; - - if (config.cache) { - _state.cache = config.cache; - } else if (_state.cache instanceof BoundedCache) { - if (config.maxCacheSize !== undefined) _state.cache.maxSize = config.maxCacheSize; - if (config.cacheTtl !== undefined) _state.cache.ttl = config.cacheTtl; - } -} - -/** - * Helper to normalize string input for cache key matching. - */ -function normalizeCacheInput(input: string): string { - return input.trim().toLowerCase().replace(/\s+/g, ' '); -} - -/** - * ## clearAiCache - * Explicitly evicts a natural language key or array of keys from the local AI cache. - * Useful for purging incorrectly parsed strings. - * - * @param input - The raw natural language string(s) to remove from the cache - */ -export function clearAiCache(input: string | string[]): void { - const inputs = Array.isArray(input) ? input : [input]; - for (const i of inputs) { - const normalized = normalizeCacheInput(i); - const prefix = `${normalized}::`; - for (const key of _state.cache.keys()) { - if (key.toLowerCase().startsWith(prefix) || key.toLowerCase() === normalized || key === i /* legacy fallback */) { - _state.cache.delete(key); - } - } - } -} - -/** - * ## getAiRateLimits - * Retrieves the rate limit and billing statistics from the most recent LLM proxy request. - * Useful for tracking quota usage and safely scheduling batch operations. - * - * @returns The current rate limit state, or null if no requests have been made - */ -export function getAiRateLimits(): AiRateLimits | null { - return _state.limits; -} - -/** - * ## parseAI - * Asynchronously parses a complex natural language string (or array of strings) - * into deterministic `Tempo` instances utilizing large language models. - * - * It automatically extracts the global configuration (TimeZone, Calendar, Locale) - * and custom Terms to build a rich context prompt for the LLM. It includes built-in - * caching to prevent redundant requests and reduce token consumption. - * - * @param input - The natural language string or array of strings to parse - * @param options - Optional configuration overrides (identical to `new Tempo(..., options)`) - * @returns A Promise that resolves to a `Tempo` instance (or `Tempo[]` if an array was passed) - */ -export async function parseAI(input: string, options?: Record & { force?: boolean; cache?: boolean }): Promise; -export async function parseAI(input: string[], options?: Record & { force?: boolean; cache?: boolean }): Promise; -export async function parseAI( - input: string | string[], - options?: Record & { force?: boolean; cache?: boolean } -): Promise { - const isArray = Array.isArray(input); - const inputs = isArray ? input : [input]; - const results: Tempo[] = []; - - for (const str of inputs) { - const isDebug = options?.debug ?? _state.config.debug; - - // 1. Try native ParseModule first (silently!) - if (!options?.force) { - try { - const native = new Tempo(str, { ...options, silent: true }); - if (native.isValid) { - if (isDebug) console.log(`[parseAI] Resolved natively: "${str}"`); - results.push(new Tempo(str, options)); - continue; - } - } catch { - // Native parsing failed, fallback to AI - } - } - - // 2. Establish Anchor for Cache & Context - let tz: string, cal: string, loc: string, sph: string, anchorStr: string; - if (Tempo.isTempo(options?.anchor)) { - tz = options!.timeZone || options!.anchor.config.timeZone; - cal = options!.calendar || options!.anchor.config.calendar; - loc = options!.locale || options!.anchor.config.locale; - sph = options!.sphere || options!.anchor.config.sphere; - anchorStr = options!.anchor.toString(); - } else { - const resolvedConfig = new Tempo().config; - tz = options?.timeZone || resolvedConfig.timeZone; - cal = options?.calendar || resolvedConfig.calendar; - loc = options?.locale || resolvedConfig.locale; - sph = options?.sphere || resolvedConfig.sphere; - anchorStr = options?.anchor || new Tempo().toString(); - } - - // The cache key salts the normalized string with the anchor's Calendar Date and resolved context (TZ/Cal/Loc/Sph). - // This allows "tomorrow" to hit the cache all day, but cleanly miss when midnight strikes or context changes! - const normalizedStr = normalizeCacheInput(str); - const cacheSalt = new Tempo(anchorStr, { ...options, timeZone: tz, calendar: cal, locale: loc, sphere: sph }).format('{yyyy}-{mm}-{dd}'); - const cacheKey = `${normalizedStr}::${cacheSalt}::${tz}::${cal}::${loc}::${sph}`; - - // 3. Check Cache - if (!options?.force && options?.cache !== false && _state.cache.has(cacheKey)) { - const cachedIso = _state.cache.get(cacheKey)!; - if (isDebug) console.log(`[parseAI] Cache hit for "${str}":`, cachedIso); - results.push(new Tempo(cachedIso, options)); - continue; - } - - // 4. Construct LLM Context - const anchorTempo = new Tempo(anchorStr, { ...options, timeZone: tz, calendar: cal, locale: loc, sphere: sph }); - let contextString = `Current Time: ${anchorTempo.format('{yyyy}-{mm}-{dd} ({wkd}) {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${loc}, Hemisphere: ${sph}.`; - - // 5. LLM Network Fetch with Fallback Loop - if (!_state.config.providers || _state.config.providers.length === 0) - throw new TempoAiError('No AI providers configured. Please call initAI().', 400); - - let parsedIso: string | null = null; - let lastError: any = null; - - for (const provider of _state.config.providers) { - try { - const url = provider.url!; - const model = provider.model!; - - const systemPrompt = `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 the Current Time to determine the target date.", - "iso": "The final local ISO 8601 string (e.g., YYYY-MM-DDThh:mm:ss) WITHOUT a timezone offset or 'Z' suffix, or 'INVALID' if ambiguous." -} - -Ambiguity Rules: -- "next [weekday/unit]": Evaluate as the immediate next chronological occurrence after Current Time. -- "last [weekday/unit]" / "previous [weekday/unit]": Evaluate as the most recent past occurrence prior to Current Time. -- "this [weekday]": Evaluate as the occurrence of that weekday in the current calendar week containing Current Time. - -Do not include markdown blocks, explanations, or any text outside the JSON.`; - - if (isDebug) - console.log(`[parseAI] Sending to ${provider.id}:`, { system: `${systemPrompt}\n${contextString}`, user: str }); - - const tokenParam = provider.tokenParam - || (provider.options?.max_completion_tokens !== undefined ? 'max_completion_tokens' : undefined) - || (provider.options?.max_tokens !== undefined ? 'max_tokens' : undefined) - || 'max_tokens'; - const tokenLimit = { [tokenParam]: 250 }; - - const controller = new AbortController(); - const timeoutMs = provider.options?.timeout ?? 15000; - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - - let response: Response; - try { - response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${provider.key}` - }, - body: JSON.stringify({ - model: model, - messages: [ - { role: 'system', content: `${systemPrompt}\n${contextString}` }, - { role: 'user', content: str } - ], - temperature: 0, - ...tokenLimit, - response_format: { type: "json_object" }, - ...provider.options - }), - signal: controller.signal - }); - } catch (fetchErr: any) { - lastError = fetchErr; - if (isDebug) console.warn(`[parseAI] Provider ${provider.id} fetch failed or timed out:`, fetchErr?.message || fetchErr); - continue; - } finally { - clearTimeout(timeoutId); - } - - // Parse rate limits from headers - const remReq = response.headers.get('x-ratelimit-remaining-requests'); - const remTok = response.headers.get('x-ratelimit-remaining-tokens'); - const resetTok = response.headers.get('x-ratelimit-reset-tokens'); - - if (remReq || remTok) { - let addString = '1 hour'; - if (resetTok) { - const val = parseFloat(resetTok); - if (resetTok.endsWith('ms')) addString = `${val} milliseconds`; - else if (resetTok.endsWith('s')) addString = `${val} seconds`; - else if (resetTok.endsWith('m')) addString = `${val} minutes`; - else addString = `${val} seconds`; - } - - _state.limits = { - remainingRequests: remReq ? parseInt(remReq, 10) : 999, - remainingTokens: remTok ? parseInt(remTok, 10) : 99999, - resetAt: new Tempo().add(addString) - }; - } - - if (!response.ok) { - const errorText = await response.text(); - const resetTime = _state.limits?.resetAt; - throw new TempoAiError(`Provider ${provider.id} failed with status ${response.status}. Details: ${errorText}`, response.status, resetTime); - } - - const data = await response.json(); - const rawContent = data?.choices?.[0]?.message?.content; - if (typeof rawContent !== 'string') - throw new TempoAiError(`Provider ${provider.id} returned invalid or missing response content payload.`, 422); - - const content = rawContent.trim(); - - if (isDebug) - console.log(`[parseAI] Received from ${provider.id}:`, content); - - let parsedData: any; - try { - const cleanContent = content.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); - parsedData = JSON.parse(cleanContent); - } catch { - throw new TempoAiError('AI returned invalid JSON.', 422); - } - - const isoContent = parsedData?.iso; - - if (typeof isoContent !== 'string') - throw new TempoAiError('AI returned a payload missing the "iso" string field.', 422); - - if (isoContent === 'INVALID') - throw new TempoAiError('AI could not parse the string.', 422); - - parsedIso = `${isoContent.replace(/Z$/i, '')}[${tz}]`; - break; // Success! Break the fallback loop - } catch (err: any) { - lastError = err; - // If it's a rate limit or timeout, the loop naturally continues to the next provider - if (err instanceof TempoAiError && err.code === 422) - // If the AI explicitly says INVALID, don't waste tokens asking the next provider - break; - } - } - - if (!parsedIso) { - throw lastError || new TempoAiError('All configured AI providers failed.', 500); - } - - // 6. Cache result and push - if (options?.cache !== false) - _state.cache.set(cacheKey, parsedIso); - - results.push(new Tempo(parsedIso, options)); - } - - return isArray ? results : results[0]; -} diff --git a/packages/plugins/parseAI/src/parseAI.type.ts b/packages/plugins/parseAI/src/parseAI.type.ts deleted file mode 100644 index f3573e54..00000000 --- a/packages/plugins/parseAI/src/parseAI.type.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Tempo } from '@magmacomputing/tempo'; - -/** - * ## AiProvider - * Represents an LLM provider and its respective BYOK API key. - */ -export interface AiProvider { - /** The provider identifier (e.g., 'groq', 'gemini', 'openai', 'mistral', 'custom') */ - id: string; - /** The raw API key for the respective provider */ - key: string; - /** Optional custom API endpoint URL (e.g., for local Ollama or Azure OpenAI) */ - url?: string; - /** Optional custom model identifier (e.g., to override the provider's default model) */ - model?: string; - /** Optional parameter name for max token limit (e.g. 'max_tokens' or 'max_completion_tokens') */ - tokenParam?: string | undefined; - /** Optional LLM parameters (e.g. temperature, max_tokens, top_p) */ - options?: Record; -} - -/** - * ## AiConfig - * Configuration options for the AI parsing plugin. - */ -export interface AiConfig { - /** An array of fallback providers to use for routing */ - providers?: AiProvider[] | undefined; - /** Optional custom cache implementation for storing parsed strings */ - cache?: Map | undefined; - /** Maximum number of entries allowed in the default cache (default: 1000) */ - maxCacheSize?: number | undefined; - /** Time to live in milliseconds for default cache entries (default: 24 hours) */ - cacheTtl?: number | undefined; - /** If true, logs the spoon-fed LLM context prompt and raw LLM response to the console */ - debug?: boolean | undefined; -} - -/** - * ## AiRateLimits - * Exposes the rate limit and billing statistics returned in the HTTP headers - * of the most recent LLM proxy request. - */ -export interface AiRateLimits { - /** Number of remaining requests allowed in the current time window */ - remainingRequests: number; - /** Number of remaining tokens allowed in the current time window */ - remainingTokens: number; - /** A Tempo instance representing the exact time the limits reset */ - resetAt: Tempo; -} diff --git a/packages/plugins/parseAI/test/index.spec.ts b/packages/plugins/parseAI/test/index.spec.ts deleted file mode 100644 index e1511008..00000000 --- a/packages/plugins/parseAI/test/index.spec.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { parseAI, initAI, clearAiCache, getAiRateLimits, TempoAiError, BoundedCache } from '../src/index.js'; -import { Tempo } from '@magmacomputing/tempo'; - -describe('AI Parsing Plugin', () => { - const liveApiKey = process.env.GROQ_API_KEY ?? process.env.OPENAI_API_KEY; - const liveProviderId = process.env.GROQ_API_KEY ? 'groq' : 'openai'; - const isLiveTest = Boolean(process.env.LIVE_AI_TEST && liveApiKey); - - beforeEach(() => { - // Suppress expected native parsing errors from polluting the test output - vi.spyOn(console, 'error').mockImplementation(() => { }); - vi.spyOn(console, 'warn').mockImplementation(() => { }); - - if (isLiveTest) { - initAI({ - providers: [{ id: liveProviderId, key: liveApiKey! }] - }); - } else { - initAI({ - providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] - }); - } - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should fall back to native parsing first', async () => { - // This should parse natively and not throw an API error even without a key - const result = await parseAI('2026-05-10'); - expect(result.isValid).toBe(true); - expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-05-10'); - }); - - it('should throw TempoAiError if no key is configured and AI is needed', async () => { - initAI({ providers: [] }); - await expect(parseAI('Next Thanksgiving')).rejects.toThrow(TempoAiError); - await expect(parseAI('Next Thanksgiving')).rejects.toThrow('No AI providers configured.'); - }); - - it('should parse natural language successfully', async () => { - if (!isLiveTest) { - vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"Two days after December 1st", "iso":"2026-12-03T00:00:00"}' } }] - }), { - status: 200, - headers: new Headers({ - 'x-ratelimit-remaining-requests': '99', - 'x-ratelimit-remaining-tokens': '4950' - }) - })); - } - - // Provide a strict anchor so we can assert the result deterministically - const anchorDate = '2026-05-10T12:00:00Z'; - const result = await parseAI('Two days after December 1st', { anchor: anchorDate, timeZone: 'UTC' }); - - expect(result).toBeInstanceOf(Tempo); - expect(result.isValid).toBe(true); - expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-12-03'); - }); - - it('should cache the result', async () => { - const anchorDate = '2026-05-10T12:00:00Z'; - // Clear cache first - clearAiCache('Two days after December 1st'); - - const fetchSpy = vi.spyOn(globalThis, 'fetch'); - if (!isLiveTest) { - fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"Two days after December 1st", "iso":"2026-12-03T00:00:00"}' } }] - }), { status: 200 })); - } - - // First parse (hits network or mock) - const dt1 = await parseAI('Two days after December 1st', { anchor: anchorDate, timeZone: 'UTC' }); - expect(dt1.format('{yyyy}-{mm}-{dd}')).toBe('2026-12-03'); - - // Second parse (hits cache instantly) - const dt2 = await parseAI('Two days after December 1st', { anchor: anchorDate, timeZone: 'UTC' }); - expect(dt2.format('{yyyy}-{mm}-{dd}')).toBe('2026-12-03'); - - if (!isLiveTest) { - expect(fetchSpy).toHaveBeenCalledTimes(1); - } - }); - - it('should expose rate limits after a request', async () => { - if (!isLiveTest) { - vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"Test date", "iso":"2026-11-26T00:00:00"}' } }] - }), { - status: 200, - headers: new Headers({ - 'x-ratelimit-remaining-requests': '99', - 'x-ratelimit-remaining-tokens': '4950' - }) - })); - } - - await parseAI('Thanksgiving', { force: true }); - - const limits = getAiRateLimits(); - expect(limits).not.toBeNull(); - expect(limits?.remainingRequests).toBeDefined(); - expect(limits?.remainingTokens).toBeDefined(); - expect(limits?.resetAt).toBeInstanceOf(Tempo); - }); - - describe('Mocked Network Failures', () => { - it('should throw TempoAiError with 401 when API key is bad, expired, or revoked', async () => { - // Temporarily inject a fake key - initAI({ providers: [{ id: 'openai', key: 'bad_key' }] }); - - vi.spyOn(global, 'fetch').mockResolvedValueOnce(new Response(null, { - status: 401, - statusText: 'Unauthorized' - })); - - // The last error in the loop should bubble up - try { - await parseAI('Thanksgiving', { force: true }); - expect.unreachable('Should have thrown an error'); - } catch (err: any) { - expect(err).toBeInstanceOf(TempoAiError); - expect(err.code).toBe(401); - expect(err.message).toContain('Provider openai failed with status 401'); - } - }); - - it('should seamlessly fallback to the next provider if the first hits a 429 Exhausted Key rate limit', async () => { - // Set up two providers. The first will fail (exhausted), the second will succeed. - initAI({ - providers: [ - { id: 'openai', key: 'exhausted_key' }, - { id: 'openai', key: 'good_key' } - ] - }); - - const fetchSpy = vi.spyOn(global, 'fetch'); - - // First fetch call: Groq hits 429 Too Many Requests - fetchSpy.mockResolvedValueOnce(new Response(null, { - status: 429, - statusText: 'Too Many Requests', - headers: new Headers({ - 'x-ratelimit-reset-tokens': '60' // Resets in 60 seconds - }) - })); - - // Second fetch call: OpenAI succeeds - fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"It is Thanksgiving.", "iso":"2026-11-26T00:00:00Z"}' } }] - }), { - status: 200, - headers: new Headers({ - 'x-ratelimit-remaining-tokens': '5000' - }) - })); - - const result = await parseAI('Thanksgiving', { force: true }); - - expect(fetchSpy).toHaveBeenCalledTimes(2); - expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-26'); - - // Verify the rate limits were updated correctly from the first 429 response before the success! - // Actually, the second success response overwrites the rate limits with OpenAI's headers. - const limits = getAiRateLimits(); - expect(limits?.remainingTokens).toBe(5000); - }); - }); - - describe('BoundedCache & Eviction', () => { - it('should enforce maxCacheSize when inserting items beyond capacity', () => { - const cache = new BoundedCache(3); - cache.set('key1', 'val1'); - cache.set('key2', 'val2'); - cache.set('key3', 'val3'); - expect(cache.size).toBe(3); - - cache.set('key4', 'val4'); - expect(cache.size).toBe(3); - expect(cache.has('key1')).toBe(false); - expect(cache.has('key4')).toBe(true); - }); - - it('should evict expired items based on TTL', async () => { - const cache = new BoundedCache(100, 50); // 50ms TTL - cache.set('tempKey', 'tempVal'); - expect(cache.has('tempKey')).toBe(true); - - await new Promise(resolve => setTimeout(resolve, 60)); - - expect(cache.has('tempKey')).toBe(false); - expect(cache.get('tempKey')).toBeUndefined(); - expect(Array.from(cache.keys())).not.toContain('tempKey'); - }); - - it('should preserve clearAiCache functionality', () => { - 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'); - - initAI({ cache }); - clearAiCache('Thanksgiving'); - - expect(cache.has('Thanksgiving::2026-05-10')).toBe(false); - expect(cache.has('Christmas::2026-05-10')).toBe(true); - }); - - it('should update BoundedCache options via initAI', () => { - const cache = new BoundedCache(1000, 3600000); - initAI({ cache }); - - initAI({ maxCacheSize: 50, cacheTtl: 5000 }); - expect(cache.maxSize).toBe(50); - expect(cache.ttl).toBe(5000); - - initAI({ maxCacheSize: 5, cacheTtl: 100 }); - expect(cache.maxSize).toBe(5); - expect(cache.ttl).toBe(100); - }); - - it('should normalize cache keys (whitespace & case) for clearAiCache', () => { - const cache = new BoundedCache(100); - cache.set('thanksgiving::2026-05-10', '2026-11-26T00:00:00Z'); - - initAI({ cache }); - clearAiCache(' THANKSGIVING '); - expect(cache.has('thanksgiving::2026-05-10')).toBe(false); - }); - }); - - describe('Configurable Token Parameter', () => { - it('should use specified tokenParam in provider request payload', async () => { - const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"test", "iso":"2026-12-25T00:00:00Z"}' } }] - }), { status: 200 })); - - initAI({ - providers: [{ id: 'custom-llm', key: 'test-key', url: 'https://api.custom.com/v1/chat', model: 'custom-model', tokenParam: 'max_tokens' }] - }); - - await parseAI('some random unparseable string', { force: true }); - - expect(fetchSpy).toHaveBeenCalled(); - const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); - expect(callBody.max_tokens).toBe(250); - expect(callBody.max_completion_tokens).toBeUndefined(); - }); - }); -}); - diff --git a/packages/plugins/vitest.shared.ts b/packages/plugins/vitest.shared.ts index 1421e842..fcc0ad19 100644 --- a/packages/plugins/vitest.shared.ts +++ b/packages/plugins/vitest.shared.ts @@ -47,7 +47,7 @@ export default defineConfig({ { find: /^@magmacomputing\/tempo\/plugin\/(.*)$/, replacement: resolve(__dirname, '../tempo/src/plugin/$1.ts') }, { find: /^@magmacomputing\/tempo\/term$/, replacement: resolve(__dirname, '../tempo/src/plugin/term/term.index.ts') }, { find: /^@magmacomputing\/tempo\/term\/(.*)$/, replacement: resolve(__dirname, '../tempo/src/plugin/term/term.$1.ts') }, - { find: /^@magmacomputing\/tempo\/extend\/(.*)$/, replacement: resolve(__dirname, '../tempo/src/plugin/extend/extend.$1.ts') }, + { find: /^@magmacomputing\/tempo\/support$/, replacement: resolve(__dirname, '../tempo/src/support/support.index.ts') }, { find: /^@magmacomputing\/tempo\/(.*)$/, replacement: resolve(__dirname, '../tempo/src/$1.ts') } ] } diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 7fbeb239..3ced2f8e 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -45,6 +45,7 @@ export default defineConfig({ 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' }, diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json index 8e560242..3fae8a86 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.1.0" + "version": "0.2.0" }, { "id": "ticker", diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 5edb1004..a5c684d4 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,25 @@ All notable changes to this project will be documented in this file. 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). +## [3.11.0] - 2026-07-31 + +### Added +- **Centralized Cache Engine (`Tempo.cache`)**: Introduced a unified `BoundedCache` singleton managing date resolution caching with LRU capacity eviction (`maxSize`), TTL expiration (`ttl`), and static immortal glossary isolation. +- **Glossary Seeding**: `Tempo.init({ cache: customMap })` appends pre-resolved terms as static immortal keys exempt from LRU and TTL eviction. +- **Cache Management Documentation**: Scaffolded the `tempo.cache.md` Core Concepts guide detailing cache topology, non-destructive appending, decision matrix (Glossary vs Aliases vs Layouts), opt-in vs automatic behavior, and AI plugin integration. + +### Changed +- **Decoupled Plugin Cache Topology**: Cleaned up `parseAI` cache configuration by delegating capacity and TTL settings to `Tempo.init()`, establishing `Tempo.cache` as the single source of truth across the monorepo. +- **Hardened AI Plugin Architecture**: Hardened `parseAI` integration for `@magmacomputing/tempo-plugin-ai`, adding support for index-locked parallel batching, multi-stream provider execution strategies (`AiMode`), `softErrors` error boundaries, and proxy-wrapped `.ai` metadata attachment on frozen `Tempo` instances. + +## [3.10.3] - 2026-07-29 + +### Performance +- **Zero-Overhead Instantiation**: Re-architected `Tempo` instance construction by introducing lazy evaluation for core private properties: + - **Lazy Instant (`#now`)**: Deferred system clock acquisition (`Temporal.Instant.fromEpochNanoseconds`) so that `instant()` is only fetched when relative duration math or parsing fallbacks require it. Passing explicit date strings or objects skips system clock calls entirely. + - **Lazy Delegators (`#fmt` & `#term`)**: Deferred creation of Proxy delegator objects until `.fmt` or `.term` properties are explicitly accessed. + - **`Interval` Acceleration**: `Tempo.Interval` and boundary set operations (`overlaps`, `contains`, `intersection`, `union`) automatically benefit from the reduced instantiation overhead. + ## [3.10.2] - 2026-07-25 ### Added diff --git a/packages/tempo/doc/1-getting-started/tempo.cookbook.md b/packages/tempo/doc/1-getting-started/tempo.cookbook.md index 9468ae68..d40e8fc2 100644 --- a/packages/tempo/doc/1-getting-started/tempo.cookbook.md +++ b/packages/tempo/doc/1-getting-started/tempo.cookbook.md @@ -16,10 +16,10 @@ A collection of recipes for solving common date and time challenges using Tempo. ## The Basics ### How do I get the current date and time? -By default, the constructor returns "now". +When invoked without arguments, the constructor initializes to the current date and time. ```typescript const now = new Tempo(); -console.log(now.toString()); +console.log(now.toString()); // e.g. "2026-07-31T14:42:11+10:00[Australia/Sydney]" ``` ### Get "Now" in UTC diff --git a/packages/tempo/doc/2-core-concepts/tempo.cache.md b/packages/tempo/doc/2-core-concepts/tempo.cache.md new file mode 100644 index 00000000..28970772 --- /dev/null +++ b/packages/tempo/doc/2-core-concepts/tempo.cache.md @@ -0,0 +1,94 @@ +# Cache Management Guide + +**Tempo** includes a centralized, high-performance **`BoundedCache`** singleton accessible via `Tempo.cache`. It provides dual-layer resolution for dynamic relative dates (with LRU eviction and TTL expiration) and static business glossaries (immortal keys). + +--- + +## 🏛️ Centralized Cache Architecture + +All date resolution caching—whether triggered by core `Tempo` parsing or plugins like `parseAI`—is managed centrally by `Tempo.cache`. + +::: info Cache Behavior: Core Tempo vs. parseAI +* **Core Tempo**: Caching is **opt-in**. Core date parsing executes at sub-microsecond speeds using standard regex matching. `Tempo.cache` is consulted when you seed a static glossary or enable caching. +* **`parseAI` Plugin**: Caching is **automatic**. To eliminate network latency (~500ms+) and avoid redundant LLM API billing, `parseAI` automatically checks `Tempo.cache` before sending requests and caches every successful LLM resolution. +::: + +### Cache Topology & Configuration + +You can configure global cache parameters using `Tempo.init()`: + +```typescript +import { Tempo } from '@magmacomputing/tempo'; + +Tempo.init({ + cache: { + maxSize: 1000, // Maximum number of entries before LRU eviction (default: 1000) + ttl: 24 * 60 * 60 * 1000 // Time-to-live in milliseconds (default: 24 hours) + } +}); +``` + +* **Capacity Management (LRU):** When the cache reaches `maxSize`, the Least Recently Used dynamic entry is automatically evicted. +* **TTL Expiration:** Dynamic entries older than `ttl` are automatically purged upon lookup. +* **Static Glossary Isolation:** Static entries added to the glossary are **exempt** from both LRU eviction and TTL expiration. + +--- + +## 📖 Seeding & Appending Glossaries + +You can seed static business terms into `Tempo.cache` using a native JavaScript `Map` or via `Tempo.init({ cache: map })`: + +```typescript +const businessGlossary = new Map([ + ['fiscal year start 2026', '2026-07-01T00:00:00Z'], + ['q3 board review', '2026-09-15T09:00:00Z'] +]); + +// Appends entries to Tempo.cache as static, immortal terms +Tempo.init({ cache: businessGlossary }); +``` + +::: tip Non-Destructive Appending +Passing a `Map` or custom key-value pairs to `Tempo.init({ cache })` or `initAI({ cache })` **appends** to the existing cache without clearing previously cached terms or resetting cache capacity settings. +::: + +--- + +## 💡 When to Use What: Glossary vs. Alias vs. Snippet/Layout + +Tempo provides multiple mechanisms for augmenting parsing intelligence. Choosing the right pattern depends on whether your logic is static, dynamic, structural, or string replacement: + +| Mechanism | Tier / Location | Evaluation Model | Best Used For... | +| :--- | :--- | :--- | :--- | +| **Glossary** (`Tempo.cache`) | Core Engine | Zero-cost `O(1)` Map lookup | Pre-calculated static ISO date/time strings or exact business dates. | +| **Aliases / Events / Periods** (`registry.events` / `periods`) | Registry Engine | Dynamic function or target string | Computing dynamic business dates (e.g. `'deadline' => () => this.add({ days: 30 })`). | +| **Snippet / Layouts** (`registry.snippets` / `layouts`) | Parser Planner | Regex pattern matcher | Structural natural language formats (e.g. `yyyy/mm/dd` or custom date tokens). | + +### Decision Tree + +1. **Use a Glossary (`Tempo.cache`)** when you have fixed, pre-resolved ISO dates for specific terms (e.g., `'eoy 2026'` -> `'2026-12-31T23:59:59Z'`). It offers instant `O(1)` resolution without invoking the regex parser. +2. **Use an Alias (`registry.events` / `periods`)** when you need dynamic rules calculated relative to the current date/time (e.g., `'market-close'` -> `'16:00'` or `'deadline'` -> `30 days from now`). +3. **Use a Snippet or Layout (`registry.snippets` / `layouts`)** when parsing custom input structures with variable numbers or tokens (e.g. `"2026-W05"` or `"Quarter 3, 2026"`). + +--- + +## 🤖 `parseAI` Plugin Cache Integration + +The `@magmacomputing/tempo-plugin-ai` plugin works hand-in-hand with `Tempo.cache` to reduce LLM API calls and costs: + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { parseAI, initAI } from '@magmacomputing/tempo-plugin-ai'; + +initAI({ providers: [...] }); + +// First lookup: Triggers LLM call -> Stores ISO result in Tempo.cache +const t1 = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); + +// Second lookup: Instantly resolves from Tempo.cache (O(1) local hit, $0 cost) +const t2 = new Tempo("The penultimate Tuesday before Thanksgiving in 2026"); +``` + +### Two-Tier Resolution Architecture +1. **Date-Salted Relative Cache**: Relative queries (e.g. `"next Tuesday"`) are salted with the anchor date so cached entries remain valid for the given day. +2. **Static Glossary Fallback**: Business glossary terms seeded via `initAI({ cache })` or `Tempo.init({ cache })` are checked first, providing zero-latency resolution without ever contacting the LLM. diff --git a/packages/tempo/doc/4-advanced-reference/tempo.locale.md b/packages/tempo/doc/4-advanced-reference/tempo.locale.md index d6d54c36..97d016dd 100644 --- a/packages/tempo/doc/4-advanced-reference/tempo.locale.md +++ b/packages/tempo/doc/4-advanced-reference/tempo.locale.md @@ -29,19 +29,24 @@ By default, Tempo parses structural English abbreviations (e.g., `Jan`, `Feb`, ` When you nominate a non-English `locale` (or an array of locales like `['fr-FR', 'es-ES']`): - Tempo asks the native ECMAScript `Intl` API how to spell months, weekdays, and relative events (like "tomorrow" or "yesterday") in the specified languages. -- It dynamically compiles new, high-performance Regular Expressions containing these localized abbreviations. -- It injects these new patterns into its lexer, allowing Tempo to instantly understand strings like `'15 Janvier 2024'` or `'el próximo lunes'`. +- It dynamically compiles new, high-performance Regular Expressions containing these localized abbreviations (and automatically handles accent variations, matching both `próximo` and `proximo`). +- It injects these patterns into its lexer, allowing Tempo to instantly understand strings like `'15 Janvier 2024'` or `'15 febrero 2024'`. ```typescript import { Tempo } from '@magmacomputing/tempo'; -// Tempo learns French and Spanish at runtime! +// Tempo learns French and Spanish months & weekdays at runtime! +// Custom relative modifier keywords ('próximo') and noise articles ('el') can be registered in the registry. Tempo.init({ - locale: ['fr-FR', 'es-ES'] + locale: ['fr-FR', 'es-ES'], + registry: { + modifiers: { '+': ['próximo', 'proximo', 'siguiente'] }, + ignores: ['el', 'la', 'los', 'las'] + } }); -const a = new Tempo('15 janvier 2024'); // Matches French -const b = new Tempo('el próximo lunes'); // Matches Spanish +const a = new Tempo('15 janvier 2024'); // Matches French +const b = new Tempo('el próximo lunes'); // Matches Spanish ("next Monday") ``` *For more details on setting up and optimizing international parsing, see [Internationalized Parsing](../2-core-concepts/tempo.parse.md#internationalized-parsing-locales).* diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 8bc38c57..03168496 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.10.2", + "version": "3.11.0", "engines": { "node": ">=20.0.0" }, @@ -210,6 +210,10 @@ "./plugin-api": { "types": "./dist/plugin-api.index.d.ts", "import": "./dist/plugin-api.index.js" + }, + "./support": { + "types": "./dist/support/support.index.d.ts", + "import": "./dist/support/support.index.js" } }, "scripts": { diff --git a/packages/tempo/src/engine/engine.composer.ts b/packages/tempo/src/engine/engine.composer.ts index d0900e51..873e16d7 100644 --- a/packages/tempo/src/engine/engine.composer.ts +++ b/packages/tempo/src/engine/engine.composer.ts @@ -50,11 +50,17 @@ export function compose( case 'String': try { - const str = value.replace(/Z$/, ''); - const zdt = Temporal.ZonedDateTime.from(str.includes('[') ? str : `${str}[${tz}]`); + let zdt: Temporal.ZonedDateTime; + if (value.includes('[')) { + zdt = Temporal.ZonedDateTime.from(value); + } else if (/Z$|[+-]\d{2}:?\d{2}/i.test(value)) { + zdt = Temporal.Instant.from(value).toZonedDateTimeISO(tz); + } else { + zdt = Temporal.PlainDateTime.from(value, { overflow: 'constrain' }).toZonedDateTime(tz); + } timeZone = getTemporalIds(zdt)[0]; temporal = zdt; - onResult?.({ type, value: str, match: 'iso8601' }); + onResult?.({ type, value, match: 'iso8601' }); } catch (err) { try { temporal = Temporal.PlainDateTime.from(value, { overflow: 'constrain' }); diff --git a/packages/tempo/src/module/module.parse.ts b/packages/tempo/src/module/module.parse.ts index 3a4149f0..57a9ddd6 100644 --- a/packages/tempo/src/module/module.parse.ts +++ b/packages/tempo/src/module/module.parse.ts @@ -17,10 +17,20 @@ import { getRange, getTermRange } from '../plugin/term/term.util.js'; import { defineInterpreterModule } from '../plugin/plugin.util.js'; import type { Range, ResolvedRange } from '../plugin/term/term.type.js'; -import { sym, isTempo, TermError, getRuntime, Match, TempoError, $setEvents, $setPeriods, markConfig, setPatterns, init, extendState } from '#tempo/support'; +import { sym, isTempo, TermError, getRuntime, Match, TempoError, $setEvents, $setPeriods, markConfig, setPatterns, init, extendState, enums } from '#tempo/support'; import { setProperty, logError, logDebug } from '#tempo/support/support.util.js'; import * as t from '../tempo.type.js'; +function buildCacheKey(str: string, today: Temporal.ZonedDateTime, state: t.Internal.State): string { + const norm = str.trim().toLowerCase(); + const dateSalt = today.toPlainDate().toString(); + const tz = String(state.config.timeZone || 'UTC'); + const cal = String(state.config.calendar || 'iso8601'); + const loc = Array.isArray(state.config.locale) ? state.config.locale.join(',') : String(state.config.locale || 'en-US'); + const sph = String(state.config.sphere || 'north'); + return `${norm}::${dateSalt}::${tz}::${cal}::${loc}::${sph}`; +} + /** * Internal Parse Engine Implementation */ @@ -153,6 +163,11 @@ const _ParseEngine = { if (isZonedDateTime(dateTime) && !state.errored) dateTime = dateTime.withTimeZone(targetTz).withCalendar(targetCal); + if ((state.config.cache === true || state.config.cache === enums.CACHE.On || state.config.cache === enums.CACHE.Refresh || state.config.cache === 'refresh') && isString(tempo) && isZonedDateTime(dateTime) && !state.errored) { + const cacheKey = buildCacheKey(tempo, today, state); + state.cache.set(cacheKey, dateTime.toString()); + } + return Object.assign(res, { type: 'Temporal.ZonedDateTime', value: (isZonedDateTime(dateTime) && !state.errored) ? dateTime : undefined as any @@ -212,6 +227,28 @@ const _ParseEngine = { if (isString(value)) { let trim = value.trim(); + const normVal = trim.toLowerCase(); + + // 1. Static Glossary Check + if (state.cache.isStatic(normVal)) { + const staticTarget = state.cache.get(normVal); + if (staticTarget) { + accumulateResult(state, { match: 'CacheHit', value: trim, source: 'glossary' as any }); + return { type: 'String', value: staticTarget }; + } + } + + // 2. Dynamic Parse Cache Check + const cacheOpt = state.config.cache; + if (cacheOpt === true || cacheOpt === enums.CACHE.On) { + const cacheKey = buildCacheKey(trim, dateTime, state); + const cachedIso = state.cache.get(cacheKey); + if (cachedIso) { + accumulateResult(state, { match: 'CacheHit', value: trim, source: 'parseCache' as any }); + return { type: 'String', value: cachedIso }; + } + } + if (state.parse.ignorePattern) { // Clone the RegExp: global/sticky flags maintain `lastIndex` state, which // cannot be mutated when `state.parse` is frozen (e.g. on a sandbox instance). diff --git a/packages/tempo/src/plugin/license/license.manager.ts b/packages/tempo/src/plugin/license/license.manager.ts index 259066df..6803717b 100644 --- a/packages/tempo/src/plugin/license/license.manager.ts +++ b/packages/tempo/src/plugin/license/license.manager.ts @@ -47,6 +47,33 @@ export function getLicenseSnapshot(state: Internal.State): Internal.LicenseState return secure(snapshot); } +export function updateScopeStatus(state: Internal.State, scopeKey: string, status: string, error?: string): void { + const license = getLicenseState(state); + if (!license || !license.scopes) return; + + const scopes = { ...license.scopes } as Record; + if (scopes[scopeKey]) { + scopes[scopeKey] = { + ...scopes[scopeKey], + status, + ...(error ? { error } : {}) + } + } else { + scopes[scopeKey] = { + status, + ...(error ? { error } : {}) + } + } + + license.scopes = scopes; + + const activeScopes = Object.values(scopes).filter((s: any) => s && s.status !== LICENSE.Revoked && s.status !== 'revoked'); + if (activeScopes.length === 0 && Object.keys(scopes).length > 0) { + license.status = LICENSE.Revoked; + if (error) license.error = error; + } +} + function disposePendingLicense(license?: Internal.LicenseState): void { const jws = license?.jws as any; if (jws && jws.isPending) { @@ -102,19 +129,30 @@ export function setLicense(state: Internal.State, key: string): void { else warnIfExpiringSoon(license, state.config); - if (res.revocationPromise) { - res.revocationPromise.then((isRevoked: boolean) => { - if (isRevoked && license.jti === initialJti && license.key === initialKey) { - license.status = LICENSE.Revoked; - license.error = 'License has been revoked by the issuer.'; - logWarn(`⚠️ ${logMessage} ${license.error}`, state.config); - } else { - warnIfExpiringSoon(license, state.config); - } - }).catch((err: unknown) => { - const { message } = asError(err); - logDebug(`${logMessage} Background revocation check failed for JTI ${initialJti} - ${message}`, state.config); - }); + const scopesList = Object.values(license.scopes || {}); + const shouldSkipRevocation = scopesList.length > 0 && scopesList.every((s: any) => s?.skipRevocationCheck === true); + + if (!shouldSkipRevocation && (res.revocationPromise || res.getRevocationPromise)) { + const promise = typeof res.getRevocationPromise === 'function' + ? res.getRevocationPromise() + : typeof res.revocationPromise === 'function' + ? res.revocationPromise() + : res.revocationPromise; + + if (promise && typeof promise.then === 'function') { + promise.then((isRevoked: boolean) => { + if (isRevoked && license.jti === initialJti && license.key === initialKey) { + license.status = LICENSE.Revoked; + license.error = 'License has been revoked by the issuer.'; + logWarn(`⚠️ ${logMessage} ${license.error}`, state.config); + } else { + warnIfExpiringSoon(license, state.config); + } + }).catch((err: unknown) => { + const { message } = asError(err); + logDebug(`${logMessage} Background revocation check failed for JTI ${initialJti} - ${message}`, state.config); + }); + } } }, onReject: (err: unknown) => { diff --git a/packages/tempo/src/support/support.cache.ts b/packages/tempo/src/support/support.cache.ts new file mode 100644 index 00000000..d863fa1c --- /dev/null +++ b/packages/tempo/src/support/support.cache.ts @@ -0,0 +1,243 @@ +import { isString, isUndefined } from '#library/assertion.library.js'; +import { secure } from '#library/proxy.library.js'; +import type * as t from '../tempo.type.js'; + +/** + * ## BoundedCache + * High-performance Map implementation enforcing maximum capacity (LRU) and TTL eviction. + * Supports static keys (immortal glossary definitions) and date-salt staleness purging. + */ +export class BoundedCache extends Map { + #timestamps = new Map(); + #staticKeys = new Set(); + maxSize: number; + ttl: number; + + constructor(maxSize = 1000, ttl = 24 * 60 * 60 * 1000) { + super(); + this.maxSize = maxSize; + this.ttl = ttl; + } + + get isBoundedCache(): boolean { + return true; + } + + #isExpired(key: K): boolean { + if (this.#staticKeys.has(key)) return false; + const time = this.#timestamps.get(key); + if (isUndefined(time)) return false; + return Date.now() - time > this.ttl; + } + + /** + * Purges TTL-expired entries. + */ + evictExpired(): void { + const now = Date.now(); + for (const key of super.keys()) { + if (this.#staticKeys.has(key)) continue; + const time = this.#timestamps.get(key); + if (time !== undefined && (now - time > this.ttl)) { + this.delete(key); + } + } + } + + /** + * Register an immortal static key (e.g. business glossary term). + * Static keys bypass TTL expiration and LRU capacity eviction. + */ + setStatic(key: K, value: V): this { + if (super.has(key)) super.delete(key); + super.set(key, value); + this.#timestamps.delete(key); + this.#staticKeys.add(key); + return this; + } + + /** + * Check if a key is a static key. + */ + isStatic(key: K): boolean { + return this.#staticKeys.has(key); + } + + override get(key: K): V | undefined { + if (this.#isExpired(key)) { + this.delete(key); + return undefined; + } + if (!super.has(key)) return undefined; + const val = super.get(key)!; + super.delete(key); + super.set(key, val); + if (!this.#staticKeys.has(key)) + this.#timestamps.set(key, Date.now()); + return val; + } + + override has(key: K): boolean { + if (this.#isExpired(key)) { + this.delete(key); + return false; + } + return super.has(key); + } + + override get size(): number { + this.evictExpired(); + return super.size; + } + + override forEach(callbackfn: (value: V, key: K, map: Map) => void, thisArg?: any): void { + this.evictExpired(); + super.forEach(callbackfn, thisArg); + } + + override set(key: K, value: V): this { + this.evictExpired(); + + if (super.has(key)) super.delete(key); + + super.set(key, value); + this.#staticKeys.delete(key); + this.#timestamps.set(key, Date.now()); + + while (this.size > this.maxSize) { + let evicted = false; + const keysIter = super.keys(); + let res = keysIter.next(); + while (!res.done) { + const k = res.value; + if (!this.#staticKeys.has(k)) { + this.delete(k); + evicted = true; + break; + } + res = keysIter.next(); + } + if (!evicted) break; + } + + return this; + } + + override delete(key: K): boolean { + this.#timestamps.delete(key); + this.#staticKeys.delete(key); + return super.delete(key); + } + + deletePrefix(prefix: string): number { + const normalizedPrefix = String(prefix).trim().toLowerCase(); + const toDelete: K[] = []; + for (const key of super.keys()) { + if (isString(key) && key.toLowerCase().startsWith(normalizedPrefix)) + toDelete.push(key); + } + + for (const k of toDelete) + this.delete(k); + + return toDelete.length; + } + + /** + * Purge cache entries. + * If `count` is specified, evicts up to `count` oldest non-static entries. + * If omitted, clears all entries, including static entries. + */ + override clear(count?: number): void { + if (count === undefined) { + this.#timestamps.clear(); + this.#staticKeys.clear(); + super.clear(); + return; + } + + let evicted = 0; + for (const key of super.keys()) { + if (evicted >= count) break; + if (!this.#staticKeys.has(key)) { + this.delete(key); + evicted++; + } + } + } + + override keys(): MapIterator { + this.evictExpired(); + return super.keys(); + } + + override values(): MapIterator { + this.evictExpired(); + return super.values(); + } + + override entries(): MapIterator<[K, V]> { + this.evictExpired(); + return super.entries(); + } + + override[Symbol.iterator](): MapIterator<[K, V]> { + this.evictExpired(); + return super[Symbol.iterator](); + } + + static fromEntries(entries: Iterable, maxSize = 1000, ttl = 24 * 60 * 60 * 1000): BoundedCache { + const cache = new BoundedCache(maxSize, ttl); + for (const [k, v] of entries) { + cache.set(k, v); + } + return cache; + } +} + +/** + * Creates a normalized cache facade exposing safe operations over the active state's BoundedCache. + */ +export function createCacheFacade(getState: () => t.Internal.State) { + return secure({ + get(key: string) { + const normalized = String(key).trim().toLowerCase(); + return getState().cache.get(normalized); + }, + has(key: string) { + const normalized = String(key).trim().toLowerCase(); + return getState().cache.has(normalized); + }, + set(key: string, value: string) { + const normalized = String(key).trim().toLowerCase(); + getState().cache.set(normalized, String(value)); + return this; + }, + setStatic(key: string, value: string) { + const normalized = String(key).trim().toLowerCase(); + getState().cache.setStatic(normalized, String(value)); + return this; + }, + delete(key: string) { + const normalized = String(key).trim().toLowerCase(); + return getState().cache.delete(normalized); + }, + deletePrefix(prefix: string) { + const normalizedPrefix = String(prefix).trim().toLowerCase(); + return getState().cache.deletePrefix(normalizedPrefix); + }, + clear(count?: number) { + getState().cache.clear(count); + }, + entries() { + return getState().cache.entries(); + }, + fromEntries(entries: Iterable) { + for (const [k, v] of entries) { + const normalized = String(k).trim().toLowerCase(); + getState().cache.set(normalized, String(v)); + } + return this; + } + }); +} diff --git a/packages/tempo/src/support/support.enum.ts b/packages/tempo/src/support/support.enum.ts index a677fb20..5504ba85 100644 --- a/packages/tempo/src/support/support.enum.ts +++ b/packages/tempo/src/support/support.enum.ts @@ -228,7 +228,7 @@ export type ZONED_DATE_TIME = ValueOf export type ZonedDateTime = KeyOf /** allowed keys for Tempo configuration options */ -const configKeys = ['config', 'parse', 'value', 'intl', 'store', 'discovery', 'debug', 'catch', 'silent', 'timeZone', 'calendar', 'locale', 'sphere', 'timeStamp', 'registry', 'plugins'] as const; +const configKeys = ['config', 'parse', 'value', 'intl', 'store', 'discovery', 'debug', 'catch', 'silent', 'timeZone', 'calendar', 'locale', 'sphere', 'timeStamp', 'registry', 'plugins', 'cache'] as const; export const CONFIG = enumify(configKeys, false); export type Config = KeyOf @@ -236,6 +236,14 @@ export type Config = KeyOf export const MODE = enumify({ Auto: 'auto', Strict: 'strict', Defer: 'defer', }, false); export type MODE = ValueOf +/** cache operation modes */ +export const CACHE = enumify({ + Off: false, + On: true, + Refresh: 'refresh', +}, false); +export type CACHE = ValueOf + /** allowed keys for internal parse state */ const parseKeys = ['monthDay', 'planner', 'layoutOrder', 'preFilter', 'mode', 'pivot', 'snippet', 'layout', 'event', 'period', 'anchor'] as const; export const PARSE = enumify(parseKeys, false); @@ -283,6 +291,7 @@ export default { ZONED_DATE_TIME, CONFIG, MODE, + CACHE, PARSE, MONTH_DAY, LICENSE, diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index 77952a5f..ac6e1ee7 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -11,6 +11,7 @@ export { REGISTRIES, DISCOVERY, MODE, + CACHE, COMPASS, WEEKDAY, WEEKDAYS, @@ -34,11 +35,12 @@ export { export { markConfig } from '#library/symbol.library.js'; export { sym, isTempo, Token, TermError, type TempoBrand } from './support.symbol.js'; -export { $Tempo, $Register, $Interpreter, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Identity, $LogConfig, $Discover, $ImmutableSkip } from './support.symbol.js'; +export { $Tempo, $Register, $Interpreter, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Identity, $LogConfig, $Discover, $ImmutableSkip, $updateScopeStatus } from './support.symbol.js'; export { registryUpdate, registryReset, onRegistryReset } from './support.register.js'; export { getRuntime, resetRuntime, TempoRuntime } from './support.runtime.js'; export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './support.default.js'; export { SCHEMA, getLargestUnit, logError, logWarn, logDebug, logTrace, setLogLevel, logTempo, hasOwn } from './support.util.js'; export { setPatterns } from '../engine/engine.pattern.js'; export { init, extendState } from './support.init.js'; -export { TempoError } from './support.error.js'; \ No newline at end of file +export { TempoError } from './support.error.js'; +export { BoundedCache, createCacheFacade } from './support.cache.js'; \ No newline at end of file diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 34d4d841..34cddf55 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -6,7 +6,7 @@ import { normalizeUtcOffset } from '#library/temporal.library.js'; import { markConfig } from '#library/symbol.library.js'; import { deepMerge } from '#library/object.library.js'; import { asType } from '#library/type.library.js'; -import { isString, isObject, isUndefined, isDefined, isRegExp, isEmpty } from '#library/assertion.library.js'; +import { isString, isObject, isUndefined, isDefined, isRegExp, isEmpty, isFunction } from '#library/assertion.library.js'; import { ScopedSet } from '#library/scopedset.class.js'; import { ownEntries } from '#library/primitive.library.js'; import { getStorage } from '#library/storage.library.js'; @@ -20,13 +20,39 @@ import { Match, Snippet, Layout, Event, Period, Ignore, Default } from './suppor import { STATE } from './support.enum.js'; import enums from './support.enum.js'; +import { BoundedCache } from './support.cache.js'; import * as t from '../tempo.type.js'; +function resolveCache(optionsCache: any, existingCache?: BoundedCache): BoundedCache { + const isBoundedCacheOpt = Boolean(optionsCache?.isBoundedCache || optionsCache instanceof BoundedCache); + if (isBoundedCacheOpt) { + return optionsCache as BoundedCache; + } else if (optionsCache instanceof Map || (optionsCache && isFunction((optionsCache as any).entries))) { + const targetCache = existingCache ?? new BoundedCache(); + for (const [k, v] of (optionsCache as any).entries()) + targetCache.setStatic(String(k).trim().toLowerCase(), String(v)); + return targetCache; + } else { + if (existingCache && (existingCache.isBoundedCache || existingCache instanceof BoundedCache)) { + if (isDefined(optionsCache?.maxSize)) existingCache.maxSize = optionsCache.maxSize; + if (isDefined(optionsCache?.ttl)) existingCache.ttl = optionsCache.ttl; + return existingCache; + } else { + const maxSize = optionsCache?.maxSize ?? 1000; + const ttl = optionsCache?.ttl ?? (24 * 60 * 60 * 1000); + return new BoundedCache(maxSize, ttl); + } + } +} + /** @internal Initialise a Tempo state */ -export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Internal.State): t.Internal.State { +export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Internal.State, prevCache?: BoundedCache): t.Internal.State { const runtime = getRuntime(); // Global init is intentionally idempotent after first hydration; late-loaded modules must use Tempo.extend(). - if (isGlobal && runtime.state && !baseState) return runtime.state; + if (isGlobal && runtime.state && !baseState) { + runtime.state.cache = resolveCache(options.cache, runtime.state.cache); + return runtime.state; + } const { timeZone, calendar } = getDateTimeFormat(); const state = (baseState ? Object.create(baseState) : { @@ -49,6 +75,9 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int }; } + const targetCache = baseState?.cache ?? runtime.state?.cache ?? prevCache; + state.cache = resolveCache(options.cache, targetCache); + // 1. Establish the base parsing state const parseState: t.Internal.Parse = { token: Token, @@ -364,6 +393,15 @@ export function extendState(state: t.Internal.State, options: t.Options): boolea setProperty(state.config, 'debug', parseLogLevel(arg.value)); break; + case 'cache': + if (isObject(arg.value)) { + if (arg.value.maxSize !== undefined) state.cache.maxSize = arg.value.maxSize; + if (arg.value.ttl !== undefined) state.cache.ttl = arg.value.ttl; + } else { + setProperty(state.config, 'cache', arg.value); + } + break; + default: setProperty(state.config, optKey, arg.value); break; @@ -394,9 +432,8 @@ export function extendState(state: t.Internal.State, options: t.Options): boolea if (state.aliasEngine) { // Ensure we don't corrupt global state if we are a local instance if (state.config.scope === 'local' && state.aliasEngine.depth === 0) { - if (typeof state.aliasEngine.fork === 'function') { + if (isFunction(state.aliasEngine.fork)) state.aliasEngine = state.aliasEngine.fork(state.config); - } } state.aliasEngine.registerAliases('evt', ownEntries(events)); } diff --git a/packages/tempo/src/support/support.symbol.ts b/packages/tempo/src/support/support.symbol.ts index 945119e7..392145c9 100644 --- a/packages/tempo/src/support/support.symbol.ts +++ b/packages/tempo/src/support/support.symbol.ts @@ -35,12 +35,13 @@ export const TermError: unique symbol = Symbol.for('magmacomputing/tempo/termErr /** @internal static alias builder */ export const $setAliases: unique symbol = Symbol.for('magmacomputing/tempo/setAliases') as any; /** @internal static guard builder */ export const $buildGuard: unique symbol = Symbol.for('magmacomputing/tempo/buildGuard') as any; /** @internal static base class marker */ export const $IsBase: unique symbol = Symbol.for('magmacomputing/tempo/isBase') as any; +/** @internal static license scope status mutator */ export const $updateScopeStatus: unique symbol = Symbol.for('magmacomputing/tempo/updateScopeStatus') as any; /** @internal Tempo Symbol Registry (Local Keys) */ const local = { $Tempo, $Register, $Interpreter, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $License, $setConfig, $setDiscovery, - $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $PluginType + $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $PluginType, $updateScopeStatus } as const; /** @internal Unified Symbol Registry (Inherits from #library via Prototype Chain) */ diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 6219f543..9b2aa8ac 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -26,11 +26,11 @@ import { PatternCompiler } from './engine/engine.pattern.js'; import { createMasterGuard } from './engine/engine.guard.js'; import { DEFAULT_LAYOUT_CLASS, resolveLayoutOrder, getLayoutOrder } from './engine/engine.layout.js'; -import { validateLicenseState, getLicenseSnapshot, setLicense, getLicenseState } from './plugin/license/license.manager.js'; +import { validateLicenseState, getLicenseSnapshot, setLicense, getLicenseState, updateScopeStatus } from './plugin/license/license.manager.js'; import { resolveMonthDay, setProperty, proto, hasOwn, resolveDisplayStatus } from './support/support.util.js'; import { datePattern } from './support/support.default.js'; -import { sym, markConfig, TermError, getRuntime, init, extendState, setPatterns, isTempo, registryUpdate, registryReset, onRegistryReset, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, LICENSE, DISCOVERY, $Internal, $setConfig, $Identity, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Tempo, $Register, $errored, $guard, $Discover, $setDiscovery, $LogConfig, $ImmutableSkip, logError, logDebug, logWarn, logTempo, setLogLevel } from '#tempo/support'; +import { sym, markConfig, TermError, getRuntime, init, extendState, setPatterns, isTempo, registryUpdate, registryReset, onRegistryReset, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, LICENSE, DISCOVERY, $Internal, $setConfig, $Identity, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Tempo, $Register, $errored, $guard, $Discover, $setDiscovery, $LogConfig, $ImmutableSkip, $updateScopeStatus, logError, logDebug, logWarn, logTempo, setLogLevel, createCacheFacade } from '#tempo/support'; import { TEMPO_VERSION } from './tempo.version.js'; import { Interval } from './interval.class.js'; import * as t from './tempo.type.js'; // namespaced types (Tempo.*) @@ -107,6 +107,7 @@ export class Tempo { /** TimeZone aliases */ static get TIMEZONE() { return enums.TIMEZONE } /** regional date-parsing configuration */ static get MONTH_DAY() { return enums.MONTH_DAY } /** initialization strategies */ static get MODE() { return enums.MODE } + /** cache operation modes */ static get CACHE() { return enums.CACHE } /** some useful Dates */ static get LIMIT() { return enums.LIMIT } /** @internal check if Tempo is currently initializing */ static get isInitializing() { return !_lifecycle.ready } @@ -117,13 +118,16 @@ export class Tempo { static get versions() { return Object.freeze({ ...Tempo.#versions }) as Readonly>; } /** the version of this Tempo build (stamped at build-time from package.json) */ static get version() { return Tempo.#versions['Tempo']; } + /** high-performance in-memory cache facade for glossary and dynamic parse results */ + static get cache() { + return createCacheFacade(() => this[$Internal]()); + } - /** mutable list of registered term plugins */ static get #terms(): TermPlugin[] { return this[$Internal]().pluginsDb.terms } - /** @internal raw license state — sandbox-aware: reads sandbox-local license if present, otherwise global */ - static get #license() { return getLicenseState(this[$Internal]()); } - /** human-readable formatted license state */ static get license() { - const { jws, key, ...raw } = getLicenseSnapshot(this[$Internal]()); // omit internal Pledge and JWT string from user-facing snapshot - const ss = { timeStamp: 'ss' } as const; // JWT timestamps are always in seconds (RFC 7519) + /** mutable list of registered term plugins */ static get #terms(): TermPlugin[] { return Tempo[$Internal]().pluginsDb.terms } + /** @internal format raw license snapshot into human-readable license object */ + static #formatLicense(state: Internal.State) { + const { jws, key, ...raw } = getLicenseSnapshot(state); + const ss = { timeStamp: 'ss' } as const; const scopesSource = (raw.scopes && isObject(raw.scopes)) ? raw.scopes : {}; const scopes = Object.fromEntries( Object.entries(scopesSource).map(([key, scope]) => { @@ -143,6 +147,16 @@ export class Tempo { ...(isNumber(raw.issuedAt) && { issuedAt: new Tempo(raw.issuedAt, ss).fmt.weekTime }), }); } + + /** human-readable formatted license state */ + static get license() { + return Tempo.#formatLicense(this[$Internal]()); + } + + /** @internal programmatically update status of a license scope (e.g. when network response determines revocation) */ + static [$updateScopeStatus](scopeKey: string, status: string, error?: string): void { + updateScopeStatus(this[$Internal](), scopeKey, status, error); + } /** mapping of terms to their resolved values */ static #termMap: Map = new Map(); /** @internal Master Guard predicate (implements RegExp-like interface) */static get [$guard]() { return (this[$Internal]() as any)[$guard] ?? { test: () => true }; } @@ -157,9 +171,8 @@ export class Tempo { /** @internal */ static get [$ImmutableSkip]() { - const global = typeof globalThis !== 'undefined' ? globalThis : (window as any); - const nodeEnv = typeof global !== 'undefined' - && typeof global.process !== 'undefined' + const global = isDefined(globalThis) ? globalThis : (window as any); + const nodeEnv = isDefined(global.process) && global.process.env && (global.process.env.NODE_ENV === 'test' || global.process.env.CI); @@ -820,11 +833,12 @@ export class Tempo { setLogLevel(options.debug ?? Default?.debug ?? LOG.Info); const rt = getRuntime(); + const prevCache = rt.state?.cache; const isBase = !!this[$IsBase]; - if (isBase) rt.state = undefined; // force fresh state + if (isBase) rt.state = undefined; const baseState = isBase ? undefined : Object.getPrototypeOf(this)[$Internal](); - const state = init(options, isBase, baseState); + const state = init(options, isBase, baseState, prevCache); (state as any)._count = 0; if (isBase) { _global = state; @@ -1059,11 +1073,11 @@ export class Tempo { /** static units since Unix epoch */ static get epoch() { - return this.#getEpoch(instant()); + return Tempo.#getEpoch(instant()); } /** get the current system Instant */ - static get instant() { return Temporal.Instant.fromEpochNanoseconds(this.now()) } + static get instant() { return Temporal.Instant.fromEpochNanoseconds(Tempo.now()) } /** static Tempo.terms (registry) */ static get terms(): Secure & Record { @@ -1197,14 +1211,14 @@ export class Tempo { /** constructor tempo */ #tempo?: t.DateTime; /** constructor options */ #options = {} as t.Options; - /** instantiation Temporal Instant */ #now: Temporal.Instant; + /** instantiation Temporal Instant */ #instant?: Temporal.Instant; /** underlying Temporal ZonedDateTime */ #zdt!: Temporal.ZonedDateTime; /** memoized TimeZone ID */ #tz?: string; /** memoized Calendar ID */ #cal?: string; /** indicator that the instance failed to parse */ #errored = false; /** temporary anchor used during parsing */ #anchor: Temporal.ZonedDateTime | undefined; - /** prebuilt formats, for convenience */ #fmt!: Record; - /** mapping of terms to their resolved values */ #term!: any; + /** prebuilt formats, for convenience */ #fmt?: Record; + /** mapping of terms to their resolved values */ #term?: any; /** a collection of parse rule-matches */ #matches: Internal.MatchResult[] | undefined; /** current parsing depth to manage state isolation */ #parseDepth = 0; /** current mutation depth to manage infinite recursion */#mutateDepth = 0; @@ -1258,7 +1272,7 @@ export class Tempo { ZONED_DATE_TIME: enums.ZONED_DATE_TIME } - return out; + return Object.setPrototypeOf(out, self.#local); } /** allow for auto-convert of Tempo to BigInt, Number or String */ @@ -1272,7 +1286,7 @@ export class Tempo { /** iterate over instance formats */ [Symbol.iterator]() { - return ownEntries(this.#fmt, true)[Symbol.iterator](); // instance Iterator over tuple of FormatType[] + return ownEntries(this.fmt, true)[Symbol.iterator](); // instance Iterator over tuple of FormatType[] } get [Symbol.toStringTag](): 'Tempo' { // default string description @@ -1293,7 +1307,6 @@ export class Tempo { */ constructor(tempo: t.DateTime, options?: t.Options); constructor(tempo?: t.DateTime | t.Options, options: t.Options = {}) { - this.#now = instant(); // stash current Instant [this.#tempo, this.#options] = this.#swap(tempo, options);// swap arguments around if (isZonedDateTime(this.#tempo)) this.#zdt = this.#tempo; @@ -1313,8 +1326,6 @@ export class Tempo { else if (isString(this.#tempo) && !isEmpty(input) && guard.test(trimAll(input))) this.#local.parse.lazy = true; // auto-switch to lazy-mode for valid strings - this.#fmt = this.#setDelegator('fmt'); // initialize the format-delegator - this.#term = this.#setDelegator('term'); // initialize the term-delegator this.#anchor = this.#options.anchor; // 🧬 Unified State Hand-off (from clone / mutate) @@ -1360,8 +1371,6 @@ export class Tempo { /** Resolve the instance to a Temporal.ZonedDateTime (with optional callback) */ #resolve(cb?: (zdt: Temporal.ZonedDateTime) => T): T | Temporal.ZonedDateTime { - const now = this.#now.toZonedDateTimeISO('UTC'); - if (!this.#zdt) { try { const skip = [this.#local.parse.format, this.#local.parse.term, this.#local.parse.result] @@ -1371,7 +1380,7 @@ export class Tempo { this.#errored = true; const msg = `Tempo parse returned undefined for: ${String(this.#tempo)}`; logError(msg, this.#local.config); - this.#zdt = now; + this.#zdt = this.#now.toZonedDateTimeISO('UTC'); } secure(this.#local.config); secure(this.#local.parse, new WeakSet(skip)); @@ -1380,7 +1389,7 @@ export class Tempo { const msg = `Cannot create Tempo: ${(err as Error).message}\n${(err as Error).stack}`; if (this.#local.config.catch === true) { logError(msg, this.#local.config); // log as error if in catch-mode - this.#zdt = now; + this.#zdt = this.#now.toZonedDateTimeISO('UTC'); } else { logError((err as Error), this.#local.config, msg); // log as error then re-throw throw err; @@ -1388,7 +1397,7 @@ export class Tempo { } } - const zdt = isZonedDateTime(this.#zdt) ? this.#zdt : now; + const zdt = isZonedDateTime(this.#zdt) ? this.#zdt : this.#now.toZonedDateTimeISO('UTC'); return cb?.(zdt) ?? zdt; } @@ -1622,8 +1631,8 @@ export class Tempo { return out as t.Internal.Parse; } - /** Keyed results for all resolved terms */ get term(): TempoTermRegistry { return this.#term } - /** Formatted results for all pre-defined format codes */ get fmt(): Record { return this.#fmt } + /** Keyed results for all resolved terms */ get term(): TempoTermRegistry { return this.#term ??= this.#setDelegator('term'); } + /** Formatted results for all pre-defined format codes */ get fmt(): Record { return this.#fmt ??= this.#setDelegator('fmt'); } /** units since epoch for this date-time instance */ get epoch() { return Tempo.#getEpoch(this.toDateTime()); } /** @@ -1633,6 +1642,7 @@ export class Tempo { * rather than using `new Tempo(..)`. */ /** @internal */ get #Tempo() { return this.constructor as typeof Tempo; } + /** @internal */ get #now(): Temporal.Instant { return this.#instant ??= instant(); } /** apply a custom format. */ format(fmt?: any, options?: any): string { return this.#resolve(() => interpret(this, 'FormatModule', () => `{${String(fmt)}}`, false, fmt, options)) as string; } /** time duration until another date-time */ @@ -1676,7 +1686,6 @@ export class Tempo { #setLocal(options: t.Options = {}) { const classState = (this.constructor as any)[$Internal](); this.#local = Object.create(classState); - (this.#local as any)._id = (this.constructor as any)[$Internal]()._count++; const self = unwrap(this); this.#local.config = markConfig(Object.create(classState.config)); if (classState.config.registry) this.#local.config.registry = Object.create(classState.config.registry); diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 53f8f8a0..6b6e7976 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -13,6 +13,7 @@ import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, import { sym, type TempoBrand } from '#tempo/support/support.symbol.js'; import * as enums from '#tempo/support/support.enum.js'; +import { BoundedCache } from '#tempo/support/support.cache.js'; import { SLICK_KEYS, type Snippet, type Layout, type Event, type Period, type Ignore } from '#tempo/support/support.default.js'; import type { Token } from '#tempo/support/support.symbol.js'; @@ -381,6 +382,7 @@ export namespace Internal { /** @internal Alias engine for this Tempo instance */ aliasEngine?: AliasEngine; /** @internal Pattern compiler for this Tempo instance */ patternCompiler?: PatternCompiler; /** @internal database of plugins scoped to this state */pluginsDb: { terms: TermPlugin[]; plugins: TempoPlugin[] }; + /** @internal internal cache engine for static terms and string parses */cache: BoundedCache; /** @internal installed-plugin dedup tracker; a ScopedSet for sandboxes (delegates has() to global rt.installed), undefined for the global state */installed?: Set | ScopedSet; /** @internal sandbox-local license state; runtime license is centralized on TempoRuntime */license?: Internal.LicenseState; } diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index 7d22d5f3..89eb178b 100644 --- a/packages/tempo/src/tempo.version.ts +++ b/packages/tempo/src/tempo.version.ts @@ -5,4 +5,4 @@ * ⚠️ This file is auto-updated by `npm run build:version` (see `bin/update-version.mjs`). * Do NOT edit manually — your changes will be overwritten on the next build. */ -export const TEMPO_VERSION = '3.10.2'; +export const TEMPO_VERSION = '3.11.0'; diff --git a/packages/tempo/test/discrete/parse.locale.test.ts b/packages/tempo/test/discrete/parse.locale.test.ts index 69188125..2f93f47c 100644 --- a/packages/tempo/test/discrete/parse.locale.test.ts +++ b/packages/tempo/test/discrete/parse.locale.test.ts @@ -78,4 +78,41 @@ describe('Localized Parsing', () => { expect(t2.isValid).toBe(true); expect(t2.mm).toBe(1); }); + + it('test Spanish el próximo lunes', () => { + Tempo.init({ + locale: ['fr-FR', 'es-ES'], + registry: { + modifiers: { + '+': ['próximo', 'proximo', 'siguiente'] + }, + ignores: ['el', 'la', 'los', 'las'] + } + }); + const anchor = '2026-07-31T12:00:00+00:00[UTC]'; + + const t1 = new Tempo('lunes', { anchor }); + expect(t1.isValid).toBe(true); + + const t2 = new Tempo('próximo lunes', { anchor }); + expect(t2.isValid).toBe(true); + expect(t2.dow).toBe(1); + expect(t2.yy).toBe(2026); + expect(t2.mm).toBe(8); + expect(t2.dd).toBe(3); + + const t3 = new Tempo('el próximo lunes', { anchor }); + expect(t3.isValid).toBe(true); + expect(t3.dow).toBe(1); + expect(t3.yy).toBe(2026); + expect(t3.mm).toBe(8); + expect(t3.dd).toBe(3); + + const t4 = new Tempo('el proximo lunes', { anchor }); + expect(t4.isValid).toBe(true); + expect(t4.dow).toBe(1); + expect(t4.yy).toBe(2026); + expect(t4.mm).toBe(8); + expect(t4.dd).toBe(3); + }); }); diff --git a/packages/tempo/test/plugins/license.phase1.test.ts b/packages/tempo/test/plugins/license.phase1.test.ts new file mode 100644 index 00000000..af1cdb64 --- /dev/null +++ b/packages/tempo/test/plugins/license.phase1.test.ts @@ -0,0 +1,133 @@ +import { Tempo } from '#tempo'; +import { LICENSE } from '#tempo/support/support.enum.js'; +import { getRuntime, resetRuntime } from '#tempo/support/support.runtime.js'; +import { $updateScopeStatus } from '#tempo/support/support.symbol.js'; +import { encodeBase64 } from '#library'; + +const { licenseModulePath, mockFactory, setMockResult } = vi.hoisted(() => { + const path = require('node:path') as typeof import('node:path'); + const licenseModulePath = path.resolve(__dirname, '../../src/plugin/license/license.validator.ts'); + + let mockResult: any = { status: 'active', scopes: {} }; + + const setMockResult = (res: any) => { + mockResult = res; + }; + + const mockFactory = () => { + const Validator = vi.fn().mockImplementation(function () { + return { + verify: vi.fn().mockImplementation(async () => mockResult) + } + }); + return { Validator }; + }; + + return { licenseModulePath, mockFactory, setMockResult }; +}); + +vi.mock('#tempo/license', mockFactory); +vi.mock(licenseModulePath, mockFactory); + +describe('Phase 1 Core Tempo Licensing Engine Enhancements', () => { + beforeEach(() => { + resetRuntime(); + vi.clearAllMocks(); + setMockResult({ status: 'active', scopes: {} }); + }); + + test('Tempo[$updateScopeStatus] symbol method programmatically mutates scope status and is protected from public static access', () => { + // 1. Verify public static access is undefined + expect((Tempo as any).updateScopeStatus).toBeUndefined(); + + const payload = { + iss: 'Magma Computing', + scopes: { + 'tempo-plugin-ai': { exp: 2000000000 }, + 'tempo-plugin-ticker': { exp: 2000000000 } + }, + jti: 'test-ai-jwt-1' + } + const mockToken = `header.${encodeBase64(JSON.stringify(payload))}.sig`; + + Tempo.init({ license: mockToken }); + const rt = getRuntime(); + rt.license.status = LICENSE.Active; + + expect((Tempo.license as any).scopes['tempo-plugin-ai'].status).toBeUndefined(); + + // Update AI scope to revoked via internal symbol + (Tempo as any)[$updateScopeStatus]('tempo-plugin-ai', 'revoked', 'Quota exceeded or token revoked'); + + const snapshot = Tempo.license as any; + expect(snapshot.scopes['tempo-plugin-ai'].status).toBe('revoked'); + expect(snapshot.scopes['tempo-plugin-ai'].error).toBe('Quota exceeded or token revoked'); + expect(snapshot.scopes['tempo-plugin-ticker'].status).toBeUndefined(); + // Top-level status should remain Active because ticker is still active + expect(rt.license.status).toBe(LICENSE.Active); + + // Now update ticker to revoked + (Tempo as any)[$updateScopeStatus]('tempo-plugin-ticker', 'revoked', 'Ticker scope revoked'); + + // Now all scopes are revoked -> top-level license state transitions to Revoked + expect(rt.license.status).toBe(LICENSE.Revoked); + }); + + test('bypasses background revocation promise factory when ALL scopes have skipRevocationCheck: true', async () => { + const payload = { + iss: 'Magma Computing', + scopes: { + 'tempo-plugin-ai': { exp: 2000000000, skipRevocationCheck: true } + }, + jti: 'ai-only-jwt' + } + const mockToken = `h.${encodeBase64(JSON.stringify(payload))}.s`; + + const revocationSpy = vi.fn().mockResolvedValue(true); + setMockResult({ + status: 'active', + scopes: payload.scopes, + getRevocationPromise: revocationSpy + }); + + Tempo.init({ license: mockToken }); + const rt = getRuntime(); + + await rt.license.jws; + await new Promise(r => setTimeout(r, 20)); + + // Lazy revocation factory spy should NOT be invoked because skipRevocationCheck is true + expect(revocationSpy).not.toHaveBeenCalled(); + expect(rt.license.status).toBe(LICENSE.Active); + }); + + test('preserves background revocation promise when ANY scope lacks skipRevocationCheck', async () => { + const payload = { + iss: 'Magma Computing', + scopes: { + 'tempo-plugin-ai': { exp: 2000000000, skipRevocationCheck: true }, + 'tempo-plugin-ticker': { exp: 2000000000 } // Lacks skipRevocationCheck! + }, + jti: 'hybrid-jwt' + } + const mockToken = `h.${encodeBase64(JSON.stringify(payload))}.s`; + + const revocationSpy = vi.fn().mockResolvedValue(true); + setMockResult({ + status: 'active', + scopes: payload.scopes, + getRevocationPromise: revocationSpy + }); + + Tempo.init({ license: mockToken }); + const rt = getRuntime(); + + await rt.license.jws; + await vi.waitFor(() => expect(rt.license.status).toBe(LICENSE.Revoked)); + + // Revocation factory spy SHOULD be invoked because ticker scope requires client-side background polling! + expect(revocationSpy).toHaveBeenCalledTimes(1); + expect(rt.license.status).toBe(LICENSE.Revoked); + expect(rt.license.error).toContain('revoked'); + }); +}); diff --git a/packages/tempo/test/support/cache.test.ts b/packages/tempo/test/support/cache.test.ts new file mode 100644 index 00000000..2f2c7f46 --- /dev/null +++ b/packages/tempo/test/support/cache.test.ts @@ -0,0 +1,121 @@ +import { Tempo } from '#tempo'; +import { BoundedCache } from '../../src/support/support.cache.js'; + +describe('Tempo Core Caching Architecture', () => { + beforeEach(() => { + Tempo.cache.clear(); + }); + + describe('BoundedCache Engine', () => { + it('should evict LRU items when maxSize is exceeded', () => { + const cache = new BoundedCache(2, 10000); + cache.set('a', '1'); + cache.set('b', '2'); + cache.set('c', '3'); // 'a' should be evicted + + expect(cache.has('a')).toBe(false); + expect(cache.get('b')).toBe('2'); + expect(cache.get('c')).toBe('3'); + expect(cache.size).toBe(2); + }); + + it('should update recency when calling get() so accessed items avoid eviction', () => { + const cache = new BoundedCache(2, 10000); + cache.set('a', '1'); + cache.set('b', '2'); + + // Read 'a' to make it most recently used + expect(cache.get('a')).toBe('1'); + + // Insert 'c'. 'b' should be evicted because 'a' was refreshed by get() + cache.set('c', '3'); + + expect(cache.has('b')).toBe(false); + expect(cache.get('a')).toBe('1'); + expect(cache.get('c')).toBe('3'); + expect(cache.size).toBe(2); + }); + + it('should protect static keys from LRU capacity eviction', () => { + const cache = new BoundedCache(2, 10000); + cache.setStatic('static_term', 'IMMORTAL'); + cache.set('a', '1'); + cache.set('b', '2'); // 'a' should be evicted, not static_term + + expect(cache.has('static_term')).toBe(true); + expect(cache.get('static_term')).toBe('IMMORTAL'); + expect(cache.has('a')).toBe(false); + expect(cache.get('b')).toBe('2'); + }); + + it('should serialize and rehydrate via entries() and fromEntries()', () => { + const cache = new BoundedCache(10, 10000); + cache.set('k1', 'v1'); + cache.set('k2', 'v2'); + + const serialized = [...cache.entries()]; + expect(serialized).toEqual([['k1', 'v1'], ['k2', 'v2']]); + + const rehydrated = BoundedCache.fromEntries(serialized); + expect(rehydrated.get('k1')).toBe('v1'); + expect(rehydrated.get('k2')).toBe('v2'); + }); + + it('should clear specific count of oldest non-static entries', () => { + const cache = new BoundedCache(10, 10000); + cache.setStatic('immortal', '1'); + cache.set('k1', 'v1'); + cache.set('k2', 'v2'); + cache.set('k3', 'v3'); + + cache.clear(2); // Evicts k1 and k2 + + expect(cache.has('immortal')).toBe(true); + expect(cache.has('k1')).toBe(false); + expect(cache.has('k2')).toBe(false); + expect(cache.has('k3')).toBe(true); + }); + }); + + describe('Tempo.CACHE Enum & Facade', () => { + it('should expose Tempo.CACHE enum values', () => { + expect(Tempo.CACHE.Off).toBe(false); + expect(Tempo.CACHE.On).toBe(true); + expect(Tempo.CACHE.Refresh).toBe('refresh'); + }); + + it('should expose normalized Tempo.cache facade methods', () => { + Tempo.cache.set(' MY_TERM ', '2026-05-10'); + expect(Tempo.cache.has('my_term')).toBe(true); + expect(Tempo.cache.get('my_term')).toBe('2026-05-10'); + + Tempo.cache.delete('MY_TERM'); + expect(Tempo.cache.has('my_term')).toBe(false); + }); + + it('should resolve static glossary terms instantly and record glossary source in parse result', () => { + Tempo.cache.setStatic('eoy_party', '2026-12-31T18:00:00'); + + const instance = new Tempo('eoy_party'); + expect(instance.isValid).toBe(true); + expect(instance.format('{yyyy}-{mm}-{dd}')).toBe('2026-12-31'); + + const hit = instance.parse.result.find((r: any) => r.match === 'CacheHit'); + expect(hit).toBeDefined(); + expect(hit?.source).toBe('glossary'); + }); + + it('should resolve dynamic parse cache when opt-in cache: true is passed', () => { + const t1 = new Tempo('2026-08-15', { cache: true }); + expect(t1.isValid).toBe(true); + + // Second instantiation uses dynamic cache + const t2 = new Tempo('2026-08-15', { cache: true }); + expect(t2.isValid).toBe(true); + + const hit = t2.parse.result.find((r: any) => r.match === 'CacheHit'); + expect(hit).toBeDefined(); + expect(hit?.source).toBe('parseCache'); + }); + }); +});