diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81465082..6e43cca7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: - name: Build Core run: npm run build:tempo - name: Run plugin tests - run: npm test --if-present --workspace=@magmacomputing/tempo-plugin-snap --workspace=@magmacomputing/tempo-plugin-batch --workspace=@magmacomputing/tempo-plugin-finance --workspace=@magmacomputing/tempo-plugin-astro --workspace=@magmacomputing/tempo-plugin-sync --workspace=@magmacomputing/tempo-plugin-ticker + run: npm test --if-present --workspace=@magmacomputing/tempo-plugin-snap --workspace=@magmacomputing/tempo-plugin-batch --workspace=@magmacomputing/tempo-plugin-finance --workspace=@magmacomputing/tempo-plugin-astro --workspace=@magmacomputing/tempo-plugin-sync --workspace=@magmacomputing/tempo-plugin-ticker --workspace=@magmacomputing/tempo-plugin-ai working-directory: packages/plugins functions: diff --git a/.gitignore b/.gitignore index 85eb44a4..ffa3eee8 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ Thumbs.db **/.vitepress/cache/ **/.vitepress/dist/ **/doc/api/ +**/public/api/ **/doc/9-plugins/ # Secrets and credentials .env diff --git a/package-lock.json b/package-lock.json index a903fe93..07e70235 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "3.10.1", + "version": "3.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "3.10.1", + "version": "3.10.2", "workspaces": [ "packages/*", "packages/plugins/*" @@ -1134,6 +1134,10 @@ "resolved": "packages/functions", "link": true }, + "node_modules/@magmacomputing/tempo-plugin-ai": { + "resolved": "packages/plugins/parseAI", + "link": true + }, "node_modules/@magmacomputing/tempo-plugin-astro": { "resolved": "packages/plugins/astro", "link": true @@ -9507,29 +9511,6 @@ "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" } }, - "node_modules/typedoc-plugin-markdown": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.12.0.tgz", - "integrity": "sha512-eJDEMAfxCmede22c/Jw7d0FA13ggAQv+KkwQYKYCdqI02cin6Rc9QRwbG/7XvvHWinuFejySnZVUWDtvGk3Vbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "typedoc": "0.28.x" - } - }, - "node_modules/typedoc-vitepress-theme": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/typedoc-vitepress-theme/-/typedoc-vitepress-theme-1.1.3.tgz", - "integrity": "sha512-EK9iV7e3+R8lFNigdc0rIPWMxqfmDku0uGac3qYUu9tS4Qf1rhWZnyZJ4zu4G3iXrP5mqNPkv2wpODzRlA7jLw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "typedoc-plugin-markdown": ">=4.11.0" - } - }, "node_modules/typedoc/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -11064,7 +11045,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "3.10.1", + "version": "3.10.2", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -11102,7 +11083,7 @@ }, "packages/plugins/finance": { "name": "@magmacomputing/tempo-plugin-finance", - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "devDependencies": { "@magmacomputing/tempo": "^3.9.0", @@ -11380,6 +11361,17 @@ } } }, + "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", @@ -11400,7 +11392,7 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "3.10.1", + "version": "3.10.2", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -11418,8 +11410,6 @@ "javascript-obfuscator": "^5.4.3", "magic-string": "^0.30.21", "typedoc": "^0.28.19", - "typedoc-plugin-markdown": "^4.11.0", - "typedoc-vitepress-theme": "^1.1.2", "vitepress": "^1.6.4" }, "engines": { diff --git a/package.json b/package.json index 43c20a9e..1b330e4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.10.1", + "version": "3.10.2", "private": true, "engines": { "node": ">=20.0.0" diff --git a/packages/library/CHANGELOG.md b/packages/library/CHANGELOG.md index 1b3c5a26..1282f066 100644 --- a/packages/library/CHANGELOG.md +++ b/packages/library/CHANGELOG.md @@ -5,6 +5,11 @@ 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.10.2] - 2026-07-25 + +### Fixed +- **Enumify Prototype Integrity**: Hardened the calling context check in the `enumify` constructor to explicitly verify `isFunction(this?.has)`, preventing invalid `Module` objects from corrupting the prototype chain during extension. + ## [3.0.0] - 2026-06-07 ### Added diff --git a/packages/library/package.json b/packages/library/package.json index db5fbcbf..045c08b1 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.10.1", + "version": "3.10.2", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/library/src/common/enumerate.library.ts b/packages/library/src/common/enumerate.library.ts index f6c841ea..a5e007e5 100644 --- a/packages/library/src/common/enumerate.library.ts +++ b/packages/library/src/common/enumerate.library.ts @@ -1,5 +1,5 @@ import { asType, getType } from '#library/type.library.js'; -import { isNumber } from '#library/assertion.library.js'; +import { isNumber, isFunction } from '#library/assertion.library.js'; import { ownEntries } from '#library/primitive.library.js'; import { secure, proxify } from '#library/proxy.library.js'; import { Serializable } from '#library/class.library.js'; @@ -70,27 +70,25 @@ function value(val: any) { } /** - * Creates a Proxy-based Registry (Enum) from an Object or Array. - * Enums are immutable (frozen) and provide methods for iteration, search, and extension. - * Arrays are converted to zero-indexed objects (e.g., `['A']` becomes `{ A: 0 }`). + * # Enumify + * create a Proxy-based Registry (Enum) from an Object or Array. + * Enums are immutable (frozen) and provide methods for iteration, search, and extension. * - * @param list - The array or object to convert into an Enum - * @param frozen - Whether to freeze the resulting Enum (default: true) - * @returns An immutable Enumify registry object * @example - * ```ts + * ```typescript * const Status = enumify(['Active', 'Inactive', 'Pending']); - * console.log(Status.Active); // 0 - * console.log(Status.has('Active'));// true - * console.log(Status.keys()); // ['Active', 'Inactive', 'Pending'] + * console.log(Status.Active); // 0 + * console.log(Status.has('Active')); // true + * console.log(Status.keys()); // ['Active', 'Inactive', 'Pending'] * ``` */ export function enumify(list: T, frozen?: boolean): Enum.wrap>; export function enumify>(list: T, frozen?: boolean): Enum.wrap; export function enumify(this: any, list: T, frozen = true): any { - const proto = (this && getType(this) !== 'Module') ? this : ENUM; - const target = Object.create(proto); + const type = getType(this); + const proto = (type !== 'Module' && isFunction(this?.has)) ? this : ENUM; const arg = asType(list); + const target = Object.create(proto); switch (arg.type) { case 'Enumify': @@ -113,10 +111,7 @@ export function enumify(this: any, list: T, frozen = true): any { return proxify(target, true, frozen); // proxy is ALWAYS frozen (read-only), but target is only 'locked' if requested } -/** - * A class wrapper for Enumify to register it with the serialization system. - * Allows Enums to be properly serialized and deserialized. - */ +/** create an entry in the Serialization Registry to describe how to rebuild an Enum */ @Serializable export class Enumify { constructor(list: Property) { diff --git a/packages/library/test/common/enumerate.test.ts b/packages/library/test/common/enumerate.test.ts index 8680f0ec..8b7cfaf5 100644 --- a/packages/library/test/common/enumerate.test.ts +++ b/packages/library/test/common/enumerate.test.ts @@ -46,16 +46,53 @@ describe('enumify stealth proxy', () => { expect(EXTENDED.values()).toEqual([1, 20, 3]); }); - it('should support Symbol keys in enums', () => { - const sym = Symbol('test'); - const MyEnum = enumify({ - [sym]: 'symbol-value', - standard: 'string-value' - }); - - expect(MyEnum.keys()).toContain(sym); - expect(MyEnum.has(sym)).toBe(true); - expect((MyEnum as any)[sym]).toBe('symbol-value'); - expect(MyEnum.entries().find(([key]) => key === sym)).toBeDefined(); - }); + it('should support Symbol keys in enums', () => { + const sym = Symbol('test'); + const MyEnum = enumify({ + [sym]: 'symbol-value', + standard: 'string-value' + }); + + expect(MyEnum.keys()).toContain(sym); + expect(MyEnum.has(sym)).toBe(true); + expect((MyEnum as any)[sym]).toBe('symbol-value'); + expect(MyEnum.entries().find(([key]) => key === sym)).toBeDefined(); + }); + + describe('caller-context branching', () => { + it('should use safe enum prototype when called with invalid Module context', () => { + const invalidModuleContext = Object.create(null, { + [Symbol.toStringTag]: { value: 'Module' }, + has: { value: () => true } + }); + + const result = enumify.call(invalidModuleContext, { A: 1, B: 2 }); + + expect(result.A).toBe(1); + expect(result.B).toBe(2); + expect(result.keys()).toEqual(['A', 'B']); + expect(result.values()).toEqual([1, 2]); + expect(result.has('A')).toBe(true); + expect(result.count()).toBe(2); + expect(Object.getPrototypeOf(result)).not.toBe(invalidModuleContext); + }); + + it('should inherit and expose expected enum methods during normal enum extend flow', () => { + const BASE = enumify({ A: 1, B: 2 }); + const EXTENDED = BASE.extend({ C: 3 }); + + expect(EXTENDED.A).toBe(1); + expect(EXTENDED.B).toBe(2); + expect(EXTENDED.C).toBe(3); + expect(EXTENDED.keys()).toEqual(['A', 'B', 'C']); + expect(EXTENDED.values()).toEqual([1, 2, 3]); + expect(EXTENDED.entries()).toEqual([['A', 1], ['B', 2], ['C', 3]]); + expect(EXTENDED.has('A')).toBe(true); + expect(EXTENDED.has('C')).toBe(true); + expect(EXTENDED.count()).toBe(3); + expect(EXTENDED.invert()).toEqual({ '1': 'A', '2': 'B', '3': 'C' }); + expect(typeof EXTENDED.extend).toBe('function'); + }); + }); }); + diff --git a/packages/plugins/.setup/catalog.json b/packages/plugins/.setup/catalog.json index 3b4938f7..c50a0046 100644 --- a/packages/plugins/.setup/catalog.json +++ b/packages/plugins/.setup/catalog.json @@ -39,6 +39,14 @@ "plan": "community", "status": "active" }, + { + "id": "parseAI", + "name": "ParseAI Plugin", + "description": "Tempo community plugin for LLM-powered natural language parsing.", + "packageName": "@magmacomputing/tempo-plugin-ai", + "plan": "community", + "status": "experimental" + }, { "id": "ticker", "name": "Ticker Plugin", diff --git a/packages/plugins/.setup/community-plugin-template.md b/packages/plugins/.setup/community-plugin-template.md index 863e26fe..6fd43fb8 100644 --- a/packages/plugins/.setup/community-plugin-template.md +++ b/packages/plugins/.setup/community-plugin-template.md @@ -36,7 +36,7 @@ Ensure the plugin's `package.json` contains the correct community configuration: } ``` - **Scripts**: - - Ensure `"build": "tsup && tsc"` and `"postbuild": "rm -rf dist/src"` are present. + - Ensure `"build": "tsup && tsc"` is present. - Include the prepublish safeguard: `"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"`. - Include the correct test script: `"test": "vitest run -c ../vitest.shared.ts"`. - **Keywords**: Ensure relevant keywords are present (`tempo`, `tempo-plugin`, `magmacomputing`, `temporal`, `plugin`, etc.). @@ -56,6 +56,9 @@ export default defineConfig({ }); ``` +> [!CAUTION] +> **Never manually override the `format` property** in your `tsup.config.ts` (e.g., `format: ['esm', 'cjs']`). The monorepo's `sharedConfig` is specifically tailored to generate strict ES Modules (`.js`) and Browser IIFE bundles (`.global.min.js`). Adding `'cjs'` will cause the build pipeline to silently overwrite your ESM bundle, breaking Node.js module resolution for users! + And a root `tsconfig.json` that outputs type declarations: ```json @@ -109,3 +112,25 @@ Community plugins must follow a uniform documentation standard. - Rely strictly on open core extensions (`definePlugin`, `defineTerm`). - While optional, it is highly recommended to provide a short `description` when using `defineTerm` (e.g., `description: 'My custom term'`) so it appears in the `Tempo.terms` registry. + +## 6. TypeScript Documentation (TSDoc) + +All exported components (functions, interfaces, classes, and types) must be properly documented using the standard Magma TSDoc format. This ensures rich intellisense tooltips for developers utilizing the plugin. + +### Format Rules +- Start the block with `/**` +- Provide a markdown header containing the component name (e.g., `* ## MyComponent`) +- Include a descriptive summary +- Document all parameters using `@param` and return types using `@returns` + +**Example:** +```typescript +/** + * ## myExportedFunction + * A brief description of what this function does. + * + * @param input - The input value to process + * @returns The successfully processed result + */ +export function myExportedFunction(input: string): string { ... } +``` diff --git a/packages/plugins/parseAI/CHANGELOG.md b/packages/plugins/parseAI/CHANGELOG.md new file mode 100644 index 00000000..30a2780b --- /dev/null +++ b/packages/plugins/parseAI/CHANGELOG.md @@ -0,0 +1,6 @@ +# @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/LICENSE b/packages/plugins/parseAI/LICENSE new file mode 100644 index 00000000..dd7db2d9 --- /dev/null +++ b/packages/plugins/parseAI/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Magma Computing + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/plugins/parseAI/README.md b/packages/plugins/parseAI/README.md new file mode 100644 index 00000000..4b880d68 --- /dev/null +++ b/packages/plugins/parseAI/README.md @@ -0,0 +1,44 @@ +![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 new file mode 100644 index 00000000..d564cd48 --- /dev/null +++ b/packages/plugins/parseAI/doc/architecture.md @@ -0,0 +1,67 @@ +# 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/context.md b/packages/plugins/parseAI/doc/context.md new file mode 100644 index 00000000..5615d50b --- /dev/null +++ b/packages/plugins/parseAI/doc/context.md @@ -0,0 +1,43 @@ +# Context & Natural Language Parsing + +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. + +## Geographic Context + +The plugin automatically reads from the global `Tempo.config` to fetch the default TimeZone, Calendar, and Locale, and establishes the "current anchor time" the moment you call it. + +Along with your string, the plugin passes a hidden context payload to the LLM: +*`Current Time: [Anchor], Timezone: [TZ], Calendar: [Cal], Locale: [Locale], Hemisphere: [Sphere]`* + +### Overriding Context +You can explicitly override any of these global settings on a per-request basis by passing an `options` object as the second argument, identical to how you pass options to a standard `new Tempo()` constructor: + +```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 parse assuming a Japanese locale and timezone +const tokyoDt = await parseAI("The day after tomorrow", { locale: 'ja-JP', timeZone: 'Asia/Tokyo' }); +``` + +### Why Locale is Critical +Passing the `Locale` is absolutely critical for the LLM to know whether "11/12" means November 12th (US format) or 11th of December (UK/EU format). The plugin handles this transparently based on your standard Tempo configuration! + +> [!WARNING] +> **Calendar Math Hallucinations**: LLMs are language predictors, not calculators. While they excel at parsing conversational times (like `"tomorrow at 5pm"`), smaller open-source models (like 8B parameter variants) are notoriously bad at complex, cross-year calendar math. For example, asking an 8B model for `"Thanksgiving in 2026"` will often result in a hallucinated day of the week because the model doesn't natively compute "the fourth Thursday of November 2026." If your application relies on heavy holiday logic or complex multi-year math, you *must* use a frontier model (like `gpt-4o` or `claude-3.5-sonnet`) or rely on deterministic plugins instead of AI. + +## The Decoupled Output Bridge + +To ensure deterministic behavior, the LLM is instructed to *only* return strict ISO 8601 strings. + +The plugin executes the network request, the LLM returns a local ISO string without a timezone offset or 'Z' suffix (like `"2026-11-26T00:00:00"`), and the plugin immediately passes that string back into the native `new Tempo()` constructor. The provider response must omit timezone suffixes to match the local ISO contract enforced by `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. + +### Relative Date Ambiguity Tie-Breakers + +To eliminate model variance on idioms like "Next Friday" or "Last Tuesday", the plugin enforces static system prompt ambiguity rules: +* `"next [weekday/unit]"`: Evaluated as the immediate next chronological occurrence after `Current Time`. +* `"last [weekday/unit]"` / `"previous [weekday/unit]"`: Evaluated as the most recent past occurrence prior to `Current Time`. +* `"this [weekday]"`: Evaluated as the occurrence within the current calendar week containing `Current Time`. + diff --git a/packages/plugins/parseAI/doc/index.md b/packages/plugins/parseAI/doc/index.md new file mode 100644 index 00000000..a61254f9 --- /dev/null +++ b/packages/plugins/parseAI/doc/index.md @@ -0,0 +1,81 @@ +![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/doc/rate-limits.md b/packages/plugins/parseAI/doc/rate-limits.md new file mode 100644 index 00000000..6a0815a1 --- /dev/null +++ b/packages/plugins/parseAI/doc/rate-limits.md @@ -0,0 +1,94 @@ +# Rate Limits & Cache Management + +When using third-party AI APIs, your application is subject to strict rate limits. + +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()`. + +```typescript +import { getAiRateLimits } from '@magmacomputing/tempo-plugin-ai'; + +// Returns the stats from the most recent LLM proxy request +const stats = getAiRateLimits(); + +if (stats) { + console.log(`Remaining Tokens: ${stats.remainingTokens}`); + console.log(`Remaining Requests: ${stats.remainingRequests}`); + console.log(`Limits Reset At: ${stats.resetAt.format('{hh}:{mi}:{ss}')}`); +} +``` + +## Handling Quota Exhaustion (429s) + +If you actually exhaust your quota and the provider rejects the request (e.g., HTTP 429 Too Many Requests), the plugin will instantly attempt to failover to the next provider in your configuration array. + +If all providers fail, the plugin will throw a `TempoAiError`. This custom error class includes a highly valuable `retryAt` property: + +```typescript +import { parseAI, TempoAiError } from '@magmacomputing/tempo-plugin-ai'; + +try { + const dt = await parseAI("The third Friday of next month"); +} catch (error) { + if (error instanceof TempoAiError && error.code === 429) { + // Safely queue the remaining batch of dates until your minute-limit resets! + console.warn(`All API quotas exhausted. Retry after: ${error.retryAt}`); + } +} +``` + +## Cache Management + +By default, the plugin maintains an internal `Map` of strings to their respective ISO 8601 results to drastically reduce LLM API calls and latency on repetitive queries. + +### 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. + +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. +2. **Token Economics**: A single request consumes ~100 tokens (System Prompt + User String + Output ISO). Given that frontier models cost pennies per million tokens, the risk of array-misalignment bugs (see below) far outweighs the negligible savings of batching system prompts. +3. **Deterministic Safety**: LLMs are language models, not arrays. If you pass 50 strings, smaller models often hallucinate and return 49 strings, completely breaking your array indexing. By querying sequentially, we guarantee a strict 1:1 mapping and ensure one invalid string doesn't crash the entire batch. + +> [!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. + +### 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: + +```typescript +// The LLM will ALWAYS be queried, and the result will NOT be cached +const dt = await parseAI("in 5 minutes", { cache: false }); +``` + +### Evicting Bad Parses +If the LLM hallucinates or returns an incorrect absolute date, you can explicitly purge the string from the cache: + +```typescript +import { clearAiCache } from '@magmacomputing/tempo-plugin-ai'; + +// Evict a single string +clearAiCache("2nd tuesday in nov"); +``` + +### Forcing a Refresh +If you want to explicitly query the LLM again and *overwrite* the existing cache entry with the new result, use the `force: true` flag: + +```typescript +const dt = await parseAI("Q3_START", { force: true }); +``` + +### Extensible Caching (Enterprise) +For edge environments or custom application architectures, the plugin supports custom cache implementations! + +You can provide any object that implements the standard **synchronous** `Map` interface (`get`, `set`, `has`, `delete`). Note that all cache adapter methods must execute synchronously, as the internal cache lookup engine does not await promise-returning cache operations. + +```typescript +// Custom synchronous cache implementation +initAI({ + providers: [{ id: 'groq', key: '...' }], + cache: new MyCustomSyncCache() +}); +``` diff --git a/packages/plugins/parseAI/package.json b/packages/plugins/parseAI/package.json new file mode 100644 index 00000000..ef26a586 --- /dev/null +++ b/packages/plugins/parseAI/package.json @@ -0,0 +1,50 @@ +{ + "name": "@magmacomputing/tempo-plugin-ai", + "version": "0.1.0", + "description": "Tempo community plugin for LLM-powered natural language parsing.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "MIT", + "files": [ + "dist", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "scripts": { + "build": "tsup && tsc", + "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" + }, + "tempo": { + "vendorVariantId": "tempo-plugin-ai", + "plan": "community" + }, + "peerDependencies": { + "@magmacomputing/tempo": "^3.10.2" + }, + "devDependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, + "keywords": [ + "tempo", + "tempo-plugin", + "magmacomputing", + "ai", + "parsing", + "llm", + "openai", + "gemini" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } +} \ No newline at end of file diff --git a/packages/plugins/parseAI/src/cache.ts b/packages/plugins/parseAI/src/cache.ts new file mode 100644 index 00000000..c5704c84 --- /dev/null +++ b/packages/plugins/parseAI/src/cache.ts @@ -0,0 +1,102 @@ +/** + * ## 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/error.ts b/packages/plugins/parseAI/src/error.ts new file mode 100644 index 00000000..fcaecabc --- /dev/null +++ b/packages/plugins/parseAI/src/error.ts @@ -0,0 +1,28 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +/** + * ## TempoAiError + * A specialized Error thrown during AI-driven parsing when network fetches fail, + * timeouts occur, or rate limits are exceeded. + */ +export class TempoAiError extends Error { + /** HTTP Status Code (e.g., 429, 500) */ + #code: number; + /** A Tempo instance representing the rate limit reset time (extracted from Headers) */ + #retryAt?: Tempo | undefined; + + constructor(message: string, code: number, retryAt?: Tempo) { + super(message); + this.name = 'TempoAiError'; + this.#code = code; + this.#retryAt = retryAt; + } + + get code(): number { + return this.#code; + } + + get retryAt(): Tempo | undefined { + return this.#retryAt; + } +} diff --git a/packages/plugins/parseAI/src/index.ts b/packages/plugins/parseAI/src/index.ts new file mode 100644 index 00000000..279a1226 --- /dev/null +++ b/packages/plugins/parseAI/src/index.ts @@ -0,0 +1,334 @@ +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 new file mode 100644 index 00000000..f3573e54 --- /dev/null +++ b/packages/plugins/parseAI/src/parseAI.type.ts @@ -0,0 +1,51 @@ +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 new file mode 100644 index 00000000..e1511008 --- /dev/null +++ b/packages/plugins/parseAI/test/index.spec.ts @@ -0,0 +1,254 @@ +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/parseAI/test/tsconfig.json b/packages/plugins/parseAI/test/tsconfig.json new file mode 100644 index 00000000..642ee082 --- /dev/null +++ b/packages/plugins/parseAI/test/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.test.json", + "include": [ + "**/*.ts" + ] +} diff --git a/packages/plugins/parseAI/tsconfig.json b/packages/plugins/parseAI/tsconfig.json new file mode 100644 index 00000000..e14056b1 --- /dev/null +++ b/packages/plugins/parseAI/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.shared.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "emitDeclarationOnly": true, + "types": ["node"] + }, + "include": [ + "src" + ] +} diff --git a/packages/plugins/parseAI/tsup.config.ts b/packages/plugins/parseAI/tsup.config.ts new file mode 100644 index 00000000..62bd4b2b --- /dev/null +++ b/packages/plugins/parseAI/tsup.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsup'; +import { sharedConfig } from '../tsup.shared.js'; + +export default defineConfig({ + ...sharedConfig, + entry: ['src/index.ts'], +}); diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 1b1b429e..7fbeb239 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -11,8 +11,6 @@ if (typeof (globalThis as any).Temporal === 'undefined') { }); } -import typedocSidebar from '../doc/api/typedoc-sidebar.json' -import librarySidebar from '../doc/api/library/typedoc-sidebar.json' export default defineConfig({ base: '/magma/', @@ -30,7 +28,7 @@ export default defineConfig({ }, nav: [ { text: 'Guide', link: '/README' }, - { text: 'API Reference', link: typedocSidebar[0].items[0].link }, + { text: 'API Reference', link: '/api/index.html', target: '_self' }, { text: 'Releases', link: '/doc/8-project-and-support/releases/' }, { text: 'Functions ↗', link: 'https://magmacomputing.github.io/magma/functions/' } ], @@ -72,7 +70,7 @@ export default defineConfig({ text: 'Advanced Reference', items: [ { text: 'API Overview', link: '/doc/api/' }, - { text: 'Technical Reference', link: typedocSidebar[0].items[0].link }, + { text: 'Technical Reference', link: '/api/index.html', target: '_self' }, { text: 'Sandbox Factory', link: '/doc/4-advanced-reference/sandbox-factory' }, { text: 'Parse Planner', link: '/doc/4-advanced-reference/tempo.planner' }, { text: 'The Role of Locale', link: '/doc/4-advanced-reference/tempo.locale' }, @@ -98,11 +96,7 @@ export default defineConfig({ { text: 'Serializers', link: '/doc/6-utility-library/tempo.serializers' }, { text: 'Decorators', link: '/doc/6-utility-library/tempo.decorators' }, { text: 'Advanced Promises (Pledge)', link: '/doc/6-utility-library/tempo.pledge' }, - { - text: 'Library API', - collapsed: true, - items: librarySidebar - } + { text: 'Library API', link: '/api/library/index.html', target: '_self' } ] }, { diff --git a/packages/tempo/.vitepress/theme/components/CatalogList.vue b/packages/tempo/.vitepress/theme/components/CatalogList.vue index 0016f77c..9940be1e 100644 --- a/packages/tempo/.vitepress/theme/components/CatalogList.vue +++ b/packages/tempo/.vitepress/theme/components/CatalogList.vue @@ -16,8 +16,8 @@ import catalogData from '../data/catalog.json'; const plugins = ref(catalogData as unknown as Plugin[]); -const communityPlugins = computed(() => plugins.value.filter(p => p.plan === 'community' && p.status === 'active')); -const premiumPlugins = computed(() => plugins.value.filter(p => p.plan !== 'community' && p.status === 'active')); +const communityPlugins = computed(() => plugins.value.filter(p => p.plan === 'community' && (p.status === 'active' || p.status === 'experimental'))); +const premiumPlugins = computed(() => plugins.value.filter(p => p.plan !== 'community' && (p.status === 'active' || p.status === 'experimental'))); const comingSoonPlugins = computed(() => plugins.value.filter(p => p.status === 'coming_soon')); const copiedPkg = ref(null); @@ -39,7 +39,8 @@ const copyInstall = (pkgName: string) => {

Community Plugins

These plugins are free, open-source extensions that do not require a license token.

-
+
+
Experimental

{{ plugin.name }}

v{{ plugin.version }} @@ -75,8 +76,9 @@ const copyInstall = (pkgName: string) => {
-
+
Premium
+
Experimental

{{ plugin.name }}

v{{ plugin.version }} @@ -100,7 +102,8 @@ const copyInstall = (pkgName: string) => {

Coming Soon

-
+
+
Experimental

{{ plugin.name }}

v{{ plugin.version }} @@ -197,6 +200,9 @@ const copyInstall = (pkgName: string) => { border-color: var(--vp-c-brand); box-shadow: 0 4px 12px rgba(0,0,0,0.1); } +.experimental-card { + border-color: var(--vp-c-warning-1, #f59e0b); +} .badge { position: absolute; top: -10px; @@ -208,6 +214,9 @@ const copyInstall = (pkgName: string) => { font-size: 0.75rem; font-weight: bold; } +.experimental-badge { + background: var(--vp-c-warning-1, #f59e0b); +} .btn-secondary { background-color: var(--vp-c-bg-soft); color: var(--vp-c-text-1); diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json index 74a8bbe1..8e560242 100644 --- a/packages/tempo/.vitepress/theme/data/catalog.json +++ b/packages/tempo/.vitepress/theme/data/catalog.json @@ -44,6 +44,15 @@ "status": "active", "version": "1.0.3" }, + { + "id": "parseAI", + "name": "ParseAI Plugin", + "description": "Tempo community plugin for LLM-powered natural language parsing.", + "packageName": "@magmacomputing/tempo-plugin-ai", + "plan": "community", + "status": "experimental", + "version": "0.1.0" + }, { "id": "ticker", "name": "Ticker Plugin", diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index fb589dcc..5edb1004 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,11 @@ 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.10.2] - 2026-07-25 + +### Added +- **Silent Config Flag**: Added `silent: false` to the default configuration and `CONFIG` enum. This allows upstream plugins (like `parseAI`) to attempt native parsing without triggering verbose console errors when a parse inevitably fails. + ## [3.10.1] - 2026-07-20 ### Changed diff --git a/packages/tempo/doc/8-project-and-support/releases/v3.x.md b/packages/tempo/doc/8-project-and-support/releases/v3.x.md index a5727034..d8d526f0 100644 --- a/packages/tempo/doc/8-project-and-support/releases/v3.x.md +++ b/packages/tempo/doc/8-project-and-support/releases/v3.x.md @@ -1,5 +1,18 @@ # 📜 Version 3.x History +## [v3.10.2] - 2026-07-25 + +### ✨ Experimental AI Parsing +- **`parseAI` Plugin**: Officially introduced the `@magmacomputing/tempo-plugin-ai` experimental plugin for natural language date parsing via LLMs. +- **Observability & Control**: Added `debug: true` (development-only verbose logging of system prompts, localized context, and raw LLM responses; enable only with non-sensitive inputs) and `force: true` configuration flags to the AI plugin to easily monitor CoT (Chain-of-Thought) JSON schemas and bypass native parsing caching layers. + +### 🪲 Bug Fixes & Stability +- **Silent Native Parsing**: Added a new `silent: false` configuration flag to Tempo Core (`Tempo.init({ silent: true })`). When enabled, this cleanly suppresses internal console error logging when native parsing throws a `TempoError`, providing a pristine terminal experience when falling back to AI parsers. +- **Prototype Integrity**: Hardened the internal `enumify` registry constructor in `@magmacomputing/library` to strictly verify its execution context (`isFunction(this?.has)`). This prevents prototype corruption when resolving ES Modules across mixed bundler environments. +- **Test Environment Collisions**: Resolved an isolated bug in Vitest workspace orchestration where duplicate core monorepo instances were loaded into memory, causing false-positive failures in plugin extensions (such as `BatchPlugin`). + +--- + ## [v3.10.0] - 2026-07-19 ### ✨ What's New diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 8c3d2abd..8bc38c57 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.10.1", + "version": "3.10.2", "engines": { "node": ">=20.0.0" }, @@ -258,8 +258,6 @@ "javascript-obfuscator": "^5.4.3", "magic-string": "^0.30.21", "typedoc": "^0.28.19", - "typedoc-plugin-markdown": "^4.11.0", - "typedoc-vitepress-theme": "^1.1.2", "vitepress": "^1.6.4" }, "directories": { diff --git a/packages/tempo/plan/ordinal-offset-parsing.md b/packages/tempo/plan/ordinal-offset-parsing.md new file mode 100644 index 00000000..306d4e82 --- /dev/null +++ b/packages/tempo/plan/ordinal-offset-parsing.md @@ -0,0 +1,75 @@ +> [!WARNING] +> **SHELVED: THOUGHT-EXPERIMENT ONLY** +> This document is a conceptual thought-experiment exploring the architecture for ordinal offset parsing. It is currently shelved and is **not** a definite plan for any future release. + +# Ordinal Offset Parsing Strategy + +**Goal**: Investigate the feasibility and architecture required to allow Tempo to parse "Ordinal Offset" queries, such as "1st day of May", "3rd Thursday of November", or "3rd day of #qtr.2". + +## 1. Core Concepts & Use Cases + +An "Ordinal Offset" query can be broken down into three logical components: +1. **The Nth Index** (`{nth}`): A numeric value (1st, 2nd, 3rd, 100th) or semantic keyword ("last" = -1). +2. **The Constraint** (`{wkd}` or `{unt}`): The unit being counted (e.g., "Thursday", "day", "week"). +3. **The Boundary** (`{mm}{yy}` or `{slk}`): The context in which the counting occurs (e.g., "November", "2026", "#qtr.2", "month"). + +**Primary Use Cases**: +- **Nth Unit of Term/Month**: "3rd day of #qtr", "100th day of year", "last day of May" +- **Nth Weekday of Term/Month**: "3rd Thursday of November", "1st Sunday of #szn", "last Friday of month" + +## 2. Proposed Architecture & Additions + +To natively support this without breaking the existing Regex Layout engine, we would need to introduce the following: + +### A. New Snippets (Lexical Tokens) +Currently, `{dd}` only captures `01 - 31`. For ordinal counts, we need a dedicated numeric index capture that also understands semantic time-shifting words. +- `[Token.nth]`: `/(?(?:[1-9][0-9]{0,2}){ord}?|first|last|next|prev)/` + *(Matches: '3rd', '100th', 'last', 'first', 'next', 'prev')* + +### B. New Layouts (Pattern Combinations) +We combine our tokens into new ordinal-specific layouts, omitting the word "of" since it is already stripped by the `Ignore` array. +- `[Token.ordWkdBound]`: `'{nth} {wkd} ({mm}({sep}?{yy})?|(?{slk}))'` + *(Matches: "3rd Thursday November", "last Friday #qtr")* +- `[Token.ordUntBound]`: `'{nth} {unt} ({mm}({sep}?{yy})?|(?{slk}))'` + *(Matches: "100th day 2026", "3rd day #qtr.2")* + +### C. Resolution Engine (Temporal Math) +When `engine.lexer.ts` successfully extracts these groups, the `Tempo` parser engine will delegate the arithmetic to the underlying `Temporal` API. + +**Example 1: "3rd Thursday of November 2026"** +1. **Resolve Boundary**: Engine parses "November 2026" and creates an `Interval` bounding start/end. +2. **Find Anchor**: Determine the date of the *first* Thursday within that month. +3. **Shift**: Add `(3 - 1) = 2` weeks to that anchor. +4. **Validate**: Assert that the resulting date still falls within the November boundary (e.g., there might not be a 5th Thursday). + +**Example 2: "last day of #qtr.2"** +1. **Resolve Boundary**: Delegate to `module.term.ts` to get the `Interval` for Quarter 2. +2. **Shift**: Because `nth` is "last" (-1), start at the `Interval.end` and subtract 1 day. + +## 3. Challenges & Considerations + +1. **The Word "of"**: Currently, "of" is in the global `Ignore` array and is blindly stripped. This is great for parsing, but we must ensure stripping "of" doesn't accidentally collide with other natural language formats. +2. **Boundary Validation**: A query like "100th day of February" or "6th Friday of May" is mathematically impossible. The engine would need to gracefully catch the out-of-bounds result and throw a meaningful `TempoError`. +3. **Slick Math Collisions**: Slick Math currently supports shifting (e.g., `#qtr > 3d`). Ordinal parsing introduces an alternative way to express the same intent in natural language. We'd need to ensure the AST doesn't confuse `3rd day of #qtr` with `#qtr > 3d`. + +## 4. Impact Analysis & Tempo-Ethos + +When proposing any new feature, we must evaluate the architectural blast radius: + +### Estimated Impact +- **Lines of Code**: ~50-80 lines of new logic. +- **Modules Affected**: + - `support.default.ts` (adding 1 Token, 2 Layouts) + - `support.symbol.ts` (adding the `ordWkdBound` and `ordUntBound` symbols) + - `engine.normalizer.ts` or `module.mutate.ts` (adding the AST resolution branch for these new layouts). + +### Opt-in vs. Auto-provided (Core vs. Plugin) +**Recommendation**: Make it a core feature (Auto-provided). +- *Why?* Tempo's ethos heavily favors keeping date-math and natural language parsing in the core engine out-of-the-box, ensuring that fundamental time-expressions work universally without users having to hunt for plugins. Since this only requires two new lightweight layouts and leverages the existing AST parsing tree, the bundle-size impact is negligible, and it aligns perfectly with the "it just works" philosophy of Tempo. + +### Alignment with Tempo-Ethos +This fits beautifully into the existing ethos. The core `Snippet -> Layout -> Pattern` pipeline was specifically designed so that new natural-language expressions could be added declaratively without writing brittle procedural parsing code. Expanding `{nth}` to gracefully handle `first`, `last`, `next`, and `prev` directly mirrors our existing robust keyword support in other areas (like `[Token.afx]`). + +## Conclusion +**Can we do it?** Yes, absolutely. The current architecture is already perfectly shaped for it. +**Should we do it?** It adds significant natural-language capability for developers building scheduling apps (e.g., "Schedule meeting for 1st Monday of every Month"), with a very low architectural impact and high alignment with Tempo's design philosophy. diff --git a/packages/tempo/src/interval.class.ts b/packages/tempo/src/interval.class.ts index 1eb45411..f2520713 100644 --- a/packages/tempo/src/interval.class.ts +++ b/packages/tempo/src/interval.class.ts @@ -11,7 +11,7 @@ function getNs(point: TemporalPoint | unknown): bigint { throw new TypeError('Invalid TemporalPoint: missing epoch.ns or epochNanoseconds'); } -/** Represents a continuous span of time with start and end boundaries, supporting set operations. */ +/** Represents a continuous span of time with optional start and end boundaries, supporting set operations. */ @Immutable export class Interval { readonly #start: T | null; diff --git a/packages/tempo/src/support/support.default.ts b/packages/tempo/src/support/support.default.ts index fa25b7dc..9a4669ec 100644 --- a/packages/tempo/src/support/support.default.ts +++ b/packages/tempo/src/support/support.default.ts @@ -212,6 +212,7 @@ export const IntlDefault: IntlOptions = { export const Default = secure({ /** log to console */ debug: LOG.Info, /** catch or throw Errors */ catch: false, + /** suppress console output during catch */ silent: false, /** initialization strategy (auto | strict | defer) */ mode: MODE.Auto, /** used to parse two-digit years*/ pivot: 75, /** @link https: //en.wikipedia.org/wiki/Date_windowing */ /** precision to measure timestamps (ss|ms|us|ns) */ timeStamp: 'ms', diff --git a/packages/tempo/src/support/support.enum.ts b/packages/tempo/src/support/support.enum.ts index c861b343..a677fb20 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', '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'] as const; export const CONFIG = enumify(configKeys, false); export type Config = KeyOf diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 01ad8e1b..6219f543 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -30,7 +30,7 @@ import { validateLicenseState, getLicenseSnapshot, setLicense, getLicenseState } 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, 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, logError, logDebug, logWarn, logTempo, setLogLevel } 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.*) @@ -145,8 +145,9 @@ export class Tempo { } /** mapping of terms to their resolved values */ static #termMap: Map = new Map(); - /** Master Guard predicate (implements RegExp-like interface) */static get [$guard]() { return (this[$Internal]() as any)[$guard] ?? { test: () => true }; } + /** @internal Master Guard predicate (implements RegExp-like interface) */static get [$guard]() { return (this[$Internal]() as any)[$guard] ?? { test: () => true }; } + /** @internal */ static [$IsBase] = true; /** @internal Static access to global private state. */ @@ -154,7 +155,8 @@ export class Tempo { return ClassStates.get(this) ?? _global; } - static get $ImmutableSkip() { + /** @internal */ + static get [$ImmutableSkip]() { const global = typeof globalThis !== 'undefined' ? globalThis : (window as any); const nodeEnv = typeof global !== 'undefined' && typeof global.process !== 'undefined' @@ -171,6 +173,7 @@ export class Tempo { /** + * @internal * {dt} is a layout that combines date-related {snippets} (e.g. dd, mm -or- evt) into a pattern against which a string can be tested. * because it will also include a list of events (e.g. 'new_years' | 'xmas'), we need to rebuild {dt} if the user adds a new event */ @@ -226,11 +229,13 @@ export class Tempo { } } + /** @internal */ static [$setEvents](shape: Internal.State, provided?: [string, any][], rebuild = true) { this[$setAliases](shape, 'evt', Token.evt, provided); if (rebuild) setPatterns(shape); } + /** @internal */ static [$setPeriods](shape: Internal.State, provided?: [string, any][], rebuild = true) { this[$setAliases](shape, 'per', Token.per, provided); if (rebuild) setPatterns(shape); @@ -327,6 +332,7 @@ export class Tempo { * conform input of Snippet / Layout / Event / Period options * This is needed because we allow the user to flexibly provide detail as {[key]:val} or {[key]:val}[] or [key,val][] */ + /** @internal */ static [$setConfig](shape: Internal.State, ...options: t.Options[]) { const providedOptions: t.Options = Object.assign({}, ...options); const storeKey = providedOptions.store; @@ -371,6 +377,7 @@ export class Tempo { } /** support "Global Discovery" of user-options */ + /** @internal */ static [$setDiscovery](shape: Internal.State, discovery?: Internal.Discovery) { if (!isObject(discovery)) return {} @@ -461,6 +468,7 @@ export class Tempo { return res; } + /** @internal */ static [$buildGuard](targetState?: Internal.State) { const state = targetState ?? this[$Internal](); // Note: We MUST use Object.keys() here instead of enums.XXX.keys() because this static guard @@ -1153,6 +1161,7 @@ export class Tempo { } /** allow instanceof to work across module boundaries via the local brand symbol */ + /** @internal */ static [$Identity] = true; static [Symbol.hasInstance](instance: any) { return isDefined(instance?.[$Identity]) @@ -1194,7 +1203,7 @@ export class Tempo { /** 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!: 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; @@ -1266,7 +1275,7 @@ export class Tempo { return ownEntries(this.#fmt, true)[Symbol.iterator](); // instance Iterator over tuple of FormatType[] } - get [Symbol.toStringTag]() { // default string description + get [Symbol.toStringTag](): 'Tempo' { // default string description return 'Tempo'; // hard-coded to avoid minification mangling } @@ -1614,7 +1623,7 @@ export class Tempo { } /** Keyed results for all resolved terms */ get term(): TempoTermRegistry { return this.#term } - /** Formatted results for all pre-defined format codes */ get fmt() { return this.#fmt } + /** Formatted results for all pre-defined format codes */ get fmt(): Record { return this.#fmt } /** units since epoch for this date-time instance */ get epoch() { return Tempo.#getEpoch(this.toDateTime()); } /** diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index 90956f91..7d22d5f3 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.1'; +export const TEMPO_VERSION = '3.10.2'; diff --git a/packages/tempo/typedoc.json b/packages/tempo/typedoc.json index 773e16d9..80b708a1 100644 --- a/packages/tempo/typedoc.json +++ b/packages/tempo/typedoc.json @@ -1,16 +1,11 @@ { + "name": "Tempo API", "entryPoints": ["src/tempo.index.ts"], - "out": "doc/api", - "plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"], - "hideBreadcrumbs": true, - "hidePageTitle": true, - "disableSources": true, - "parametersFormat": "table", - "outputFileStrategy": "members", - "flattenOutputFiles": true, - "expandObjects": true, - "useCodeBlocks": true, - "readme": "none", + "out": "public/api", + "plugin": [], "excludeInternal": true, - "githubPages": false + "navigationLinks": { + "API Overview": "/api/index.html", + "⬅ Back to Guides": "/magma/" + } } diff --git a/packages/tempo/typedoc.library.json b/packages/tempo/typedoc.library.json index 2028f638..6b1811ee 100644 --- a/packages/tempo/typedoc.library.json +++ b/packages/tempo/typedoc.library.json @@ -1,24 +1,15 @@ { + "name": "Library API", "entryPoints": [ "../library/src/common.index.ts", "../library/src/browser.index.ts", "../library/src/server.index.ts" ], - "out": "doc/api/library", + "out": "public/api/library", "tsconfig": "../library/tsconfig.json", - "plugin": [ - "typedoc-plugin-markdown", - "typedoc-vitepress-theme" - ], - "hideBreadcrumbs": true, - "hidePageTitle": true, - "disableSources": true, - "parametersFormat": "table", - "outputFileStrategy": "members", - "flattenOutputFiles": true, - "expandObjects": true, - "useCodeBlocks": true, - "readme": "none", + "plugin": [], "excludeInternal": true, - "githubPages": false + "navigationLinks": { + "⬅ Back to Guides": "/magma/" + } } \ No newline at end of file diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index 293eca74..a44fb150 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -83,6 +83,7 @@ export default defineConfig({ { find: /^@magmacomputing\/tempo\/term\/(.*)$/, replacement: resolve(__dirname, './dist/plugin/term/term.$1.js') }, { find: /^@magmacomputing\/tempo\/core$/, replacement: resolve(__dirname, './dist/core.index.js') }, { find: /^@magmacomputing\/tempo\/library$/, replacement: resolve(__dirname, './dist/library.index.js') }, + { find: /^@magmacomputing\/tempo$/, replacement: resolve(__dirname, './dist/tempo.index.js') }, ] : [ { find: /^#tempo\/license$/, replacement: isPremiumAvailable ? (licensePremium as string) : licenseDefault }, // Also alias the relative path used by the dynamic import in tempo.class.ts, so vi.mock('#tempo/license') intercepts it @@ -95,6 +96,7 @@ export default defineConfig({ { find: /^@magmacomputing\/tempo\/term\/(.*)$/, replacement: resolve(__dirname, './src/plugin/term/term.$1.ts') }, { find: /^@magmacomputing\/tempo\/core$/, replacement: resolve(__dirname, './src/core.index.ts') }, { find: /^@magmacomputing\/tempo\/library$/, replacement: resolve(__dirname, './src/library.index.ts') }, + { find: /^@magmacomputing\/tempo$/, replacement: resolve(__dirname, './src/tempo.index.ts') }, { find: /^#tempo\/core$/, replacement: resolve(__dirname, './src/core.index.ts') }, { find: /^#tempo\/term$/, replacement: resolve(__dirname, './src/plugin/term/term.index.ts') }, { find: /^#tempo\/term\/(.*)$/, replacement: resolve(__dirname, './src/plugin/term/$1') }, diff --git a/vitest.config.ts b/vitest.config.ts index b0bf6da8..1f1bf575 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -50,6 +50,7 @@ export default defineConfig({ } }, { + extends: './packages/tempo/vitest.config.ts', test: { name: 'Plugins', include: ['packages/plugins/*/test/**/*.{test,spec}.ts'],