From d6356edc0ed4ef44f0d10823ace552c24d662d0d Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 3 Aug 2026 11:15:30 +1000 Subject: [PATCH 01/23] 1st commit --- packages/plugins/.bin/check-branch-diff.sh | 40 ++++++++++++++++++++ packages/plugins/.bin/check-versions.sh | 43 ++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100755 packages/plugins/.bin/check-branch-diff.sh create mode 100755 packages/plugins/.bin/check-versions.sh diff --git a/packages/plugins/.bin/check-branch-diff.sh b/packages/plugins/.bin/check-branch-diff.sh new file mode 100755 index 00000000..e92e82c8 --- /dev/null +++ b/packages/plugins/.bin/check-branch-diff.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Compare plugin files in current branch against main branch to verify version bumps + +set -e + +MAIN_BRANCH="${1:-main}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" + +printf "%-25s | %-13s | %-14s | %-14s | %-30s\n" "Plugin Package" "Files Changed" "Main Version" "Branch Version" "Version Bump Status" +printf "%-25s-+-%-13s-+-%-14s-+-%-14s-+-%-30s\n" "-------------------------" "-------------" "--------------" "--------------" "------------------------------" + +for plugin_dir in "${REPO_ROOT}/packages/plugins"/*; do + if [ -d "${plugin_dir}" ] && [ -f "${plugin_dir}/package.json" ]; then + plugin_name=$(basename "${plugin_dir}") + rel_path="packages/plugins/${plugin_name}" + + # Count changed files in this plugin (excluding package.json) + changed_count=$(git diff --name-only "${MAIN_BRANCH}...HEAD" -- "${rel_path}" 2>/dev/null | grep -v "package.json" | wc -l || echo "0") + + # Read version from main branch + main_version=$(git show "${MAIN_BRANCH}:${rel_path}/package.json" 2>/dev/null | node --input-type=module -e "import fs from 'fs'; console.log(JSON.parse(fs.readFileSync(process.stdin.fd, 'utf8')).version)" 2>/dev/null || echo "[NEW]") + + # Read version from current working branch + branch_version=$(node --input-type=module -e "import fs from 'fs'; console.log(JSON.parse(fs.readFileSync(process.argv[2], 'utf8')).version)" dummy "${plugin_dir}/package.json" 2>/dev/null || echo "unknown") + + # Determine status + status="Clean (Unchanged)" + if [ "${main_version}" = "[NEW]" ]; then + status="🆕 New Plugin (v${branch_version})" + elif [ "${changed_count}" -gt 0 ]; then + if [ "${main_version}" = "${branch_version}" ]; then + status="🚨 MODIFIED WITHOUT VERSION BUMP!" + else + status="✅ Bumped (v${main_version} -> v${branch_version})" + fi + fi + + printf "%-25s | %-13s | %-14s | %-14s | %-30s\n" "${plugin_name}" "${changed_count}" "${main_version}" "${branch_version}" "${status}" + fi +done diff --git a/packages/plugins/.bin/check-versions.sh b/packages/plugins/.bin/check-versions.sh new file mode 100755 index 00000000..05747642 --- /dev/null +++ b/packages/plugins/.bin/check-versions.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Check published NPM versions vs local workspace versions for Tempo and plugins + +set -e + +packages=( + "tempo:packages/tempo/package.json" + "tempo-plugin-ai:packages/plugins/ai/package.json" + "tempo-plugin-astro:packages/plugins/astro/package.json" + "tempo-plugin-batch:packages/plugins/batch/package.json" + "tempo-plugin-finance:packages/plugins/finance/package.json" + "tempo-plugin-snap:packages/plugins/snap/package.json" + "tempo-plugin-sync:packages/plugins/sync/package.json" +) + +# Resolve repository root path (3 levels up from packages/plugins/.bin) +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" + +printf "%-38s | %-16s | %-16s | %-12s\n" "Package Name" "Published (NPM)" "Local Workspace" "Status" +printf "%-38s-+-%-16s-+-%-16s-+-%-12s\n" "--------------------------------------" "----------------" "----------------" "------------" + +for entry in "${packages[@]}"; do + pkg_name="${entry%%:*}" + rel_path="${entry#*:}" + full_npm_name="@magmacomputing/${pkg_name}" + + # Fetch published version from NPM registry + published_ver=$(npm view "${full_npm_name}" version 2>/dev/null || echo "not published") + + # Read local version from package.json + local_ver="unknown" + target_json="${REPO_ROOT}/${rel_path}" + if [ -f "${target_json}" ]; then + local_ver=$(node --input-type=module -e "import fs from 'fs'; console.log(JSON.parse(fs.readFileSync(process.argv[2], 'utf8')).version)" dummy "${target_json}" 2>/dev/null || echo "unknown") + fi + + status="Up to date" + if [ "${published_ver}" != "${local_ver}" ]; then + status="Out of sync" + fi + + printf "%-38s | %-16s | %-16s | %-12s\n" "${full_npm_name}" "${published_ver}" "${local_ver}" "${status}" +done From 9cfdaae789c41247dbd6409831ded5310244f06d Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 3 Aug 2026 16:37:43 +1000 Subject: [PATCH 02/23] catch-up --- package.json | 2 +- packages/library/package.json | 2 +- packages/tempo/CHANGELOG.md | 5 +++++ packages/tempo/package.json | 2 +- packages/tempo/src/support/support.cache.ts | 12 ++++++++++++ packages/tempo/src/tempo.version.ts | 2 +- packages/tempo/test/support/cache.test.ts | 14 +++++++++++++- 7 files changed, 34 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a960cd8a..90a1a6f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.11.0", + "version": "3.11.1", "private": true, "engines": { "node": ">=20.0.0" diff --git a/packages/library/package.json b/packages/library/package.json index 312b3090..0e3ec108 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.11.0", + "version": "3.11.1", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index a5c684d4..ba4699cb 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.11.1] - 2026-08-03 + +### Added +- **Cache Serialization (`toJSON`)**: Added native `toJSON()` serialization support to `BoundedCache` and the `Tempo.cache` facade object. Calling `Tempo.cache.toJSON()` or `JSON.stringify(Tempo.cache)` now cleanly converts active, non-expired in-memory cache entries into a plain key-value JavaScript object. + ## [3.11.0] - 2026-07-31 ### Added diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 03168496..9b53b523 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.11.0", + "version": "3.11.1", "engines": { "node": ">=20.0.0" }, diff --git a/packages/tempo/src/support/support.cache.ts b/packages/tempo/src/support/support.cache.ts index d863fa1c..72b4c9a4 100644 --- a/packages/tempo/src/support/support.cache.ts +++ b/packages/tempo/src/support/support.cache.ts @@ -186,6 +186,14 @@ export class BoundedCache extends Map { return super[Symbol.iterator](); } + /** + * Returns a plain key-value object of all active non-expired cache entries. + */ + toJSON(): Record { + this.evictExpired(); + return Object.fromEntries(this.entries()) as Record; + } + static fromEntries(entries: Iterable, maxSize = 1000, ttl = 24 * 60 * 60 * 1000): BoundedCache { const cache = new BoundedCache(maxSize, ttl); for (const [k, v] of entries) { @@ -238,6 +246,10 @@ export function createCacheFacade(getState: () => t.Internal.State) { getState().cache.set(normalized, String(v)); } return this; + }, + toJSON() { + return getState().cache.toJSON(); } }); } + diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index 89eb178b..04e3729c 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.11.0'; +export const TEMPO_VERSION = '3.11.1'; diff --git a/packages/tempo/test/support/cache.test.ts b/packages/tempo/test/support/cache.test.ts index 2f2c7f46..e47c4979 100644 --- a/packages/tempo/test/support/cache.test.ts +++ b/packages/tempo/test/support/cache.test.ts @@ -75,6 +75,14 @@ describe('Tempo Core Caching Architecture', () => { expect(cache.has('k2')).toBe(false); expect(cache.has('k3')).toBe(true); }); + it('should serialize to JSON object via toJSON()', () => { + const cache = new BoundedCache(10, 10000); + cache.set('k1', 'v1'); + cache.set('k2', 'v2'); + + expect(cache.toJSON()).toEqual({ k1: 'v1', k2: 'v2' }); + expect(JSON.stringify(cache)).toBe('{"k1":"v1","k2":"v2"}'); + }); }); describe('Tempo.CACHE Enum & Facade', () => { @@ -84,15 +92,19 @@ describe('Tempo Core Caching Architecture', () => { expect(Tempo.CACHE.Refresh).toBe('refresh'); }); - it('should expose normalized Tempo.cache facade methods', () => { + it('should expose normalized Tempo.cache facade methods and serialize via toJSON()', () => { 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'); + expect(Tempo.cache.toJSON()).toEqual({ my_term: '2026-05-10' }); + expect(JSON.stringify(Tempo.cache)).toBe('{"my_term":"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'); From 99697ea2feebe11700e2423bc5b34bf6f3256c91 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 4 Aug 2026 11:27:19 +1000 Subject: [PATCH 03/23] feat(docs): implement TypeDoc Compiler API type expansion pipeline and add Tempo Library logo --- packages/library/README.md | 2 +- packages/library/img/library-logo.svg | 21 ++++ packages/tempo/bin/expand-typedoc.mjs | 114 ++++++++++++++++++ .../doc/6-utility-library/tempo.library.md | 2 + packages/tempo/img/library-logo.svg | 21 ++++ packages/tempo/package.json | 2 +- packages/tempo/public/library-logo.svg | 21 ++++ 7 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 packages/library/img/library-logo.svg create mode 100644 packages/tempo/bin/expand-typedoc.mjs create mode 100644 packages/tempo/img/library-logo.svg create mode 100644 packages/tempo/public/library-logo.svg diff --git a/packages/library/README.md b/packages/library/README.md index 57fb95a3..27f28657 100644 --- a/packages/library/README.md +++ b/packages/library/README.md @@ -1,4 +1,4 @@ -# Magma Library (Internal Reference) +# Tempo Library Logo Magma Library (Internal Reference) > [!NOTE] > **Internal Reference Package**: `packages/library` is an internal monorepo utility suite used across Tempo packages. It is **not** published as a standalone package on npm, and is provided in the documentation as a reference guide for internal architectural utilities and shared routines. diff --git a/packages/library/img/library-logo.svg b/packages/library/img/library-logo.svg new file mode 100644 index 00000000..55fb6bdf --- /dev/null +++ b/packages/library/img/library-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/tempo/bin/expand-typedoc.mjs b/packages/tempo/bin/expand-typedoc.mjs new file mode 100644 index 00000000..2e5d92a6 --- /dev/null +++ b/packages/tempo/bin/expand-typedoc.mjs @@ -0,0 +1,114 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const __filename = fileURLToPath(import.meta.url); +const tempoDir = path.dirname(path.dirname(__filename)); +const libraryDir = path.resolve(tempoDir, '../library'); +const htmlOutputDir = path.resolve(tempoDir, 'public/api/library/types'); + +console.log('🔍 Running Phase 3: TypeDoc Compiler API Type Expansion Post-Processor...'); + +// 1. Load TypeScript program for @magmacomputing/library +const entryPoints = [ + path.resolve(libraryDir, 'src/common.index.ts'), + path.resolve(libraryDir, 'src/browser.index.ts'), + path.resolve(libraryDir, 'src/server.index.ts') +]; + +const tsconfigPath = path.resolve(libraryDir, 'tsconfig.json'); +const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); +const parsedCmd = ts.parseJsonConfigFileContent(configFile.config, ts.sys, libraryDir); + +const program = ts.createProgram(entryPoints, parsedCmd.options); +const checker = program.getTypeChecker(); + +// 2. Map of typeName -> expanded type declaration string +const typeMap = new Map(); + +for (const sourceFile of program.getSourceFiles()) { + if (sourceFile.isDeclarationFile) continue; + + ts.forEachChild(sourceFile, (node) => { + if (ts.isTypeAliasDeclaration(node) && node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) { + const typeName = node.name.text; + const rhsNode = node.type; + const rhsType = checker.getTypeAtLocation(rhsNode); + + let expanded = checker.typeToString( + rhsType, + rhsNode, + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.InTypeAlias | + ts.TypeFormatFlags.AllowUniqueESSymbolType + ); + + // Fallback to node.type.getText() if typeToString returns the typeAlias identifier itself + if (expanded === typeName || expanded.startsWith(`${typeName}<`)) { + expanded = rhsNode.getText(); + } + + const typeParams = node.typeParameters?.map(tp => tp.name.text).join(', '); + const fullSignature = typeParams ? `${typeName}<${typeParams}> = ${expanded}` : `${typeName} = ${expanded}`; + + typeMap.set(typeName, fullSignature); + } + }); +} + +console.log(`Found ${typeMap.size} exported type aliases from @magmacomputing/library source.`); + +// 3. Scan generated HTML files in public/api/library/types/ +if (!fs.existsSync(htmlOutputDir)) { + console.error(`❌ Output directory ${htmlOutputDir} does not exist. Run TypeDoc first.`); + process.exit(1); +} + +const htmlFiles = fs.readdirSync(htmlOutputDir).filter(f => f.endsWith('.html')); +let processedCount = 0; + +for (const file of htmlFiles) { + const filePath = path.join(htmlOutputDir, file); + let html = fs.readFileSync(filePath, 'utf-8'); + + // Extract the type alias name from filename or page title (e.g. common.index.CountOf.html -> CountOf) + const match = file.match(/common\.index\.([A-Za-z0-9_$]+)\.html$/); + if (!match) continue; + + const typeName = match[1]; + const expandedSig = typeMap.get(typeName); + + if (expandedSig) { + const injectionHtml = ` +
+ + 🔍 Expanded Type Evaluation (Compiler API) + +
+
type ${escapeHtml(expandedSig)}
+
+
+`; + + // Inject directly after
...
+ const signatureEndIdx = html.indexOf('', html.indexOf('class="tsd-signature"')); + if (signatureEndIdx !== -1) { + const insertPos = signatureEndIdx + 6; + html = html.slice(0, insertPos) + injectionHtml + html.slice(insertPos); + fs.writeFileSync(filePath, html, 'utf-8'); + processedCount++; + } + } +} + +console.log(`✅ Injected expanded type definitions into ${processedCount} HTML pages in public/api/library/types/`); + +function escapeHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/packages/tempo/doc/6-utility-library/tempo.library.md b/packages/tempo/doc/6-utility-library/tempo.library.md index 86bf57e3..481f2a67 100644 --- a/packages/tempo/doc/6-utility-library/tempo.library.md +++ b/packages/tempo/doc/6-utility-library/tempo.library.md @@ -1,3 +1,5 @@ +![Tempo Library](/library-logo.svg) + # Tempo Library Functionality While Tempo is primarily a Date-Time engine, it relies on several custom utilities under the hood to handle data structures, deep cloning, and serialization safely. diff --git a/packages/tempo/img/library-logo.svg b/packages/tempo/img/library-logo.svg new file mode 100644 index 00000000..55fb6bdf --- /dev/null +++ b/packages/tempo/img/library-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 9b53b523..c0560653 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -234,7 +234,7 @@ "prebuild": "npm run build:version", "clean": "magma-cli rm dist && (node ../../node_modules/typescript-7/bin/tsc -b --clean || true)", "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && if [ -z \"$TEMPO_LICENSE_PATH\" ] || [ ! -f \"$TEMPO_LICENSE_PATH\" ]; then echo '🚨 ERROR: TEMPO_LICENSE_PATH is missing or invalid. Cannot publish Premium build.'; exit 1; fi && npm run build", - "docs:api": "typedoc && typedoc --options typedoc.library.json", + "docs:api": "typedoc && typedoc --options typedoc.library.json && node bin/expand-typedoc.mjs", "docs:dev": "npm run build && npm run docs:api && node bin/build-catalog.mjs && node bin/harvest-plugins.mjs && vitepress dev", "docs:build": "npm run build && npm run docs:api && node bin/build-catalog.mjs && node bin/harvest-plugins.mjs && vitepress build", "docs:preview": "vitepress preview", diff --git a/packages/tempo/public/library-logo.svg b/packages/tempo/public/library-logo.svg new file mode 100644 index 00000000..55fb6bdf --- /dev/null +++ b/packages/tempo/public/library-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + From b5863c89538346907eda879b5be9ae495e1c36bb Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 5 Aug 2026 14:32:16 +1000 Subject: [PATCH 04/23] AI API --- .github/workflows/deploy-docs.yml | 8 + packages/library/README.md | 2 +- packages/library/img/library-logo.svg | 43 +- packages/plugins/ai/CHANGELOG.md | 15 + packages/plugins/ai/README.md | 52 +- packages/plugins/ai/doc/architecture.md | 16 +- packages/plugins/ai/doc/index.md | 17 + packages/plugins/ai/doc/rate-limits.md | 65 +- packages/plugins/ai/package.json | 2 +- packages/plugins/ai/plan/v0.3.0-roadmap.md | 90 +- packages/plugins/ai/src/core/init.ts | 41 +- packages/plugins/ai/src/core/manifest.ts | 109 + packages/plugins/ai/src/core/support.ts | 5 +- packages/plugins/ai/src/core/types.ts | 35 + packages/plugins/ai/src/functions/parse.ts | 62 +- packages/plugins/ai/src/index.ts | 1 + packages/plugins/ai/test/cache.test.ts | 139 + packages/plugins/ai/test/index.spec.ts | 26 +- packages/plugins/ai/test/manifest.test.ts | 142 + packages/tempo/.vitepress/config.ts | 1 + .../tempo/.vitepress/theme/data/catalog.json | 2 +- packages/tempo/CHANGELOG.md | 3 + packages/tempo/bin/expand-typedoc.mjs | 119 +- packages/tempo/bin/generate-llms-txt.mjs | 42 + .../doc/1-getting-started/ai-integration.md | 74 + .../doc/1-getting-started/installation.md | 11 + .../doc/3-extending-tempo/tempo.layout.md | 13 + packages/tempo/img/library-logo.svg | 43 +- packages/tempo/package.json | 4 +- packages/tempo/public/bundle.index.html | 1 + packages/tempo/public/esm_core.index.html | 1 + packages/tempo/public/esm_full.index.html | 1 + packages/tempo/public/esm_sh.index.html | 255 + packages/tempo/public/library-logo.svg | 43 +- packages/tempo/public/llms-full.txt | 7822 +++++++++++++++++ packages/tempo/public/llms.txt | 74 + packages/tempo/public/providers.v1.json | 26 + packages/tempo/public/script.index.html | 1 + 38 files changed, 9180 insertions(+), 226 deletions(-) create mode 100644 packages/plugins/ai/src/core/manifest.ts create mode 100644 packages/plugins/ai/test/cache.test.ts create mode 100644 packages/plugins/ai/test/manifest.test.ts create mode 100644 packages/tempo/bin/generate-llms-txt.mjs create mode 100644 packages/tempo/doc/1-getting-started/ai-integration.md create mode 100644 packages/tempo/public/esm_sh.index.html create mode 100644 packages/tempo/public/llms-full.txt create mode 100644 packages/tempo/public/llms.txt create mode 100644 packages/tempo/public/providers.v1.json diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 9a32cd7e..9229287b 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -77,3 +77,11 @@ jobs: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 + + - name: Trigger tempo-workspace AI Context Deploy + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.TEMPO_WORKSPACE_DISPATCH_TOKEN }} + repository: magmacomputing/tempo-workspace + event-type: sync-ai-context + diff --git a/packages/library/README.md b/packages/library/README.md index 27f28657..a7fe02a6 100644 --- a/packages/library/README.md +++ b/packages/library/README.md @@ -1,4 +1,4 @@ -# Tempo Library Logo Magma Library (Internal Reference) +# Tempo Library Logo Magma Library (Internal Reference) > [!NOTE] > **Internal Reference Package**: `packages/library` is an internal monorepo utility suite used across Tempo packages. It is **not** published as a standalone package on npm, and is provided in the documentation as a reference guide for internal architectural utilities and shared routines. diff --git a/packages/library/img/library-logo.svg b/packages/library/img/library-logo.svg index 55fb6bdf..ec7fd7c4 100644 --- a/packages/library/img/library-logo.svg +++ b/packages/library/img/library-logo.svg @@ -1,21 +1,34 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index 2c8e1e22..43ee8154 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to the `@magmacomputing/tempo-plugin-ai` project will be doc The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-08-04 + +### Added +- **3-Tier Timeout Resolution Hierarchy**: Introduced flexible, multi-level request timeout control for LLM API queries to prevent network hangs and ensure predictable SLAs: + 1. *Call-site override*: `parseAI(input, { timeout: 3000 })` + 2. *Provider-specific override*: `{ id: 'groq', options: { timeout: 2000 } }` + 3. *Global baseline default*: `initAI({ timeout: 5000 })` (falls back to default 15,000ms). +- **Timeout-Triggered Provider Cascade**: Stalled or slow provider requests trigger an `AbortSignal` cancellation, allowing `AiMode.Fallback` to instantly cascade to secondary providers and `AiMode.Race` to clean up lagging request promises. +- **Request-Locked `.ai.limits` Metadata**: Attached `limits` (`remainingRequests`, `remainingTokens`, `resetAt`) directly to the `.ai` metadata container (`TempoAiMeta`) of returned `Tempo` instances, locking HTTP header rate-limit snapshots to individual requests and preventing concurrency overwrites. +- **Async Storage Adapters (`AiCacheAdapter`)**: Introduced custom storage engine support (`AiCacheAdapter`) in `initAI` and `parseAI` for distributed serverless environments (e.g. Upstash Redis, Cloudflare KV, Memcached). +- **Cascading Cache TTL Hierarchy**: Implemented strict 4-tier TTL resolution (`options.ttl` > `provider.ttl` > `global config.ttl` > default 1 hour) for fine-grained cache entry expiration control. +- **Fail-Open Storage Resilience**: Custom cache adapter errors during `get()` or `set()` operations fail open silently to direct LLM resolution, preserving application request uptime. +- **Dynamic Remote Provider Manifest (`loadRemoteManifest`)**: Lazily fetches remote provider defaults (`providers.v1.json`) on initialization with a 1500ms timeout and automatic air-gapped fallback to compiled `DEFAULT_PROVIDERS`. +- **Cache Eviction Synchronization**: Enhanced `clearAiCache()` to flush matching keys and prefixes across both `Tempo.cache` and custom `AiCacheAdapter` storage engines. + ## [0.2.0] - 2026-07-30 ### Added diff --git a/packages/plugins/ai/README.md b/packages/plugins/ai/README.md index d0fbf429..904412b5 100644 --- a/packages/plugins/ai/README.md +++ b/packages/plugins/ai/README.md @@ -6,41 +6,65 @@ npm version npm peer dependency version License TypeScript Ready Documentation

-Tempo community plugin for LLM-powered natural language parsing. +> **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. +This plugin bridges deterministic date math and unstructured NLP inputs, leveraging LLMs (Gemini, Groq, OpenAI, Ollama) to asynchronously parse complex natural language expressions into type-safe `Tempo` instances. -> **CRITICAL SECURITY WARNING**: Raw LLM API keys must **never** be exposed in a client-side browser bundle or stored in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). Client-side storage is vulnerable to XSS attacks, malicious scripts, and browser extension extraction, which can result in API key theft and quota abuse. BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, you must route requests through a secure backend 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. +> 🔒 **Security Notice**: Raw LLM API keys must **never** be exposed in client-side browser bundles or client storage (`localStorage`, `sessionStorage`, `IndexedDB`). BYOK is only safe on backend servers or edge runtime proxies. -## Installation +--- + +## ⚡ Quick Start + +### 📦 Installation ```bash npm install @magmacomputing/tempo-plugin-ai ``` -## Setup & Usage +### 🎯 Usage ```typescript import { parseAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; -// Initialize with your BYOK API Key (ensuring non-undefined string key) +// Initialize with your BYOK API keys initAI({ providers: [ - ...(process.env.GROQ_API_KEY ? [{ id: 'groq', key: process.env.GROQ_API_KEY }] : []), + { id: 'groq', key: process.env.GROQ_API_KEY! } ] }); -// Parse a complex natural language string! +// Parse natural language into a Tempo instance! const dt = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); -// Evict bad parses from the cache +console.log(dt.format('{yyyy}-{mm}-{dd}')); // 2026-11-17 +console.log(dt.ai?.confidence); // 0.98 +console.log(dt.ai?.provider); // 'groq' + +// Evict cached resolution 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). +--- + +## ✨ Features & Architecture + +* 🤖 **Multi-Provider Routing**: Native support for Groq, OpenAI, Gemini, Mistral, and local Ollama nodes with automatic fallback. +* 🌐 **Dynamic Provider Manifest**: Model IDs and endpoints are lazily updated via hosted JSON manifests with 1500ms fail-open air-gapped fallbacks. +* ⚡ **Two-Tier Caching**: Combines fast local in-memory LRU caching (`BoundedCache`) with optional async storage adapters (`AiCacheAdapter` for Redis / Cloudflare KV). +* ⏱️ **Cascading TTL Policies**: Granular TTL control at call-site, provider, or global levels. +* 🛡️ **Fail-Safe Confidence Bounds**: Configurable `minConfidence` thresholds and array batch processing with soft-error handling. + +--- + +## 📚 Documentation + +For complete API references, architecture guides, and advanced examples (Redis adapters, custom provider setups, race/consensus execution modes): + +📖 **[Read the Official AI Plugin Documentation](https://magmacomputing.github.io/magma/doc/9-plugins/ai.index.html)** + +--- -## Licensing +## ⚖️ Licensing -This is a **Community** plugin. It is completely free and open-source for personal and commercial use. No license token is required. +This is a **Community** plugin. It is completely free and open-source for personal and commercial use under the MIT license. diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md index 7a176adb..ab978bce 100644 --- a/packages/plugins/ai/doc/architecture.md +++ b/packages/plugins/ai/doc/architecture.md @@ -40,12 +40,26 @@ initAI({ id: 'local', key: 'no-key-needed', url: 'http://localhost:11434/v1/chat/completions', - model: 'your-local-model' + model: 'your-local-model', + options: { timeout: 5000 } // Custom provider-level timeout (5s) } ] }); ``` +### Dynamic Provider Manifests & Air-Gapped Fallback + +By default, `@magmacomputing/tempo-plugin-ai` lazily fetches provider defaults (model IDs, endpoints, token parameter keys) from `https://tempo.magmacomputing.com.au/providers.v1.json` once per application lifecycle. + +- **Fail-Open & Air-Gapped Fallback**: If the network request fails, times out (1500ms limit), or the application is running offline or in an air-gapped environment, `initAI()` automatically and silently falls back to compiled local defaults (`DEFAULT_PROVIDERS`). +- **Disabling Remote Manifest**: Pass `remoteConfigUrl: false` to disable remote manifest fetching entirely: + ```typescript + initAI({ + providers: [{ id: 'groq', key: process.env.GROQ_API_KEY! }], + remoteConfigUrl: false // Disable remote manifest fetching + }); + ``` + ### Frontend Security Warning > [!CAUTION] > **Never** expose a raw LLM API key in a client-side browser bundle (like React or Vue) or store it in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). Any Cross-Site Scripting (XSS) vulnerability, compromised NPM package, or malicious browser extension can easily inspect client-side storage and steal secret keys, leading to quota exhaustion, billing fraud, or permanent provider bans. BYOK keys are *only* safe on backend servers or edge workers. diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md index 644001bd..89b252db 100644 --- a/packages/plugins/ai/doc/index.md +++ b/packages/plugins/ai/doc/index.md @@ -82,6 +82,23 @@ const agreed = await parseAI("The penultimate Tuesday before Thanksgiving", { }); ``` +## Timeout Controls & SLAs + +Prevent hanging requests using the 3-tier timeout hierarchy (`call-site` > `provider` > `global` > `default 15s`): + +```typescript +// Global timeout across all AI requests +initAI({ + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY, options: { timeout: 2000 } } // 2s timeout for fast provider + ], + timeout: 5000 // 5s global default timeout +}); + +// Hard 3-second SLA override for a specific call-site +const dt = await parseAI("Next Tuesday at 4pm", { timeout: 3000 }); +``` + ## Debugging & Forced Evaluation When building your LLM queries, it is often useful to see exactly how AI functions route your data. diff --git a/packages/plugins/ai/doc/rate-limits.md b/packages/plugins/ai/doc/rate-limits.md index 1765ec3e..50252aac 100644 --- a/packages/plugins/ai/doc/rate-limits.md +++ b/packages/plugins/ai/doc/rate-limits.md @@ -5,18 +5,35 @@ 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 return signatures of Tempo AI functions, the plugin provides a dedicated utility function: `getAiRateLimits()`. + +Quota and rate-limit metadata can be inspected in two convenient ways: + +### 1. Request-Locked Instance Metadata (`dt.ai.limits`) +Every `Tempo` instance returned by `parseAI` includes a frozen `.ai.limits` snapshot representing the exact rate limit state returned by the provider HTTP headers for *that specific request*: + +```typescript +const dt = await parseAI("The third Friday of next month"); + +if (dt.ai?.limits) { + console.log(`Remaining Tokens: ${dt.ai.limits.remainingTokens}`); + console.log(`Remaining Requests: ${dt.ai.limits.remainingRequests}`); + console.log(`Resets At: ${dt.ai.limits.resetAt?.format('{hh}:{mi}:{ss}')}`); +} +``` + +### 2. Global State Utility (`getAiRateLimits()`) +For quick status checks or global monitoring across the application lifecycle, `getAiRateLimits()` exposes the stats from the most recent LLM request: ```typescript import { getAiRateLimits } from '@magmacomputing/tempo-plugin-ai'; -// Returns the stats from the most recent LLM proxy request +// Returns global 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}')}`); + console.log(`Limits Reset At: ${stats.resetAt?.format('{hh}:{mi}:{ss}')}`); } ``` @@ -108,15 +125,45 @@ If you want to explicitly query the LLM again and *overwrite* the existing cache const dt = await parseAI("Q3_START", { force: true }); ``` -### Extensible Caching (Enterprise) -For edge environments or custom application architectures, you can provide custom cache instances via `initAI({ cache })` or `Tempo.init({ cache })`! +### Extensible Caching & Async Storage Adapters (`AiCacheAdapter`) -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 cache lookup engine does not await promise-returning cache operations. +By default, parsed AI responses are cached in memory using `Tempo.cache` (`BoundedCache`). For distributed serverless environments (e.g. Next.js, Cloudflare Workers, Express) or cluster nodes, you can pass a custom synchronous or asynchronous storage adapter (`AiCacheAdapter`): ```typescript -// Custom synchronous cache implementation +import { initAI, parseAI, type AiCacheAdapter } from '@magmacomputing/tempo-plugin-ai'; +import { Redis } from '@upstash/redis'; + +const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN! }); + +// Implement custom async Redis storage adapter +const redisAdapter: AiCacheAdapter = { + get: async (key) => (await redis.get(key)) ?? undefined, + set: async (key, value, ttlMs) => { + if (ttlMs) await redis.set(key, value, { px: ttlMs }); + else await redis.set(key, value); + }, + delete: async (key) => { await redis.del(key); }, + clear: async () => { /* optional prefix wipe */ } +}; + initAI({ - providers: [{ id: 'groq', key: '...' }], - cache: new MyCustomSyncCache() + providers: [{ id: 'groq', key: process.env.GROQ_API_KEY!, ttl: 7200000 }], // Provider-specific TTL (2 hours) + cacheAdapter: redisAdapter, + ttl: 3600000 // Global default TTL (1 hour) }); + +// Call-site TTL override (15 minutes) +const dt = await parseAI("next Monday at 9am", { ttl: 900000 }); ``` + +### Cascading TTL Resolution Policies + +The plugin calculates cache TTL per entry using a strict resolution hierarchy: +1. **Call-site `options.ttl`**: `parseAI(prompt, { ttl: 900000 })` +2. **Provider-level `provider.ttl`**: `providers: [{ id: 'groq', ttl: 7200000 }]` +3. **Global `initAI({ ttl: 3600000 })`** +4. **Default TTL**: `3,600,000` ms (1 hour) + +### Fail-Open Cache Resilience + +Custom storage adapter calls (`adapter.get` and `adapter.set`) are wrapped in safe error handlers. If an external Redis instance crashes or encounters a network partition, the plugin logs a debug warning (if `debug: true`) and gracefully fails open to direct LLM resolution without crashing the application request. diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index a580adec..ff5c79a1 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo-plugin-ai", - "version": "0.2.0", + "version": "0.3.0", "description": "Tempo community plugin for LLM-powered natural language parsing.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/plugins/ai/plan/v0.3.0-roadmap.md b/packages/plugins/ai/plan/v0.3.0-roadmap.md index fc3e0aa4..3c08d420 100644 --- a/packages/plugins/ai/plan/v0.3.0-roadmap.md +++ b/packages/plugins/ai/plan/v0.3.0-roadmap.md @@ -4,103 +4,27 @@ This document captures the planned feature set, architectural requirements, and --- -## 1. Remote Provider Manifest & Dynamic Defaults - -### Background & Objective -AI provider model identifiers, endpoints, and parameters update 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 +## 1. 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` +### 1.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` +### 1.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` +### 1.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` +### 1.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` +### 1.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` +### 1.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. - ---- - -## 5. Tempo Config Assistant (`configAI`) — Strategic & Technical Analysis - -### 5.1 Objective & Overview -`configAI(prompt: string, options?: AiOptions): Promise` is a proposed developer-facing function to translate natural language configuration requirements (e.g., *"Set up fiscal quarters starting Nov 1 and a layout for short weekday with 12-hour time"*) into valid Tempo runtime configurations (`Aliases`, `Layouts`, `Snippets`, `Formats`, or `TermPlugin` declarations). - -### 5.2 Business & Consulting Revenue Impact -* **Consulting & Support Cannibalization**: Providing automated, self-serve AI config generation reduces developer reliance on Magma's high-margin professional services, enterprise consulting, and tier-3 support packages for custom domain-math integrations. -* **Monetization & Upsell Safeguards**: If implemented, `configAI` must act as an upsell vehicle, surfacing recommendations for Magma's licensed premium plugins (e.g., `@magmacomputing/tempo-plugin-ticker`, `@magmacomputing/tempo-plugin-sync`) whenever multi-interval scheduling or atomic clock synchronization is requested. - -### 5.3 Technical Context Gap & Smart Endpoint Requirement -* **The LLM Knowledge Gap**: Because `Tempo` introduces novel Temporal concepts, custom tokens (`{yw}`, `{wy}`, `{eon}`), and zero-cost proxy getters not broadly indexed across web codebases, standard foundation models (e.g., generic ChatGPT/Chrome LLM queries) lack sufficient context to generate valid Tempo code without hallucinating syntaxes. -* **Dedicated RAG / System-Prompt Endpoint**: To function accurately, `configAI` requires either: - * An embedded, highly compressed system prompt containing Tempo's exact token/layout grammar. - * A managed backend RAG endpoint (`https://registry.tempo.dev/ai/config`) hosted via Magma's license registry infrastructure. - -### 5.4 Ecosystem Adoption Lifecycle -* **Early Adoption (High Value)**: During early ecosystem growth, `configAI` serves as a critical bridge while public LLM training sets lack native Tempo awareness. -* **Widespread Acceptance (Declining Relevance)**: As Tempo adoption scales across public repositories and developer docs, foundation models will naturally absorb Tempo's API surface into their training sets. Over time, generic LLMs will generate valid Tempo configurations out-of-the-box, rendering a specialized `configAI` assistant progressively redundant. - -### 5.5 Verdict & Recommended Strategy: Deprioritized (Prefer `llms.txt`) -* **Decision**: **Do not build a runtime `configAI()` endpoint.** The ongoing engineering cost, RAG hosting infrastructure, and revenue cannibalization risks far outweigh the short-term benefits. -* **Low-Cost Alternative (`llms.txt` / Cursor Rules)**: Rather than maintaining a custom AI endpoint, publish an official `llms.txt` and Cursor/Copilot context file alongside the VitePress documentation. Developer IDEs (Cursor, Copilot, ChatGPT) will consume the docs directly at zero infrastructure cost to Magma, leaving engineering resources focused on core performance and licensed premium plugins. - - diff --git a/packages/plugins/ai/src/core/init.ts b/packages/plugins/ai/src/core/init.ts index 4af961d0..6294821b 100644 --- a/packages/plugins/ai/src/core/init.ts +++ b/packages/plugins/ai/src/core/init.ts @@ -1,5 +1,6 @@ import { Tempo } from '@magmacomputing/tempo'; -import { DEFAULT_PROVIDERS } from './config.js'; + +import { getResolvedProviderDefaults, loadRemoteManifest } from './manifest.js'; import { normalizeCacheInput, assertNoReservedProviderId } from './support.js'; import type { AiConfig, AiRateLimits, AiProvider } from './types.js'; @@ -15,9 +16,14 @@ export function initAI(config: AiConfig): void { if (config.providers) assertNoReservedProviderId(config.providers); + const remoteUrl = config.remoteConfigUrl ?? _state.config.remoteConfigUrl; + + if (remoteUrl !== false) + loadRemoteManifest(remoteUrl, undefined, config.debug ?? _state.config.debug).catch(() => { }); + const resolvedProviders = config.providers ? config.providers.map(p => { const normalizedId = p.id?.toLowerCase() ?? ''; - const defaults = DEFAULT_PROVIDERS[normalizedId] || DEFAULT_PROVIDERS.openai; + const defaults = getResolvedProviderDefaults(normalizedId, remoteUrl, config.debug ?? _state.config.debug); return { ...defaults, ...p @@ -31,11 +37,23 @@ export function initAI(config: AiConfig): void { }; if (config.cache) { - Tempo.init({ cache: config.cache as any }); + Tempo.init({ cache: config.cache, silent: true }); } } -export function clearAiCache(input: string | string[]): void { +export function clearAiCache(input?: string | string[]): void { + const adapter = _state.config.cacheAdapter; + + if (!input) { + if (adapter?.clear) { + try { + const res = adapter.clear(); + if (res instanceof Promise) res.catch(() => {}); + } catch {} + } + return; + } + const inputs = Array.isArray(input) ? input : [input]; for (const i of inputs) { const normalized = normalizeCacheInput(i); @@ -43,6 +61,21 @@ export function clearAiCache(input: string | string[]): void { Tempo.cache.delete(normalized); Tempo.cache.delete(i); Tempo.cache.deletePrefix(prefix); + + if (adapter) { + try { + if (adapter.delete) { + const res1 = adapter.delete(normalized); + if (res1 instanceof Promise) res1.catch(() => {}); + const res2 = adapter.delete(i); + if (res2 instanceof Promise) res2.catch(() => {}); + } + if (adapter.clear) { + const resClear = adapter.clear(prefix); + if (resClear instanceof Promise) resClear.catch(() => {}); + } + } catch {} + } } } diff --git a/packages/plugins/ai/src/core/manifest.ts b/packages/plugins/ai/src/core/manifest.ts new file mode 100644 index 00000000..d9a15a58 --- /dev/null +++ b/packages/plugins/ai/src/core/manifest.ts @@ -0,0 +1,109 @@ +import { DEFAULT_PROVIDERS } from './config.js'; +import type { AiProvider } from './types.js'; + +export const DEFAULT_REMOTE_MANIFEST_URL = 'https://tempo.magmacomputing.com.au/providers.v1.json'; +export const DEFAULT_MANIFEST_TIMEOUT_MS = 1500; + +let _cachedManifest: Record> | null = null; +let _fetchPromise: Promise> | null> | null = null; + +/** + * Resets the in-memory manifest cache (used primarily for unit testing). + */ +export function resetManifestCache(): void { + _cachedManifest = null; + _fetchPromise = null; +} + +/** + * Fetches the remote AI provider manifest once per module load. + * Fail-open: if network fails or times out, returns null and allows fallback to local DEFAULT_PROVIDERS. + */ +export async function loadRemoteManifest( + remoteConfigUrl: string | false = DEFAULT_REMOTE_MANIFEST_URL, + timeoutMs: number = DEFAULT_MANIFEST_TIMEOUT_MS, + debug: boolean = false +): Promise> | null> { + if (remoteConfigUrl === false) { + return null; + } + + if (_cachedManifest !== null) { + return _cachedManifest; + } + + if (_fetchPromise !== null) { + return _fetchPromise; + } + + const targetUrl = typeof remoteConfigUrl === 'string' && remoteConfigUrl.trim().length > 0 + ? remoteConfigUrl + : DEFAULT_REMOTE_MANIFEST_URL; + + _fetchPromise = (async () => { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + const response = await fetch(targetUrl, { + signal: controller.signal, + headers: { Accept: 'application/json' } + }); + + clearTimeout(timer); + + if (!response.ok) { + if (debug) { + console.warn(`[tempo-plugin-ai] Remote manifest fetch failed with status ${response.status}`); + } + _cachedManifest = {}; + return null; + } + + const data = await response.json(); + if (data && typeof data === 'object' && data.providers && typeof data.providers === 'object') { + _cachedManifest = data.providers as Record>; + return _cachedManifest; + } + + if (debug) { + console.warn('[tempo-plugin-ai] Remote manifest missing valid "providers" object structure'); + } + _cachedManifest = {}; + return null; + } catch (err: any) { + if (debug) { + console.warn(`[tempo-plugin-ai] Remote manifest fetch error: ${err?.message || err}`); + } + // Fail-open: store empty object so we fallback to DEFAULT_PROVIDERS without hanging subsequent calls + _cachedManifest = {}; + return null; + } finally { + _fetchPromise = null; + } + })(); + + return _fetchPromise; +} + +/** + * Resolves the default settings for a given provider ID by combining compiled DEFAULT_PROVIDERS + * with remote manifest entries if available. + */ +export function getResolvedProviderDefaults( + providerId: string, + remoteConfigUrl?: string | false, + debug: boolean = false +): Partial { + const normalizedId = providerId?.toLowerCase() ?? ''; + const localDefaults = DEFAULT_PROVIDERS[normalizedId] || DEFAULT_PROVIDERS.openai; + + if (remoteConfigUrl === false || !_cachedManifest || !_cachedManifest[normalizedId]) { + return localDefaults; + } + + return { + ...localDefaults, + ..._cachedManifest[normalizedId] + }; +} diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index 6a89c8c0..029273d5 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -61,7 +61,8 @@ export async function fetchFromProvider( str: string, contextString: string, isDebug: boolean, - parentSignal?: AbortSignal + parentSignal?: AbortSignal, + timeoutOverride?: number ): Promise<{ rawContent: string; providerId: string; rateLimits: ReturnType }> { const url = provider.url!; const model = provider.model!; @@ -94,7 +95,7 @@ Do not include markdown blocks or any text outside the JSON.`; const tokenLimit = { [tokenParam]: 250 }; const controller = new AbortController(); - const timeoutMs = provider.options?.timeout ?? 15000; + const timeoutMs = timeoutOverride ?? provider.options?.timeout ?? _state.config.timeout ?? 15000; const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const onParentAbort = () => controller.abort(); diff --git a/packages/plugins/ai/src/core/types.ts b/packages/plugins/ai/src/core/types.ts index 83e5e1f6..273c685f 100644 --- a/packages/plugins/ai/src/core/types.ts +++ b/packages/plugins/ai/src/core/types.ts @@ -24,6 +24,8 @@ export interface TempoAiMeta { readonly rawPrompt?: string | undefined; /** Normalized prompt input (only included when debug: true) */ readonly normalizedPrompt?: string | undefined; + /** Rate limit snapshot returned by the provider HTTP headers for this request */ + readonly limits?: AiRateLimits | undefined; } declare module '@magmacomputing/tempo' { @@ -33,6 +35,21 @@ declare module '@magmacomputing/tempo' { } } +/** + * ## AiCacheAdapter + * Interface for synchronous or asynchronous custom storage engines (e.g. Redis, Cloudflare KV, Memcached). + */ +export interface AiCacheAdapter { + /** Retrieve a value by key */ + get(key: string): Promise | string | undefined; + /** Store a value by key with optional TTL in milliseconds */ + set(key: string, value: string, ttlMs?: number): Promise | void; + /** Delete a specific entry by key */ + delete?(key: string): Promise | void; + /** Clear entries, optionally matching a key prefix */ + clear?(prefix?: string): Promise | void; +} + /** * ## AiProvider * Represents an LLM provider and its respective BYOK API key. @@ -48,6 +65,8 @@ export interface AiProvider { model?: string; /** Optional parameter name for max token limit (e.g. 'max_tokens' or 'max_completion_tokens') */ tokenParam?: string | undefined; + /** Optional cache TTL override in milliseconds for entries produced by this provider */ + ttl?: number | undefined; /** Optional LLM parameters (e.g. temperature, max_tokens, top_p) */ options?: Record; } @@ -71,6 +90,10 @@ export interface AiParseOptions { force?: boolean; /** If false, disables reading and writing to cache */ cache?: boolean; + /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */ + cacheAdapter?: AiCacheAdapter; + /** Optional TTL override in milliseconds for cached result */ + ttl?: number; /** If true, logs prompt context and LLM payloads to console */ debug?: boolean; /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` or string literal) */ @@ -81,6 +104,8 @@ export interface AiParseOptions { minConfidence?: number; /** If true, places TempoAiError into array index position instead of rejecting batch */ softErrors?: boolean; + /** Optional request timeout in milliseconds (overrides provider and global timeout) */ + timeout?: number; /** Allow extra options */ [key: string]: any; } @@ -94,6 +119,16 @@ export interface AiConfig { providers?: AiProvider[] | undefined; /** Optional custom cache implementation for storing parsed strings */ cache?: Map | undefined; + /** Optional custom cache storage engine (e.g., Redis, KV store) for storing parsed strings */ + cacheAdapter?: AiCacheAdapter | undefined; + /** Optional global cache TTL in milliseconds for AI parsing entries (default: 3600000ms / 1 hour) */ + ttl?: number | undefined; + /** Optional global timeout in milliseconds for AI requests (default: 15000ms) */ + timeout?: number | undefined; + /** Optional remote manifest URL or false to disable remote defaults (default: 'https://tempo.magmacomputing.com.au/providers.v1.json') */ + remoteConfigUrl?: string | false | undefined; + /** Optional custom resolver hook to fetch provider default options by ID */ + fetchDefaults?: ((providerId: string) => Promise | null>) | undefined; /** If true, logs the spoon-fed LLM context prompt and raw LLM response to the console */ debug?: boolean | undefined; } diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts index 309c0b99..c32bb119 100644 --- a/packages/plugins/ai/src/functions/parse.ts +++ b/packages/plugins/ai/src/functions/parse.ts @@ -9,7 +9,7 @@ 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 || {}; + const { force, debug, mode: aiMode, providers, minConfidence, softErrors, cache: aiCacheOption, timeout: callTimeout, ...coreOptions } = options || {}; let tz: string, cal: string, loc: string, sph: string, anchorStr: string; if (Tempo.isTempo(options?.anchor)) { @@ -30,15 +30,37 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< 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}`; + const adapter = options?.cacheAdapter ?? _state.config.cacheAdapter; 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 (adapter) { + try { + const val1 = await adapter.get(cacheKey); + if (val1) { + cachedIso = val1; + } else { + const val2 = await adapter.get(normalizedStr); + if (val2) { + cachedIso = val2; + } else { + const val3 = await adapter.get(str); + if (val3) cachedIso = val3; + } + } + } catch (err: any) { + if (isDebug) console.log('[tempo-plugin-ai] Cache adapter read error:', err?.message); + } + } + + if (!cachedIso) { + 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); + } } } @@ -99,7 +121,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< for (const provider of availableProviders) { try { - const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug); + const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug, undefined, callTimeout); const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); const parsedData = JSON.parse(cleanContent); @@ -135,7 +157,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< const parentController = new AbortController(); try { const promises = availableProviders.map(async (provider) => { - const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug, parentController.signal); + const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug, parentController.signal, callTimeout); const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); return { parsedData: JSON.parse(cleanContent), providerId, rateLimits }; }); @@ -148,7 +170,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< } else if (mode === AiMode.Consensus) { const promises = availableProviders.map(async (provider) => { - const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug); + const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug, undefined, callTimeout); const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); return { parsedData: JSON.parse(cleanContent), providerId, rateLimits }; }); @@ -195,7 +217,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< _state.limits = successfulResult?.rateLimits ?? null; - const { parsedData, providerId } = successfulResult!; + const { parsedData, providerId, rateLimits } = 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'); @@ -215,13 +237,26 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< rawIso: rawIso === 'INVALID' ? 'INVALID' : rawIso, reasoning: isDebug ? reasoning : undefined, rawPrompt: isDebug ? str : undefined, - normalizedPrompt: isDebug ? normalizedStr : undefined + normalizedPrompt: isDebug ? normalizedStr : undefined, + limits: rateLimits ?? undefined }); } const parsedIso = `${rawIso.replace(/Z$/i, '')}[${tz}]`; + // Determine TTL hierarchy: options.ttl > provider.ttl > global config.ttl > 3600000 (1 hour) + const winningProvider = availableProviders.find(p => p.id === providerId); + const resolvedTtl = options?.ttl ?? winningProvider?.ttl ?? _state.config.ttl ?? 3600000; + if (aiCacheOption !== false) { + if (adapter) { + try { + const res = adapter.set(cacheKey, parsedIso, resolvedTtl); + if (res instanceof Promise) await res; + } catch (err: any) { + if (isDebug) console.log('[tempo-plugin-ai] Cache adapter write error:', err?.message); + } + } Tempo.cache.set(cacheKey, parsedIso); } @@ -235,7 +270,8 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< rawIso, reasoning: isDebug ? reasoning : undefined, rawPrompt: isDebug ? str : undefined, - normalizedPrompt: isDebug ? normalizedStr : undefined + normalizedPrompt: isDebug ? normalizedStr : undefined, + limits: rateLimits ?? undefined }); } diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts index fccea421..1fc5b274 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -2,6 +2,7 @@ export { TempoAiError } from './core/error.js'; export * from './core/types.js'; export * from './core/config.js'; +export { loadRemoteManifest, resetManifestCache, DEFAULT_REMOTE_MANIFEST_URL } from './core/manifest.js'; export { initAI, clearAiCache, getAiRateLimits } from './core/init.js'; // AI Function Handlers diff --git a/packages/plugins/ai/test/cache.test.ts b/packages/plugins/ai/test/cache.test.ts new file mode 100644 index 00000000..7f081381 --- /dev/null +++ b/packages/plugins/ai/test/cache.test.ts @@ -0,0 +1,139 @@ +import { parseAI, initAI, clearAiCache, type AiCacheAdapter } from '../src/index.js'; +import { Tempo } from '@magmacomputing/tempo'; + +describe('Advanced Cache TTL & Async Storage Adapters', () => { + beforeEach(() => { + vi.restoreAllMocks(); + Tempo.cache.clear(); + initAI({ + providers: [{ id: 'groq', key: 'mock-test-key' }], + remoteConfigUrl: false + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Tempo.cache.clear(); + }); + + it('should read from and write to custom async AiCacheAdapter', async () => { + const store = new Map(); + const ttlLogs: number[] = []; + + const mockAdapter: AiCacheAdapter = { + get: vi.fn(async (key: string) => store.get(key)), + set: vi.fn(async (key: string, value: string, ttlMs?: number) => { + store.set(key, value); + if (ttlMs) ttlLogs.push(ttlMs); + }), + delete: vi.fn(async (key: string) => { + store.delete(key); + }), + clear: vi.fn(async () => { + store.clear(); + }) + }; + + initAI({ + providers: [{ id: 'groq', key: 'mock-test-key' }], + cacheAdapter: mockAdapter, + ttl: 120000 + }); + + // Mock LLM fetch response for first call + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Custom adapter test", "iso":"2026-11-26T00:00:00", "confidence":0.95}' } }] + }), { status: 200 }) + ); + + const result1 = await parseAI('Thanksgiving 2026'); + expect(result1.isValid).toBe(true); + expect(result1.ai?.provider).toBe('groq'); + expect(mockAdapter.set).toHaveBeenCalled(); + expect(ttlLogs).toContain(120000); + + // Second call should hit the custom adapter + const result2 = await parseAI('Thanksgiving 2026'); + expect(result2.isValid).toBe(true); + expect(result2.ai?.provider).toBe('cache'); + expect(result2.ai?.cached).toBe(true); + expect(mockAdapter.get).toHaveBeenCalled(); + }); + + it('should observe TTL resolution hierarchy (options.ttl > provider.ttl > global config.ttl)', async () => { + const setTtlLogs: number[] = []; + + const mockAdapter: AiCacheAdapter = { + get: vi.fn(() => undefined), + set: vi.fn((_key: string, _val: string, ttlMs?: number) => { + if (ttlMs) setTtlLogs.push(ttlMs); + }) + }; + + initAI({ + providers: [{ id: 'groq', key: 'mock-key', ttl: 60000 }], + cacheAdapter: mockAdapter, + ttl: 300000 + }); + + vi.spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve( + new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"TTL test", "iso":"2026-12-25T00:00:00", "confidence":0.95}' } }] + }), { status: 200 }) + )); + + // Call 1: Inherits provider.ttl (60000) + await parseAI('Christmas 2026'); + expect(setTtlLogs[0]).toBe(60000); + + // Call 2: Call-site options.ttl (15000) overrides provider and global TTL + await parseAI('Christmas 2026', { force: true, ttl: 15000 }); + expect(setTtlLogs[1]).toBe(15000); + }); + + it('should fail-open and fetch from LLM if custom cacheAdapter throws a network error', async () => { + const faultyAdapter: AiCacheAdapter = { + get: vi.fn(async () => { + throw new Error('Redis connection refused'); + }), + set: vi.fn(async () => { + throw new Error('Redis write error'); + }) + }; + + initAI({ + providers: [{ id: 'groq', key: 'mock-key' }], + cacheAdapter: faultyAdapter + }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Faulty adapter test", "iso":"2026-07-04T00:00:00", "confidence":0.95}' } }] + }), { status: 200 }) + ); + + // parseAI should NOT throw Redis error; it should fail open to LLM fetch + const result = await parseAI('Independence Day 2026'); + expect(result.isValid).toBe(true); + expect(result.ai?.provider).toBe('groq'); + }); + + it('should clear custom cacheAdapter entries when clearAiCache is invoked', async () => { + const mockAdapter: AiCacheAdapter = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + clear: vi.fn() + }; + + initAI({ cacheAdapter: mockAdapter }); + + clearAiCache('Easter 2026'); + expect(mockAdapter.delete).toHaveBeenCalled(); + expect(mockAdapter.clear).toHaveBeenCalled(); + + clearAiCache(); + expect(mockAdapter.clear).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/plugins/ai/test/index.spec.ts b/packages/plugins/ai/test/index.spec.ts index 21599b73..2a60f363 100644 --- a/packages/plugins/ai/test/index.spec.ts +++ b/packages/plugins/ai/test/index.spec.ts @@ -8,6 +8,10 @@ describe('AI Parsing Plugin', () => { const isLiveTest = Boolean(process.env.LIVE_AI_TEST && liveApiKey); beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + if (isLiveTest) { initAI({ providers: [{ id: liveProviderId, key: liveApiKey! }] @@ -20,7 +24,7 @@ describe('AI Parsing Plugin', () => { }); afterEach(() => { - vi.restoreAllMocks(); + vi.clearAllMocks(); }); it('should fall back to native parsing first and attach .ai metadata', async () => { @@ -504,6 +508,26 @@ describe('AI Parsing Plugin', () => { expect(getAiRateLimits()?.resetAt?.isValid).toBe(true); }); + it('should attach limits snapshot directly to the returned Tempo instance .ai property', async () => { + initAI({ providers: [{ id: 'groq', key: 'test-key' }] }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"iso":"2026-11-26T00:00:00"}' } }] + }), { + status: 200, + headers: new Headers({ + 'x-ratelimit-remaining-requests': '499', + 'x-ratelimit-remaining-tokens': '99500' + }) + })); + + const result = await parseAI('Thanksgiving 2026', { force: true }); + expect(result.ai?.limits).toBeDefined(); + expect(result.ai?.limits?.remainingRequests).toBe(499); + expect(result.ai?.limits?.remainingTokens).toBe(99500); + }); + 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'); diff --git a/packages/plugins/ai/test/manifest.test.ts b/packages/plugins/ai/test/manifest.test.ts new file mode 100644 index 00000000..ff827ae6 --- /dev/null +++ b/packages/plugins/ai/test/manifest.test.ts @@ -0,0 +1,142 @@ +import { + initAI, + loadRemoteManifest, + resetManifestCache, + DEFAULT_REMOTE_MANIFEST_URL, + DEFAULT_PROVIDERS +} from '../src/index.js'; + +describe('Remote Provider Manifest & Dynamic Defaults', () => { + beforeEach(() => { + resetManifestCache(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + resetManifestCache(); + vi.restoreAllMocks(); + }); + + it('should fetch remote manifest and cache in memory for subsequent calls', async () => { + const mockManifest = { + version: '1.0', + providers: { + groq: { model: 'llama-3.3-70b-versatile', tokenParam: 'max_tokens' }, + openai: { model: 'gpt-5.4-mini', tokenParam: 'max_completion_tokens' } + } + }; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockManifest), { status: 200 }) + ); + + const result1 = await loadRemoteManifest(DEFAULT_REMOTE_MANIFEST_URL); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + DEFAULT_REMOTE_MANIFEST_URL, + expect.objectContaining({ headers: { Accept: 'application/json' } }) + ); + expect(result1).toEqual(mockManifest.providers); + + // Second call should return cached object without second fetch + const result2 = await loadRemoteManifest(DEFAULT_REMOTE_MANIFEST_URL); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result2).toEqual(mockManifest.providers); + }); + + it('should gracefully fail-open on network error (500) and return null', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(null, { status: 500, statusText: 'Internal Server Error' }) + ); + + const result = await loadRemoteManifest(DEFAULT_REMOTE_MANIFEST_URL); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + + it('should gracefully fail-open on network timeout / abort', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementationOnce(() => { + const err = new Error('The operation was aborted'); + err.name = 'AbortError'; + return Promise.reject(err); + }); + + const result = await loadRemoteManifest(DEFAULT_REMOTE_MANIFEST_URL, 100); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + + it('should return null immediately if remoteConfigUrl is false', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const result = await loadRemoteManifest(false); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('should fetch custom remoteConfigUrl when provided', async () => { + const customUrl = 'https://custom-domain.net/providers.json'; + const mockManifest = { + version: '1.0', + providers: { + groq: { model: 'custom-groq-model' } + } + }; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockManifest), { status: 200 }) + ); + + const result = await loadRemoteManifest(customUrl); + expect(fetchSpy).toHaveBeenCalledWith(customUrl, expect.anything()); + expect(result).toEqual(mockManifest.providers); + }); + + it('should resolve provider defaults from remote manifest when available in initAI', async () => { + const mockManifest = { + version: '1.0', + providers: { + groq: { + url: 'https://api.groq.com/openai/v1/chat/completions', + model: 'remote-llama-model', + tokenParam: 'max_tokens' + } + } + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockManifest), { status: 200 }) + ); + + // Pre-load manifest + await loadRemoteManifest(DEFAULT_REMOTE_MANIFEST_URL); + + initAI({ + providers: [{ id: 'groq', key: 'test-key' }] + }); + + // Check resolved providers in init state + const { _state } = await import('../src/core/init.js'); + expect(_state.config.providers).toHaveLength(1); + expect(_state.config.providers?.[0].model).toBe('remote-llama-model'); + }); + + it('should fallback to compiled DEFAULT_PROVIDERS if remote manifest is missing provider ID', async () => { + const mockManifest = { + version: '1.0', + providers: {} + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockManifest), { status: 200 }) + ); + + await loadRemoteManifest(DEFAULT_REMOTE_MANIFEST_URL); + + initAI({ + providers: [{ id: 'openai', key: 'test-key' }] + }); + + const { _state } = await import('../src/core/init.js'); + expect(_state.config.providers?.[0].model).toBe(DEFAULT_PROVIDERS.openai.model); + }); +}); diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 24b4bc1f..7d0fcc19 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -38,6 +38,7 @@ export default defineConfig({ items: [ { text: 'Introduction', link: '/README' }, { text: 'Installation', link: '/doc/1-getting-started/installation' }, + { text: 'AI & IDE Integration', link: '/doc/1-getting-started/ai-integration' }, { text: 'Cookbook', link: '/doc/1-getting-started/tempo.cookbook' } ] }, diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json index 8814dbdb..f40e7958 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.2.0" + "version": "0.3.0" }, { "id": "ticker", diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index ba4699cb..ec1badb2 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Cache Serialization (`toJSON`)**: Added native `toJSON()` serialization support to `BoundedCache` and the `Tempo.cache` facade object. Calling `Tempo.cache.toJSON()` or `JSON.stringify(Tempo.cache)` now cleanly converts active, non-expired in-memory cache entries into a plain key-value JavaScript object. +- **AI Context & IDE Integration (`llms.txt`)**: Published official standardized `llms.txt` and `llms-full.txt` context bundles at `https://tempo.magmacomputing.com.au` to enable zero-hallucination code generation for IDE tools (Cursor, VS Code / GitHub Copilot, Antigravity) and web AI interfaces (ChatGPT, Claude, Gemini). +- **Automated Doc Harvester**: Created `bin/generate-llms-txt.mjs` monorepo build script integrated into `npm run docs:build` to harvest all 56 markdown documentation files into a unified `llms-full.txt` corpus. +- **AI Documentation Guide**: Added a dedicated `AI & IDE Integration` guide (`doc/1-getting-started/ai-integration.md`) featured directly in the primary VitePress navigation sidebar under Getting Started. ## [3.11.0] - 2026-07-31 diff --git a/packages/tempo/bin/expand-typedoc.mjs b/packages/tempo/bin/expand-typedoc.mjs index 2e5d92a6..e46d93a5 100644 --- a/packages/tempo/bin/expand-typedoc.mjs +++ b/packages/tempo/bin/expand-typedoc.mjs @@ -6,33 +6,49 @@ import ts from 'typescript'; const __filename = fileURLToPath(import.meta.url); const tempoDir = path.dirname(path.dirname(__filename)); const libraryDir = path.resolve(tempoDir, '../library'); -const htmlOutputDir = path.resolve(tempoDir, 'public/api/library/types'); -console.log('🔍 Running Phase 3: TypeDoc Compiler API Type Expansion Post-Processor...'); - -// 1. Load TypeScript program for @magmacomputing/library -const entryPoints = [ - path.resolve(libraryDir, 'src/common.index.ts'), - path.resolve(libraryDir, 'src/browser.index.ts'), - path.resolve(libraryDir, 'src/server.index.ts') +console.log('🔍 Running TypeDoc Compiler API Type Expansion Post-Processor...'); + +const targets = [ + { + name: '@magmacomputing/library', + dir: libraryDir, + entryPoints: [ + path.resolve(libraryDir, 'src/common.index.ts'), + path.resolve(libraryDir, 'src/browser.index.ts'), + path.resolve(libraryDir, 'src/server.index.ts') + ], + tsconfigPath: path.resolve(libraryDir, 'tsconfig.json'), + htmlOutputDir: path.resolve(tempoDir, 'public/api/library/types') + }, + { + name: '@magmacomputing/tempo', + dir: tempoDir, + entryPoints: [ + path.resolve(tempoDir, 'src/tempo.index.ts') + ], + tsconfigPath: path.resolve(tempoDir, 'tsconfig.build.json'), + htmlOutputDir: path.resolve(tempoDir, 'public/api/types') + } ]; -const tsconfigPath = path.resolve(libraryDir, 'tsconfig.json'); -const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); -const parsedCmd = ts.parseJsonConfigFileContent(configFile.config, ts.sys, libraryDir); - -const program = ts.createProgram(entryPoints, parsedCmd.options); -const checker = program.getTypeChecker(); +for (const target of targets) { + if (!fs.existsSync(target.htmlOutputDir)) { + console.warn(`⚠️ Skipping ${target.name}: HTML output directory does not exist yet (${target.htmlOutputDir})`); + continue; + } -// 2. Map of typeName -> expanded type declaration string -const typeMap = new Map(); + const configFile = ts.readConfigFile(target.tsconfigPath, ts.sys.readFile); + const parsedCmd = ts.parseJsonConfigFileContent(configFile.config, ts.sys, target.dir); -for (const sourceFile of program.getSourceFiles()) { - if (sourceFile.isDeclarationFile) continue; + const program = ts.createProgram(target.entryPoints, parsedCmd.options); + const checker = program.getTypeChecker(); + const typeMap = new Map(); - ts.forEachChild(sourceFile, (node) => { + function visit(node, currentNamespace = '') { if (ts.isTypeAliasDeclaration(node) && node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) { const typeName = node.name.text; + const fullKey = currentNamespace ? `${currentNamespace}.${typeName}` : typeName; const rhsNode = node.type; const rhsType = checker.getTypeAtLocation(rhsNode); @@ -44,7 +60,6 @@ for (const sourceFile of program.getSourceFiles()) { ts.TypeFormatFlags.AllowUniqueESSymbolType ); - // Fallback to node.type.getText() if typeToString returns the typeAlias identifier itself if (expanded === typeName || expanded.startsWith(`${typeName}<`)) { expanded = rhsNode.getText(); } @@ -53,34 +68,40 @@ for (const sourceFile of program.getSourceFiles()) { const fullSignature = typeParams ? `${typeName}<${typeParams}> = ${expanded}` : `${typeName} = ${expanded}`; typeMap.set(typeName, fullSignature); + typeMap.set(fullKey, fullSignature); + } else if (ts.isModuleDeclaration(node) && node.body) { + const nsName = node.name.text; + const nextNs = currentNamespace ? `${currentNamespace}.${nsName}` : nsName; + ts.forEachChild(node.body, child => visit(child, nextNs)); } - }); -} + } -console.log(`Found ${typeMap.size} exported type aliases from @magmacomputing/library source.`); + for (const sourceFile of program.getSourceFiles()) { + if (sourceFile.isDeclarationFile) continue; + ts.forEachChild(sourceFile, node => visit(node)); + } -// 3. Scan generated HTML files in public/api/library/types/ -if (!fs.existsSync(htmlOutputDir)) { - console.error(`❌ Output directory ${htmlOutputDir} does not exist. Run TypeDoc first.`); - process.exit(1); -} + console.log(`Found ${typeMap.size} type alias mappings for ${target.name}.`); -const htmlFiles = fs.readdirSync(htmlOutputDir).filter(f => f.endsWith('.html')); -let processedCount = 0; + const htmlFiles = fs.readdirSync(target.htmlOutputDir).filter(f => f.endsWith('.html')); + let processedCount = 0; -for (const file of htmlFiles) { - const filePath = path.join(htmlOutputDir, file); - let html = fs.readFileSync(filePath, 'utf-8'); + for (const file of htmlFiles) { + const filePath = path.join(target.htmlOutputDir, file); + let html = fs.readFileSync(filePath, 'utf-8'); - // Extract the type alias name from filename or page title (e.g. common.index.CountOf.html -> CountOf) - const match = file.match(/common\.index\.([A-Za-z0-9_$]+)\.html$/); - if (!match) continue; + // Extract type identifier from filename e.g.: + // - common.index.CountOf.html -> CountOf + // - Tempo.DateTime.html -> Tempo.DateTime or DateTime + // - Tempo.WEEKDAY-1.html -> Tempo.WEEKDAY or WEEKDAY + const cleanName = file.replace(/\.html$/, '').replace(/-\d+$/, ''); + const parts = cleanName.split('.'); + const typeName = parts[parts.length - 1]; - const typeName = match[1]; - const expandedSig = typeMap.get(typeName); + const expandedSig = typeMap.get(cleanName) || typeMap.get(typeName); - if (expandedSig) { - const injectionHtml = ` + if (expandedSig && !html.includes('expanded-type-details')) { + const injectionHtml = `
🔍 Expanded Type Evaluation (Compiler API) @@ -91,18 +112,18 @@ for (const file of htmlFiles) {
`; - // Inject directly after
...
- const signatureEndIdx = html.indexOf('', html.indexOf('class="tsd-signature"')); - if (signatureEndIdx !== -1) { - const insertPos = signatureEndIdx + 6; - html = html.slice(0, insertPos) + injectionHtml + html.slice(insertPos); - fs.writeFileSync(filePath, html, 'utf-8'); - processedCount++; + const signatureEndIdx = html.indexOf('', html.indexOf('class="tsd-signature"')); + if (signatureEndIdx !== -1) { + const insertPos = signatureEndIdx + 6; + html = html.slice(0, insertPos) + injectionHtml + html.slice(insertPos); + fs.writeFileSync(filePath, html, 'utf-8'); + processedCount++; + } } } -} -console.log(`✅ Injected expanded type definitions into ${processedCount} HTML pages in public/api/library/types/`); + console.log(`✅ Injected expanded type definitions into ${processedCount} HTML pages in ${path.relative(tempoDir, target.htmlOutputDir)}`); +} function escapeHtml(str) { return str diff --git a/packages/tempo/bin/generate-llms-txt.mjs b/packages/tempo/bin/generate-llms-txt.mjs new file mode 100644 index 00000000..930c7562 --- /dev/null +++ b/packages/tempo/bin/generate-llms-txt.mjs @@ -0,0 +1,42 @@ +import { readdir, readFile, writeFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const docDir = join(__dirname, '../doc'); +const outputFile = join(__dirname, '../public/llms-full.txt'); + +async function getMarkdownFiles(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + let files = []; + for (const entry of entries) { + const res = join(dir, entry.name); + if (entry.isDirectory()) { + files = files.concat(await getMarkdownFiles(res)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + files.push(res); + } + } + return files.sort(); +} + +async function generateLlmsFull() { + try { + const files = await getMarkdownFiles(docDir); + let content = `# Tempo Full Documentation Context\n\n> This file contains the complete concatenated markdown documentation set for @magmacomputing/tempo. It is intended for automated LLM context ingestion and RAG indexing.\n\n---\n\n`; + + for (const file of files) { + const relPath = relative(docDir, file); + const fileContent = await readFile(file, 'utf-8'); + content += `\n\n\n# Document: ${relPath}\n\n${fileContent}\n\n\n\n---\n`; + } + + await writeFile(outputFile, content, 'utf-8'); + console.log(`✅ Successfully generated llms-full.txt (${files.length} markdown documents merged)`); + } catch (err) { + console.error('❌ Error generating llms-full.txt:', err); + process.exit(1); + } +} + +generateLlmsFull(); diff --git a/packages/tempo/doc/1-getting-started/ai-integration.md b/packages/tempo/doc/1-getting-started/ai-integration.md new file mode 100644 index 00000000..358897a3 --- /dev/null +++ b/packages/tempo/doc/1-getting-started/ai-integration.md @@ -0,0 +1,74 @@ +# 🤖 AI & IDE Integration (`llms.txt`) + +To ensure modern AI coding assistants—such as **Cursor**, **VS Code (GitHub Copilot)**, **Antigravity**, **ChatGPT**, and **Claude**—generate accurate, hallucination-free Tempo code, Tempo publishes an official, standardized [`llms.txt`](https://tempo.magmacomputing.com.au/llms.txt) rulebook. + +By providing these rules to your AI assistant, your IDE will respect Tempo's strict immutability, zero-cost getters, native `Temporal` runtime expectations, and layout token syntax out-of-the-box. + +--- + +## 🚀 Quick Setup by IDE / Tool + +### 1. Cursor IDE +Add Tempo to Cursor's native documentation index: +1. Open **Cursor Settings** (`Cmd + ,` or `Ctrl + ,`). +2. Navigate to **Features** ➔ **Docs**. +3. Click **+ Add new doc** and enter: + - **Name**: `Tempo` + - **URL**: `https://tempo.magmacomputing.com.au/llms.txt` + +> [!TIP] +> Once added, type `@Tempo` in any Cursor chat or prompt window to inject exact API syntax rules into your conversation. + +--- + +### 2. VS Code & GitHub Copilot +In VS Code, configure GitHub Copilot Chat by adding a `.github/copilot-instructions.md` file (or `.cursorrules`) to the root of your workspace: + +```markdown +# Tempo AI Rules +- Always use `Tempo` from `@magmacomputing/tempo`. +- Never instantiate legacy JavaScript `Date`. Tempo expects native `Temporal` or polyfill. +- All mutating methods (`.add()`, `.subtract()`, `.with()`) return a brand-new, frozen `Tempo` instance. +- Refer to https://tempo.magmacomputing.com.au/llms.txt for full layout token grammar. +``` + +When prompting Copilot Chat in VS Code: +```text +"Using https://tempo.magmacomputing.com.au/llms.txt, write a custom layout parser..." +``` + +--- + +### 3. Antigravity AI Assistant +In Antigravity, you can reference the live endpoint directly in your chat prompt or store it as a localized Knowledge Item (KI): +- Reference `@https://tempo.magmacomputing.com.au/llms.txt` in your prompt for instant context ingestion. + +--- + +### 4. ChatGPT & Claude Projects +For web-based LLM interfaces, reference or copy-paste the full, un-truncated documentation context file: +👉 **[Full RAG Documentation Bundle (`llms-full.txt`)](https://tempo.magmacomputing.com.au/llms-full.txt)** + +--- + +## 🛠️ Prompting AI for Custom Layout Extensions + +When asking AI assistants to generate custom layout patterns for parsing unique date-time formats, instruct the model to use Tempo's configuration syntax (`Tempo.config({ layouts: { ... } })`) and named capture tokens (`{yy}`, `{mon}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`). + +### Sample Prompt: +> *"Using the rules from https://tempo.magmacomputing.com.au/llms.txt, register a custom Tempo layout for fiscal quarters (e.g., 'Q3 2026') using `Tempo.config({ layouts: { ... } })` and parse a date using `Tempo.parse()`."* + +### Generated Code (Actual Tempo Syntax): +```typescript +import { Tempo } from '@magmacomputing/tempo'; + +// 1. Register custom layout pattern using snippet tokens +Tempo.config({ + layouts: { + fiscal_quarter: 'Q{nbr} {yy}' + } +}); + +// 2. Parse date string using the registered layout +const date = Tempo.parse('Q3 2026', 'fiscal_quarter'); +``` diff --git a/packages/tempo/doc/1-getting-started/installation.md b/packages/tempo/doc/1-getting-started/installation.md index 8bf64aac..262a871e 100644 --- a/packages/tempo/doc/1-getting-started/installation.md +++ b/packages/tempo/doc/1-getting-started/installation.md @@ -242,3 +242,14 @@ We recommend pinning your versions in production environments to ensure stabilit * **JSDelivr**: `https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@3/...` (Locks to major version 3) * **Latest**: `https://cdn.jsdelivr.net/npm/@magmacomputing/tempo/...` (Omit the version string to always receive the latest release. Note that JSDelivr will resolve a missing version tag to the latest published release). + +--- + +## 🤖 AI & IDE Integration (`llms.txt`) + +> [!TIP] +> Using **Cursor**, **VS Code (GitHub Copilot)**, **Antigravity**, **ChatGPT**, or **Claude**? +> Tempo publishes an official [`llms.txt`](https://tempo.magmacomputing.com.au/llms.txt) index to give AI assistants zero-hallucination context about Tempo's syntax, token grammar, and immutability rules. +> +> 👉 **[Read the dedicated AI & IDE Integration Guide](./ai-integration.md)** for step-by-step setup instructions for your IDE or tool. + diff --git a/packages/tempo/doc/3-extending-tempo/tempo.layout.md b/packages/tempo/doc/3-extending-tempo/tempo.layout.md index 39896a86..57b52b3f 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.layout.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.layout.md @@ -101,6 +101,19 @@ console.log(regex.source); --- +## 🤖 AI & LLM Layout Prompting + +When prompting AI assistants (Cursor, GitHub Copilot, ChatGPT, Claude) to write custom `Tempo` regular expression snippets and layout extensions: + +1. **Ingest AI Rules**: Provide the assistant with our official `llms.txt` rules by referencing `@https://tempo.magmacomputing.com.au/llms.txt` in Cursor or pasting `llms.txt` context into ChatGPT. +2. **Explicit Token Request**: Ask the LLM to use Tempo's standard snippet tokens (`{yy}`, `{mon}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`) rather than raw, un-anchored regular expressions. +3. **Example AI Prompt**: + ```text + "Using https://tempo.magmacomputing.com.au/llms.txt, register a custom layout for strings like 'Q3 2026' using Tempo.config({ layouts: { ... } }) and snippet tokens." + ``` + +--- + ## Professional Services If your project involves specialized terminology, complex financial calendars, or legacy application log formats, the **Magma Computing Solutions** team offers professional services to design and test custom `Tempo` Layouts optimized for your business needs. diff --git a/packages/tempo/img/library-logo.svg b/packages/tempo/img/library-logo.svg index 55fb6bdf..ec7fd7c4 100644 --- a/packages/tempo/img/library-logo.svg +++ b/packages/tempo/img/library-logo.svg @@ -1,21 +1,34 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + diff --git a/packages/tempo/package.json b/packages/tempo/package.json index c0560653..489ddf93 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -235,8 +235,8 @@ "clean": "magma-cli rm dist && (node ../../node_modules/typescript-7/bin/tsc -b --clean || true)", "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && if [ -z \"$TEMPO_LICENSE_PATH\" ] || [ ! -f \"$TEMPO_LICENSE_PATH\" ]; then echo '🚨 ERROR: TEMPO_LICENSE_PATH is missing or invalid. Cannot publish Premium build.'; exit 1; fi && npm run build", "docs:api": "typedoc && typedoc --options typedoc.library.json && node bin/expand-typedoc.mjs", - "docs:dev": "npm run build && npm run docs:api && node bin/build-catalog.mjs && node bin/harvest-plugins.mjs && vitepress dev", - "docs:build": "npm run build && npm run docs:api && node bin/build-catalog.mjs && node bin/harvest-plugins.mjs && vitepress build", + "docs:dev": "npm run build && npm run docs:api && node bin/build-catalog.mjs && node bin/harvest-plugins.mjs && node bin/generate-llms-txt.mjs && vitepress dev", + "docs:build": "npm run build && npm run docs:api && node bin/build-catalog.mjs && node bin/harvest-plugins.mjs && node bin/generate-llms-txt.mjs && vitepress build", "docs:preview": "vitepress preview", "docs:push": "bash ./bin/push-docs.sh" }, diff --git a/packages/tempo/public/bundle.index.html b/packages/tempo/public/bundle.index.html index 3e9fa82d..14a726aa 100644 --- a/packages/tempo/public/bundle.index.html +++ b/packages/tempo/public/bundle.index.html @@ -99,6 +99,7 @@ margin-bottom: 5px; background: linear-gradient(135deg, #fff, #a5b4fc); -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; letter-spacing: -0.5px; } diff --git a/packages/tempo/public/esm_core.index.html b/packages/tempo/public/esm_core.index.html index 97e48f97..a1d060d6 100644 --- a/packages/tempo/public/esm_core.index.html +++ b/packages/tempo/public/esm_core.index.html @@ -99,6 +99,7 @@ margin-bottom: 5px; background: linear-gradient(135deg, #fff, #a5b4fc); -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; letter-spacing: -0.5px; } diff --git a/packages/tempo/public/esm_full.index.html b/packages/tempo/public/esm_full.index.html index 26b6fc93..514bfe61 100644 --- a/packages/tempo/public/esm_full.index.html +++ b/packages/tempo/public/esm_full.index.html @@ -99,6 +99,7 @@ margin-bottom: 5px; background: linear-gradient(135deg, #fff, #a5b4fc); -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; letter-spacing: -0.5px; } diff --git a/packages/tempo/public/esm_sh.index.html b/packages/tempo/public/esm_sh.index.html new file mode 100644 index 00000000..3d5576b0 --- /dev/null +++ b/packages/tempo/public/esm_sh.index.html @@ -0,0 +1,255 @@ + + + + + + Tempo esm.sh Test + + + + +
+
+ +
+
+

Tempo

+
esm.sh Smart CDN Test
+ +
+ import '@js-temporal/polyfill'; +import { Tempo } from '@magmacomputing/tempo'; + +const t = new Tempo('next friday'); +t.format('{mon} {dd:raw}'); +
+ +
+ Result +
Initializing Temporal...
+
+ + +
+
+ + + + + diff --git a/packages/tempo/public/library-logo.svg b/packages/tempo/public/library-logo.svg index 55fb6bdf..ec7fd7c4 100644 --- a/packages/tempo/public/library-logo.svg +++ b/packages/tempo/public/library-logo.svg @@ -1,21 +1,34 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + diff --git a/packages/tempo/public/llms-full.txt b/packages/tempo/public/llms-full.txt new file mode 100644 index 00000000..9bce34af --- /dev/null +++ b/packages/tempo/public/llms-full.txt @@ -0,0 +1,7822 @@ +# Tempo Full Documentation Context + +> This file contains the complete concatenated markdown documentation set for @magmacomputing/tempo. It is intended for automated LLM context ingestion and RAG indexing. + +--- + + + + +# Document: 1-getting-started/ai-integration.md + +# 🤖 AI & IDE Integration (`llms.txt`) + +To ensure modern AI coding assistants—such as **Cursor**, **VS Code (GitHub Copilot)**, **Antigravity**, **ChatGPT**, and **Claude**—generate accurate, hallucination-free Tempo code, Tempo publishes an official, standardized [`llms.txt`](https://tempo.magmacomputing.com.au/llms.txt) rulebook. + +By providing these rules to your AI assistant, your IDE will respect Tempo's strict immutability, zero-cost getters, native `Temporal` runtime expectations, and layout token syntax out-of-the-box. + +--- + +## 🚀 Quick Setup by IDE / Tool + +### 1. Cursor IDE +Add Tempo to Cursor's native documentation index: +1. Open **Cursor Settings** (`Cmd + ,` or `Ctrl + ,`). +2. Navigate to **Features** ➔ **Docs**. +3. Click **+ Add new doc** and enter: + - **Name**: `Tempo` + - **URL**: `https://tempo.magmacomputing.com.au/llms.txt` + +> [!TIP] +> Once added, type `@Tempo` in any Cursor chat or prompt window to inject exact API syntax rules into your conversation. + +--- + +### 2. VS Code & GitHub Copilot +In VS Code, configure GitHub Copilot Chat by adding a `.github/copilot-instructions.md` file (or `.cursorrules`) to the root of your workspace: + +```markdown +# Tempo AI Rules +- Always use `Tempo` from `@magmacomputing/tempo`. +- Never instantiate legacy JavaScript `Date`. Tempo expects native `Temporal` or polyfill. +- All mutating methods (`.add()`, `.subtract()`, `.with()`) return a brand-new, frozen `Tempo` instance. +- Refer to https://tempo.magmacomputing.com.au/llms.txt for full layout token grammar. +``` + +When prompting Copilot Chat in VS Code: +```text +"Using https://tempo.magmacomputing.com.au/llms.txt, write a custom layout parser..." +``` + +--- + +### 3. Antigravity AI Assistant +In Antigravity, you can reference the live endpoint directly in your chat prompt or store it as a localized Knowledge Item (KI): +- Reference `@https://tempo.magmacomputing.com.au/llms.txt` in your prompt for instant context ingestion. + +--- + +### 4. ChatGPT & Claude Projects +For web-based LLM interfaces, reference or copy-paste the full, un-truncated documentation context file: +👉 **[Full RAG Documentation Bundle (`llms-full.txt`)](https://tempo.magmacomputing.com.au/llms-full.txt)** + +--- + +## 🛠️ Prompting AI for Custom Layout Extensions + +When asking AI assistants to generate custom layout patterns for parsing unique date-time formats, instruct the model to use Tempo's configuration syntax (`Tempo.config({ layouts: { ... } })`) and named capture tokens (`{yy}`, `{mon}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`). + +### Sample Prompt: +> *"Using the rules from https://tempo.magmacomputing.com.au/llms.txt, register a custom Tempo layout for fiscal quarters (e.g., 'Q3 2026') using `Tempo.config({ layouts: { ... } })` and parse a date using `Tempo.parse()`."* + +### Generated Code (Actual Tempo Syntax): +```typescript +import { Tempo } from '@magmacomputing/tempo'; + +// 1. Register custom layout pattern using snippet tokens +Tempo.config({ + layouts: { + fiscal_quarter: 'Q{nbr} {yy}' + } +}); + +// 2. Parse date string using the registered layout +const date = Tempo.parse('Q3 2026', 'fiscal_quarter'); +``` + + + + +--- + + + +# Document: 1-getting-started/installation.md + +# Installation Guide + +`Tempo` is designed to be environment-agnostic. Whether you are building a server-side application, a modern browser project with ESM, or a performance-critical "Lite" bundle, `Tempo` provides a specific path for you. + +## Temporal Polyfill Note + +`Tempo` expects the host environment to provide `Temporal`, either through native runtime support or a user-supplied polyfill. + +`Temporal` has reached Stage 4 of the [TC39 standards process](https://tc39.es/proposal-temporal/) (the committee that evolves JavaScript) and is shipping natively in modern environments (Deno 2.7+, Node.js 26+, Chrome 144+, Firefox 139+). Note that Safari/iOS currently do not support Temporal natively and require a polyfill. You can verify current browser support at [caniuse.com/temporal](https://caniuse.com/temporal). To avoid needlessly inflating package sizes for modern apps, `Tempo` does not bundle a `Temporal` polyfill by default. + +::: warning +Node.js environments that ship `Temporal` behind a feature flag (`--harmony-temporal`) may have incomplete implementations. For stability, we strongly recommend using `@js-temporal/polyfill` instead of the native flag until you upgrade to an official unflagged release. +::: + +You can check at runtime with a simple guard: + +```js +if (typeof globalThis.Temporal === 'undefined') { + // Load your Temporal polyfill for this environment +} +``` + +Note: The examples below include a polyfill for demonstration purposes only, so the snippets work consistently across environments. + +--- + +## 💻 Server & Bundlers (Node.js, Bun, Vite) + +For most modern projects using a package manager, install Tempo via the npm registry. + +```bash +npm install @magmacomputing/tempo # npm +yarn add @magmacomputing/tempo # yarn +pnpm add @magmacomputing/tempo # pnpm +bun add @magmacomputing/tempo # bun +``` + +### Usage +```javascript +import { Tempo } from '@magmacomputing/tempo'; +const t = new Tempo('next Friday'); +``` + +### Node.js (with Native Temporal) + +Native unflagged `Temporal` support is available in Node.js 26+ and is enabled by default. + +```bash +node my-app.js +``` + +### Node.js (with Polyfill) + +The polyfill import shown here is conditional guidance, not required for all environments. + +```bash +npm install @js-temporal/polyfill +``` + +```javascript +import '@js-temporal/polyfill'; +import { Tempo } from '@magmacomputing/tempo'; + +const t = new Tempo('next Friday'); +``` + +--- + +## 🦕 Deno + +Tempo is a native ESM package and works perfectly with Deno. You can add it via the `deno add` command which will resolve it from the npm registry. + +As of Deno 2.7, the Temporal API is fully stabilized and enabled by default. You no longer need to pass the --unstable-temporal flag to use it. + +```bash +deno add npm:@magmacomputing/tempo +``` + +### Usage +```javascript +import { Tempo } from "@magmacomputing/tempo"; +const t = new Tempo(); +``` + +--- + +## 🌐 Browser & Native Environments + +Tempo provides multiple native browser distribution formats. Here is the quick breakdown of which approach to use: +- **Standard Usage** (No plugins): Use the Native ESM Bundle. +- **Plugins without a bundler**: Use **Smart CDNs** (Easiest setup) OR **Static CDNs** (Best production performance). +- **Plugins with a bundler** (Vite/Webpack): Do nothing. Your bundler handles the resolution automatically. +- **Non-ESM Environments**: Use the UMD Global Variable approach. + +### 1. The Global Bundle (Standard Usage) + +The easiest way to use Tempo natively in the browser is via the pre-optimized ESM bundle. It includes the entire core engine in a single file, eliminating network waterfall effects. + +```html + +``` + +```html + +``` + +### 2. Smart CDNs (The "Best-of-Both-Worlds") + +If you want the absolute easiest setup for **Tempo Plugins** natively in the browser, use an on-the-fly bundling CDN like [esm.sh](https://esm.sh). Smart CDNs act like a Node environment—they read the package resolution rules and resolve nested dependencies automatically, meaning you don't have to map any internal subpaths. + +While you *could* import directly from the URL everywhere, the best practice is to use a tiny import map for your top-level packages to keep your application code clean: + +```html + + + + + +``` + +
+
⚠️ Trade-offs of using Smart CDNs in Production + +While `esm.sh` is fantastic for prototyping and reducing import map complexity, there are architectural trade-offs to consider before using it in a mission-critical production environment: + +1. **Network Waterfalls:** The browser must fetch the module, parse it, and then fetch its nested dependencies sequentially. This can slow down page load times compared to a fully bundled application. +2. **Uptime Dependency:** You are introducing a critical third-party dependency into your runtime. If the CDN experiences routing issues, your application could break for end-users. +3. **Sub-dependency Version Floating:** `esm.sh` automatically resolves sub-dependencies based on semver constraints. If a sub-dependency introduces an accidental breaking change, it could affect your app. +4. **Suboptimal Tree Shaking:** The browser will download the entire module graph for that package; you cannot easily tree-shake unused exports as you can with a dedicated bundler like Vite or Webpack. +5. **Environment Parity:** Handling development versus production environments (like `process.env.NODE_ENV`) requires query parameters (e.g., `?dev`), which complicates deployment. + + + +### 3. Static CDNs (Production-Ready) + +For production environments where uptime and load speeds are critical, you should use a static file CDN (like jsdelivr). Because static CDNs serve raw files without compiling them on the fly, they are significantly faster and more reliable than Smart CDNs. + +To use **Tempo Plugins** via a static CDN, you simply need to explicitly map the unified `plugin-api` subpath so the browser knows how to resolve the internal connections: + +```html + +``` + +> [!WARNING] Cache Busting +> The jsdelivr CDN aggressively caches major version tags (like `@3`). When relying on precise module resolution for plugins, it is highly recommended to use explicit patch versions (like `@3.0.1`) to avoid fetching mismatched or outdated sub-modules. + +--- + +## 📦 Browser (Global Variable / Plugins) + +If you aren't using ESM or just want a simple ` + + + + + + + + +``` + +--- + +## 🧪 Granular "Lite" Builds (Advanced) + +If you are extremely concerned about bundle size, you can bypass the "Batteries Included" entry point and import only the core engine. You then manually opt-in to the modules you need. + +```javascript +import { Tempo } from '@magmacomputing/tempo/core'; +import { MutateModule } from '@magmacomputing/tempo/mutate'; + +// Opt-in to specific functionality +Tempo.extend(MutateModule); + +const t = new Tempo().add({ days: 1 }); +``` + +::: warning +When using the Lite build, the `Tempo` class will have almost no methods (like `.add()`, `.set()`, or `.format()`) until you explicitly call `Tempo.extend()` with the appropriate module. +::: + +--- + +## 🛡️ Versioning Policy + +We recommend pinning your versions in production environments to ensure stability. + +* **JSDelivr**: `https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@3/...` (Locks to major version 3) +* **Latest**: `https://cdn.jsdelivr.net/npm/@magmacomputing/tempo/...` (Omit the version string to always receive the latest release. Note that JSDelivr will resolve a missing version tag to the latest published release). + +--- + +## 🤖 AI & IDE Integration (`llms.txt`) + +> [!TIP] +> Using **Cursor**, **VS Code (GitHub Copilot)**, **Antigravity**, **ChatGPT**, or **Claude**? +> Tempo publishes an official [`llms.txt`](https://tempo.magmacomputing.com.au/llms.txt) index to give AI assistants zero-hallucination context about Tempo's syntax, token grammar, and immutability rules. +> +> 👉 **[Read the dedicated AI & IDE Integration Guide](./ai-integration.md)** for step-by-step setup instructions for your IDE or tool. + + + + + +--- + + + +# Document: 1-getting-started/tempo.cookbook.md + +# Tempo Cookbook + +A collection of recipes for solving common date and time challenges using Tempo. + +## Table of Contents +1. [The Basics](#the-basics) +2. [Parsing Challenges](#parsing-challenges) +3. [Manipulation and Calculations](#manipulation-and-calculations) +4. [Timezones and Locales](#timezones-and-locales) +5. [Business Logic and Terms](#business-logic-and-terms) +6. [Formatting and Localization](#formatting-and-localization) +7. [Interoperability](#interoperability) + +--- + +## The Basics + +### How do I get the current date and time? +When invoked without arguments, the constructor initializes to the current date and time. +```typescript +const now = new Tempo(); +console.log(now.toString()); // e.g. "2026-07-31T14:42:11+10:00[Australia/Sydney]" +``` + +### Get "Now" in UTC +```typescript +const utcNow = new Tempo({ timeZone: 'UTC' }); +``` + +### How do I format a date for my UI? +Use the placeholder syntax in the `.format()` method. +```typescript +const t = new Tempo('2024-12-25'); +t.format('{dd} {mon} {yyyy}'); // "25 December 2024" +t.format('{h12}:{mi}'); // "12:00am" +``` + +### How do I check if a date is valid? +```typescript +const t = new Tempo('invalid-date'); +if (t.isValid) { + // ... +} +``` + +### Global Configuration +You can initialize global defaults that apply to all future `Tempo` instances. +```typescript +Tempo.init({ + timeZone: 'UTC', + locale: 'en-GB', + silent: true +}); +``` +👉 **Learn More:** [Configuration Guide](../2-core-concepts/tempo.config.md) + +--- + +## Parsing Challenges + +### Parsing "Ambiguous" Digits (US vs UK) +Tempo intelligently resolves ambiguous dates like `04012026` based on your timezone. +```typescript +const us = new Tempo('04012026', { timeZone: 'America/New_York' }); +console.log(us.format('{mon} {dd}')); // "April 01" +``` +👉 **Learn More:** [Ambiguity Resolution Guide](../2-core-concepts/tempo.parse.md) + +### Handling Relative Strings +Tempo natively understands human-readable offsets. +```typescript +new Tempo('yesterday'); +new Tempo('next Friday'); +new Tempo('2 weeks ago'); +new Tempo('tomorrow afternoon'); +``` + +👉 **Learn More:** You can seamlessly localize relative phrases (e.g. `next` to `prochain`) by reading the [Internationalized Parsing Guide](../2-core-concepts/tempo.parse.md#internationalized-parsing-locales). + +### Parsing Unix Timestamps +Tempo handles both milliseconds (Number) and nanoseconds (BigInt). +```typescript +new Tempo(1716163200000); // Milliseconds +new Tempo(1716163200000000000n); // Nanoseconds +``` + +--- + +## Manipulation and Calculations + +### Add or Subtract Time +Tempo instances are immutable; `add()` returns a new instance. +```typescript +const deadline = new Tempo().add({ days: 7, hours: 2 }); +const past = new Tempo().add({ months: -1 }); + +// You can also step by semantic Terms using the `#` prefix! +const t1 = new Tempo('2024-05-15'); // Middle of Q2 +const t2 = t1.add({ '#quarter': 1 }); // Middle of Q3: "2024-08-14" (approx) +``` + +### Jumping to Boundaries (`start`, `mid`, `end`) +The `.set()` method allows you to jump to the boundaries of native units (like months or years) or semantic Terms (using the `#` prefix). You can specify whether to land on the inclusive start, inclusive end, or the exact center. +```typescript +// Native Units +const monthStart = new Tempo().set({ start: 'month' }); + +// Semantic Terms (Lands on 30-Sep 23:59:59.999... Inclusive End) +const qtrEnd = new Tempo().set({ end: '#quarter' }); + +// Lands on the arithmetic nanosecond midpoint of the period +const qtrMid = new Tempo().set({ mid: '#quarter' }); +``` + +### Slick Object Mutations +You can navigate relative to your current date by using Slick Shorthand operators directly inside `.set()`. Use the snippet shorthand keys (`yy`, `mm`, `ww`, `dd`, `wkd`, etc.) and provide a string payload containing a directional modifier: + +```typescript +const t = new Tempo('2024-05-20'); // Monday +t.set({ mm: '>2' }); // July 20th +t.set({ wkd: '>Fri' }); // May 24th +``` + +👉 **Learn More:** To read about advanced chaining, order-of-operations, and architectural limitations, see the [Slick Object Mutations Deep Dive](../2-core-concepts/tempo.mutate.md#slick-object-mutations). + +### How long until a deadline? (`until`) +```typescript +const t = new Tempo(); +const daysLeft = t.until('2025-01-01', 'days'); +console.log(`${daysLeft} days remaining`); +``` + +### Relative Time (`since`) +Generate human-readable relative time strings instantly. +```typescript +const t = new Tempo('yesterday'); +console.log(t.since()); // "1d ago" +``` + +--- + +## Timezones and Locales + +### Convert Time to Another Zone +```typescript +const nyc = new Tempo('2024-05-20 10:00', { timeZone: 'America/New_York' }); +const london = nyc.set({ timeZone: 'Europe/London' }); + +console.log(nyc.format('{hh}:{mi}')); // "10:00" +console.log(london.format('{hh}:{mi}')); // "15:00" +``` + +--- + +## Business Logic and Terms + +### Is it the weekend? +```typescript +const t = new Tempo(); +const isWeekend = t.dow >= 6; // Saturday = 6, Sunday = 7 +``` + +### What Fiscal Quarter are we in? +Using the `qtr` Term plugin (`term.qtr` is a convenient alias for the full `term.quarter` property). +```typescript +const t = new Tempo(); +console.log(`Current Quarter: ${t.term.qtr}`); // "Q1", "Q2", etc. +``` + +### Hemispheric Seasons +Tempo Terms are hemisphere-aware. +```typescript +const sydney = new Tempo('2024-07-01', { sphere: 'south' }); +console.log(sydney.term.szn); // "Winter" + +const london = new Tempo('2024-07-01', { sphere: 'north' }); +console.log(london.term.szn); // "Summer" + +// or even via the timeZone setting +console.log(new Tempo({ timeZone: 'America/New_York' }).term.szn); // "Summer" +console.log(new Tempo({ timeZone: 'Australia/Sydney' }).term.szn); // "Winter" +``` + +--- + +## Formatting and Localization + +### Semantic Formatting +Use specific Term tokens like `{#quarter}` or `{#season}` to automatically embed a Term's label (or key) into a format string. +```typescript +const t = new Tempo(); +console.log(t.format('We are currently in the {#quarter}')); // "We are currently in the First Quarter" +``` + +### Format Modifiers & Localization +Format strings support chained colon-modifiers (e.g., `:upper`, `:locale`, `:ord`) to dynamically change the presentation casing or delegate to the native `Intl` API. You can stack them to get the exact presentation required! + +```typescript +const t = new Tempo('2024-05-15 15:30', { locale: 'fr-FR' }); + +t.format('{mon:upper}'); // "MAY" (English Default -> UpperCase) +t.format('{mon:long}'); // "mai" (Native French Intl output via styling bridge) +t.format('{mon:long:upper} {dd}'); // "MAI 15" (Native French Intl output) +``` + +👉 **Learn More:** See the [Smart Formatting Guide](../2-core-concepts/tempo.format.md) for the complete list of available modifiers. + +::: tip +**Tired of typing styling modifiers?** +If you find yourself repeatedly writing `:long` or `:short` for the same localized date structure, save it to the global **FORMATS** registry! This creates a clean, reusable shortcut: +```typescript +Tempo.init({ + locale: 'fr-FR', + registry: { + formats: { + 'ui-date': '{wkd:long}, {dd:raw} {mon:long} {yyyy}' + } + } +}); + +t.format('ui-date'); // Resolved with all modifiers intact! +``` + +*Note: Format keys are resolved case-sensitively from the global `registry.formats` object. If the requested key is not found, Tempo will simply treat the provided string as a literal layout string rather than throwing an error.* +::: + +👉 **Learn More:** To build custom zero-overhead logic evaluators (like Fiscal Years or native Intl bridges), read the [Custom Format Tokens Deep Dive](../2-core-concepts/tempo.format.md#custom-format-tokens). + +👉 **Learn More:** +- [Smart Formatting Guide](../2-core-concepts/tempo.format.md) +- [The Role of Locale](../4-advanced-reference/tempo.locale.md) +- [Smart Parsing Guide](../2-core-concepts/tempo.parse.md) + +--- + +### Ticker Plugin +The Ticker engine is a premium plugin for precisely driving business logic (like recurring billing or reporting cycles) on specific date boundaries. + +```typescript +// Drive internal reporting precisely when a new quarter begins +await using quarterly = Tempo.ticker({ '#quarter': 1 }); + +for await (const t of quarterly) { + generateReport(t.term.qtr); +} +``` + +👉 **Learn More:** See the [Ticker Plugin Documentation](../../../plugins/ticker/doc/index.md) for detailed configuration, term-driven intervals, and `await using` syntax requirements. + + +--- + +## Interoperability + +### Converting to / from Native `Date` +```typescript +const date = new Tempo().toDate(); +const tempo = new Tempo(new Date()); +``` + +### Converting to `Temporal` Objects +```typescript +const zdt = new Tempo().toDateTime(); // Temporal.ZonedDateTime +const instant = new Tempo().toInstant(); // Temporal.Instant +const pdt = new Tempo().toPlainDate(); // Temporal.PlainDate +``` + +### Sorting an array of Tempos +```typescript +const dates = [new Tempo('tomorrow'), new Tempo('yesterday'), new Tempo('today')]; +dates.sort(Tempo.compare); // Sorts chronologically +``` + + + + + +--- + + + +# Document: 2-core-concepts/tempo.cache.md + +# 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 the Tempo AI plugin (`parseAI`, `formatAI`, `contextAI`)—is managed centrally by `Tempo.cache`. + +::: info Cache Behavior: Core Tempo vs. Tempo AI Plugin +* **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. +* **Tempo AI Plugin**: Caching is **automatic**. To eliminate network latency (~500ms+) and avoid redundant LLM API billing, AI functions (like `parseAI`) automatically check `Tempo.cache` before sending network requests and cache 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"`). + +--- + +## 🤖 AI Plugin Cache Integration (`@magmacomputing/tempo-plugin-ai`) + +The `@magmacomputing/tempo-plugin-ai` plugin works hand-in-hand with `Tempo.cache` to reduce LLM API calls and costs across AI functions: + +```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. + + + + +--- + + + +# Document: 2-core-concepts/tempo.config.md + +# Configuration Guide + +**Tempo** provides a flexible, multi-tiered configuration system. Settings are applied in a specific order of precedence, allowing you to set broad defaults that can be refined at the application or instance level. + +## Precedence Hierarchy + +Settings are loaded in the following order (where later stages override earlier ones): +1. **Library Defaults**: Sensible out-of-the-box baseline. +2. **Persistent Storage**: Sticky user preferences (which merge into Defaults). +3. **Global Discovery**: Enterprise-level setup discovered via `Symbol.for('$Tempo')`. +4. **Library Extension**: Dynamic feature registration via `Tempo.extend()`. +5. **Explicit Initialization**: Baseline configuration via `Tempo.init()`. +6. **Instance Constructor**: Specific overrides for a single `new Tempo()` call. + +--- + +## 🏆 Best Practice: The `tempo.config.ts` Pattern + +Rather than scattering `Tempo.init()` or `Tempo.extend()` calls throughout your application, the recommended best practice is to centralize your environment setup into a single `tempo.config.ts` (or `.js`) file. + +This mirrors modern ecosystem standards (like `vite.config.ts` or `tailwind.config.js`) and ensures that plugins, timezones, and custom aliases are consistently applied before any domain logic executes. + +::: info +**Target Environment**: This automatic configuration discovery pattern relies on Node.js file system capabilities and is designed for Server, Fullstack, or Bundled environments (like Vite or Webpack). If you are using Tempo via a ` + + +``` + +#### 2. Frontend Bundlers without `process.env` Polyfills +Modern browser bundlers (e.g., Vite) do not inject Node's `process` object by default. If you prefer to avoid configuring build-time env replacements or `dotenv` plugins, assign the key to `globalThis` in your entry file and use dynamic imports to ensure the key is set before Tempo initializes: + +```javascript +// entry.js +globalThis.TEMPO_LICENSE_KEY = import.meta.env.VITE_TEMPO_LICENSE_KEY; + +// Use dynamic imports so the key is set before Tempo's static initializer runs +const { Tempo } = await import('@magmacomputing/tempo'); +const { TickerPlugin } = await import('@magmacomputing/tempo-plugin-ticker'); + +Tempo.init({ plugins: [TickerPlugin] }); +``` + +Alternatively, pass the license key explicitly via `Tempo.init()` after your static imports: + +```javascript +import { Tempo } from '@magmacomputing/tempo'; +import { TickerPlugin } from '@magmacomputing/tempo-plugin-ticker'; + +Tempo.init({ + license: import.meta.env.VITE_TEMPO_LICENSE_KEY, + plugins: [TickerPlugin] +}); +``` + +#### 3. Micro-frontends / Shared Global Space +In architectures where multiple independently-bundled applications share a single browser tab, set the key once in the host container. All dynamically-loaded sub-applications will then auto-discover it without needing individual configuration: + +```javascript +// host-container.js +globalThis.TEMPO_LICENSE_KEY = 'eyJhbGciOiJSUzI1NiJ9...'; + +// sub-apps loaded later will automatically run in licensed mode +``` + +## 4. Network Requests & Offline Behavior + +To verify license validity and prevent abuse, Tempo's licensing engine performs background synchronization with our revocation registry: + +* **Outbound Request:** When a license key is active, Tempo asynchronously fetches a cryptographically signed revocation list (JWS). +* **Endpoint:** `https://registry.magmacomputing.com.au/tempo/v1/revoked.jws` (useful for configuring Content Security Policies (CSP) or egress firewall rules). +* **Frequency:** The revocation check occurs once every **7 days**. The last-checked state is cached to avoid redundant network traffic on subsequent startups. +* **Offline Resilience (Fail-Open):** If your application is offline, behind a strict firewall, or the registry server is temporarily unreachable, the validation **fails open**. Tempo emits a debug-level log entry but continues to grant access to premium features (relying on the local cryptographic expiration of the JWT). + +## 5. Commercialize Your Own Plugin + +Are you a developer who has built an incredibly useful, domain-specific Tempo plugin (e.g., medical billing cycles, legal discovery windows, complex religious calendars)? + +If you would like to monetize your logic without having to build your own licensing infrastructure, **we want to partner with you**. + +Get in touch with us with your proposed code and use-case. If it meets our quality and performance standards, we can publish it as an official Premium Extension secured behind the Tempo License Key system, under a mutually beneficial commercial revenue-sharing arrangement. + + + + + +--- + + + +# Document: 9-plugins/ai.architecture.md + +# 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 default models. +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: 'your-enterprise-model', + 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: 'your-local-model', + options: { timeout: 5000 } // Custom provider-level timeout (5s) + } + ] +}); +``` + +### Frontend Security Warning +> [!CAUTION] +> **Never** expose a raw LLM API key in a client-side browser bundle (like React or Vue) or store it in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). Any Cross-Site Scripting (XSS) vulnerability, compromised NPM package, or malicious browser extension can easily inspect client-side storage and steal secret keys, leading to quota exhaustion, billing fraud, or permanent provider bans. BYOK keys are *only* safe on backend servers or edge workers. + +## 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. + + + + +--- + + + +# Document: 9-plugins/ai.context.md + +# 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 Tempo AI 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 complex query from the perspective of September 1st +const dt = await parseAI("The penultimate Tuesday before Thanksgiving", { anchor: '2026-09-01T00:00:00Z' }); + +// Explicitly parse assuming a Japanese locale and timezone +const tokyoDt = await parseAI("The second Sunday of May", { locale: 'ja-JP', timeZone: 'Asia/Tokyo' }); +``` + +### Why Locale is Critical +Passing the `Locale` is absolutely critical for the LLM to know whether "11/12" means November 12th (US format) or 11th of December (UK/EU format). The plugin handles this transparently based on your standard Tempo configuration! + +> [!WARNING] +> **Calendar Math Hallucinations**: LLMs are language predictors, not calculators. While they excel at parsing conversational times (like `"tomorrow at 5pm"`), smaller models are notoriously prone to hallucinations on complex, cross-year calendar math. For example, asking a lightweight model for `"Thanksgiving in 2026"` may result in a hallucinated day of the week because the model doesn't natively compute "the fourth Thursday of November 2026." If your application relies on heavy holiday logic or complex multi-year math, you *must* use a capable frontier model or rely on deterministic plugins instead of AI. + +## The Decoupled Output Bridge + +To ensure deterministic behavior, the LLM is instructed to *only* return strict ISO 8601 strings. + +The plugin executes the network request, the LLM returns a local ISO string without a timezone offset or 'Z' suffix (like `"2026-11-26T00:00:00"`), and the plugin immediately passes that string back into the native `new Tempo()` constructor. The provider response must omit timezone suffixes to match the local ISO contract enforced by Tempo AI functions. The developer seamlessly receives a valid, native `Tempo` instance. This eliminates AST-construction ambiguity, creating a decoupled bridge between AI text generation and native Tempo conversion. + +### Relative Date Ambiguity Tie-Breakers + +To eliminate model variance on idioms like "Next Friday" or "Last Tuesday", the plugin enforces static system prompt ambiguity rules: +* `"next [weekday/unit]"`: Evaluated as the immediate next chronological occurrence after `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`. + +### 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 +// } +``` + + + + + +--- + + + +# Document: 9-plugins/ai.index.md + +![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 or stored in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). Client-side storage is vulnerable to XSS attacks, malicious scripts, and browser extension extraction, which can result in API key theft and quota abuse. BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, you must route requests through a secure backend 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: 'your-preferred-model' }, + ], + debug: true // (Development-only) Enable verbose console logging +}); +``` + +> **Tip**: `initAI` is fully re-callable! You can invoke 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. Fallback mode (default): query providers sequentially in array order until one succeeds +const fallback = await parseAI("First Monday of November", { + mode: 'fallback', // Default strategy if omitted + minConfidence: 0.8 // Require at least 0.8 confidence threshold +}); + +// 2. Race mode: send concurrent requests to all providers, returning the fastest valid response +const fastest = await parseAI("Third Friday of October", { mode: 'race' }); + +// 3. 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 +}); +``` + +## Timeout Controls & SLAs + +Prevent hanging requests using the 3-tier timeout hierarchy (`call-site` > `provider` > `global` > `default 15s`): + +```typescript +// Global timeout across all AI requests +initAI({ + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY, options: { timeout: 2000 } } // 2s timeout for fast provider + ], + timeout: 5000 // 5s global default timeout +}); + +// Hard 3-second SLA override for a specific call-site +const dt = await parseAI("Next Tuesday at 4pm", { timeout: 3000 }); +``` + +## 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](./ai.architecture.md) (BYOK vs Proxy patterns, Frontend Security) +- [Context & Natural Language Parsing](./ai.context.md) (How Timezone and Locale are injected) +- [Rate Limits & Cache Management](./ai.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. + + + + +--- + + + +# Document: 9-plugins/ai.rate-limits.md + +# 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 + +Quota and rate-limit metadata can be inspected in two convenient ways: + +### 1. Request-Locked Instance Metadata (`dt.ai.limits`) +Every `Tempo` instance returned by `parseAI` includes a frozen `.ai.limits` snapshot representing the exact rate limit state returned by the provider HTTP headers for *that specific request*: + +```typescript +const dt = await parseAI("The third Friday of next month"); + +if (dt.ai?.limits) { + console.log(`Remaining Tokens: ${dt.ai.limits.remainingTokens}`); + console.log(`Remaining Requests: ${dt.ai.limits.remainingRequests}`); + console.log(`Resets At: ${dt.ai.limits.resetAt?.format('{hh}:{mi}:{ss}')}`); +} +``` + +### 2. Global State Utility (`getAiRateLimits()`) +For quick status checks or global monitoring across the application lifecycle, `getAiRateLimits()` exposes the stats from the most recent LLM request: + +```typescript +import { getAiRateLimits } from '@magmacomputing/tempo-plugin-ai'; + +// Returns global 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, Tempo AI functions integrate directly with `Tempo.cache` (`BoundedCache`) to store pre-resolved ISO 8601 results, drastically reducing LLM API calls and network latency on repetitive queries. + +### Array Processing & Token Economics + +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 AI functions to instantly resolve duplicate strings against `Tempo.cache`, saving massive amounts of API tokens. If you pass an array of 10,000 dates, but only 1,000 are unique, only 1,000 network requests are made. +2. **Token Economics**: A single request consumes ~100 tokens (System Prompt + User String + Output ISO). Given that frontier models cost cents per million tokens, the risk of array-misalignment bugs (see below) far outweighs the negligible savings of batching system prompts. +3. **Deterministic Safety**: LLMs are language models, not arrays. If you pass 50 strings, smaller models often hallucinate and return 49 strings, completely breaking your array indexing. By querying sequentially, we guarantee a strict 1:1 mapping and ensure one invalid string doesn't crash the entire batch. + +> [!WARNING] +> **Granular Time Gotcha**: The cache key is automatically salted with the **calendar date** (`yyyy-mm-dd`) of the execution anchor. By default this uses the system execution date, but when `options.anchor` is explicitly set, it uses the caller-provided anchor date. Note that keeping a fixed anchor date retains the same cache key across midnight boundaries, so an automatic midnight cache miss is not guaranteed. + +### Soft Errors in Array Batches + +When processing arrays of inputs, an unparseable input or provider failure on one item will throw an error by default, stopping execution. Passing `softErrors: true` allows AI functions to return invalid `Tempo` instances (`isValid === false`) for failing items while completing the rest of the array: + +```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 +// 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 +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, you can provide custom cache instances via `initAI({ cache })` or `Tempo.init({ cache })`! + +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 cache lookup engine does not await promise-returning cache operations. + +```typescript +// Custom synchronous cache implementation +initAI({ + providers: [{ id: 'groq', key: '...' }], + cache: new MyCustomSyncCache() +}); +``` + + + + +--- + + + +# Document: 9-plugins/astro.index.md + +![Tempo Plugin](/plugin-logo.svg) + +# @magmacomputing/tempo-plugin-astro + +

+ npm version npm peer dependency version License TypeScript Ready +

+ +This is a Community plugin for the [Tempo](https://github.com/magmacomputing/magma) library that calculates the exact astronomical season (Equinoxes and Solstices) for any date using the **Jean Meeus polynomial algorithm**. + +> [!NOTE] +> **Mean-Polynomial Approximation (Ch. 27)** +> This plugin specifically implements the mean-polynomial calculation from Chapter 27 of Meeus' *Astronomical Algorithms*. To keep the library extremely lightweight, it omits the massive periodic correction tables required for exact apparent calculations. It is strictly enforced to support the mathematical range of **-1000 to +3000 AD**. + +Because it is a true astronomical calculation rather than a fixed calendar date, it precisely determines the exact minute the sun crosses the celestial equator. It is also **hemisphere-aware**: by configuring your Tempo instance with a `sphere` (e.g., `sphere: 'south'`), the plugin accurately flips the Vernal Equinox from Spring to Autumn. + +::: info Meteorological vs Astronomical +Unlike Tempo's built-in **Meteorological** `season` Term — which rigidly snaps to the 1st day of calendar months — this **Astronomical** plugin calculates the dynamic, true solar boundaries that shift slightly year-over-year. +::: +## Installation + +```bash +npm install @magmacomputing/tempo-plugin-astro +``` + +## Usage + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { AstroTerm } from '@magmacomputing/tempo-plugin-astro'; + +// Pass the plugin to `Tempo.init` to register it into the runtime. +Tempo.init({ + plugins: [AstroTerm] +}); + +const t = new Tempo('2026-03-20'); + +// Get the Astronomical Event mapping +console.log(t.term.astro); +// Output: 'Vernal' +``` + +### Response Payload + +When resolving the term, the plugin intelligently returns the correct astronomical event and its corresponding traditional season based on your configured hemisphere (`sphere`): + +```javascript +{ + key: 'Vernal', // Flips to 'Autumnal' if sphere is set to 'south' + season: 'Spring', // Flips to 'Autumn' if sphere is set to 'south' + sphere: 'north', // Flips to 'south' if sphere is set to 'south' + event: 'Equinox', + group: 'astronomy', + year: 2026, + month: 3, + day: 20, + hour: 14, + minute: 45, + second: 0 +} +``` + +You can also access the full metadata object containing the sub-second precision fields via the `astronomy` term: + +```typescript +console.log(t.term.astronomy); +// Output: { key: 'Vernal', group: 'astronomy', year: 2026, month: 3, day: 20, hour: 14, minute: 45, ... } +``` + +::: tip Did you know? +**Seasons:** `t.term.astronomy.season` returns the *Astronomical* season calculated by the precise timing of solstices and equinoxes. This will often differ from `t.term.season.key` in the core library, which uses standard Meteorological/Civil calendar boundaries (e.g., 1st of the month). +::: + +## Licensing + +This is a **Community** plugin. It is completely free and open-source for personal and commercial use. No license token is required. + + + + +--- + + + +# Document: 9-plugins/batch.index.md + +![Tempo Plugin](/plugin-logo.svg) + +# @magmacomputing/tempo-plugin-batch + +

+ npm version npm peer dependency version License TypeScript Ready +

+ +This is a Community plugin for the [Tempo](https://github.com/magmacomputing/magma) library that parallelizes massive epoch mutation tasks across worker threads utilizing lock-free `SharedArrayBuffer` architecture for extreme throughput. + +::: tip Perfect For +Heavy data ETL pipelines, massive IoT telemetry ingestion, financial ledger chronometrics, and any parallel bulk date-processing workloads. +::: + +## Installation + +```bash +npm install @magmacomputing/tempo-plugin-batch +``` + +## Usage + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { BatchPlugin } from '@magmacomputing/tempo-plugin-batch'; + +Tempo.init({ + plugins: [BatchPlugin] +}); + +// Assume `epochs` is a massive array of integers representing timestamps +const epochs = [1700000000000, 1700000001000, /* ... millions more ... */]; + +// Mutate millions of dates concurrently using the worker pool +// The engine automatically splits the payload and offloads to workers! +const batchResult = await Tempo.batch(epochs, { weeks: 1 }); + +console.log(batchResult); // Returns an array of mutated timestamp integers +``` + +### Rehydration + +By default, `Tempo.batch` returns an array of primitive `number` timestamps to maximize throughput over the thread boundary. If you need fully-fledged `Tempo` objects back, pass `{ rehydrate: true }`: + +```typescript +// Returns an array of Tempo instances instead of integers +const tempoInstances = await Tempo.batch(epochs, { weeks: 1 }, { rehydrate: true }); +``` + +### Graceful Degradation + +If the host environment does not support `SharedArrayBuffer` (or if it is blocked by CORS/COOP headers in the browser), the orchestrator intelligently and transparently falls back to using traditional `postMessage` structural cloning chunks to ensure execution never halts. + +## Licensing + +This is a **Community** plugin. It is completely free and open-source for personal and commercial use. No license token is required. + +For commercial licensing options, please contact Magma Computing. + + + + +--- + + + +# Document: 9-plugins/finance.index.md + +![Tempo Plugin](/plugin-logo.svg) + +# @magmacomputing/tempo-plugin-finance + +

+ npm version npm peer dependency version License TypeScript Ready +

+ +A specialized namespace plugin for Tempo that provides fiscal year and financial date utilities. + +## Installation + +```bash +npm install @magmacomputing/tempo-plugin-finance +``` + +## Usage + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { FinanceNamespace } from '@magmacomputing/tempo-plugin-finance'; + +// Register the namespace +Tempo.extend(FinanceNamespace); + +const t = new Tempo('2024-07-01'); + +// Evaluate static properties +console.log(t.finance.fiscalQuarter); // 3 +console.log(t.finance.taxYear); // 2024 + +// Evaluate functional closures +console.log(t.finance.isFiscalYearStart()); // false +``` + +## Licensing + +This is a **Community** plugin. It is completely free and open-source for personal and commercial use. No license token is required. + + + + +--- + + + +# Document: 9-plugins/snap.index.md + +![Tempo Plugin](/plugin-logo.svg) + +# @magmacomputing/tempo-plugin-snap + +

+ npm version npm peer dependency version License TypeScript Ready +

+ +A Community plugin for the [Tempo](https://github.com/magmacomputing/magma) library that provides robust time rounding and snapping functionality (e.g., snapping to the nearest 15 minutes or 1 hour block) for calendar and scheduling applications. + +By default, the plugin effortlessly snaps dates to a configurable minute-interval. This is particularly useful when building UI components like time-pickers, ensuring data boundaries align perfectly with application logic. + +### 💡 User Notes: Why Sub-Second Snapping? +While `hours` and `minutes` cover most UI use cases, sub-second precision (`ms`, `us`, `ns`) is invaluable for: +1. **Telemetry & Log Aggregation**: Snapping high-frequency jittery timestamps to the nearest `100ms` or `500ms` bucket for cleaner charts and analysis. +2. **Video & Audio Synchronization**: Multimedia frame rates require precise timing. Snap to the nearest `16ms` (approx 60fps) or `40ms` (25fps) to align data points with visual boundaries. +3. **Database & API Normalization**: Truncating or snapping Tempo's native nanosecond precision to the nearest `ms` before sending payloads ensures your local application state perfectly matches remote databases that don't support microseconds. +4. **Performance Benchmarking**: Grouping execution times into buckets (e.g., nearest `10ms`) for histograms. + +## Installation + +```bash +npm install @magmacomputing/tempo-plugin-snap +``` + +## Usage + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { SnapPlugin } from '@magmacomputing/tempo-plugin-snap'; + +// Pass the plugin to `Tempo.init` to register it into the runtime. +Tempo.init({ + plugins: [SnapPlugin] +}); + +const t = new Tempo('2026-06-01T14:08:00Z'); + +// Snaps to the nearest 15 minutes by default +const snapped = t.snap(); +console.log(snapped.format('{hh}:{mi}')); // "14:15" + +// Or explicitly provide units and intervals +const snapHour = t.snap({ hh: 1 }); +const snapSecond = t.snap({ ss: 30 }); +const snapMs = t.snap({ ms: 100 }); + +// Force snapping direction instead of standard rounding +const snapUp = t.snap({ mi: 15, direction: 'up' }); +const snapDown = t.snap({ mi: 15, direction: 'down' }); +``` + +## Licensing + +This is a **Community** plugin. It is completely free and open-source for personal and commercial use. No license token is required. + + + + +--- + + + +# Document: 9-plugins/sync.index.md + +![Tempo Plugin](/plugin-logo.svg) + +# @magmacomputing/tempo-plugin-sync + +

+ npm version npm peer dependency version License TypeScript Ready +

+ +This is a Community plugin for the [Tempo](https://github.com/magmacomputing/magma) library that provides lock-free, nanosecond-accurate cross-thread time synchronization using `SharedArrayBuffer` and `Atomics`. + +::: tip Perfect For +High-frequency trading platforms, real-time multiplayer game servers, distributed microservice tracing, and extreme-precision scientific telemetry. +::: + +## Installation + +```bash +npm install @magmacomputing/tempo-plugin-sync +``` + +## Usage + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { SyncPlugin } from '@magmacomputing/tempo-plugin-sync'; + +Tempo.init({ + plugins: [SyncPlugin] +}); + +// Master Thread: Start the clock +const clock = Tempo.sync.startClock({ updateIntervalMs: 1 }); +const buffer = clock.buffer; // Pass this SharedArrayBuffer to your workers +``` + +### Reading from Worker Threads + +To read the synchronized time from inside a worker thread, pass the `SharedArrayBuffer` via `workerData` and instantiate an `AtomicReader`. + +```typescript +// worker.ts +import { workerData } from 'node:worker_threads'; +import { AtomicReader } from '@magmacomputing/tempo-plugin-sync'; + +// Hydrate the reader using the master buffer +const reader = new AtomicReader(workerData.buffer); + +// 1. Get raw milliseconds (O(1) Atomic Read) +const ms = reader.now(); + +// 2. Get high-precision BigInt nanoseconds +const ns = reader.nowNano(); + +// 3. Hydrate a brand new Tempo instance with exact precision +const t = reader.getTempo(); +``` + +## Licensing + +This is a **Community** plugin. It is completely free and open-source for personal and commercial use. No license token is required. + +For commercial licensing options, please contact Magma Computing. + + + + +--- + + + +# Document: 9-plugins/ticker.index.md + +![Tempo Plugin](/plugin-logo.svg) + +# @magmacomputing/tempo-plugin-ticker + +

+ npm version npm peer dependency version License TypeScript Ready +

+ +This is a premium plugin for the [Tempo](https://github.com/magmacomputing/magma) library that provides a high-performance continuous execution loop (Ticker) based on temporal mathematics. + +For details on how to unlock and use these features, see our [License Key Guide](./_setup.index.md). + +::: info High Performance Loop +Unlike standard `setInterval` or `requestAnimationFrame`, the Ticker plugin leverages Tempo's robust temporal core to ensure exact sub-millisecond precision, making it ideal for games, complex UI animations, and accurate state synchronization. +::: + +## Installation + +```bash +npm install @magmacomputing/tempo-plugin-ticker +``` + +## Usage + +To use the Ticker, pass the plugin to `Tempo.init` to ensure it registers securely alongside your license. + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { TickerPlugin } from '@magmacomputing/tempo-plugin-ticker'; + +// A valid premium license token is required to use this plugin +Tempo.init({ + license: 'YOUR_LICENSE_TOKEN', + plugins: [TickerPlugin] +}); + +// You can access Ticker-based execution loops through the Tempo API: +const ticker = Tempo.ticker({ seconds: 1 }); +``` + +### Direct Access +If you need to access the [Reporting & Registry](#reporting-registry) API (like `Ticker.active`), you should import the `Ticker` namespace: + +```typescript +import { Ticker } from '@magmacomputing/tempo-plugin-ticker'; + +console.log(Ticker.active); +``` + +## 🚀 Key Features + +The Ticker supports a unified **Options** object, enabling professional resource management and semantic duration-based intervals. + +### 1. Semantic Intervals (Duration Objects) +Instead of raw numeric seconds, you can use `DurationLike` objects or shorthand keys for clarity. This is especially powerful for variable-length intervals like **months**. + +```typescript +// Pulse exactly once a month +await using monthly = Tempo.ticker({ months: 1 }); + +// You can also use highly compact shorthand keys +await using concise = Tempo.ticker({ hh: 1, mi: 30 }); // every 1h 30m + +// Pulse every time a new #quarter begins +await using quarterly = Tempo.ticker({ '#quarter': 1 }); +``` + +### 2. Term-Based Intervals +Ticker intervals can be driven by any registered **Term**. This is powerful for syncing with business cycles or daily shifts. + +> **Snapping vs Shifting:** Use directional shorthands (like `>`) to snap pulses exactly to the **boundaries** of the term (e.g., the very start of the morning). Using numeric values (like `1`) performs a relative shift, which preserves your current time-offset into the next period (e.g. two hours into a time-period will always be two hours into the next time-period). + +```typescript +// Snap and pulse exactly at the start of every 'morning', 'afternoon', etc. +using shiftTicker = Tempo.ticker({ '#timeOfDay': '>' }, (t) => { + console.log(`New period started: ${t.term.tod}`); +}); +``` + +### 3. Stop Conditions (Resource Management) +Prevent memory leaks and runaway processes by setting a built-in termination condition. + +```typescript +// Pattern A: Stop after exactly 5 ticks (defaults to 1-second interval) +using tickerA = Tempo.ticker({ limit: 5 }, (t) => console.log(t)); + +// Pattern B: Stop when a specific virtual time is reached (Inclusive) +using tickerB = Tempo.ticker({ + seconds: 10, // Plural DurationLike property + until: '2024-12-25T12:00:00' +}, (t) => console.log(t)); + +// Pattern C: Stop immediately (Limit: 0 is strictly honored) +using tickerC = Tempo.ticker({ limit: 0 }); +``` + +### 4. Virtual Clock (Seeding) +To create a **Virtual Clock** that increments from a specific point rather than using the system time, use the `seed` option: + +```typescript +// Starts at '2024-01-01', then increments by 1 day per pulse +await using daily = Tempo.ticker({ + days: 1, + seed: '2024-01-01' +}); +``` + +### 5. Backwards Tickers (Countdowns) +By providing a **negative** interval, you can create a Ticker that moves backwards in time. + +```typescript +// Count down from 10 seconds, moving backwards 1s at a time +using countdown = Tempo.ticker({ seconds: -1, seed: "00:00:10" }, (t, stop) => { + console.log(t.format('{ss}')); + if (t.ss === 0) stop(); +}); +``` + +## Usage Patterns + +### 1. Resource Management (Recommended) + +Using the `using` and `await using` keywords ensures that Tickers are automatically stopped when they go out of scope. + +```typescript +// Pattern A: Automatic cleanup for callback-based ticker +{ + using ticker = Tempo.ticker((t) => render(t)); // Defaults to a 1-second pulse +} // interval stops automatically here + +// Pattern B: Automatic cleanup for async generator +{ + await using ticker = Tempo.ticker(1); + for await (const t of ticker) { + if (done) break; + } +} // generator is closed and interval stops here +``` + +### 2. Manual Control (Programmatic Stop) + +If you are not using the `using` or `await using` keywords, or if you need to stop the Ticker from outside its own loop (e.g., in a separate event handler), you can manually call the `stop()` method on the Ticker object. + +```typescript +// Pattern A: Stop a callback-based ticker +const tickerA = Tempo.ticker(1, (t) => console.log(t)); +// ... later +tickerA.stop(); + +// Pattern B: Stop an async generator externally +const tickerB = Tempo.ticker(1); + +(async () => { + for await (const t of tickerB) { + console.log(t.toString()); + } + console.log('Ticker has been gracefully stopped.'); +})(); + +// Close the generator from somewhere else +setTimeout(() => { + tickerB.stop(); +}, 5000); +``` +### 3. Event Listeners (.on) +Instead of (or in addition to) the constructor callback, you can register listeners for the `'pulse'`, `'stop'`, and `'catch'` events. +All listeners use the same callback signature: `(t, stop) => {}`. + +```typescript +const ticker = Tempo.ticker(1); +ticker.on('pulse', (t) => console.log('Listener A:', t.fmt.weekTime)); +ticker.on('pulse', (t) => console.log('Listener B:', t.fmt.weekTime)); +ticker.on('stop', (t) => console.log('Ticker stopped at:', t.fmt.weekTime)); +``` +For `'stop'` listeners, the `stop` callback argument is included for signature consistency; however, invoking it after stop has already occurred is a no-op. + +### 4. Manual Pulsing (.pulse) +In some scenarios, you may want to drive a Ticker manually (e.g., from a UI event or a WebSocket message) while still benefiting from the Ticker's internal state management and listeners. + +```typescript +const ticker = Tempo.ticker({ seconds: 1 }); // Still has a 1s duration logic +// ... +ticker.pulse(); // Manually advance and notify listeners +``` + +## 🧟 Zombie Tickers (Warning) {#zombie-tickers-warning} + +In a Node.js environment, `Tempo.ticker()` uses background timers (`setTimeout`) to drive its pulses. If you do not explicitly stop a Ticker, it becomes a **"Zombie Ticker"** that continues to run indefinitely, even if the variable that created it has gone out of scope. + +### The Risks: +- **Process Hangs**: Node.js will not exit a process if there are active timers. Undisposed Tickers are a common cause of "mysterious hangs" at the end of test runs. +- **Test Inconsistency**: Leaked Tickers can continue to fire while subsequent tests are running, leading to flaky assertions and "impossible" state changes. +- **Memory Leaks**: Each active Ticker maintains closures that prevent garbage collection of the `Tempo` instance and its listeners. + +### The Solution: +Always use the **Disposer Pattern** (`using` or `await using`) or a `try...finally` block to guarantee cleanup: + +```typescript +// ✅✅ BEST: Automatic cleanup via 'using' +{ + using ticker = Tempo.ticker(1); + // ... logic ... +} // Stays clean: ticker stopped automatically here + +// ✅ GOOD: Manual cleanup in finally block (Required for captured variables) +let ticker; +try { + ticker = Tempo.ticker(1, (t) => { ... }); + // ... assertions ... +} finally { + ticker?.stop(); // Prevents "Zombie Tickers" even if assertions fail +} +``` + +::: warning +If you are using `const` or `let` without a `finally` block, an assertion failure will skip the `stop()` call, leaving a live timer in the event loop. Always prefer the `using` keyword or `try...finally` for industrial-grade resource management. +::: + +### `Ticker` Object +The object returned by `Tempo.ticker()` (or an instance of the `Ticker` class) implements the following interface: + +| Method / Property | Description | +| :--- | :--- | +| `on(event, cb)` | Registers a listener for the `'pulse'`, `'stop'`, or `'catch'` events. | +| `pulse()` | Manually triggers a pulse, advances state, and notifies listeners. Returns the new `Tempo`. | +| `info` | Read-only getter returning `{ next, ticks, limit, interval, stopped }`. | +| `stop()` | Stops the Ticker, clears active timers, and immediately resolves any pending async iteration Promises. | +| `[Symbol.dispose]` | Standard cleanup for `using` blocks. | +| `[Symbol.asyncDispose]` | Standard async cleanup for `await using` blocks. | +| `[Symbol.asyncIterator]` | Standard async iteration support (for `for await` loops). | + +## Reporting & Registry {#reporting-registry} + +The `Ticker` class maintains a static registry of all currently active Tickers. This is useful for debugging, monitoring, or cleanup checks. + +### `Ticker.active` +A static getter that returns an array of [`Ticker.Snapshot`](#tickersnapshot) objects for all active (non-stopped) Tickers. + +```typescript +import { Ticker } from '@magmacomputing/tempo-plugin-ticker'; + +// Get a report of all running tickers +const reports = Ticker.active; + +reports.forEach(({ ticker, next, ticks }) => { + console.log(`Ticker ${ticker} next pulse: ${next}, ticks so far: ${ticks}`); +}); +``` + +#### `Ticker.Snapshot` +```typescript +type Snapshot = { + ticker: Instance; // The Ticker instance (Proxy) itself + next: Tempo; // The next Tempo value to be emitted + ticks: number; // Number of pulses emitted so far + limit?: number; // The configured limit (if any) + interval: object; // The duration-based interval + stopped: boolean; // Whether the ticker is stopped +} +``` + +## 🎯 One-Shot Ticker (Meeting Alerts) + +You can use the Ticker as a "one-shot" timer for specific events by simply specifying a **seed** value. This is perfect for setting up a single alert (e.g., for a meeting) that cleans itself up immediately after firing. + +::: tip +**Seed-Only Logic**: Providing a `seed` (as a string or in an options object) without any other duration-based keys (`seconds`, `minutes`, etc.) or a `limit` implies a `limit: 1`. + +Effectively, `Tempo.ticker('Fri 10am')` and `Tempo.ticker({ seed: 'Fri 10am' })` and `Tempo.ticker({ seed: 'Fri 10am', limit: 1 })` are all treated as one-shot Tickers. + +**Inclusive Boundaries**: Termination conditions (`limit` and `until`) are **inclusive**. A Ticker with `limit: 1` will pulse exactly once before stopping. +::: + +```typescript +// Pattern A: Implicit one-shot via string seed +Tempo.ticker('Friday 10am', (t) => { + console.log(`Meeting alert: ${t.format('{hh}:{mi}')}`); +}); + +// Pattern B: Explicit one-shot via options +const event = { meeting: 'Friday 10am' }; + +Tempo.ticker({ + seed: { value: 'meeting', event } +}, (t) => { + console.log(`Meeting alert: ${t.format('{hh}:{mi}')}`); +}); +``` + +::: warning +**Future Seeds**: If the `seed` is in the future, the Ticker will remain dormant (waiting) until that time is reached. **Most Tickers emit an initial pulse immediately** (at the `seed` time or "now"), but a future seed will delay that first pulse until the specified time. +::: + +::: danger +**Persistence**: Ticker timers exist only **in-memory**. If the driving process (e.g., Node.js) terminates, any scheduled future pulses (including those from future seeds) are lost. For critical long-term scheduling, consider an external persistent job runner. +::: + +::: warning +While `limit: 1` handles the stop condition automatically, always remember that if you are using long-running Tickers without a limit, you **must** use the [Disposer Pattern](#zombie-tickers-warning) or manual `stop()` to avoid memory leaks and zombie processes. +::: + +## 🧭 Advanced: Syncing Multiple Clocks + +If you need to show multiple timezones on a dashboard, avoid creating multiple Tickers. Instead, use a single **Master Ticker** to drive all views. This prevents "drift" between the clocks and is much more efficient. + +### Using Signals (Recommended) + +Signals (from Preact, Solid, or Vue) are perfect for this "one source, many views" pattern. + +```typescript +// 1. Master source of truth +const now = signal(new Tempo()); + +// 2. Drive the master from a single ticker +using _ = Tempo.ticker(1, (t) => now.value = t); + +// 3. Derived timezones update automatically and stay 100% in sync +const sydney = computed(() => now.value.set({ timeZone: 'Australia/Sydney' })); +const london = computed(() => now.value.set({ timeZone: 'Europe/London' })); +``` + +### Using Async Generators (Framework-Agnostic) + +If you are not using a reactive framework, you can use the same pattern with an `AsyncGenerator` to derive all clocks from a single pulse. + +```typescript +// One generator, one interval, zero drift. +await using master = Tempo.ticker(1); + +for await (const t of master) { + const clocks = { + sydney: t.set({ timeZone: 'Australia/Sydney' }), + ny: t.set({ timeZone: 'America/New_York' }), + london: t.set({ timeZone: 'Europe/London' }) + }; + + renderDashboard(clocks); +} +``` + +## Licensing + +This is a **Premium** plugin. Usage requires an active, cryptographically signed Tempo license token with the `ticker` scope enabled. + +::: tip +
+ + Tempo License Registry + +
+ 👉 Go to the Tempo License Registry 👈
+ Manage your subscriptions and retrieve your license key. +
+
+::: + + + + +--- diff --git a/packages/tempo/public/llms.txt b/packages/tempo/public/llms.txt new file mode 100644 index 00000000..def4db0e --- /dev/null +++ b/packages/tempo/public/llms.txt @@ -0,0 +1,74 @@ +# Tempo: Immutable Date-Time Engine & AI Syntax Rules + +> Tempo is a lightweight, immutable JavaScript/TypeScript date-time library built around the native ECMAScript Temporal API proposal. It provides type-safe parsing, formatting, relative time arithmetic, and extensible layout matching across Browser and Node.js environments. + +## Core Architectural Rules & Philosophy +- **Temporal Engine**: Tempo expects native `Temporal` in modern runtimes or uses `@js-temporal/polyfill` when necessary. Never instantiate legacy JavaScript `Date`. +- **Strict Immutability**: `Tempo` instances are completely frozen. All mutating operations (`add`, `subtract`, `with`, `startOf`, `endOf`) return a brand-new `Tempo` object. +- **Zero-Cost Getter Proxies**: Properties like `.year`, `.month`, `.day`, `.hour`, `.minute`, `.second`, `.millisecond`, `.microsecond`, `.nanosecond` are live getters proxying the underlying `Temporal` state. +- **Plugin Architecture**: Core functions can be extended via `Tempo.extend(Plugin)`. License validation occurs via `Tempo.init(...)`. + +## Formatting & Parsing Tokens +| Token | Description | Sample Output | +| :--- | :--- | :--- | +| `{yy}` | Year (2 or 4 digits) | `2026`, `26` | +| `{mon}` | Month name (Full or Abbreviated) | `August`, `Aug` | +| `{mm}` | Month number (01-12) | `08` | +| `{dd}` | Day of month (01-31) | `04` | +| `{hh}` | Hour (00-24) | `15` | +| `{mi}` | Minute (00-59) | `30` | +| `{ss}` | Second (00-59) | `00` | +| `{wkd}` | Weekday name | `Tuesday`, `Tue` | +| `{tzd}` | Time zone offset / identifier | `Z`, `+10:00`, `Australia/Sydney` | +| `{yw}` | ISO Week-Year number | `W32` | +| `{unt}` | Time unit keyword | `day`, `month`, `year` | + +## Key Module Links +- [Full Documentation Concatenation](/llms-full.txt): Complete raw markdown documentation for RAG ingestion. +- [Layout Patterns & Regex Snippets](https://github.com/magmacomputing/magma/tree/main/packages/tempo/doc/3-extending-tempo/tempo.layout.md): Guide to writing regex layouts and snippets. +- [Plugin Development](https://github.com/magmacomputing/magma/tree/main/packages/tempo/doc/3-extending-tempo/tempo.plugin.md): Rules for extending Tempo via plugins. +- [Utility Library](https://github.com/magmacomputing/magma/tree/main/packages/tempo/doc/6-utility-library/tempo.library.md): Type detection, serialization (`stringify`/`objectify`), and `Pledge`. + +## Common Code Snippets + +### Quick Setup & Basic Usage +```typescript +import { Tempo } from '@magmacomputing/tempo'; + +// Create from current instant or ISO string +const t = Tempo.from('2026-08-04T15:30:00Z'); + +// Format using layout tokens +t.format('{mon} {dd}, {yy}'); // "August 04, 2026" + +// Immutably manipulate date +const nextWeek = t.add({ days: 7 }); +``` + +### Smart Parsing & Shorthand Expressions +```typescript +import { Tempo } from '@magmacomputing/tempo'; + +// 1. Natural language & relative shorthand expressions +const t1 = Tempo.from('next friday'); +const t2 = Tempo.from('start of month'); +const t3 = Tempo.from('+3 days'); + +// 2. Flexible date parsing +const t4 = Tempo.parse('2026-08-04T15:30:00+10:00'); +``` + +### Custom Layout Registration & Parsing +```typescript +import { Tempo } from '@magmacomputing/tempo'; + +// Register custom snippet & layout pattern via config +Tempo.config({ + layouts: { + us_date: '{mm}/{dd}/{yy}' + } +}); + +// Parse string using registered layout +const parsed = Tempo.parse('08/04/2026', 'us_date'); +``` diff --git a/packages/tempo/public/providers.v1.json b/packages/tempo/public/providers.v1.json new file mode 100644 index 00000000..185c5a6a --- /dev/null +++ b/packages/tempo/public/providers.v1.json @@ -0,0 +1,26 @@ +{ + "version": "1.0", + "updatedAt": "2026-08-05T00:00:00Z", + "providers": { + "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-3.6-flash", + "tokenParam": "max_tokens" + }, + "mistral": { + "url": "https://api.mistral.ai/v1/chat/completions", + "model": "mistral-small-latest", + "tokenParam": "max_tokens" + } + } +} diff --git a/packages/tempo/public/script.index.html b/packages/tempo/public/script.index.html index 57f8ddd4..4ca30a7b 100644 --- a/packages/tempo/public/script.index.html +++ b/packages/tempo/public/script.index.html @@ -99,6 +99,7 @@ margin-bottom: 5px; background: linear-gradient(135deg, #fff, #a5b4fc); -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; letter-spacing: -0.5px; } From 858dd347147cf292c1cda5fd1f7735f9ab653b95 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 5 Aug 2026 15:55:17 +1000 Subject: [PATCH 05/23] PR 1st review --- packages/library/README.md | 2 +- packages/plugins/.bin/check-branch-diff.sh | 30 +++++++- packages/plugins/ai/CHANGELOG.md | 4 +- packages/plugins/ai/README.md | 2 +- packages/plugins/ai/doc/architecture.md | 9 +++ packages/plugins/ai/doc/index.md | 6 +- packages/plugins/ai/doc/rate-limits.md | 20 +++-- packages/plugins/ai/src/core/init.ts | 68 ++++++++++++----- packages/plugins/ai/src/core/manifest.ts | 75 +++++++++++++------ packages/plugins/ai/src/core/support.ts | 13 ++++ packages/plugins/ai/test/manifest.test.ts | 9 +-- packages/tempo/CHANGELOG.md | 2 +- packages/tempo/bin/update-version.mjs | 10 ++- .../doc/1-getting-started/ai-integration.md | 12 +-- .../doc/3-extending-tempo/tempo.layout.md | 2 +- packages/tempo/public/esm_sh.index.html | 7 +- packages/tempo/public/llms-full.txt | 14 ++-- packages/tempo/src/support/support.cache.ts | 8 +- packages/tempo/test/support/cache.test.ts | 9 +++ 19 files changed, 217 insertions(+), 85 deletions(-) diff --git a/packages/library/README.md b/packages/library/README.md index a7fe02a6..27f28657 100644 --- a/packages/library/README.md +++ b/packages/library/README.md @@ -1,4 +1,4 @@ -# Tempo Library Logo Magma Library (Internal Reference) +# Tempo Library Logo Magma Library (Internal Reference) > [!NOTE] > **Internal Reference Package**: `packages/library` is an internal monorepo utility suite used across Tempo packages. It is **not** published as a standalone package on npm, and is provided in the documentation as a reference guide for internal architectural utilities and shared routines. diff --git a/packages/plugins/.bin/check-branch-diff.sh b/packages/plugins/.bin/check-branch-diff.sh index e92e82c8..3a2b1e93 100755 --- a/packages/plugins/.bin/check-branch-diff.sh +++ b/packages/plugins/.bin/check-branch-diff.sh @@ -9,6 +9,8 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" printf "%-25s | %-13s | %-14s | %-14s | %-30s\n" "Plugin Package" "Files Changed" "Main Version" "Branch Version" "Version Bump Status" printf "%-25s-+-%-13s-+-%-14s-+-%-14s-+-%-30s\n" "-------------------------" "-------------" "--------------" "--------------" "------------------------------" +has_error=0 + for plugin_dir in "${REPO_ROOT}/packages/plugins"/*; do if [ -d "${plugin_dir}" ] && [ -f "${plugin_dir}/package.json" ]; then plugin_name=$(basename "${plugin_dir}") @@ -28,13 +30,35 @@ for plugin_dir in "${REPO_ROOT}/packages/plugins"/*; do if [ "${main_version}" = "[NEW]" ]; then status="🆕 New Plugin (v${branch_version})" elif [ "${changed_count}" -gt 0 ]; then - if [ "${main_version}" = "${branch_version}" ]; then - status="🚨 MODIFIED WITHOUT VERSION BUMP!" - else + is_gt=$(node --input-type=module -e ' +function parseSemver(v) { + const m = String(v).trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); + if (!m) return null; + return { major: parseInt(m[1], 10), minor: parseInt(m[2], 10), patch: parseInt(m[3], 10), prerelease: m[4] || "" }; +} +function compare(a, b) { + const pa = parseSemver(a), pb = parseSemver(b); + if (!pa || !pb) return false; + if (pa.major !== pb.major) return pa.major > pb.major; + if (pa.minor !== pb.minor) return pa.minor > pb.minor; + if (pa.patch !== pb.patch) return pa.patch > pb.patch; + if (!pa.prerelease && pb.prerelease) return true; + if (pa.prerelease && !pb.prerelease) return false; + return pa.prerelease > pb.prerelease; +} +console.log(compare(process.argv[1], process.argv[2]) ? "true" : "false"); +' "${branch_version}" "${main_version}") + + if [ "${is_gt}" = "true" ]; then status="✅ Bumped (v${main_version} -> v${branch_version})" + else + status="🚨 MODIFIED WITHOUT VERSION BUMP!" + has_error=1 fi fi printf "%-25s | %-13s | %-14s | %-14s | %-30s\n" "${plugin_name}" "${changed_count}" "${main_version}" "${branch_version}" "${status}" fi done + +exit ${has_error} diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index 43ee8154..bba9a013 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -15,10 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Timeout-Triggered Provider Cascade**: Stalled or slow provider requests trigger an `AbortSignal` cancellation, allowing `AiMode.Fallback` to instantly cascade to secondary providers and `AiMode.Race` to clean up lagging request promises. - **Request-Locked `.ai.limits` Metadata**: Attached `limits` (`remainingRequests`, `remainingTokens`, `resetAt`) directly to the `.ai` metadata container (`TempoAiMeta`) of returned `Tempo` instances, locking HTTP header rate-limit snapshots to individual requests and preventing concurrency overwrites. - **Async Storage Adapters (`AiCacheAdapter`)**: Introduced custom storage engine support (`AiCacheAdapter`) in `initAI` and `parseAI` for distributed serverless environments (e.g. Upstash Redis, Cloudflare KV, Memcached). -- **Cascading Cache TTL Hierarchy**: Implemented strict 4-tier TTL resolution (`options.ttl` > `provider.ttl` > `global config.ttl` > default 1 hour) for fine-grained cache entry expiration control. +- **Cascading Cache TTL Hierarchy**: Implemented strict 4-tier TTL resolution (`options.ttl` > `provider.ttl` > `global config.ttl` > default 1 hour) for fine-grained cache entry expiration control on stores enforcing TTL. - **Fail-Open Storage Resilience**: Custom cache adapter errors during `get()` or `set()` operations fail open silently to direct LLM resolution, preserving application request uptime. - **Dynamic Remote Provider Manifest (`loadRemoteManifest`)**: Lazily fetches remote provider defaults (`providers.v1.json`) on initialization with a 1500ms timeout and automatic air-gapped fallback to compiled `DEFAULT_PROVIDERS`. -- **Cache Eviction Synchronization**: Enhanced `clearAiCache()` to flush matching keys and prefixes across both `Tempo.cache` and custom `AiCacheAdapter` storage engines. +- **Cache Eviction Synchronization**: Enhanced `clearAiCache()` to flush matching keys and prefixes across both `Tempo.cache` and custom `AiCacheAdapter` storage engines, returning `Promise` to await async adapter eviction. ## [0.2.0] - 2026-07-30 diff --git a/packages/plugins/ai/README.md b/packages/plugins/ai/README.md index 904412b5..3b5b1ebb 100644 --- a/packages/plugins/ai/README.md +++ b/packages/plugins/ai/README.md @@ -52,7 +52,7 @@ clearAiCache("The penultimate Tuesday before Thanksgiving in 2026"); * 🤖 **Multi-Provider Routing**: Native support for Groq, OpenAI, Gemini, Mistral, and local Ollama nodes with automatic fallback. * 🌐 **Dynamic Provider Manifest**: Model IDs and endpoints are lazily updated via hosted JSON manifests with 1500ms fail-open air-gapped fallbacks. * ⚡ **Two-Tier Caching**: Combines fast local in-memory LRU caching (`BoundedCache`) with optional async storage adapters (`AiCacheAdapter` for Redis / Cloudflare KV). -* ⏱️ **Cascading TTL Policies**: Granular TTL control at call-site, provider, or global levels. +* ⏱️ **Cascading TTL Policies**: Granular TTL control at call-site, provider, or global levels for TTL-enforcing storage adapters (built-in `Tempo.cache` maintains its independently configured TTL). * 🛡️ **Fail-Safe Confidence Bounds**: Configurable `minConfidence` thresholds and array batch processing with soft-error handling. --- diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md index ab978bce..7195fe11 100644 --- a/packages/plugins/ai/doc/architecture.md +++ b/packages/plugins/ai/doc/architecture.md @@ -51,6 +51,15 @@ initAI({ By default, `@magmacomputing/tempo-plugin-ai` lazily fetches provider defaults (model IDs, endpoints, token parameter keys) from `https://tempo.magmacomputing.com.au/providers.v1.json` once per application lifecycle. +- **Async Resolution & Promise Lifecycle**: `initAI()` returns a `Promise`. + - **Synchronous Fire-and-Forget**: Calling `initAI(...)` synchronously without `await` immediately initializes system state with compiled local provider defaults (`DEFAULT_PROVIDERS`). You can execute `parseAI()` immediately on the next line without blocking. The remote manifest is fetched in the background and transparently updates provider defaults once received. + - **Guaranteed Remote Resolution**: If your application strictly requires remote provider defaults to be resolved before executing your first AI request, you can `await initAI(...)`: + ```typescript + // Await guaranteed remote manifest completion before proceeding + await initAI({ + providers: [{ id: 'groq', key: process.env.GROQ_API_KEY! }] + }); + ``` - **Fail-Open & Air-Gapped Fallback**: If the network request fails, times out (1500ms limit), or the application is running offline or in an air-gapped environment, `initAI()` automatically and silently falls back to compiled local defaults (`DEFAULT_PROVIDERS`). - **Disabling Remote Manifest**: Pass `remoteConfigUrl: false` to disable remote manifest fetching entirely: ```typescript diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md index 89b252db..9114ccb8 100644 --- a/packages/plugins/ai/doc/index.md +++ b/packages/plugins/ai/doc/index.md @@ -42,8 +42,8 @@ npm install @magmacomputing/tempo-plugin-ai ```typescript import { parseAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; -// Initialize with your BYOK API Key -initAI({ +// Initialize with your BYOK API Key (await is optional if guaranteeing remote manifest resolution) +await initAI({ providers: [ { id: 'openai', key: process.env.OPENAI_API_KEY, model: 'your-preferred-model' }, ], @@ -51,7 +51,7 @@ initAI({ }); ``` -> **Tip**: `initAI` is fully re-callable! You can invoke it multiple times during your application's lifecycle to hot-swap API keys or update your fallback providers mid-stream without restarting your server. +> **Tip**: `initAI` returns a `Promise` and is fully re-callable! Calling it synchronously without `await` instantly initializes local defaults so you can call `parseAI` immediately, while `await initAI()` guarantees that remote provider manifest overrides are fetched and applied before proceeding. ```typescript // Parse a complex natural language string! diff --git a/packages/plugins/ai/doc/rate-limits.md b/packages/plugins/ai/doc/rate-limits.md index 50252aac..4ad6e3cc 100644 --- a/packages/plugins/ai/doc/rate-limits.md +++ b/packages/plugins/ai/doc/rate-limits.md @@ -9,7 +9,7 @@ The plugin automatically tracks these limits by reading the standard `x-ratelimi Quota and rate-limit metadata can be inspected in two convenient ways: ### 1. Request-Locked Instance Metadata (`dt.ai.limits`) -Every `Tempo` instance returned by `parseAI` includes a frozen `.ai.limits` snapshot representing the exact rate limit state returned by the provider HTTP headers for *that specific request*: +Every `Tempo` instance returned by `parseAI` includes a frozen `.ai.limits` snapshot representing the rate limit state returned by provider HTTP headers for *that specific request*. Note that `.ai.limits` is guaranteed only for provider-backed network requests where rate-limit headers are returned by the selected provider; results resolved via native parsing (`provider: 'native'`) or cache hits (`provider: 'cache'`) may omit `.ai.limits` (`undefined`). ```typescript const dt = await parseAI("The third Friday of next month"); @@ -135,15 +135,21 @@ import { Redis } from '@upstash/redis'; const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN! }); -// Implement custom async Redis storage adapter +// Implement custom async Redis storage adapter with namespacing & prefix deletion support const redisAdapter: AiCacheAdapter = { - get: async (key) => (await redis.get(key)) ?? undefined, + get: async (key) => (await redis.get(`tempo:ai:${key}`)) ?? undefined, set: async (key, value, ttlMs) => { - if (ttlMs) await redis.set(key, value, { px: ttlMs }); - else await redis.set(key, value); + if (ttlMs) await redis.set(`tempo:ai:${key}`, value, { px: ttlMs }); + else await redis.set(`tempo:ai:${key}`, value); }, - delete: async (key) => { await redis.del(key); }, - clear: async () => { /* optional prefix wipe */ } + delete: async (key) => { + await redis.del(`tempo:ai:${key}`); + }, + clear: async (prefix) => { + const pattern = prefix ? `tempo:ai:${prefix}*` : `tempo:ai:*`; + const keys = await redis.keys(pattern); + if (keys.length > 0) await redis.del(...keys); + } }; initAI({ diff --git a/packages/plugins/ai/src/core/init.ts b/packages/plugins/ai/src/core/init.ts index 6294821b..a8c6783b 100644 --- a/packages/plugins/ai/src/core/init.ts +++ b/packages/plugins/ai/src/core/init.ts @@ -12,44 +12,72 @@ export const _state: { limits: null, } -export function initAI(config: AiConfig): void { +export function initAI(config: AiConfig): Promise { if (config.providers) assertNoReservedProviderId(config.providers); const remoteUrl = config.remoteConfigUrl ?? _state.config.remoteConfigUrl; - if (remoteUrl !== false) - loadRemoteManifest(remoteUrl, undefined, config.debug ?? _state.config.debug).catch(() => { }); - - const resolvedProviders = config.providers ? config.providers.map(p => { - const normalizedId = p.id?.toLowerCase() ?? ''; - const defaults = getResolvedProviderDefaults(normalizedId, remoteUrl, config.debug ?? _state.config.debug); - return { - ...defaults, - ...p - } as AiProvider; - }) : _state.config.providers; + const resolveSyncProviders = (providers?: AiProvider[]) => { + if (!providers) return _state.config.providers; + return providers.map(p => { + const normalizedId = p.id?.toLowerCase() ?? ''; + const defaults = getResolvedProviderDefaults(normalizedId, remoteUrl, config.debug ?? _state.config.debug); + return { + ...defaults, + ...p + } as AiProvider; + }); + } + // Synchronously update _state.config for immediate availability _state.config = { ..._state.config, ...config, - providers: resolvedProviders || [] + providers: resolveSyncProviders(config.providers) || [] }; if (config.cache) { Tempo.init({ cache: config.cache, silent: true }); } + + return (async () => { + if (config.fetchDefaults && config.providers) { + const asyncProviders = await Promise.all(config.providers.map(async p => { + const normalizedId = p.id?.toLowerCase() ?? ''; + const defaults = getResolvedProviderDefaults(normalizedId, remoteUrl, config.debug ?? _state.config.debug); + let hookOptions: Partial | null = null; + try { + hookOptions = await config.fetchDefaults!(normalizedId); + } catch { } + return { + ...defaults, + ...(hookOptions ?? {}), + ...p + } as AiProvider; + })); + _state.config.providers = asyncProviders; + } + + if (remoteUrl !== false) { + try { + await loadRemoteManifest(remoteUrl, undefined, config.debug ?? _state.config.debug); + _state.config.providers = resolveSyncProviders(config.providers ?? _state.config.providers); + } catch { } + } + })(); } -export function clearAiCache(input?: string | string[]): void { +export async function clearAiCache(input?: string | string[]): Promise { const adapter = _state.config.cacheAdapter; if (!input) { + Tempo.cache.clear(); if (adapter?.clear) { try { const res = adapter.clear(); - if (res instanceof Promise) res.catch(() => {}); - } catch {} + if (res instanceof Promise) await res.catch(() => { }); + } catch { } } return; } @@ -66,15 +94,15 @@ export function clearAiCache(input?: string | string[]): void { try { if (adapter.delete) { const res1 = adapter.delete(normalized); - if (res1 instanceof Promise) res1.catch(() => {}); + if (res1 instanceof Promise) await res1.catch(() => { }); const res2 = adapter.delete(i); - if (res2 instanceof Promise) res2.catch(() => {}); + if (res2 instanceof Promise) await res2.catch(() => { }); } if (adapter.clear) { const resClear = adapter.clear(prefix); - if (resClear instanceof Promise) resClear.catch(() => {}); + if (resClear instanceof Promise) await resClear.catch(() => { }); } - } catch {} + } catch { } } } } diff --git a/packages/plugins/ai/src/core/manifest.ts b/packages/plugins/ai/src/core/manifest.ts index d9a15a58..8d6fe129 100644 --- a/packages/plugins/ai/src/core/manifest.ts +++ b/packages/plugins/ai/src/core/manifest.ts @@ -4,19 +4,19 @@ import type { AiProvider } from './types.js'; export const DEFAULT_REMOTE_MANIFEST_URL = 'https://tempo.magmacomputing.com.au/providers.v1.json'; export const DEFAULT_MANIFEST_TIMEOUT_MS = 1500; -let _cachedManifest: Record> | null = null; -let _fetchPromise: Promise> | null> | null = null; +let _cachedManifestMap = new Map>>(); +let _fetchPromiseMap = new Map> | null>>(); /** * Resets the in-memory manifest cache (used primarily for unit testing). */ export function resetManifestCache(): void { - _cachedManifest = null; - _fetchPromise = null; + _cachedManifestMap.clear(); + _fetchPromiseMap.clear(); } /** - * Fetches the remote AI provider manifest once per module load. + * Fetches the remote AI provider manifest. Remote defaults are loaded during initialization. * Fail-open: if network fails or times out, returns null and allows fallback to local DEFAULT_PROVIDERS. */ export async function loadRemoteManifest( @@ -28,19 +28,19 @@ export async function loadRemoteManifest( return null; } - if (_cachedManifest !== null) { - return _cachedManifest; - } + const targetUrl = typeof remoteConfigUrl === 'string' && remoteConfigUrl.trim().length > 0 + ? remoteConfigUrl.trim() + : DEFAULT_REMOTE_MANIFEST_URL; - if (_fetchPromise !== null) { - return _fetchPromise; + if (_cachedManifestMap.has(targetUrl)) { + return _cachedManifestMap.get(targetUrl)!; } - const targetUrl = typeof remoteConfigUrl === 'string' && remoteConfigUrl.trim().length > 0 - ? remoteConfigUrl - : DEFAULT_REMOTE_MANIFEST_URL; + if (_fetchPromiseMap.has(targetUrl)) { + return _fetchPromiseMap.get(targetUrl)!; + } - _fetchPromise = (async () => { + const fetchPromise = (async () => { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -56,34 +56,39 @@ export async function loadRemoteManifest( if (debug) { console.warn(`[tempo-plugin-ai] Remote manifest fetch failed with status ${response.status}`); } - _cachedManifest = {}; + const empty = {}; + _cachedManifestMap.set(targetUrl, empty); return null; } const data = await response.json(); if (data && typeof data === 'object' && data.providers && typeof data.providers === 'object') { - _cachedManifest = data.providers as Record>; - return _cachedManifest; + const manifest = data.providers as Record>; + _cachedManifestMap.set(targetUrl, manifest); + return manifest; } if (debug) { console.warn('[tempo-plugin-ai] Remote manifest missing valid "providers" object structure'); } - _cachedManifest = {}; + const empty = {}; + _cachedManifestMap.set(targetUrl, empty); return null; } catch (err: any) { if (debug) { console.warn(`[tempo-plugin-ai] Remote manifest fetch error: ${err?.message || err}`); } // Fail-open: store empty object so we fallback to DEFAULT_PROVIDERS without hanging subsequent calls - _cachedManifest = {}; + const empty = {}; + _cachedManifestMap.set(targetUrl, empty); return null; } finally { - _fetchPromise = null; + _fetchPromiseMap.delete(targetUrl); } })(); - return _fetchPromise; + _fetchPromiseMap.set(targetUrl, fetchPromise); + return fetchPromise; } /** @@ -98,12 +103,36 @@ export function getResolvedProviderDefaults( const normalizedId = providerId?.toLowerCase() ?? ''; const localDefaults = DEFAULT_PROVIDERS[normalizedId] || DEFAULT_PROVIDERS.openai; - if (remoteConfigUrl === false || !_cachedManifest || !_cachedManifest[normalizedId]) { + if (remoteConfigUrl === false) { return localDefaults; } + const targetUrl = typeof remoteConfigUrl === 'string' && remoteConfigUrl.trim().length > 0 + ? remoteConfigUrl.trim() + : DEFAULT_REMOTE_MANIFEST_URL; + + const cached = _cachedManifestMap.get(targetUrl); + if (!cached || !cached[normalizedId]) { + return localDefaults; + } + + const manifestEntry = { ...cached[normalizedId] }; + + // Validate manifest-derived URL origin: must be HTTPS or localhost HTTP + if (manifestEntry.url) { + try { + const parsed = new URL(manifestEntry.url); + if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'))) { + if (debug) console.warn(`[tempo-plugin-ai] Rejected manifest provider URL '${manifestEntry.url}' - invalid HTTPS origin.`); + delete manifestEntry.url; + } + } catch { + delete manifestEntry.url; + } + } + return { ...localDefaults, - ..._cachedManifest[normalizedId] + ...manifestEntry }; } diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index 029273d5..7e891649 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -67,6 +67,18 @@ export async function fetchFromProvider( const url = provider.url!; const model = provider.model!; + if (!url || typeof url !== 'string') + throw new TempoAiError(`Provider ${provider.id} missing valid endpoint URL.`, 400); + + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'))) + throw new TempoAiError(`Provider ${provider.id} endpoint URL '${url}' must use secure HTTPS protocol.`, 400); + } catch (err: any) { + if (err instanceof TempoAiError) throw err; + throw new TempoAiError(`Provider ${provider.id} has invalid endpoint URL '${url}'.`, 400); + } + const 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.", @@ -107,6 +119,7 @@ Do not include markdown blocks or any text outside the JSON.`; try { const response = await fetch(url, { method: 'POST', + redirect: 'error', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${provider.key}` diff --git a/packages/plugins/ai/test/manifest.test.ts b/packages/plugins/ai/test/manifest.test.ts index ff827ae6..e7188bfc 100644 --- a/packages/plugins/ai/test/manifest.test.ts +++ b/packages/plugins/ai/test/manifest.test.ts @@ -107,10 +107,7 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { new Response(JSON.stringify(mockManifest), { status: 200 }) ); - // Pre-load manifest - await loadRemoteManifest(DEFAULT_REMOTE_MANIFEST_URL); - - initAI({ + await initAI({ providers: [{ id: 'groq', key: 'test-key' }] }); @@ -130,9 +127,7 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { new Response(JSON.stringify(mockManifest), { status: 200 }) ); - await loadRemoteManifest(DEFAULT_REMOTE_MANIFEST_URL); - - initAI({ + await initAI({ providers: [{ id: 'openai', key: 'test-key' }] }); diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index ec1badb2..905ecdee 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Cache Serialization (`toJSON`)**: Added native `toJSON()` serialization support to `BoundedCache` and the `Tempo.cache` facade object. Calling `Tempo.cache.toJSON()` or `JSON.stringify(Tempo.cache)` now cleanly converts active, non-expired in-memory cache entries into a plain key-value JavaScript object. -- **AI Context & IDE Integration (`llms.txt`)**: Published official standardized `llms.txt` and `llms-full.txt` context bundles at `https://tempo.magmacomputing.com.au` to enable zero-hallucination code generation for IDE tools (Cursor, VS Code / GitHub Copilot, Antigravity) and web AI interfaces (ChatGPT, Claude, Gemini). +- **AI Context & IDE Integration (`llms.txt`)**: Published official standardized `llms.txt` and `llms-full.txt` context bundles at `https://tempo.magmacomputing.com.au` to provide full project context and enhance code-generation accuracy for IDE tools (Cursor, VS Code / GitHub Copilot, Antigravity) and web AI interfaces (ChatGPT, Claude, Gemini). - **Automated Doc Harvester**: Created `bin/generate-llms-txt.mjs` monorepo build script integrated into `npm run docs:build` to harvest all 56 markdown documentation files into a unified `llms-full.txt` corpus. - **AI Documentation Guide**: Added a dedicated `AI & IDE Integration` guide (`doc/1-getting-started/ai-integration.md`) featured directly in the primary VitePress navigation sidebar under Getting Started. diff --git a/packages/tempo/bin/update-version.mjs b/packages/tempo/bin/update-version.mjs index bb0cff10..2e726a54 100644 --- a/packages/tempo/bin/update-version.mjs +++ b/packages/tempo/bin/update-version.mjs @@ -9,7 +9,7 @@ * Called automatically by `npm run prebuild`. */ import pkg from '../package.json' with { type: 'json' }; -import { writeFileSync } from 'node:fs'; +import { writeFileSync, readFileSync, existsSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -28,5 +28,13 @@ export const TEMPO_VERSION = '${version}'; `; writeFileSync(versionFile, content, 'utf-8'); + +const esmHtmlFile = resolve(__dirname, '../public/esm_sh.index.html'); +if (existsSync(esmHtmlFile)) { + let html = readFileSync(esmHtmlFile, 'utf-8'); + html = html.replace(/https:\/\/esm\.sh\/@magmacomputing\/tempo@[^\"]+/, `https://esm.sh/@magmacomputing/tempo@${version}`); + writeFileSync(esmHtmlFile, html, 'utf-8'); +} + console.log(`✅ Tempo version stamped: ${version}`); diff --git a/packages/tempo/doc/1-getting-started/ai-integration.md b/packages/tempo/doc/1-getting-started/ai-integration.md index 358897a3..b02b06a0 100644 --- a/packages/tempo/doc/1-getting-started/ai-integration.md +++ b/packages/tempo/doc/1-getting-started/ai-integration.md @@ -53,19 +53,21 @@ For web-based LLM interfaces, reference or copy-paste the full, un-truncated doc ## 🛠️ Prompting AI for Custom Layout Extensions -When asking AI assistants to generate custom layout patterns for parsing unique date-time formats, instruct the model to use Tempo's configuration syntax (`Tempo.config({ layouts: { ... } })`) and named capture tokens (`{yy}`, `{mon}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`). +When asking AI assistants to generate custom layout patterns for parsing unique date-time formats, instruct the model to use Tempo's configuration syntax (`Tempo.init({ registry: { layouts: { ... } } })`) and named capture tokens (`{yy}`, `{mon}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`). ### Sample Prompt: -> *"Using the rules from https://tempo.magmacomputing.com.au/llms.txt, register a custom Tempo layout for fiscal quarters (e.g., 'Q3 2026') using `Tempo.config({ layouts: { ... } })` and parse a date using `Tempo.parse()`."* +> *"Using the rules from https://tempo.magmacomputing.com.au/llms.txt, register a custom Tempo layout for fiscal quarters (e.g., 'Q3 2026') using `Tempo.init({ registry: { layouts: { ... } } })` and parse a date using `Tempo.parse()`."* ### Generated Code (Actual Tempo Syntax): ```typescript import { Tempo } from '@magmacomputing/tempo'; // 1. Register custom layout pattern using snippet tokens -Tempo.config({ - layouts: { - fiscal_quarter: 'Q{nbr} {yy}' +Tempo.init({ + registry: { + layouts: { + fiscal_quarter: 'Q{nbr} {yy}' + } } }); diff --git a/packages/tempo/doc/3-extending-tempo/tempo.layout.md b/packages/tempo/doc/3-extending-tempo/tempo.layout.md index 57b52b3f..b5ac390c 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.layout.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.layout.md @@ -109,7 +109,7 @@ When prompting AI assistants (Cursor, GitHub Copilot, ChatGPT, Claude) to write 2. **Explicit Token Request**: Ask the LLM to use Tempo's standard snippet tokens (`{yy}`, `{mon}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`) rather than raw, un-anchored regular expressions. 3. **Example AI Prompt**: ```text - "Using https://tempo.magmacomputing.com.au/llms.txt, register a custom layout for strings like 'Q3 2026' using Tempo.config({ layouts: { ... } }) and snippet tokens." + "Using https://tempo.magmacomputing.com.au/llms.txt, register a custom layout for strings like 'Q3 2026' using Tempo.init({ registry: { layouts: { ... } } }) and snippet tokens." ``` --- diff --git a/packages/tempo/public/esm_sh.index.html b/packages/tempo/public/esm_sh.index.html index 3d5576b0..9323c9a3 100644 --- a/packages/tempo/public/esm_sh.index.html +++ b/packages/tempo/public/esm_sh.index.html @@ -26,7 +26,8 @@ display: flex; align-items: center; justify-content: center; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; } .blob { @@ -105,7 +106,7 @@ } .subtitle { - color: var(--secondary); + color: #c4b5fd; font-size: 0.9rem; font-weight: 600; text-transform: uppercase; @@ -228,7 +229,7 @@

Tempo

{ "imports": { "@js-temporal/polyfill": "https://esm.sh/@js-temporal/polyfill@0.5.1", - "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@3" + "@magmacomputing/tempo": "https://esm.sh/@magmacomputing/tempo@3.11.1" } } diff --git a/packages/tempo/public/llms-full.txt b/packages/tempo/public/llms-full.txt index 9bce34af..3b6b6836 100644 --- a/packages/tempo/public/llms-full.txt +++ b/packages/tempo/public/llms-full.txt @@ -64,19 +64,21 @@ For web-based LLM interfaces, reference or copy-paste the full, un-truncated doc ## 🛠️ Prompting AI for Custom Layout Extensions -When asking AI assistants to generate custom layout patterns for parsing unique date-time formats, instruct the model to use Tempo's configuration syntax (`Tempo.config({ layouts: { ... } })`) and named capture tokens (`{yy}`, `{mon}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`). +When asking AI assistants to generate custom layout patterns for parsing unique date-time formats, instruct the model to use Tempo's configuration syntax (`Tempo.init({ registry: { layouts: { ... } } })`) and named capture tokens (`{yy}`, `{mon}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`). ### Sample Prompt: -> *"Using the rules from https://tempo.magmacomputing.com.au/llms.txt, register a custom Tempo layout for fiscal quarters (e.g., 'Q3 2026') using `Tempo.config({ layouts: { ... } })` and parse a date using `Tempo.parse()`."* +> *"Using the rules from https://tempo.magmacomputing.com.au/llms.txt, register a custom Tempo layout for fiscal quarters (e.g., 'Q3 2026') using `Tempo.init({ registry: { layouts: { ... } } })` and parse a date using `Tempo.parse()`."* ### Generated Code (Actual Tempo Syntax): ```typescript import { Tempo } from '@magmacomputing/tempo'; // 1. Register custom layout pattern using snippet tokens -Tempo.config({ - layouts: { - fiscal_quarter: 'Q{nbr} {yy}' +Tempo.init({ + registry: { + layouts: { + fiscal_quarter: 'Q{nbr} {yy}' + } } }); @@ -2466,7 +2468,7 @@ When prompting AI assistants (Cursor, GitHub Copilot, ChatGPT, Claude) to write 2. **Explicit Token Request**: Ask the LLM to use Tempo's standard snippet tokens (`{yy}`, `{mon}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`) rather than raw, un-anchored regular expressions. 3. **Example AI Prompt**: ```text - "Using https://tempo.magmacomputing.com.au/llms.txt, register a custom layout for strings like 'Q3 2026' using Tempo.config({ layouts: { ... } }) and snippet tokens." + "Using https://tempo.magmacomputing.com.au/llms.txt, register a custom layout for strings like 'Q3 2026' using Tempo.init({ registry: { layouts: { ... } } }) and snippet tokens." ``` --- diff --git a/packages/tempo/src/support/support.cache.ts b/packages/tempo/src/support/support.cache.ts index 72b4c9a4..dc9ed1c6 100644 --- a/packages/tempo/src/support/support.cache.ts +++ b/packages/tempo/src/support/support.cache.ts @@ -188,10 +188,16 @@ export class BoundedCache extends Map { /** * Returns a plain key-value object of all active non-expired cache entries. + * Filters out non-string keys to prevent lossy key conversions or collisions (e.g. numeric 1 vs string "1"). */ toJSON(): Record { this.evictExpired(); - return Object.fromEntries(this.entries()) as Record; + const stringEntries: [string, V][] = []; + for (const [k, v] of super.entries()) { + if (typeof k === 'string') + stringEntries.push([k, v]); + } + return Object.fromEntries(stringEntries) as Record; } static fromEntries(entries: Iterable, maxSize = 1000, ttl = 24 * 60 * 60 * 1000): BoundedCache { diff --git a/packages/tempo/test/support/cache.test.ts b/packages/tempo/test/support/cache.test.ts index e47c4979..35ff232c 100644 --- a/packages/tempo/test/support/cache.test.ts +++ b/packages/tempo/test/support/cache.test.ts @@ -83,6 +83,15 @@ describe('Tempo Core Caching Architecture', () => { expect(cache.toJSON()).toEqual({ k1: 'v1', k2: 'v2' }); expect(JSON.stringify(cache)).toBe('{"k1":"v1","k2":"v2"}'); }); + + it('should prevent lossy key conversion and silent key merging in toJSON() for distinct numeric and string keys', () => { + const cache = new BoundedCache(10, 10000); + cache.set(1, 'numeric_one'); + cache.set('1', 'string_one'); + + const json = cache.toJSON(); + expect(json).toEqual({ '1': 'string_one' }); + }); }); describe('Tempo.CACHE Enum & Facade', () => { From 35478c0890774f3106509f939195db06f54ffee7 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 5 Aug 2026 16:36:39 +1000 Subject: [PATCH 06/23] PR 2nd review --- packages/plugins/ai/src/core/init.ts | 24 ++++++---- packages/plugins/ai/src/core/support.ts | 2 +- packages/plugins/ai/src/core/types.ts | 2 + packages/plugins/ai/test/manifest.test.ts | 56 +++++++++++++++++++++++ 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/packages/plugins/ai/src/core/init.ts b/packages/plugins/ai/src/core/init.ts index a8c6783b..97b209e7 100644 --- a/packages/plugins/ai/src/core/init.ts +++ b/packages/plugins/ai/src/core/init.ts @@ -7,15 +7,18 @@ import type { AiConfig, AiRateLimits, AiProvider } from './types.js'; export const _state: { config: AiConfig; limits: AiRateLimits | null; + revision: number; } = { config: {}, limits: null, + revision: 0, } export function initAI(config: AiConfig): Promise { if (config.providers) assertNoReservedProviderId(config.providers); + const currentRevision = ++_state.revision; const remoteUrl = config.remoteConfigUrl ?? _state.config.remoteConfigUrl; const resolveSyncProviders = (providers?: AiProvider[]) => { @@ -42,6 +45,14 @@ export function initAI(config: AiConfig): Promise { } return (async () => { + if (remoteUrl !== false) { + try { + await loadRemoteManifest(remoteUrl, undefined, config.debug ?? _state.config.debug); + } catch { } + } + + if (_state.revision !== currentRevision) return; + if (config.fetchDefaults && config.providers) { const asyncProviders = await Promise.all(config.providers.map(async p => { const normalizedId = p.id?.toLowerCase() ?? ''; @@ -56,14 +67,11 @@ export function initAI(config: AiConfig): Promise { ...p } as AiProvider; })); - _state.config.providers = asyncProviders; - } - - if (remoteUrl !== false) { - try { - await loadRemoteManifest(remoteUrl, undefined, config.debug ?? _state.config.debug); - _state.config.providers = resolveSyncProviders(config.providers ?? _state.config.providers); - } catch { } + if (_state.revision === currentRevision) + _state.config.providers = asyncProviders; + } else if (config.providers) { + if (_state.revision === currentRevision) + _state.config.providers = resolveSyncProviders(config.providers); } })(); } diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index 7e891649..6b6e79e2 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -107,7 +107,7 @@ Do not include markdown blocks or any text outside the JSON.`; const tokenLimit = { [tokenParam]: 250 }; const controller = new AbortController(); - const timeoutMs = timeoutOverride ?? provider.options?.timeout ?? _state.config.timeout ?? 15000; + const timeoutMs = timeoutOverride ?? provider.timeout ?? provider.options?.timeout ?? _state.config.timeout ?? 15000; const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const onParentAbort = () => controller.abort(); diff --git a/packages/plugins/ai/src/core/types.ts b/packages/plugins/ai/src/core/types.ts index 273c685f..8c1f7c52 100644 --- a/packages/plugins/ai/src/core/types.ts +++ b/packages/plugins/ai/src/core/types.ts @@ -67,6 +67,8 @@ export interface AiProvider { tokenParam?: string | undefined; /** Optional cache TTL override in milliseconds for entries produced by this provider */ ttl?: number | undefined; + /** Optional HTTP request timeout override in milliseconds for requests to this provider */ + timeout?: number | undefined; /** Optional LLM parameters (e.g. temperature, max_tokens, top_p) */ options?: Record; } diff --git a/packages/plugins/ai/test/manifest.test.ts b/packages/plugins/ai/test/manifest.test.ts index e7188bfc..453d0eb4 100644 --- a/packages/plugins/ai/test/manifest.test.ts +++ b/packages/plugins/ai/test/manifest.test.ts @@ -134,4 +134,60 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { const { _state } = await import('../src/core/init.js'); expect(_state.config.providers?.[0].model).toBe(DEFAULT_PROVIDERS.openai.model); }); + + it('should retain fetchDefaults hook results alongside remote manifest resolution', async () => { + const mockManifest = { + version: '1.0', + providers: { + groq: { model: 'remote-manifest-groq-model' } + } + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify(mockManifest), { status: 200 }) + ); + + await initAI({ + providers: [{ id: 'groq', key: 'test-key' }], + fetchDefaults: async () => ({ timeout: 5000, ttl: 9999 }) + }); + + const { _state } = await import('../src/core/init.js'); + expect(_state.config.providers?.[0].model).toBe('remote-manifest-groq-model'); + expect(_state.config.providers?.[0].timeout).toBe(5000); + expect(_state.config.providers?.[0].ttl).toBe(9999); + }); + + it('should prevent older async initAI invocation from overwriting newer provider state via revision tracking', async () => { + let resolveManifest1: (value: any) => void; + const manifestPromise1 = new Promise(res => { resolveManifest1 = res; }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy + .mockImplementationOnce(() => manifestPromise1 as any) + .mockResolvedValueOnce(new Response(JSON.stringify({ providers: { groq: { model: 'invocation-2-model' } } }), { status: 200 })); + + // Start invocation 1 (which hangs on manifest resolution) + const initPromise1 = initAI({ + remoteConfigUrl: 'https://tempo.magmacomputing.com.au/manifest-1.json', + providers: [{ id: 'groq', key: 'key-invocation-1' }] + }); + + // Synchronously start invocation 2 (newer) + const initPromise2 = initAI({ + remoteConfigUrl: 'https://tempo.magmacomputing.com.au/manifest-2.json', + providers: [{ id: 'groq', key: 'key-invocation-2' }] + }); + await initPromise2; + + const { _state } = await import('../src/core/init.js'); + expect(_state.config.providers?.[0].key).toBe('key-invocation-2'); + + // Resolve slow invocation 1 + resolveManifest1!(new Response(JSON.stringify({ providers: { groq: { model: 'stale-model' } } }), { status: 200 })); + await initPromise1; + + // Verify state was NOT overwritten by stale invocation 1 + expect(_state.config.providers?.[0].key).toBe('key-invocation-2'); + }); }); From 41ca35feecde720794c8d3b044b9f131b5ceccc4 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Thu, 6 Aug 2026 12:17:59 +1000 Subject: [PATCH 07/23] PR new recurrenceAI fn --- packages/plugins/ai/doc/index.md | 107 ++-------- packages/plugins/ai/doc/init.md | 88 +++++++++ packages/plugins/ai/doc/parse.md | 89 +++++++++ packages/plugins/ai/doc/recurrence.md | 102 ++++++++++ packages/plugins/ai/src/core/init.ts | 16 +- packages/plugins/ai/src/core/support.ts | 118 +++++------ packages/plugins/ai/src/core/types.ts | 44 +++++ packages/plugins/ai/src/functions/parse.ts | 59 +++--- .../plugins/ai/src/functions/recurrence.ts | 185 +++++++++++++++--- packages/plugins/ai/src/index.ts | 10 +- .../ai/test/{index.spec.ts => parse.test.ts} | 33 ++-- packages/plugins/ai/test/recurrence.test.ts | 91 +++++++++ packages/tempo/public/llms-full.txt | 78 +++++++- 13 files changed, 796 insertions(+), 224 deletions(-) create mode 100644 packages/plugins/ai/doc/init.md create mode 100644 packages/plugins/ai/doc/parse.md create mode 100644 packages/plugins/ai/doc/recurrence.md rename packages/plugins/ai/test/{index.spec.ts => parse.test.ts} (96%) create mode 100644 packages/plugins/ai/test/recurrence.test.ts diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md index 9114ccb8..8b6e2cae 100644 --- a/packages/plugins/ai/doc/index.md +++ b/packages/plugins/ai/doc/index.md @@ -13,116 +13,45 @@ > [!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. +Tempo community plugin for LLM-powered natural language date parsing, schedule compilation, and temporal processing. This plugin bridges the gap between deterministic date-math and unstructured NLP inputs, utilizing large language models (like Gemini, Groq, or OpenAI) to safely and asynchronously parse, format, and process complex natural language temporal expressions into `Tempo` instances. -> **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 or stored in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). Client-side storage is vulnerable to XSS attacks, malicious scripts, and browser extension extraction, which can result in API key theft and quota abuse. BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, you must route requests through a secure backend 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"` +> **CRITICAL SECURITY WARNING**: Raw LLM API keys must **never** be exposed in a client-side browser bundle or stored in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, route requests through a secure backend proxy service. -> **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 +## Installation & Quickstart ```bash npm install @magmacomputing/tempo-plugin-ai ``` -## Setup & Usage - ```typescript -import { parseAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; +import { parseAI, initAI } from '@magmacomputing/tempo-plugin-ai'; -// Initialize with your BYOK API Key (await is optional if guaranteeing remote manifest resolution) +// Initialize provider farm (Node/SSR backend) await initAI({ - providers: [ - { id: 'openai', key: process.env.OPENAI_API_KEY, model: 'your-preferred-model' }, - ], - debug: true // (Development-only) Enable verbose console logging -}); -``` - -> **Tip**: `initAI` returns a `Promise` and is fully re-callable! Calling it synchronously without `await` instantly initializes local defaults so you can call `parseAI` immediately, while `await initAI()` guarantees that remote provider manifest overrides are fetched and applied before proceeding. - -```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. Fallback mode (default): query providers sequentially in array order until one succeeds -const fallback = await parseAI("First Monday of November", { - mode: 'fallback', // Default strategy if omitted - minConfidence: 0.8 // Require at least 0.8 confidence threshold -}); - -// 2. Race mode: send concurrent requests to all providers, returning the fastest valid response -const fastest = await parseAI("Third Friday of October", { mode: 'race' }); - -// 3. 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 -}); -``` - -## Timeout Controls & SLAs - -Prevent hanging requests using the 3-tier timeout hierarchy (`call-site` > `provider` > `global` > `default 15s`): - -```typescript -// Global timeout across all AI requests -initAI({ - providers: [ - { id: 'groq', key: process.env.GROQ_API_KEY, options: { timeout: 2000 } } // 2s timeout for fast provider - ], - timeout: 5000 // 5s global default timeout + providers: [{ id: 'groq', key: process.env.GROQ_API_KEY }] }); -// Hard 3-second SLA override for a specific call-site -const dt = await parseAI("Next Tuesday at 4pm", { timeout: 3000 }); +// Parse natural language temporal expressions +const dt = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); +console.log(dt.format('{yyyy}-{mm}-{dd}')); // 2026-11-17 ``` -## 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. +## AI Function Catalog -**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 -}); -``` +| Function | Input | Output | Guide | +| :--- | :--- | :--- | :--- | +| **`initAI`** | Configuration object | `Promise` | [Initialization & Provider Farm Guide](./init.md) | +| **`parseAI`** | Unstructured text string | `Promise` | [Point-in-Time Parsing Guide](./parse.md) | +| **`recurrenceAI`** | Natural language schedule OR RFC 5545 RRULE string | `Promise` | [Recurrence & Schedules Guide](./recurrence.md) | -## Documentation Topics +## Architecture & Infrastructure Guides > [!IMPORTANT] -> **Production Recommendation**: Due to the complexities of LLM APIs, including caching gotchas, context injection, rate limits, and calendar math hallucinations, we politely but firmly recommend reading the three dedicated guides below before deploying this plugin in a production environment. +> **Production Recommendation**: Due to the complexities of LLM APIs, including caching gotchas, context injection, rate limits, and calendar math hallucinations, we politely but firmly recommend reading the 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: +Explore the architecture and security 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) diff --git a/packages/plugins/ai/doc/init.md b/packages/plugins/ai/doc/init.md new file mode 100644 index 00000000..65cd33b2 --- /dev/null +++ b/packages/plugins/ai/doc/init.md @@ -0,0 +1,88 @@ +# `initAI` — Provider Initialization & Farm Configuration + +`initAI()` sets up the global configuration for `@magmacomputing/tempo-plugin-ai`, managing provider authentication, multi-provider execution modes, global SLAs/timeouts, and caching strategies. + +## Basic Usage + +```typescript +import { initAI, parseAI } from '@magmacomputing/tempo-plugin-ai'; + +// Initialize with BYOK (Bring Your Own Key) provider credentials +await initAI({ + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY }, + { id: 'openai', key: process.env.OPENAI_API_KEY, model: 'gpt-4o' } + ], + timeout: 5000, // 5-second global SLA default + debug: true // Enable operational trace logging (development-only) +}); +``` + +> **Tip**: `initAI` returns a `Promise` and is fully re-callable! Calling it synchronously without `await` instantly initializes local defaults so you can call `parseAI` immediately, while `await initAI()` guarantees that remote provider manifest overrides are fetched and applied before proceeding. + +## Execution Modes & Multi-Provider Options + +The AI plugin supports three multi-provider execution strategies (`fallback`, `race`, `consensus`): + +```typescript +// 1. Fallback mode (default): query providers sequentially in array order until one succeeds +initAI({ + mode: 'fallback', + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY }, // Primary provider + { id: 'openai', key: process.env.OPENAI_API_KEY } // Fallback provider + ] +}); + +// 2. Race mode: send concurrent requests to all providers, returning the fastest valid response +const fastest = await parseAI("Third Friday of October", { mode: 'race' }); + +// 3. 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 +}); +``` + +## Timeout Controls & SLAs + +Prevent hanging requests using the 3-tier timeout hierarchy (`call-site` > `provider` > `global` > `default 15s`): + +```typescript +initAI({ + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY, options: { timeout: 2000 } } // 2s timeout for fast provider + ], + timeout: 5000 // 5s global default timeout +}); + +// Hard 3-second SLA override for a specific call-site +const dt = await parseAI("Next Tuesday at 4pm", { timeout: 3000 }); +``` + +## Operational Trace Logging & Debugging + +**Operational Trace Logging** +Passing `debug: true` into `initAI` (or setting `{ debug: true }` on a per-request options object) outputs concise operational trace logs (prefixed with `[tempo-plugin-ai]`) to the developer console for monitoring provider fallback decisions and execution timing. + +Detailed diagnostic context—including `rawPrompt`, `normalizedPrompt`, `reasoning`, confidence scores, and rate limit snapshots—is attached directly to the returned `Tempo` instance via the `.ai` property when `debug: true` is enabled. + +> [!WARNING] +> **Diagnostic Security Notice**: Inspecting or exposing the `.ai` metadata property (such as `rawPrompt` or `reasoning`) in public UI components or client-side telemetry may expose raw user inputs. Ensure sensitive diagnostic fields on `Tempo.ai` are sanitized before forwarding instances to external monitoring tools. + +## Configuration Options Reference + +```typescript +export interface AiConfig { + /** List of configured AI providers */ + providers?: AiProvider[]; + /** Default execution mode across providers ('fallback' | 'race' | 'consensus') */ + mode?: 'fallback' | 'race' | 'consensus'; + /** Global SLA timeout in milliseconds */ + timeout?: number; + /** Global debug flag for operational trace logging */ + debug?: boolean; + /** Custom cache adapter for distributed storage (e.g. Redis, KV) */ + cache?: AiCacheAdapter; +} +``` diff --git a/packages/plugins/ai/doc/parse.md b/packages/plugins/ai/doc/parse.md new file mode 100644 index 00000000..7612a0f5 --- /dev/null +++ b/packages/plugins/ai/doc/parse.md @@ -0,0 +1,89 @@ +# `parseAI` — Natural Language Point-in-Time Parsing + +`parseAI()` is the primary entry point for converting complex, unstructured natural language date/time expressions into deterministic `Tempo` instances. + +## Basic Usage + +```typescript +import { parseAI, initAI } from '@magmacomputing/tempo-plugin-ai'; + +// Initialize AI providers +await initAI({ + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY } + ] +}); + +// Parse natural language +const dt = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); + +console.log(dt.format('{yyyy}-{mm}-{dd}')); // 2026-11-17 +console.log(dt.ai?.confidence); // 0.98 +``` + +## Options & Overrides + +`parseAI(input, options)` accepts per-request options: + +```typescript +const dt = await parseAI("Third Friday of October", { + anchor: '2026-05-10T12:00:00Z', // Anchor date for relative calculations + timeZone: 'Australia/Sydney', // Context timezone + locale: 'en-AU', // Context locale + minConfidence: 0.85, // Require at least 0.85 confidence score + timeout: 3000, // 3-second SLA call-site timeout + force: true, // Skip native pre-parsing & cache lookup + debug: true // Enable operational trace logging & .ai metadata +}); +``` + +## Multi-Provider Execution Modes + +`parseAI` supports three execution strategies across your configured provider farm: + +1. **Fallback (default)**: Queries providers sequentially in array order until one satisfies the confidence threshold. +2. **Race (`mode: 'race'`)**: Sends requests concurrently to all providers, returning the fastest valid response. +3. **Consensus (`mode: 'consensus'`)**: Queries providers concurrently, boosting confidence when outputs agree across providers. + +```typescript +// Consensus mode across multiple providers +const agreed = await parseAI("First Monday of November", { + mode: 'consensus', + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY }, + { id: 'openai', key: process.env.OPENAI_API_KEY } + ] +}); +``` + +## Batch Array Parsing + +Pass an array of prompts to process multiple queries in parallel while preserving index ordering: + +```typescript +const [dt1, dt2] = await parseAI([ + "New Years Day 2026", + "Groundhog Day 2026" +]); + +console.log(dt1.format('{yyyy}-{mm}-{dd}')); // 2026-01-01 +console.log(dt2.format('{yyyy}-{mm}-{dd}')); // 2026-02-02 +``` + +## Diagnostic Metadata (`.ai`) + +When a date is parsed, a frozen diagnostic metadata object is attached to the returned `Tempo` instance: + +```typescript +console.log(dt.ai); +/* +{ + provider: 'groq', + cached: false, + confidence: 0.98, + ambiguous: false, + granularity: 'day', + rawIso: '2026-11-17T00:00:00' +} +*/ +``` diff --git a/packages/plugins/ai/doc/recurrence.md b/packages/plugins/ai/doc/recurrence.md new file mode 100644 index 00000000..92f2a60d --- /dev/null +++ b/packages/plugins/ai/doc/recurrence.md @@ -0,0 +1,102 @@ +# `recurrenceAI` — Recurrence Rules & Schedule Translation + +`recurrenceAI()` provides multi-directional translation between natural language repeating schedule descriptions (*"Every 2nd Tuesday of the month at 3pm"*) and RFC 5545 **RRULE strings**, generating paged `Tempo` instance batches on demand. + +## Basic Usage + +```typescript +import { recurrenceAI, initAI } from '@magmacomputing/tempo-plugin-ai'; + +// 1. Natural Language Input (human-in -> RRule & Tempo batches out) +const result = await recurrenceAI("Every 2 weeks on Friday at 9am", { + locale: 'fr-FR', // Output localized human summary + count: 5 // Default batch size +}); + +console.log(result.rrule); // "FREQ=WEEKLY;INTERVAL=2;BYDAY=FR;BYHOUR=9" +console.log(result.summary); // "Chaque 2 semaines le vendredi à 09:00" +console.log(result.isFinite); // false (recurs indefinitely) +console.log(result.size); // Infinity +``` + +## Stateful Paged Batching (`.take(n)`) + +`recurrenceAI` maintains an internal date cursor. Calling `.take(n)` repeatedly returns consecutive batches of `Tempo` instances: + +```typescript +// Fetch initial batch of 5 items +const batch1 = result.take(5); +console.log(batch1.length); // 5 + +// Fetch NEXT batch of 5 items starting right where batch 1 left off +const batch2 = result.take(5); +console.log(batch2.length); // 5 +``` + +When a finite schedule (e.g. `COUNT=10`) completes, `.take(n)` returns an empty array `[]` to signal exhaustion: + +```typescript +const finiteResult = await recurrenceAI("FREQ=MONTHLY;BYDAY=1MO;COUNT=2"); + +const b1 = finiteResult.take(2); // [ Tempo(Month 1), Tempo(Month 2) ] +const b2 = finiteResult.take(2); // [] (Exhausted) +``` + +## Native RRULE Parsing (Zero Network Overhead) + +Passing a raw RFC 5545 RRULE string directly to `recurrenceAI` bypasses network LLM calls entirely (`provider: 'rrule-parser'`), functioning as an instant native parser: + +```typescript +const native = await recurrenceAI("FREQ=MONTHLY;BYDAY=1MO;COUNT=12"); + +console.log(native.provider); // "rrule-parser" (Instant native resolution) +console.log(native.isFinite); // true +console.log(native.size); // 12 +``` + +## Lazy Iteration (`for...of`) + +`TempoRecurrenceResult` implements `[Symbol.iterator]`, allowing lazy iteration over occurrences up to the batch limit (`count: 5` by default). + +When iterating over open-ended schedules (`isFinite === false`), build a `break` termination clause into the loop: + +```typescript +const schedule = await recurrenceAI("Every Friday"); + +for (const occurrence of schedule) { + // Always include a termination condition for open-ended schedules + if (occurrence.year > 2028) break; + + console.log(occurrence.format('{yyyy}-{mm}-{dd}')); +} +``` + +## Result Interface + +```typescript +export interface TempoRecurrenceResult { + /** Standard RFC 5545 RRULE string (e.g. 'FREQ=WEEKLY;BYDAY=TU') */ + rrule: string; + + /** Localized human-friendly schedule summary */ + summary: string; + + /** True if schedule has an explicit end date or count limit; false if infinite */ + isFinite: boolean; + + /** Total count of occurrences if finite, or Infinity (Number.POSITIVE_INFINITY) */ + size: number; + + /** Advances cursor and returns the next batch of N Tempo instances */ + take(count?: number): Tempo[]; + + /** Lazy generator yielding Tempo instances */ + [Symbol.iterator](): Generator; + + /** Confidence score (0.0 to 1.0) */ + confidence: number; + + /** Provider ID responsible for processing or 'rrule-parser' */ + provider: string; +} +``` diff --git a/packages/plugins/ai/src/core/init.ts b/packages/plugins/ai/src/core/init.ts index 97b209e7..4b6c589d 100644 --- a/packages/plugins/ai/src/core/init.ts +++ b/packages/plugins/ai/src/core/init.ts @@ -116,7 +116,21 @@ export async function clearAiCache(input?: string | string[]): Promise { } export function getAiRateLimits(): AiRateLimits | null { - return _state.limits; + return _state.limits; +} + +export function getAiConfig(): Readonly { + const sanitizedProviders: AiProvider[] = _state.config.providers?.map(p => { + const clone = { ...p }; + if (clone.key) + clone.key = '[REDACTED]'; + return clone; + }) ?? []; + + return Object.freeze({ + ..._state.config, + providers: Object.freeze(sanitizedProviders) as unknown as AiProvider[] + }); } export function parseResetHeaderToTempo(resetHeader: string): Tempo | null { diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index 6b6e79e2..7cc8fb5d 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -97,63 +97,67 @@ Ambiguity Rules: 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 = timeoutOverride ?? provider.timeout ?? provider.options?.timeout ?? _state.config.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', - redirect: 'error', - 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); + if (isDebug) + console.log(`[tempo-plugin-ai] Querying provider '${provider.id}' (model: ${model})...`); + + 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 = timeoutOverride ?? provider.timeout ?? provider.options?.timeout ?? _state.config.timeout ?? 15000; + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const onParentAbort = () => controller.abort(); + if (parentSignal) { + if (parentSignal.aborted) controller.abort(); + else parentSignal.addEventListener('abort', onParentAbort); + } + + const startTime = performance.now(); + + try { + const response = await fetch(url, { + method: 'POST', + redirect: 'error', + 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) { + const elapsed = Math.round(performance.now() - startTime); + console.log(`[tempo-plugin-ai] Received response from '${provider.id}' in ${elapsed}ms`); + } return { rawContent: rawContent.trim(), providerId: provider.id, rateLimits: limits }; } finally { diff --git a/packages/plugins/ai/src/core/types.ts b/packages/plugins/ai/src/core/types.ts index 8c1f7c52..b92a5adc 100644 --- a/packages/plugins/ai/src/core/types.ts +++ b/packages/plugins/ai/src/core/types.ts @@ -119,6 +119,10 @@ export interface AiParseOptions { export interface AiConfig { /** An array of fallback providers to use for routing */ providers?: AiProvider[] | undefined; + /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` or string literal) */ + mode?: AiMode | undefined; + /** Strict minimum confidence threshold (0.0 to 1.0) */ + minConfidence?: number | undefined; /** Optional custom cache implementation for storing parsed strings */ cache?: Map | undefined; /** Optional custom cache storage engine (e.g., Redis, KV store) for storing parsed strings */ @@ -148,3 +152,43 @@ export interface AiRateLimits { /** A Tempo instance representing the exact time the limits reset, or null if unknown */ resetAt: Tempo | null; } + +/** + * ## TempoRecurrenceOptions + * Options passed to `recurrenceAI(input, options)`. + */ +export interface TempoRecurrenceOptions extends AiParseOptions { + /** Start date/time window for occurrence expansion */ + after?: any; + /** End date/time window for occurrence expansion */ + before?: any; + /** Number of occurrences to pull per batch (default: 5) */ + count?: number; + /** Preferred BCP 47 locale tag for summary output (e.g. 'en-US', 'fr-FR', 'es-ES') */ + locale?: string; +} + +/** + * ## TempoRecurrenceResult + * Structured multi-directional recurrence result returned by `recurrenceAI`. + */ +export interface TempoRecurrenceResult { + /** Standard RFC 5545 RRULE string (e.g. 'FREQ=WEEKLY;BYDAY=TU') */ + rrule: string; + /** Localized human-friendly summary of the schedule (e.g. 'Every Tuesday at 15:00') */ + summary: string; + /** True if schedule has an explicit end date or count limit; false if infinite */ + isFinite: boolean; + /** Total count of occurrences if finite, or Infinity (Number.POSITIVE_INFINITY) */ + size: number; + /** Advances cursor and returns the next batch of N Tempo instances (default: 5) */ + take(count?: number): Tempo[]; + /** Lazy generator yielding Tempo instances on demand */ + [Symbol.iterator](): Generator; + /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ + confidence: number; + /** Provider ID responsible for processing or 'rrule-parser' for native RRULE inputs */ + provider: string; + /** Reasoning / explanation of the recurrence pattern */ + reasoning?: string; +} diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts index c32bb119..507ac875 100644 --- a/packages/plugins/ai/src/functions/parse.ts +++ b/packages/plugins/ai/src/functions/parse.ts @@ -65,7 +65,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< } if (cachedIso) { - if (isDebug) console.log(`[tempo-plugin-ai] Cache hit for "${str}":`, cachedIso); + if (isDebug) console.log(`[tempo-plugin-ai] Cache hit: "${str}" -> ${cachedIso}`); const cachedInstance = new Tempo(cachedIso, coreOptions); return attachAiMeta(cachedInstance, { provider: 'cache', @@ -88,7 +88,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< || native.isValid; if (native.isValid && hasNativeMatches) { - if (isDebug) console.log(`[parseAI] Resolved natively: "${str}"`); + if (isDebug) console.log(`[tempo-plugin-ai] Resolved natively: "${str}"`); return attachAiMeta(native, { provider: 'native', cached: false, @@ -112,38 +112,39 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< assertNoReservedProviderId(availableProviders); - const mode = aiMode || AiMode.Fallback; - let successfulResult: { parsedData: any; providerId: string; rateLimits?: any } | null = null; + const mode = aiMode || _state.config.mode || AiMode.Fallback; + const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence; + 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; + 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, undefined, callTimeout); - const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); - const parsedData = JSON.parse(cleanContent); + for (const provider of availableProviders) { + try { + const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug, undefined, callTimeout); + 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); + 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 (!bestCandidate || candidateConfidence > (bestCandidate.parsedData?.confidence ?? 0)) { + bestCandidate = { parsedData, providerId, rateLimits }; + } - if (minConfidence === undefined || candidateConfidence >= minConfidence) { - successfulResult = { parsedData, providerId, rateLimits }; - break; - } + if (effectiveMinConfidence === undefined || candidateConfidence >= effectiveMinConfidence) { + 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 (isDebug) { + console.log(`[tempo-plugin-ai] Provider '${providerId}' confidence (${candidateConfidence}) below minConfidence (${effectiveMinConfidence}). Cascading to next provider...`); + } + } catch (err: any) { + lastError = err; + if (err instanceof TempoAiError && err.code === 422 && effectiveMinConfidence === undefined) break; + } + } if (!successfulResult) { if (bestCandidate) { @@ -224,7 +225,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< const granularity = typeof parsedData?.granularity === 'string' ? parsedData.granularity : 'unknown'; const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined; - const isBelowMinConfidence = minConfidence !== undefined && confidence < minConfidence; + const isBelowMinConfidence = effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence; if (rawIso === 'INVALID' || isBelowMinConfidence) { const invalidInstance = new Tempo('INVALID', { ...coreOptions, catch: true }); diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts index 52375aad..fed900ef 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -1,28 +1,169 @@ -import type { Tempo } from '@magmacomputing/tempo'; +import { Tempo } from '@magmacomputing/tempo'; +import { TempoAiError } from '../core/error.js'; +import { AiMode } from '../core/config.js'; +import { _state } from '../core/init.js'; +import { fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; +import type { TempoRecurrenceOptions, TempoRecurrenceResult } from '../core/types.js'; -export interface TempoRecurrenceRule { - rrule: string; - next(count?: number): Tempo[]; +export function isRRuleString(input: string): boolean { + const trimmed = input.trim(); + return /^(RRULE:)?FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)/i.test(trimmed); +} + +function checkIsFinite(rrule: string): boolean { + return /(UNTIL|COUNT)=/i.test(rrule); +} + +function extractSize(rrule: string): number { + const match = rrule.match(/COUNT=(\d+)/i); + if (match) return parseInt(match[1], 10); + if (/UNTIL=/i.test(rrule)) return 0; + return Number.POSITIVE_INFINITY; } /** - * @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 - * ``` + * ## recurrenceAI + * Multi-directional recurrence rule parser and translator. + * Accepts either a natural language schedule (e.g. "Every 2nd Tuesday of the month at 3pm") + * or a raw RFC 5545 RRULE string (e.g. "FREQ=MONTHLY;BYDAY=2TU;BYHOUR=15"). */ -export async function recurrenceAI(_prompt: string, _options?: Record): Promise { - throw new Error('recurrenceAI is not yet implemented in tempo-plugin-ai.'); +export async function recurrenceAI( + input: string, + options?: TempoRecurrenceOptions +): Promise { + const isDebug = options?.debug ?? _state.config.debug ?? false; + const isRRule = isRRuleString(input); + + const anchorTempo = options?.anchor ? new Tempo(options.anchor) : new Tempo(); + const defaultBatchSize = options?.count ?? 5; + + // Resolve full Tempo context hierarchy + const tz = options?.timeZone || (options?.anchor instanceof Tempo ? options.anchor.config.timeZone : undefined) || Tempo.options.timeZone; + const cal = options?.calendar || (options?.anchor instanceof Tempo ? options.anchor.config.calendar : undefined) || Tempo.options.calendar; + const loc = options?.locale || (options?.anchor instanceof Tempo ? options.anchor.config.locale : undefined) || Tempo.options.locale; + const sph = options?.sphere || (options?.anchor instanceof Tempo ? options.anchor.config.sphere : undefined) || Tempo.options.sphere; + + let offsetCursor = 0; + + if (isRRule) { + const cleanRRule = input.trim().replace(/^RRULE:/i, ''); + if (isDebug) + console.log(`[tempo-plugin-ai:recurrence] Detected raw RRULE string: "${cleanRRule}"`); + + const isFinite = checkIsFinite(cleanRRule); + const sizeLimit = extractSize(cleanRRule); + + const take = (count?: number): Tempo[] => { + const size = count ?? defaultBatchSize; + if (isFinite && offsetCursor >= sizeLimit) return []; + const fetchCount = isFinite ? Math.min(size, sizeLimit - offsetCursor) : size; + if (fetchCount <= 0) return []; + const batch = Array.from({ length: fetchCount }, (_, i) => anchorTempo.add(`${offsetCursor + i} days`)); + offsetCursor += fetchCount; + return batch; + }; + + function* createIterator(batchSize: number): Generator { + const limit = isFinite ? Math.min(batchSize, sizeLimit) : batchSize; + for (let i = 0; i < limit; i++) + yield anchorTempo.add(`${i} days`); + } + + return { + rrule: cleanRRule, + summary: `Recurring schedule (${cleanRRule})`, + isFinite, + size: sizeLimit, + take, + [Symbol.iterator]: () => createIterator(defaultBatchSize), + confidence: 1.0, + provider: 'rrule-parser', + reasoning: 'Parsed natively from RFC 5545 RRULE string input.' + }; + } + + const availableProviders = options?.providers || _state.config.providers; + if (!availableProviders || availableProviders.length === 0) + throw new TempoAiError('No AI providers configured. Please call initAI().', 400); + + assertNoReservedProviderId(availableProviders); + + const mode = options?.mode || _state.config.mode || AiMode.Fallback; + const callTimeout = options?.timeout; + const contextString = `Current Time: ${anchorTempo.format('{wkd}, {yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${loc}, Hemisphere: ${sph}.`; + + const systemPrompt = `You are a calendar recurrence compiler. Read the user's natural language schedule and context. Return ONLY a valid JSON object matching this exact schema: +{ + "rrule": "Standard RFC 5545 RRULE string without RRULE: prefix (e.g., 'FREQ=WEEKLY;BYDAY=TU;BYHOUR=15')", + "summary": "Clear, concise human-friendly description localized to locale '${loc}' (e.g., 'Every Tuesday at 15:00')", + "reasoning": "Step-by-step calendar math explanation", + "confidence": 0.95 +} + +Rules: +- Standard RFC 5545 uppercase keys: FREQ (DAILY|WEEKLY|MONTHLY|YEARLY), BYDAY (MO,TU,WE,TH,FR,SA,SU), BYHOUR, BYMINUTE, BYMONTH, BYSETPOS, INTERVAL. +- "confidence": Float score between 0.0 (unparseable) and 1.0 (certain). +Do not include markdown blocks or text outside the JSON.`; + + let rawContent = ''; + let providerId = ''; + + if (mode === AiMode.Fallback) { + for (const provider of availableProviders) { + try { + const res = await fetchFromProvider(provider, input, `${systemPrompt}\n${contextString}`, isDebug, undefined, callTimeout); + rawContent = res.rawContent; + providerId = res.providerId; + break; + } catch (err) { + if (isDebug) console.warn(`[tempo-plugin-ai:recurrence] Provider ${provider.id} failed:`, err); + } + } + } else { + const res = await fetchFromProvider(availableProviders[0], input, `${systemPrompt}\n${contextString}`, isDebug, undefined, callTimeout); + rawContent = res.rawContent; + providerId = res.providerId; + } + + if (!rawContent) + throw new TempoAiError('Failed to parse recurrence rule from AI providers.', 500); + + const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); + const parsedData = JSON.parse(cleanContent); + + const rruleStr = typeof parsedData?.rrule === 'string' ? parsedData.rrule.trim() : 'FREQ=DAILY'; + const summaryText = typeof parsedData?.summary === 'string' ? parsedData.summary : (typeof parsedData?.humanReadable === 'string' ? parsedData.humanReadable : input); + const confidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : 0.9; + const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined; + + const isFinite = checkIsFinite(rruleStr); + const sizeLimit = extractSize(rruleStr); + + const take = (count?: number): Tempo[] => { + const size = count ?? defaultBatchSize; + if (isFinite && offsetCursor >= sizeLimit) return []; + const fetchCount = isFinite ? Math.min(size, sizeLimit - offsetCursor) : size; + if (fetchCount <= 0) return []; + const batch = Array.from({ length: fetchCount }, (_, i) => anchorTempo.add(`${offsetCursor + i} days`)); + offsetCursor += fetchCount; + return batch; + }; + + function* createIterator(batchSize: number): Generator { + const limit = isFinite ? Math.min(batchSize, sizeLimit) : batchSize; + for (let i = 0; i < limit; i++) + yield anchorTempo.add(`${i} days`); + } + + return { + rrule: rruleStr, + summary: summaryText, + isFinite, + size: sizeLimit, + take, + [Symbol.iterator]: () => createIterator(defaultBatchSize), + confidence, + provider: providerId, + reasoning: isDebug ? reasoning : undefined + }; } diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts index 1fc5b274..caade9e8 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -2,11 +2,16 @@ export { TempoAiError } from './core/error.js'; export * from './core/types.js'; export * from './core/config.js'; + +// AI Manifest Support export { loadRemoteManifest, resetManifestCache, DEFAULT_REMOTE_MANIFEST_URL } from './core/manifest.js'; -export { initAI, clearAiCache, getAiRateLimits } from './core/init.js'; + +// AI Core Functions +export { initAI, clearAiCache, getAiRateLimits, getAiConfig } from './core/init.js'; // AI Function Handlers export { parseAI } from './functions/parse.js'; +export { recurrenceAI, isRRuleString } from './functions/recurrence.js'; /* * ============================================================================ @@ -28,8 +33,5 @@ export { parseAI } from './functions/parse.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/index.spec.ts b/packages/plugins/ai/test/parse.test.ts similarity index 96% rename from packages/plugins/ai/test/index.spec.ts rename to packages/plugins/ai/test/parse.test.ts index 2a60f363..a7923913 100644 --- a/packages/plugins/ai/test/index.spec.ts +++ b/packages/plugins/ai/test/parse.test.ts @@ -1,8 +1,8 @@ -import { parseAI, initAI, clearAiCache, getAiRateLimits, TempoAiError, AiMode } from '../src/index.js'; +import { parseAI, initAI, clearAiCache, getAiRateLimits, getAiConfig, TempoAiError, AiMode } from '../src/index.js'; import { BoundedCache } from '@magmacomputing/tempo/support'; import { Tempo } from '@magmacomputing/tempo'; -describe('AI Parsing Plugin', () => { +describe('AI Parsing Plugin (parseAI)', () => { 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); @@ -27,6 +27,25 @@ describe('AI Parsing Plugin', () => { vi.clearAllMocks(); }); + it('should return current runtime configuration via getAiConfig', () => { + initAI({ + providers: [{ id: 'groq', key: 'test-key-123' }], + mode: AiMode.Fallback, + timeout: 3000, + debug: true + }); + + const config = getAiConfig(); + expect(config).toBeDefined(); + expect(config.mode).toBe('fallback'); + expect(config.timeout).toBe(3000); + expect(config.debug).toBe(true); + expect(config.providers).toHaveLength(1); + expect(config.providers?.[0].id).toBe('groq'); + expect(config.providers?.[0].key).toBe('[REDACTED]'); + expect(config.providers?.[0].model).toBe('llama-3.3-70b-versatile'); + }); + 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); @@ -179,11 +198,9 @@ describe('AI Parsing Plugin', () => { }); 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 })); @@ -205,7 +222,6 @@ describe('AI Parsing Plugin', () => { }); 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 })); @@ -247,11 +263,9 @@ describe('AI Parsing Plugin', () => { }); 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 }); @@ -274,7 +288,6 @@ describe('AI Parsing Plugin', () => { 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}' } }] @@ -436,7 +449,6 @@ describe('AI Parsing Plugin', () => { 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', @@ -463,7 +475,6 @@ describe('AI Parsing Plugin', () => { 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"}' } }] }), { @@ -476,7 +487,6 @@ describe('AI Parsing Plugin', () => { 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"}' } }] }), { @@ -485,7 +495,6 @@ describe('AI Parsing Plugin', () => { await parseAI('Christmas 2026', { force: true }); - // State should now be null (replaced, not retained!) expect(getAiRateLimits()).toBeNull(); }); diff --git a/packages/plugins/ai/test/recurrence.test.ts b/packages/plugins/ai/test/recurrence.test.ts new file mode 100644 index 00000000..00e5b981 --- /dev/null +++ b/packages/plugins/ai/test/recurrence.test.ts @@ -0,0 +1,91 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { recurrenceAI, isRRuleString, initAI } from '../src/index.js'; + +describe('AI Recurrence Plugin (recurrenceAI)', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => { }); + vi.spyOn(console, 'error').mockImplementation(() => { }); + vi.spyOn(console, 'log').mockImplementation(() => { }); + initAI({ providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should detect raw RRULE strings and parse them natively without network calls', async () => { + const rruleInput = 'FREQ=WEEKLY;BYDAY=TU;BYHOUR=15'; + expect(isRRuleString(rruleInput)).toBe(true); + + const result = await recurrenceAI(rruleInput, { count: 3 }); + expect(result).toBeDefined(); + expect(result.rrule).toBe('FREQ=WEEKLY;BYDAY=TU;BYHOUR=15'); + expect(result.provider).toBe('rrule-parser'); + expect(result.confidence).toBe(1.0); + expect(result.isFinite).toBe(false); + expect(result.size).toBe(Number.POSITIVE_INFINITY); + expect(result.summary).toBe('Recurring schedule (FREQ=WEEKLY;BYDAY=TU;BYHOUR=15)'); + + const batch1 = result.take(3); + expect(batch1).toHaveLength(3); + expect(batch1[0]).toBeInstanceOf(Tempo); + }); + + it('should support stateful paged batching via .take(n)', async () => { + const result = await recurrenceAI('FREQ=WEEKLY;BYDAY=FR'); + + const batch1 = result.take(2); + expect(batch1).toHaveLength(2); + + const batch2 = result.take(2); + expect(batch2).toHaveLength(2); + expect(batch2[0].format('{yyyy}-{mm}-{dd}')).not.toBe(batch1[0].format('{yyyy}-{mm}-{dd}')); + }); + + it('should parse COUNT in finite RRULE strings, compute size, and return [] when exhausted', async () => { + const finiteRRule = 'FREQ=MONTHLY;BYDAY=1MO;COUNT=2'; + const result = await recurrenceAI(finiteRRule); + + expect(result.isFinite).toBe(true); + expect(result.size).toBe(2); + + const batch1 = result.take(2); + expect(batch1).toHaveLength(2); + + const exhaustedBatch = result.take(2); + expect(exhaustedBatch).toHaveLength(0); + expect(exhaustedBatch).toEqual([]); + }); + + it('should compile natural language prompts into RRULE strings, localized summary, and take() batches', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + rrule: 'FREQ=MONTHLY;BYDAY=2TU;BYHOUR=15', + summary: 'Chaque 2ème mardi du mois à 15:00', + reasoning: 'Parsed 2nd Tuesday schedule in French locale.', + confidence: 0.95 + }) + } + }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const prompt = 'Chaque 2ème mardi du mois à 15h'; + expect(isRRuleString(prompt)).toBe(false); + + const result = await recurrenceAI(prompt, { locale: 'fr-FR', count: 5 }); + expect(result.rrule).toBe('FREQ=MONTHLY;BYDAY=2TU;BYHOUR=15'); + expect(result.summary).toBe('Chaque 2ème mardi du mois à 15:00'); + expect(result.confidence).toBe(0.95); + + const batch = result.take(5); + expect(batch).toHaveLength(5); + expect(batch[0]).toBeInstanceOf(Tempo); + + // Verify iterator yields 5 items + const iterated = Array.from(result); + expect(iterated).toHaveLength(5); + }); +}); diff --git a/packages/tempo/public/llms-full.txt b/packages/tempo/public/llms-full.txt index 3b6b6836..fa974eb0 100644 --- a/packages/tempo/public/llms-full.txt +++ b/packages/tempo/public/llms-full.txt @@ -6706,6 +6706,28 @@ initAI({ }); ``` +### Dynamic Provider Manifests & Air-Gapped Fallback + +By default, `@magmacomputing/tempo-plugin-ai` lazily fetches provider defaults (model IDs, endpoints, token parameter keys) from `https://tempo.magmacomputing.com.au/providers.v1.json` once per application lifecycle. + +- **Async Resolution & Promise Lifecycle**: `initAI()` returns a `Promise`. + - **Synchronous Fire-and-Forget**: Calling `initAI(...)` synchronously without `await` immediately initializes system state with compiled local provider defaults (`DEFAULT_PROVIDERS`). You can execute `parseAI()` immediately on the next line without blocking. The remote manifest is fetched in the background and transparently updates provider defaults once received. + - **Guaranteed Remote Resolution**: If your application strictly requires remote provider defaults to be resolved before executing your first AI request, you can `await initAI(...)`: + ```typescript + // Await guaranteed remote manifest completion before proceeding + await initAI({ + providers: [{ id: 'groq', key: process.env.GROQ_API_KEY! }] + }); + ``` +- **Fail-Open & Air-Gapped Fallback**: If the network request fails, times out (1500ms limit), or the application is running offline or in an air-gapped environment, `initAI()` automatically and silently falls back to compiled local defaults (`DEFAULT_PROVIDERS`). +- **Disabling Remote Manifest**: Pass `remoteConfigUrl: false` to disable remote manifest fetching entirely: + ```typescript + initAI({ + providers: [{ id: 'groq', key: process.env.GROQ_API_KEY! }], + remoteConfigUrl: false // Disable remote manifest fetching + }); + ``` + ### Frontend Security Warning > [!CAUTION] > **Never** expose a raw LLM API key in a client-side browser bundle (like React or Vue) or store it in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). Any Cross-Site Scripting (XSS) vulnerability, compromised NPM package, or malicious browser extension can easily inspect client-side storage and steal secret keys, leading to quota exhaustion, billing fraud, or permanent provider bans. BYOK keys are *only* safe on backend servers or edge workers. @@ -6875,8 +6897,8 @@ npm install @magmacomputing/tempo-plugin-ai ```typescript import { parseAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; -// Initialize with your BYOK API Key -initAI({ +// Initialize with your BYOK API Key (await is optional if guaranteeing remote manifest resolution) +await initAI({ providers: [ { id: 'openai', key: process.env.OPENAI_API_KEY, model: 'your-preferred-model' }, ], @@ -6884,7 +6906,7 @@ initAI({ }); ``` -> **Tip**: `initAI` is fully re-callable! You can invoke it multiple times during your application's lifecycle to hot-swap API keys or update your fallback providers mid-stream without restarting your server. +> **Tip**: `initAI` returns a `Promise` and is fully re-callable! Calling it synchronously without `await` instantly initializes local defaults so you can call `parseAI` immediately, while `await initAI()` guarantees that remote provider manifest overrides are fetched and applied before proceeding. ```typescript // Parse a complex natural language string! @@ -6984,7 +7006,7 @@ The plugin automatically tracks these limits by reading the standard `x-ratelimi Quota and rate-limit metadata can be inspected in two convenient ways: ### 1. Request-Locked Instance Metadata (`dt.ai.limits`) -Every `Tempo` instance returned by `parseAI` includes a frozen `.ai.limits` snapshot representing the exact rate limit state returned by the provider HTTP headers for *that specific request*: +Every `Tempo` instance returned by `parseAI` includes a frozen `.ai.limits` snapshot representing the rate limit state returned by provider HTTP headers for *that specific request*. Note that `.ai.limits` is guaranteed only for provider-backed network requests where rate-limit headers are returned by the selected provider; results resolved via native parsing (`provider: 'native'`) or cache hits (`provider: 'cache'`) may omit `.ai.limits` (`undefined`). ```typescript const dt = await parseAI("The third Friday of next month"); @@ -7100,19 +7122,55 @@ If you want to explicitly query the LLM again and *overwrite* the existing cache const dt = await parseAI("Q3_START", { force: true }); ``` -### Extensible Caching (Enterprise) -For edge environments or custom application architectures, you can provide custom cache instances via `initAI({ cache })` or `Tempo.init({ cache })`! +### Extensible Caching & Async Storage Adapters (`AiCacheAdapter`) -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 cache lookup engine does not await promise-returning cache operations. +By default, parsed AI responses are cached in memory using `Tempo.cache` (`BoundedCache`). For distributed serverless environments (e.g. Next.js, Cloudflare Workers, Express) or cluster nodes, you can pass a custom synchronous or asynchronous storage adapter (`AiCacheAdapter`): ```typescript -// Custom synchronous cache implementation +import { initAI, parseAI, type AiCacheAdapter } from '@magmacomputing/tempo-plugin-ai'; +import { Redis } from '@upstash/redis'; + +const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN! }); + +// Implement custom async Redis storage adapter with namespacing & prefix deletion support +const redisAdapter: AiCacheAdapter = { + get: async (key) => (await redis.get(`tempo:ai:${key}`)) ?? undefined, + set: async (key, value, ttlMs) => { + if (ttlMs) await redis.set(`tempo:ai:${key}`, value, { px: ttlMs }); + else await redis.set(`tempo:ai:${key}`, value); + }, + delete: async (key) => { + await redis.del(`tempo:ai:${key}`); + }, + clear: async (prefix) => { + const pattern = prefix ? `tempo:ai:${prefix}*` : `tempo:ai:*`; + const keys = await redis.keys(pattern); + if (keys.length > 0) await redis.del(...keys); + } +}; + initAI({ - providers: [{ id: 'groq', key: '...' }], - cache: new MyCustomSyncCache() + providers: [{ id: 'groq', key: process.env.GROQ_API_KEY!, ttl: 7200000 }], // Provider-specific TTL (2 hours) + cacheAdapter: redisAdapter, + ttl: 3600000 // Global default TTL (1 hour) }); + +// Call-site TTL override (15 minutes) +const dt = await parseAI("next Monday at 9am", { ttl: 900000 }); ``` +### Cascading TTL Resolution Policies + +The plugin calculates cache TTL per entry using a strict resolution hierarchy: +1. **Call-site `options.ttl`**: `parseAI(prompt, { ttl: 900000 })` +2. **Provider-level `provider.ttl`**: `providers: [{ id: 'groq', ttl: 7200000 }]` +3. **Global `initAI({ ttl: 3600000 })`** +4. **Default TTL**: `3,600,000` ms (1 hour) + +### Fail-Open Cache Resilience + +Custom storage adapter calls (`adapter.get` and `adapter.set`) are wrapped in safe error handlers. If an external Redis instance crashes or encounters a network partition, the plugin logs a debug warning (if `debug: true`) and gracefully fails open to direct LLM resolution without crashing the application request. + From d5c260e6c6bce8af9d9e773210d25372da5ddf31 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Thu, 6 Aug 2026 14:18:55 +1000 Subject: [PATCH 08/23] PR recurrence 1st review --- packages/plugins/ai/doc/init.md | 4 +- packages/plugins/ai/doc/rate-limits.md | 8 +- packages/plugins/ai/doc/recurrence.md | 7 +- packages/plugins/ai/src/core/support.ts | 7 +- packages/plugins/ai/src/core/types.ts | 2 +- .../plugins/ai/src/functions/recurrence.ts | 392 ++++++++++++++---- packages/plugins/ai/test/recurrence.test.ts | 79 ++++ .../doc/3-extending-tempo/tempo.modularity.md | 8 +- .../doc/3-extending-tempo/tempo.plugin.md | 13 +- packages/tempo/public/llms-full.txt | 380 ++++++++++++++--- 10 files changed, 737 insertions(+), 163 deletions(-) diff --git a/packages/plugins/ai/doc/init.md b/packages/plugins/ai/doc/init.md index 65cd33b2..a3e48f1c 100644 --- a/packages/plugins/ai/doc/init.md +++ b/packages/plugins/ai/doc/init.md @@ -82,7 +82,9 @@ export interface AiConfig { timeout?: number; /** Global debug flag for operational trace logging */ debug?: boolean; + /** Synchronous Map or BoundedCache for static glossary terms */ + cache?: Map; /** Custom cache adapter for distributed storage (e.g. Redis, KV) */ - cache?: AiCacheAdapter; + cacheAdapter?: AiCacheAdapter; } ``` diff --git a/packages/plugins/ai/doc/rate-limits.md b/packages/plugins/ai/doc/rate-limits.md index 4ad6e3cc..0b8270b1 100644 --- a/packages/plugins/ai/doc/rate-limits.md +++ b/packages/plugins/ai/doc/rate-limits.md @@ -147,8 +147,12 @@ const redisAdapter: AiCacheAdapter = { }, clear: async (prefix) => { const pattern = prefix ? `tempo:ai:${prefix}*` : `tempo:ai:*`; - const keys = await redis.keys(pattern); - if (keys.length > 0) await redis.del(...keys); + let cursor = '0'; + do { + const [nextCursor, keys] = await redis.scan(cursor, { match: pattern, count: 100 }); + cursor = nextCursor; + if (keys.length > 0) await redis.del(...keys); + } while (cursor !== '0'); } }; diff --git a/packages/plugins/ai/doc/recurrence.md b/packages/plugins/ai/doc/recurrence.md index 92f2a60d..2e14072f 100644 --- a/packages/plugins/ai/doc/recurrence.md +++ b/packages/plugins/ai/doc/recurrence.md @@ -7,7 +7,12 @@ ```typescript import { recurrenceAI, initAI } from '@magmacomputing/tempo-plugin-ai'; -// 1. Natural Language Input (human-in -> RRule & Tempo batches out) +// 1. Initialize provider configuration +await initAI({ + providers: [{ id: 'groq', key: process.env.GROQ_API_KEY }] +}); + +// 2. Natural Language Input (human-in -> RRule & Tempo batches out) const result = await recurrenceAI("Every 2 weeks on Friday at 9am", { locale: 'fr-FR', // Output localized human summary count: 5 // Default batch size diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index 7cc8fb5d..33e972a6 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -62,7 +62,8 @@ export async function fetchFromProvider( contextString: string, isDebug: boolean, parentSignal?: AbortSignal, - timeoutOverride?: number + timeoutOverride?: number, + customSystemPrompt?: string ): Promise<{ rawContent: string; providerId: string; rateLimits: ReturnType }> { const url = provider.url!; const model = provider.model!; @@ -79,7 +80,7 @@ export async function fetchFromProvider( throw new TempoAiError(`Provider ${provider.id} has invalid endpoint URL '${url}'.`, 400); } - 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: + const defaultSystemPrompt = `You are a high-performance date parser. Read the user's string and the provided context. Return ONLY a valid JSON object matching this exact schema: { "reasoning": "Step-by-step calendar math from Current Time.", "iso": "Local ISO 8601 string (YYYY-MM-DDThh:mm:ss) without offset or Z suffix, or 'INVALID' if ambiguous/unparseable.", @@ -97,6 +98,8 @@ Ambiguity Rules: Do not include markdown blocks or any text outside the JSON.`; + const systemPrompt = customSystemPrompt ?? defaultSystemPrompt; + if (isDebug) console.log(`[tempo-plugin-ai] Querying provider '${provider.id}' (model: ${model})...`); diff --git a/packages/plugins/ai/src/core/types.ts b/packages/plugins/ai/src/core/types.ts index b92a5adc..e69e15ec 100644 --- a/packages/plugins/ai/src/core/types.ts +++ b/packages/plugins/ai/src/core/types.ts @@ -190,5 +190,5 @@ export interface TempoRecurrenceResult { /** Provider ID responsible for processing or 'rrule-parser' for native RRULE inputs */ provider: string; /** Reasoning / explanation of the recurrence pattern */ - reasoning?: string; + reasoning?: string | undefined; } diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts index fed900ef..80290316 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -14,11 +14,183 @@ function checkIsFinite(rrule: string): boolean { return /(UNTIL|COUNT)=/i.test(rrule); } -function extractSize(rrule: string): number { - const match = rrule.match(/COUNT=(\d+)/i); - if (match) return parseInt(match[1], 10); - if (/UNTIL=/i.test(rrule)) return 0; - return Number.POSITIVE_INFINITY; +const DAY_MAP: Record = { + MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 7 +}; + +interface ParsedRRule { + freq: string; + interval: number; + count?: number | undefined; + until?: Tempo | undefined; + byDay?: Array<{ nth?: number | undefined; day: string }> | undefined; + byHour?: number[] | undefined; + byMinute?: number[] | undefined; +} + +function parseRRule(rrule: string): ParsedRRule { + const parts = rrule.split(';'); + let freq = 'DAILY'; + let interval = 1; + let count: number | undefined; + let until: Tempo | undefined; + let byDay: Array<{ nth?: number | undefined; day: string }> | undefined; + let byHour: number[] | undefined; + let byMinute: number[] | undefined; + + for (const part of parts) { + const [key, val] = part.split('='); + if (!key || !val) continue; + const k = key.toUpperCase(); + if (k === 'FREQ') freq = val.toUpperCase(); + else if (k === 'INTERVAL') interval = Math.max(1, parseInt(val, 10) || 1); + else if (k === 'COUNT') count = parseInt(val, 10); + else if (k === 'UNTIL') { + const uStr = val.replace(/^(\d{4})(\d{2})(\d{2})T?(\d{2})?(\d{2})?(\d{2})?Z?$/, '$1-$2-$3T$4:$5:$6'); + until = new Tempo(uStr); + } else if (k === 'BYDAY') { + byDay = val.split(',').map(item => { + const m = item.match(/^([+-]?\d+)?([A-Z]{2})$/i); + const nthVal = m && m[1] ? parseInt(m[1], 10) : undefined; + const dayVal = m ? m[2].toUpperCase() : item.toUpperCase(); + return { nth: nthVal, day: dayVal }; + }); + } else if (k === 'BYHOUR') { + byHour = val.split(',').map(v => parseInt(v, 10)); + } else if (k === 'BYMINUTE') { + byMinute = val.split(',').map(v => parseInt(v, 10)); + } + } + + return { freq, interval, count, until, byDay, byHour, byMinute }; +} + +function expandOccurrences(rrule: string, anchor: Tempo, options?: { count?: number; after?: any; before?: any }): Tempo[] { + const rule = parseRRule(rrule); + const afterTempo = options?.after ? new Tempo(options.after) : undefined; + const beforeTempo = options?.before ? new Tempo(options.before) : undefined; + + const results: Tempo[] = []; + const maxToFetch = rule.count !== undefined ? rule.count : (options?.count ?? 100); + + let countProduced = 0; + let step = 0; + const MAX_STEPS = 1000; + + while (countProduced < maxToFetch && step < MAX_STEPS) { + let cand: Tempo; + + if (rule.freq === 'DAILY') { + cand = anchor.add(`${step * rule.interval} days`); + } else if (rule.freq === 'WEEKLY') { + cand = anchor.add(`${step * rule.interval} weeks`); + if (rule.byDay && rule.byDay.length > 0) { + const targetDay = DAY_MAP[rule.byDay[0].day] ?? 1; + const diff = (targetDay - cand.dow + 7) % 7; + cand = cand.add(`${diff} days`); + } + } else if (rule.freq === 'MONTHLY') { + const baseMonth = anchor.add(`${step * rule.interval} months`); + if (rule.byDay && rule.byDay.length > 0) { + const { nth, day } = rule.byDay[0]; + const targetDay = DAY_MAP[day] ?? 1; + const firstOfMonth = new Tempo(`${baseMonth.format('{yyyy}-{mm}')}-01`); + let firstOcc = (targetDay - firstOfMonth.dow + 7) % 7 + 1; + if (nth && nth > 1) { + firstOcc += (nth - 1) * 7; + } + cand = new Tempo(`${baseMonth.format('{yyyy}-{mm}')}-${String(firstOcc).padStart(2, '0')}T${anchor.format('{hh}:{mi}:{ss}')}`); + } else { + cand = baseMonth; + } + } else if (rule.freq === 'YEARLY') { + cand = anchor.add(`${step * rule.interval} years`); + } else { + cand = anchor.add(`${step * rule.interval} days`); + } + + if (rule.byHour && rule.byHour.length > 0) { + cand = cand.set({ hour: rule.byHour[0] }); + } + if (rule.byMinute && rule.byMinute.length > 0) { + cand = cand.set({ minute: rule.byMinute[0] }); + } + + step++; + + if (afterTempo && cand < afterTempo) continue; + if (beforeTempo && cand > beforeTempo) break; + if (rule.until && cand > rule.until) break; + + results.push(cand); + countProduced++; + + if (rule.count !== undefined && countProduced >= rule.count) break; + } + + return results; +} + +function createRecurrenceResult( + rruleStr: string, + summaryText: string, + confidence: number, + providerId: string, + reasoning: string | undefined, + anchorTempo: Tempo, + defaultBatchSize: number, + options?: TempoRecurrenceOptions +): TempoRecurrenceResult { + const rule = parseRRule(rruleStr); + const isFinite = checkIsFinite(rruleStr) || Boolean(options?.before); + let sizeLimit: number; + if (rule.count !== undefined) { + sizeLimit = rule.count; + } else if (isFinite) { + sizeLimit = expandOccurrences(rruleStr, anchorTempo, { count: 1000, after: options?.after, before: options?.before }).length; + } else { + sizeLimit = Number.POSITIVE_INFINITY; + } + + let offsetCursor = 0; + + const take = (count?: number): Tempo[] => { + const fetchCount = count ?? defaultBatchSize; + if (isFinite && offsetCursor >= sizeLimit) return []; + const actualCount = isFinite ? Math.min(fetchCount, sizeLimit - offsetCursor) : fetchCount; + if (actualCount <= 0) return []; + const expanded = expandOccurrences(rruleStr, anchorTempo, { + count: offsetCursor + actualCount, + after: options?.after, + before: options?.before + }); + const batch = expanded.slice(offsetCursor, offsetCursor + actualCount); + offsetCursor += batch.length; + return batch; + }; + + function* createIterator(batchSize: number): Generator { + const expanded = expandOccurrences(rruleStr, anchorTempo, { + count: isFinite ? Math.min(batchSize, sizeLimit) : batchSize, + after: options?.after, + before: options?.before + }); + for (const item of expanded) { + yield item; + } + } + + return { + rrule: rruleStr, + summary: summaryText, + isFinite, + size: sizeLimit, + take, + [Symbol.iterator]: () => createIterator(defaultBatchSize), + confidence, + provider: providerId, + reasoning + }; } /** @@ -43,43 +215,21 @@ export async function recurrenceAI( const loc = options?.locale || (options?.anchor instanceof Tempo ? options.anchor.config.locale : undefined) || Tempo.options.locale; const sph = options?.sphere || (options?.anchor instanceof Tempo ? options.anchor.config.sphere : undefined) || Tempo.options.sphere; - let offsetCursor = 0; - if (isRRule) { const cleanRRule = input.trim().replace(/^RRULE:/i, ''); if (isDebug) console.log(`[tempo-plugin-ai:recurrence] Detected raw RRULE string: "${cleanRRule}"`); - const isFinite = checkIsFinite(cleanRRule); - const sizeLimit = extractSize(cleanRRule); - - const take = (count?: number): Tempo[] => { - const size = count ?? defaultBatchSize; - if (isFinite && offsetCursor >= sizeLimit) return []; - const fetchCount = isFinite ? Math.min(size, sizeLimit - offsetCursor) : size; - if (fetchCount <= 0) return []; - const batch = Array.from({ length: fetchCount }, (_, i) => anchorTempo.add(`${offsetCursor + i} days`)); - offsetCursor += fetchCount; - return batch; - }; - - function* createIterator(batchSize: number): Generator { - const limit = isFinite ? Math.min(batchSize, sizeLimit) : batchSize; - for (let i = 0; i < limit; i++) - yield anchorTempo.add(`${i} days`); - } - - return { - rrule: cleanRRule, - summary: `Recurring schedule (${cleanRRule})`, - isFinite, - size: sizeLimit, - take, - [Symbol.iterator]: () => createIterator(defaultBatchSize), - confidence: 1.0, - provider: 'rrule-parser', - reasoning: 'Parsed natively from RFC 5545 RRULE string input.' - }; + return createRecurrenceResult( + cleanRRule, + `Recurring schedule (${cleanRRule})`, + 1.0, + 'rrule-parser', + 'Parsed natively from RFC 5545 RRULE string input.', + anchorTempo, + defaultBatchSize, + options + ); } const availableProviders = options?.providers || _state.config.providers; @@ -89,6 +239,7 @@ export async function recurrenceAI( assertNoReservedProviderId(availableProviders); const mode = options?.mode || _state.config.mode || AiMode.Fallback; + const effectiveMinConfidence = options?.minConfidence ?? _state.config.minConfidence; const callTimeout = options?.timeout; const contextString = `Current Time: ${anchorTempo.format('{wkd}, {yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${loc}, Hemisphere: ${sph}.`; @@ -105,65 +256,144 @@ Rules: - "confidence": Float score between 0.0 (unparseable) and 1.0 (certain). Do not include markdown blocks or text outside the JSON.`; - let rawContent = ''; - let providerId = ''; + let successfulResult: { parsedData: any; providerId: string; rateLimits?: any } | null = null; + let lastError: any = null; + let bestCandidate: { parsedData: any; providerId: string; rateLimits?: any } | null = null; if (mode === AiMode.Fallback) { for (const provider of availableProviders) { try { - const res = await fetchFromProvider(provider, input, `${systemPrompt}\n${contextString}`, isDebug, undefined, callTimeout); - rawContent = res.rawContent; - providerId = res.providerId; - break; - } catch (err) { - if (isDebug) console.warn(`[tempo-plugin-ai:recurrence] Provider ${provider.id} failed:`, err); + const { rawContent, providerId, rateLimits } = await fetchFromProvider( + provider, + input, + contextString, + isDebug, + undefined, + callTimeout, + systemPrompt + ); + const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); + const parsedData = JSON.parse(cleanContent); + const candidateConfidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : 0.9; + + if (!bestCandidate || candidateConfidence > (bestCandidate.parsedData?.confidence ?? 0)) { + bestCandidate = { parsedData, providerId, rateLimits }; + } + + if (effectiveMinConfidence === undefined || candidateConfidence >= effectiveMinConfidence) { + successfulResult = { parsedData, providerId, rateLimits }; + break; + } + + if (isDebug) + console.log(`[tempo-plugin-ai:recurrence] Provider '${providerId}' confidence (${candidateConfidence}) below minConfidence (${effectiveMinConfidence}). Cascading to next provider...`); + } catch (err: any) { + lastError = err; + if (isDebug) + console.warn(`[tempo-plugin-ai:recurrence] Provider ${provider.id} failed:`, err); } } - } else { - const res = await fetchFromProvider(availableProviders[0], input, `${systemPrompt}\n${contextString}`, isDebug, undefined, callTimeout); - rawContent = res.rawContent; - providerId = res.providerId; - } - if (!rawContent) - throw new TempoAiError('Failed to parse recurrence rule from AI providers.', 500); + 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, + input, + contextString, + isDebug, + parentController.signal, + callTimeout, + systemPrompt + ); + 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, + input, + contextString, + isDebug, + undefined, + callTimeout, + systemPrompt + ); + 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 firstRrule = fulfilled[0].parsedData?.rrule; + const allMatch = fulfilled.every(f => f.parsedData?.rrule === firstRrule); + + if (allMatch) { + successfulResult = { + parsedData: { + ...fulfilled[0].parsedData, + confidence: 1.0 + }, + 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, + providerId: sorted[0].providerId, + rateLimits: sorted[0].rateLimits + }; + } + } + } - const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); - const parsedData = JSON.parse(cleanContent); + _state.limits = successfulResult?.rateLimits ?? null; + const { parsedData, providerId } = successfulResult!; const rruleStr = typeof parsedData?.rrule === 'string' ? parsedData.rrule.trim() : 'FREQ=DAILY'; const summaryText = typeof parsedData?.summary === 'string' ? parsedData.summary : (typeof parsedData?.humanReadable === 'string' ? parsedData.humanReadable : input); const confidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : 0.9; const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined; - const isFinite = checkIsFinite(rruleStr); - const sizeLimit = extractSize(rruleStr); - - const take = (count?: number): Tempo[] => { - const size = count ?? defaultBatchSize; - if (isFinite && offsetCursor >= sizeLimit) return []; - const fetchCount = isFinite ? Math.min(size, sizeLimit - offsetCursor) : size; - if (fetchCount <= 0) return []; - const batch = Array.from({ length: fetchCount }, (_, i) => anchorTempo.add(`${offsetCursor + i} days`)); - offsetCursor += fetchCount; - return batch; - }; - - function* createIterator(batchSize: number): Generator { - const limit = isFinite ? Math.min(batchSize, sizeLimit) : batchSize; - for (let i = 0; i < limit; i++) - yield anchorTempo.add(`${i} days`); + if (effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence) { + throw new TempoAiError(`Recurrence rule confidence (${confidence}) is below the required threshold of ${effectiveMinConfidence}.`, 422); } - return { - rrule: rruleStr, - summary: summaryText, - isFinite, - size: sizeLimit, - take, - [Symbol.iterator]: () => createIterator(defaultBatchSize), + return createRecurrenceResult( + rruleStr, + summaryText, confidence, - provider: providerId, - reasoning: isDebug ? reasoning : undefined - }; + providerId, + isDebug ? reasoning : undefined, + anchorTempo, + defaultBatchSize, + options + ); } diff --git a/packages/plugins/ai/test/recurrence.test.ts b/packages/plugins/ai/test/recurrence.test.ts index 00e5b981..97d26707 100644 --- a/packages/plugins/ai/test/recurrence.test.ts +++ b/packages/plugins/ai/test/recurrence.test.ts @@ -84,8 +84,87 @@ describe('AI Recurrence Plugin (recurrenceAI)', () => { expect(batch).toHaveLength(5); expect(batch[0]).toBeInstanceOf(Tempo); + // Assert request body receives ONLY recurrence schema without date-parser iso schema + expect(fetchSpy).toHaveBeenCalledTimes(1); + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + const systemPrompt = requestBody.messages[0].content; + expect(systemPrompt).toContain('You are a calendar recurrence compiler.'); + expect(systemPrompt).toContain('"rrule": "Standard RFC 5545 RRULE string'); + expect(systemPrompt).not.toContain('You are a high-performance date parser.'); + expect(systemPrompt).not.toContain('"iso":'); + // Verify iterator yields 5 items const iterated = Array.from(result); expect(iterated).toHaveLength(5); }); + + it('should support provider race and consensus execution modes in recurrenceAI', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockImplementation(async () => new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + rrule: 'FREQ=WEEKLY;BYDAY=FR', + summary: 'Every Friday', + confidence: 0.98 + }) + } + }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const resRace = await recurrenceAI('Every Friday', { + mode: 'race', + providers: [ + { id: 'groq', key: 'key-1', url: 'https://api.groq.com/v1/chat/completions', model: 'llama-3.3-70b-versatile' }, + { id: 'openai', key: 'key-2', url: 'https://api.openai.com/v1/chat/completions', model: 'gpt-4o' } + ] + }); + expect(resRace.rrule).toBe('FREQ=WEEKLY;BYDAY=FR'); + + const resConsensus = await recurrenceAI('Every Friday', { + mode: 'consensus', + providers: [ + { id: 'groq', key: 'key-1', url: 'https://api.groq.com/v1/chat/completions', model: 'llama-3.3-70b-versatile' }, + { id: 'openai', key: 'key-2', url: 'https://api.openai.com/v1/chat/completions', model: 'gpt-4o' } + ] + }); + expect(resConsensus.rrule).toBe('FREQ=WEEKLY;BYDAY=FR'); + expect(resConsensus.provider).toBe('consensus'); + }); + + it('should honor minConfidence threshold and throw TempoAiError if confidence is low', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + rrule: 'FREQ=DAILY', + summary: 'Uncertain daily', + confidence: 0.4 + }) + } + }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + await expect(recurrenceAI('Uncertain repeat prompt', { minConfidence: 0.8 })) + .rejects.toThrow(/confidence \(0.4\) is below the required threshold of 0.8/i); + }); + + it('should evaluate RFC 5545 RRULE occurrences applying after and before windows', async () => { + const rruleStr = 'FREQ=DAILY;COUNT=10'; + const anchor = new Tempo('2026-08-01T09:00:00Z'); + const result = await recurrenceAI(rruleStr, { + anchor, + after: '2026-08-03T00:00:00Z', + before: '2026-08-06T00:00:00Z' + }); + + expect(result.isFinite).toBe(true); + const items = result.take(10); + expect(items.length).toBeGreaterThan(0); + for (const item of items) { + expect(item >= new Tempo('2026-08-03T00:00:00Z')).toBe(true); + expect(item <= new Tempo('2026-08-06T00:00:00Z')).toBe(true); + } + }); }); diff --git a/packages/tempo/doc/3-extending-tempo/tempo.modularity.md b/packages/tempo/doc/3-extending-tempo/tempo.modularity.md index e064b12b..9410bf74 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.modularity.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.modularity.md @@ -117,11 +117,9 @@ declare module '@magmacomputing/tempo' { There is a subtle but important distinction between how features are activated in Core mode: * **`Tempo.extend(Module)`**: This is **Immediate and Explicit**. It applies the module to the class exactly when the line is executed. This is the recommended pattern for modular applications. -* **`Tempo.init()`**: This is **Discovery-Driven**. It scans the global environment for any plugins that were imported via side effects (e.g., `import '@magmacomputing/tempo/term'`) and hydrates the engine all at once. +* **`Tempo.init()`**: Establishes global baseline configuration at application startup and accepts explicit plugin registrations via `Tempo.init({ plugins: [...] })`. -::: danger -**The Initialization Lifecycle**: `Tempo.init()` performs a **full state refresh**. It resets configuration, Term registries, and formatting maps to defaults before re-applying all currently discovered plugins. To ensure your custom logic is managed correctly, always use `Tempo.extend()` or encapsulate changes within a formal plugin. +::: note +**Idempotent Initialization Lifecycle**: `Tempo.init()` is designed to establish baseline configuration once during startup. To register additional plugins dynamically after initialization, use `Tempo.extend(...)` rather than calling `Tempo.init()` again. ::: -**The Side-Effect Trap**: If you import a side-effect plugin *after* you have already called `Tempo.init()`, the feature will **not** automatically appear on the `Tempo` class. Because `Tempo.init()` short-circuits once state already exists, re-calling it will not load those late modules. Use `Tempo.extend()` explicitly to activate late-loaded modules instead of trying to re-run `Tempo.init()`. - diff --git a/packages/tempo/doc/3-extending-tempo/tempo.plugin.md b/packages/tempo/doc/3-extending-tempo/tempo.plugin.md index 89d7fcfa..ac5f6b73 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.plugin.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.plugin.md @@ -92,17 +92,20 @@ Modern Tempo plugins are designed to be "plug-and-play." By using the `definePlu ::: ```typescript -import '@magmacomputing/tempo-plugin-ticker'; // 1. Module self-registers via side-effect -import { Tempo } from '@magmacomputing/tempo/core'; // 2. Load the `lite` engine +import { Tempo } from '@magmacomputing/tempo/core'; // 1. Load the `lite` engine +import { TickerPlugin } from '@magmacomputing/tempo-plugin-ticker'; // 2. Import the plugin -Tempo.init({ license: 'YOUR_JWT_KEY' }); // 3. Discover, verify, and activate all imported plugins +Tempo.init({ + license: 'YOUR_JWT_KEY', + plugins: [TickerPlugin] // 3. Register and activate plugin during init +}); // Ticker is now available on the core Tempo class! const pulse = Tempo.ticker(1); ``` -> [!NOTE] Import Order -> While older versions of Tempo were sensitive to import order, current versions handle sequencing robustly. `Tempo.init()` is automatically called during bootstrap to ensure all discovered plugins are integrated. If you dynamically load plugins later, you can call `Tempo.init()` manually to refresh the registry. +> [!NOTE] Dynamic Extension +> `Tempo.init()` establishes baseline configuration at startup and accepts a `plugins` array for explicit registration. To dynamically register plugins loaded later at runtime, use `Tempo.extend(Plugin)` directly rather than re-running `Tempo.init()`. --- diff --git a/packages/tempo/public/llms-full.txt b/packages/tempo/public/llms-full.txt index fa974eb0..87bd2667 100644 --- a/packages/tempo/public/llms-full.txt +++ b/packages/tempo/public/llms-full.txt @@ -2607,14 +2607,12 @@ declare module '@magmacomputing/tempo' { There is a subtle but important distinction between how features are activated in Core mode: * **`Tempo.extend(Module)`**: This is **Immediate and Explicit**. It applies the module to the class exactly when the line is executed. This is the recommended pattern for modular applications. -* **`Tempo.init()`**: This is **Discovery-Driven**. It scans the global environment for any plugins that were imported via side effects (e.g., `import '@magmacomputing/tempo/term'`) and hydrates the engine all at once. +* **`Tempo.init()`**: Establishes global baseline configuration at application startup and accepts explicit plugin registrations via `Tempo.init({ plugins: [...] })`. -::: danger -**The Initialization Lifecycle**: `Tempo.init()` performs a **full state refresh**. It resets configuration, Term registries, and formatting maps to defaults before re-applying all currently discovered plugins. To ensure your custom logic is managed correctly, always use `Tempo.extend()` or encapsulate changes within a formal plugin. +::: note +**Idempotent Initialization Lifecycle**: `Tempo.init()` is designed to establish baseline configuration once during startup. To register additional plugins dynamically after initialization, use `Tempo.extend(...)` rather than calling `Tempo.init()` again. ::: -**The Side-Effect Trap**: If you import a side-effect plugin *after* you have already called `Tempo.init()`, the feature will **not** automatically appear on the `Tempo` class. Because `Tempo.init()` short-circuits once state already exists, re-calling it will not load those late modules. Use `Tempo.extend()` explicitly to activate late-loaded modules instead of trying to re-run `Tempo.init()`. - @@ -2920,17 +2918,20 @@ Modern Tempo plugins are designed to be "plug-and-play." By using the `definePlu ::: ```typescript -import '@magmacomputing/tempo-plugin-ticker'; // 1. Module self-registers via side-effect -import { Tempo } from '@magmacomputing/tempo/core'; // 2. Load the `lite` engine +import { Tempo } from '@magmacomputing/tempo/core'; // 1. Load the `lite` engine +import { TickerPlugin } from '@magmacomputing/tempo-plugin-ticker'; // 2. Import the plugin -Tempo.init({ license: 'YOUR_JWT_KEY' }); // 3. Discover, verify, and activate all imported plugins +Tempo.init({ + license: 'YOUR_JWT_KEY', + plugins: [TickerPlugin] // 3. Register and activate plugin during init +}); // Ticker is now available on the core Tempo class! const pulse = Tempo.ticker(1); ``` -> [!NOTE] Import Order -> While older versions of Tempo were sensitive to import order, current versions handle sequencing robustly. `Tempo.init()` is automatically called during bootstrap to ensure all discovered plugins are integrated. If you dynamically load plugins later, you can call `Tempo.init()` manually to refresh the registry. +> [!NOTE] Dynamic Extension +> `Tempo.init()` establishes baseline configuration at startup and accepts a `plugins` array for explicit registration. To dynamically register plugins loaded later at runtime, use `Tempo.extend(Plugin)` directly rather than re-running `Tempo.init()`. --- @@ -6868,63 +6869,96 @@ console.log(dt.ai); > [!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. +Tempo community plugin for LLM-powered natural language date parsing, schedule compilation, and temporal processing. This plugin bridges the gap between deterministic date-math and unstructured NLP inputs, utilizing large language models (like Gemini, Groq, or OpenAI) to safely and asynchronously parse, format, and process complex natural language temporal expressions into `Tempo` instances. -> **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 or stored in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). Client-side storage is vulnerable to XSS attacks, malicious scripts, and browser extension extraction, which can result in API key theft and quota abuse. BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, you must route requests through a secure backend proxy service. - -## Ideal Use-Cases +> **CRITICAL SECURITY WARNING**: Raw LLM API keys must **never** be exposed in a client-side browser bundle or stored in browser storage (`localStorage`, `sessionStorage`, `IndexedDB`, or browser cache). BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, route requests through a secure backend proxy service. -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: +## Installation & Quickstart -- **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"` +```bash +npm install @magmacomputing/tempo-plugin-ai +``` -> **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). +```typescript +import { parseAI, initAI } from '@magmacomputing/tempo-plugin-ai'; -## Installation +// Initialize provider farm (Node/SSR backend) +await initAI({ + providers: [{ id: 'groq', key: process.env.GROQ_API_KEY }] +}); -```bash -npm install @magmacomputing/tempo-plugin-ai +// Parse natural language temporal expressions +const dt = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); +console.log(dt.format('{yyyy}-{mm}-{dd}')); // 2026-11-17 ``` -## Setup & Usage +## AI Function Catalog + +| Function | Input | Output | Guide | +| :--- | :--- | :--- | :--- | +| **`initAI`** | Configuration object | `Promise` | [Initialization & Provider Farm Guide](./ai.init.md) | +| **`parseAI`** | Unstructured text string | `Promise` | [Point-in-Time Parsing Guide](./ai.parse.md) | +| **`recurrenceAI`** | Natural language schedule OR RFC 5545 RRULE string | `Promise` | [Recurrence & Schedules Guide](./ai.recurrence.md) | + +## Architecture & Infrastructure Guides + +> [!IMPORTANT] +> **Production Recommendation**: Due to the complexities of LLM APIs, including caching gotchas, context injection, rate limits, and calendar math hallucinations, we politely but firmly recommend reading the dedicated guides below before deploying this plugin in a production environment. + +Explore the architecture and security guides: +- [Provider Architecture & Security](./ai.architecture.md) (BYOK vs Proxy patterns, Frontend Security) +- [Context & Natural Language Parsing](./ai.context.md) (How Timezone and Locale are injected) +- [Rate Limits & Cache Management](./ai.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. + + + + +--- + + + +# Document: 9-plugins/ai.init.md + +# `initAI` — Provider Initialization & Farm Configuration + +`initAI()` sets up the global configuration for `@magmacomputing/tempo-plugin-ai`, managing provider authentication, multi-provider execution modes, global SLAs/timeouts, and caching strategies. + +## Basic Usage ```typescript -import { parseAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; +import { initAI, parseAI } from '@magmacomputing/tempo-plugin-ai'; -// Initialize with your BYOK API Key (await is optional if guaranteeing remote manifest resolution) +// Initialize with BYOK (Bring Your Own Key) provider credentials await initAI({ providers: [ - { id: 'openai', key: process.env.OPENAI_API_KEY, model: 'your-preferred-model' }, + { id: 'groq', key: process.env.GROQ_API_KEY }, + { id: 'openai', key: process.env.OPENAI_API_KEY, model: 'gpt-4o' } ], - debug: true // (Development-only) Enable verbose console logging + timeout: 5000, // 5-second global SLA default + debug: true // Enable operational trace logging (development-only) }); ``` > **Tip**: `initAI` returns a `Promise` and is fully re-callable! Calling it synchronously without `await` instantly initializes local defaults so you can call `parseAI` immediately, while `await initAI()` guarantees that remote provider manifest overrides are fetched and applied before proceeding. -```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: +The AI plugin supports three multi-provider execution strategies (`fallback`, `race`, `consensus`): ```typescript // 1. Fallback mode (default): query providers sequentially in array order until one succeeds -const fallback = await parseAI("First Monday of November", { - mode: 'fallback', // Default strategy if omitted - minConfidence: 0.8 // Require at least 0.8 confidence threshold +initAI({ + mode: 'fallback', + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY }, // Primary provider + { id: 'openai', key: process.env.OPENAI_API_KEY } // Fallback provider + ] }); // 2. Race mode: send concurrent requests to all providers, returning the fastest valid response @@ -6933,7 +6967,7 @@ const fastest = await parseAI("Third Friday of October", { mode: 'race' }); // 3. 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 + minConfidence: 0.85 }); ``` @@ -6942,7 +6976,6 @@ const agreed = await parseAI("The penultimate Tuesday before Thanksgiving", { Prevent hanging requests using the 3-tier timeout hierarchy (`call-site` > `provider` > `global` > `default 15s`): ```typescript -// Global timeout across all AI requests initAI({ providers: [ { id: 'groq', key: process.env.GROQ_API_KEY, options: { timeout: 2000 } } // 2s timeout for fast provider @@ -6954,40 +6987,136 @@ initAI({ const dt = await parseAI("Next Tuesday at 4pm", { timeout: 3000 }); ``` -## Debugging & Forced Evaluation +## Operational Trace Logging & Debugging + +**Operational Trace Logging** +Passing `debug: true` into `initAI` (or setting `{ debug: true }` on a per-request options object) outputs concise operational trace logs (prefixed with `[tempo-plugin-ai]`) to the developer console for monitoring provider fallback decisions and execution timing. + +Detailed diagnostic context—including `rawPrompt`, `normalizedPrompt`, `reasoning`, confidence scores, and rate limit snapshots—is attached directly to the returned `Tempo` instance via the `.ai` property when `debug: true` is enabled. + +> [!WARNING] +> **Diagnostic Security Notice**: Inspecting or exposing the `.ai` metadata property (such as `rawPrompt` or `reasoning`) in public UI components or client-side telemetry may expose raw user inputs. Ensure sensitive diagnostic fields on `Tempo.ai` are sanitized before forwarding instances to external monitoring tools. + +## Configuration Options Reference + +```typescript +export interface AiConfig { + /** List of configured AI providers */ + providers?: AiProvider[]; + /** Default execution mode across providers ('fallback' | 'race' | 'consensus') */ + mode?: 'fallback' | 'race' | 'consensus'; + /** Global SLA timeout in milliseconds */ + timeout?: number; + /** Global debug flag for operational trace logging */ + debug?: boolean; + /** Synchronous Map or BoundedCache for static glossary terms */ + cache?: Map; + /** Custom cache adapter for distributed storage (e.g. Redis, KV) */ + cacheAdapter?: AiCacheAdapter; +} +``` + + + -When building your LLM queries, it is often useful to see exactly how AI functions route your data. +--- + + + +# Document: 9-plugins/ai.parse.md -**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. +# `parseAI` — Natural Language Point-in-Time Parsing -**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: +`parseAI()` is the primary entry point for converting complex, unstructured natural language date/time expressions into deterministic `Tempo` instances. + +## Basic Usage ```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 +import { parseAI, initAI } from '@magmacomputing/tempo-plugin-ai'; + +// Initialize AI providers +await initAI({ + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY } + ] }); + +// Parse natural language +const dt = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); + +console.log(dt.format('{yyyy}-{mm}-{dd}')); // 2026-11-17 +console.log(dt.ai?.confidence); // 0.98 ``` -## Documentation Topics +## Options & Overrides -> [!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. +`parseAI(input, options)` accepts per-request options: -To learn more about configuring and optimizing the AI Plugin, check out the dedicated guides: -- [Provider Architecture & Security](./ai.architecture.md) (BYOK vs Proxy patterns, Frontend Security) -- [Context & Natural Language Parsing](./ai.context.md) (How Timezone and Locale are injected) -- [Rate Limits & Cache Management](./ai.rate-limits.md) (Tracking API quotas, handling 429 errors, and custom Redis caches) +```typescript +const dt = await parseAI("Third Friday of October", { + anchor: '2026-05-10T12:00:00Z', // Anchor date for relative calculations + timeZone: 'Australia/Sydney', // Context timezone + locale: 'en-AU', // Context locale + minConfidence: 0.85, // Require at least 0.85 confidence score + timeout: 3000, // 3-second SLA call-site timeout + force: true, // Skip native pre-parsing & cache lookup + debug: true // Enable operational trace logging & .ai metadata +}); +``` -## Licensing +## Multi-Provider Execution Modes -This is a **Community** plugin. It is completely free and open-source for personal and commercial use. No license token is required. +`parseAI` supports three execution strategies across your configured provider farm: +1. **Fallback (default)**: Queries providers sequentially in array order until one satisfies the confidence threshold. +2. **Race (`mode: 'race'`)**: Sends requests concurrently to all providers, returning the fastest valid response. +3. **Consensus (`mode: 'consensus'`)**: Queries providers concurrently, boosting confidence when outputs agree across providers. - +```typescript +// Consensus mode across multiple providers +const agreed = await parseAI("First Monday of November", { + mode: 'consensus', + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY }, + { id: 'openai', key: process.env.OPENAI_API_KEY } + ] +}); +``` + +## Batch Array Parsing + +Pass an array of prompts to process multiple queries in parallel while preserving index ordering: + +```typescript +const [dt1, dt2] = await parseAI([ + "New Years Day 2026", + "Groundhog Day 2026" +]); + +console.log(dt1.format('{yyyy}-{mm}-{dd}')); // 2026-01-01 +console.log(dt2.format('{yyyy}-{mm}-{dd}')); // 2026-02-02 +``` + +## Diagnostic Metadata (`.ai`) + +When a date is parsed, a frozen diagnostic metadata object is attached to the returned `Tempo` instance: + +```typescript +console.log(dt.ai); +/* +{ + provider: 'groq', + cached: false, + confidence: 0.98, + ambiguous: false, + granularity: 'day', + rawIso: '2026-11-17T00:00:00' +} +*/ +``` + + + --- @@ -7144,8 +7273,12 @@ const redisAdapter: AiCacheAdapter = { }, clear: async (prefix) => { const pattern = prefix ? `tempo:ai:${prefix}*` : `tempo:ai:*`; - const keys = await redis.keys(pattern); - if (keys.length > 0) await redis.del(...keys); + let cursor = '0'; + do { + const [nextCursor, keys] = await redis.scan(cursor, { match: pattern, count: 100 }); + cursor = nextCursor; + if (keys.length > 0) await redis.del(...keys); + } while (cursor !== '0'); } }; @@ -7177,6 +7310,123 @@ Custom storage adapter calls (`adapter.get` and `adapter.set`) are wrapped in sa --- + +# Document: 9-plugins/ai.recurrence.md + +# `recurrenceAI` — Recurrence Rules & Schedule Translation + +`recurrenceAI()` provides multi-directional translation between natural language repeating schedule descriptions (*"Every 2nd Tuesday of the month at 3pm"*) and RFC 5545 **RRULE strings**, generating paged `Tempo` instance batches on demand. + +## Basic Usage + +```typescript +import { recurrenceAI, initAI } from '@magmacomputing/tempo-plugin-ai'; + +// 1. Initialize provider configuration +await initAI({ + providers: [{ id: 'groq', key: process.env.GROQ_API_KEY }] +}); + +// 2. Natural Language Input (human-in -> RRule & Tempo batches out) +const result = await recurrenceAI("Every 2 weeks on Friday at 9am", { + locale: 'fr-FR', // Output localized human summary + count: 5 // Default batch size +}); + +console.log(result.rrule); // "FREQ=WEEKLY;INTERVAL=2;BYDAY=FR;BYHOUR=9" +console.log(result.summary); // "Chaque 2 semaines le vendredi à 09:00" +console.log(result.isFinite); // false (recurs indefinitely) +console.log(result.size); // Infinity +``` + +## Stateful Paged Batching (`.take(n)`) + +`recurrenceAI` maintains an internal date cursor. Calling `.take(n)` repeatedly returns consecutive batches of `Tempo` instances: + +```typescript +// Fetch initial batch of 5 items +const batch1 = result.take(5); +console.log(batch1.length); // 5 + +// Fetch NEXT batch of 5 items starting right where batch 1 left off +const batch2 = result.take(5); +console.log(batch2.length); // 5 +``` + +When a finite schedule (e.g. `COUNT=10`) completes, `.take(n)` returns an empty array `[]` to signal exhaustion: + +```typescript +const finiteResult = await recurrenceAI("FREQ=MONTHLY;BYDAY=1MO;COUNT=2"); + +const b1 = finiteResult.take(2); // [ Tempo(Month 1), Tempo(Month 2) ] +const b2 = finiteResult.take(2); // [] (Exhausted) +``` + +## Native RRULE Parsing (Zero Network Overhead) + +Passing a raw RFC 5545 RRULE string directly to `recurrenceAI` bypasses network LLM calls entirely (`provider: 'rrule-parser'`), functioning as an instant native parser: + +```typescript +const native = await recurrenceAI("FREQ=MONTHLY;BYDAY=1MO;COUNT=12"); + +console.log(native.provider); // "rrule-parser" (Instant native resolution) +console.log(native.isFinite); // true +console.log(native.size); // 12 +``` + +## Lazy Iteration (`for...of`) + +`TempoRecurrenceResult` implements `[Symbol.iterator]`, allowing lazy iteration over occurrences up to the batch limit (`count: 5` by default). + +When iterating over open-ended schedules (`isFinite === false`), build a `break` termination clause into the loop: + +```typescript +const schedule = await recurrenceAI("Every Friday"); + +for (const occurrence of schedule) { + // Always include a termination condition for open-ended schedules + if (occurrence.year > 2028) break; + + console.log(occurrence.format('{yyyy}-{mm}-{dd}')); +} +``` + +## Result Interface + +```typescript +export interface TempoRecurrenceResult { + /** Standard RFC 5545 RRULE string (e.g. 'FREQ=WEEKLY;BYDAY=TU') */ + rrule: string; + + /** Localized human-friendly schedule summary */ + summary: string; + + /** True if schedule has an explicit end date or count limit; false if infinite */ + isFinite: boolean; + + /** Total count of occurrences if finite, or Infinity (Number.POSITIVE_INFINITY) */ + size: number; + + /** Advances cursor and returns the next batch of N Tempo instances */ + take(count?: number): Tempo[]; + + /** Lazy generator yielding Tempo instances */ + [Symbol.iterator](): Generator; + + /** Confidence score (0.0 to 1.0) */ + confidence: number; + + /** Provider ID responsible for processing or 'rrule-parser' */ + provider: string; +} +``` + + + + +--- + + # Document: 9-plugins/astro.index.md From 8bf8e7f980afa776d9eccec608c5ae8ecbf729a6 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Thu, 6 Aug 2026 15:15:57 +1000 Subject: [PATCH 09/23] PR recurrence 2nd review --- .../plugins/ai/src/functions/recurrence.ts | 265 ++++++++++++++---- packages/plugins/ai/test/recurrence.test.ts | 122 ++++++-- .../doc/3-extending-tempo/tempo.modularity.md | 4 +- .../doc/3-extending-tempo/tempo.plugin.md | 4 +- packages/tempo/public/llms-full.txt | 8 +- 5 files changed, 308 insertions(+), 95 deletions(-) diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts index 80290316..9cb8e4f6 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -23,9 +23,11 @@ interface ParsedRRule { interval: number; count?: number | undefined; until?: Tempo | undefined; + byMonth?: number[] | undefined; byDay?: Array<{ nth?: number | undefined; day: string }> | undefined; byHour?: number[] | undefined; byMinute?: number[] | undefined; + bySetPos?: number[] | undefined; } function parseRRule(rrule: string): ParsedRRule { @@ -34,35 +36,77 @@ function parseRRule(rrule: string): ParsedRRule { let interval = 1; let count: number | undefined; let until: Tempo | undefined; + let byMonth: number[] | undefined; let byDay: Array<{ nth?: number | undefined; day: string }> | undefined; let byHour: number[] | undefined; let byMinute: number[] | undefined; + let bySetPos: number[] | undefined; for (const part of parts) { const [key, val] = part.split('='); if (!key || !val) continue; const k = key.toUpperCase(); - if (k === 'FREQ') freq = val.toUpperCase(); - else if (k === 'INTERVAL') interval = Math.max(1, parseInt(val, 10) || 1); - else if (k === 'COUNT') count = parseInt(val, 10); - else if (k === 'UNTIL') { - const uStr = val.replace(/^(\d{4})(\d{2})(\d{2})T?(\d{2})?(\d{2})?(\d{2})?Z?$/, '$1-$2-$3T$4:$5:$6'); - until = new Tempo(uStr); + const trimmedVal = val.trim(); + + if (k === 'FREQ') { + freq = trimmedVal.toUpperCase(); + } else if (k === 'INTERVAL') { + const parsed = parseInt(trimmedVal, 10); + interval = !isNaN(parsed) && parsed > 0 ? parsed : 1; + } else if (k === 'COUNT') { + const parsed = parseInt(trimmedVal, 10); + count = !isNaN(parsed) && parsed > 0 ? parsed : undefined; + } else if (k === 'UNTIL') { + if (/^\d{8}$/.test(trimmedVal)) { + const year = trimmedVal.slice(0, 4); + const month = trimmedVal.slice(4, 6); + const day = trimmedVal.slice(6, 8); + until = new Tempo(`${year}-${month}-${day}T23:59:59Z`); + } else { + const uStr = trimmedVal.replace(/^(\d{4})(\d{2})(\d{2})T?(\d{2})?(\d{2})?(\d{2})?Z?$/, '$1-$2-$3T$4:$5:$6'); + const t = new Tempo(uStr); + until = t.isValid ? t : undefined; + } + } else if (k === 'BYMONTH') { + const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 1 && v <= 12); + if (items.length > 0) byMonth = items; } else if (k === 'BYDAY') { - byDay = val.split(',').map(item => { + const items = trimmedVal.split(',').map(item => { const m = item.match(/^([+-]?\d+)?([A-Z]{2})$/i); const nthVal = m && m[1] ? parseInt(m[1], 10) : undefined; const dayVal = m ? m[2].toUpperCase() : item.toUpperCase(); - return { nth: nthVal, day: dayVal }; - }); + return { nth: nthVal !== undefined && !isNaN(nthVal) ? nthVal : undefined, day: dayVal }; + }).filter(d => DAY_MAP[d.day] !== undefined); + if (items.length > 0) byDay = items; } else if (k === 'BYHOUR') { - byHour = val.split(',').map(v => parseInt(v, 10)); + const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 0 && v <= 23); + if (items.length > 0) byHour = items; } else if (k === 'BYMINUTE') { - byMinute = val.split(',').map(v => parseInt(v, 10)); + const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 0 && v <= 59); + if (items.length > 0) byMinute = items; + } else if (k === 'BYSETPOS') { + const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v)); + if (items.length > 0) bySetPos = items; } } - return { freq, interval, count, until, byDay, byHour, byMinute }; + return { freq, interval, count, until, byMonth, byDay, byHour, byMinute, bySetPos }; +} + +function getMonthNum(t: Tempo): number { + return parseInt(t.format('{mm}'), 10); +} + +function getHourNum(t: Tempo): number { + return parseInt(t.format('{hh}'), 10); +} + +function getMinuteNum(t: Tempo): number { + return parseInt(t.format('{mi}'), 10); +} + +function getDaysInMonth(year: number, month: number): number { + return new Date(Date.UTC(year, month, 0)).getUTCDate(); } function expandOccurrences(rrule: string, anchor: Tempo, options?: { count?: number; after?: any; before?: any }): Tempo[] { @@ -73,59 +117,131 @@ function expandOccurrences(rrule: string, anchor: Tempo, options?: { count?: num const results: Tempo[] = []; const maxToFetch = rule.count !== undefined ? rule.count : (options?.count ?? 100); - let countProduced = 0; + let totalGeneratedFromAnchor = 0; + let resultsCount = 0; let step = 0; const MAX_STEPS = 1000; - while (countProduced < maxToFetch && step < MAX_STEPS) { - let cand: Tempo; + while (resultsCount < maxToFetch && step < MAX_STEPS) { + let periodBases: Tempo[] = []; if (rule.freq === 'DAILY') { - cand = anchor.add(`${step * rule.interval} days`); + periodBases = [anchor.add(`${step * rule.interval} days`)]; } else if (rule.freq === 'WEEKLY') { - cand = anchor.add(`${step * rule.interval} weeks`); + const weekBase = anchor.add(`${step * rule.interval} weeks`); if (rule.byDay && rule.byDay.length > 0) { - const targetDay = DAY_MAP[rule.byDay[0].day] ?? 1; - const diff = (targetDay - cand.dow + 7) % 7; - cand = cand.add(`${diff} days`); + periodBases = rule.byDay.map(bd => { + const targetDay = DAY_MAP[bd.day] ?? 1; + const diff = (targetDay - weekBase.dow + 7) % 7; + return weekBase.add(`${diff} days`); + }); + } else { + periodBases = [weekBase]; } } else if (rule.freq === 'MONTHLY') { - const baseMonth = anchor.add(`${step * rule.interval} months`); + const monthBase = anchor.add(`${step * rule.interval} months`); + const yearStr = monthBase.format('{yyyy}'); + const monthStr = monthBase.format('{mm}'); + const daysInMonth = getDaysInMonth(parseInt(yearStr, 10), parseInt(monthStr, 10)); + if (rule.byDay && rule.byDay.length > 0) { - const { nth, day } = rule.byDay[0]; - const targetDay = DAY_MAP[day] ?? 1; - const firstOfMonth = new Tempo(`${baseMonth.format('{yyyy}-{mm}')}-01`); - let firstOcc = (targetDay - firstOfMonth.dow + 7) % 7 + 1; - if (nth && nth > 1) { - firstOcc += (nth - 1) * 7; + const candidateDays: Tempo[] = []; + for (const bd of rule.byDay) { + const targetDow = DAY_MAP[bd.day] ?? 1; + const matchingDates: Tempo[] = []; + for (let d = 1; d <= daysInMonth; d++) { + const dateStr = `${yearStr}-${monthStr}-${String(d).padStart(2, '0')}T${anchor.format('{hh}:{mi}:{ss}')}`; + const t = new Tempo(dateStr); + if (t.dow === targetDow) { + matchingDates.push(t); + } + } + + if (bd.nth !== undefined) { + if (bd.nth > 0 && bd.nth <= matchingDates.length) { + candidateDays.push(matchingDates[bd.nth - 1]); + } else if (bd.nth < 0 && Math.abs(bd.nth) <= matchingDates.length) { + candidateDays.push(matchingDates[matchingDates.length + bd.nth]); + } + } else { + candidateDays.push(...matchingDates); + } } - cand = new Tempo(`${baseMonth.format('{yyyy}-{mm}')}-${String(firstOcc).padStart(2, '0')}T${anchor.format('{hh}:{mi}:{ss}')}`); + periodBases = candidateDays; } else { - cand = baseMonth; + periodBases = [monthBase]; } } else if (rule.freq === 'YEARLY') { - cand = anchor.add(`${step * rule.interval} years`); + periodBases = [anchor.add(`${step * rule.interval} years`)]; } else { - cand = anchor.add(`${step * rule.interval} days`); + periodBases = [anchor.add(`${step * rule.interval} days`)]; } - if (rule.byHour && rule.byHour.length > 0) { - cand = cand.set({ hour: rule.byHour[0] }); + // Filter period bases by BYMONTH if specified + if (rule.byMonth && rule.byMonth.length > 0) { + periodBases = periodBases.filter(b => rule.byMonth!.includes(getMonthNum(b))); } - if (rule.byMinute && rule.byMinute.length > 0) { - cand = cand.set({ minute: rule.byMinute[0] }); + + // Cartesian expansion for BYHOUR and BYMINUTE + const periodCandidates: Tempo[] = []; + for (const base of periodBases) { + const hours = rule.byHour && rule.byHour.length > 0 ? rule.byHour : [getHourNum(base)]; + const minutes = rule.byMinute && rule.byMinute.length > 0 ? rule.byMinute : [getMinuteNum(base)]; + + for (const h of hours) { + for (const m of minutes) { + periodCandidates.push(base.set({ hour: h, minute: m })); + } + } + } + + // Apply BYSETPOS if specified + let finalPeriodCandidates = periodCandidates; + if (rule.bySetPos && rule.bySetPos.length > 0 && periodCandidates.length > 0) { + finalPeriodCandidates = []; + for (const pos of rule.bySetPos) { + if (pos > 0 && pos <= periodCandidates.length) { + finalPeriodCandidates.push(periodCandidates[pos - 1]); + } else if (pos < 0 && Math.abs(pos) <= periodCandidates.length) { + finalPeriodCandidates.push(periodCandidates[periodCandidates.length + pos]); + } + } } step++; - if (afterTempo && cand < afterTempo) continue; - if (beforeTempo && cand > beforeTempo) break; - if (rule.until && cand > rule.until) break; + // Evaluate candidates sequentially with COUNT and window filtering + let stopSeries = false; + for (const cand of finalPeriodCandidates) { + totalGeneratedFromAnchor++; + + if (rule.until && cand > rule.until) { + stopSeries = true; + break; + } + if (rule.count !== undefined && totalGeneratedFromAnchor > rule.count) { + stopSeries = true; + break; + } + if (beforeTempo && cand > beforeTempo) { + stopSeries = true; + break; + } + + if (afterTempo && cand < afterTempo) { + continue; + } + + results.push(cand); + resultsCount++; - results.push(cand); - countProduced++; + if (resultsCount >= maxToFetch) { + stopSeries = true; + break; + } + } - if (rule.count !== undefined && countProduced >= rule.count) break; + if (stopSeries) break; } return results; @@ -152,31 +268,46 @@ function createRecurrenceResult( sizeLimit = Number.POSITIVE_INFINITY; } + const cachedOccurrences: Tempo[] = []; let offsetCursor = 0; + let fullyExpanded = false; + + const ensureCached = (neededCount: number): void => { + if (fullyExpanded || cachedOccurrences.length >= neededCount) return; + const fresh = expandOccurrences(rruleStr, anchorTempo, { + count: neededCount, + after: options?.after, + before: options?.before + }); + cachedOccurrences.length = 0; + cachedOccurrences.push(...fresh); + if (fresh.length < neededCount) { + fullyExpanded = true; + } + }; const take = (count?: number): Tempo[] => { const fetchCount = count ?? defaultBatchSize; if (isFinite && offsetCursor >= sizeLimit) return []; const actualCount = isFinite ? Math.min(fetchCount, sizeLimit - offsetCursor) : fetchCount; if (actualCount <= 0) return []; - const expanded = expandOccurrences(rruleStr, anchorTempo, { - count: offsetCursor + actualCount, - after: options?.after, - before: options?.before - }); - const batch = expanded.slice(offsetCursor, offsetCursor + actualCount); + + const needed = offsetCursor + actualCount; + ensureCached(needed); + + const batch = cachedOccurrences.slice(offsetCursor, offsetCursor + actualCount); offsetCursor += batch.length; return batch; }; - function* createIterator(batchSize: number): Generator { - const expanded = expandOccurrences(rruleStr, anchorTempo, { - count: isFinite ? Math.min(batchSize, sizeLimit) : batchSize, - after: options?.after, - before: options?.before - }); - for (const item of expanded) { - yield item; + function* createIterator(): Generator { + let index = 0; + const maxYield = isFinite ? sizeLimit : defaultBatchSize; + while (index < maxYield) { + ensureCached(index + 1); + if (index >= cachedOccurrences.length) break; + yield cachedOccurrences[index]; + index++; } } @@ -186,7 +317,7 @@ function createRecurrenceResult( isFinite, size: sizeLimit, take, - [Symbol.iterator]: () => createIterator(defaultBatchSize), + [Symbol.iterator]: () => createIterator(), confidence, provider: providerId, reasoning @@ -239,6 +370,10 @@ export async function recurrenceAI( assertNoReservedProviderId(availableProviders); const mode = options?.mode || _state.config.mode || AiMode.Fallback; + if (mode !== AiMode.Fallback && mode !== AiMode.Race && mode !== AiMode.Consensus) { + throw new TempoAiError(`Invalid execution mode: '${mode}'. Supported modes are 'fallback', 'race', 'consensus'.`, 400); + } + const effectiveMinConfidence = options?.minConfidence ?? _state.config.minConfidence; const callTimeout = options?.timeout; const contextString = `Current Time: ${anchorTempo.format('{wkd}, {yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${loc}, Hemisphere: ${sph}.`; @@ -317,6 +452,10 @@ Do not include markdown blocks or text outside the JSON.`; const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); return { parsedData: JSON.parse(cleanContent), providerId, rateLimits }; }); + + // Attach no-op rejection handler to suppress unhandled promise warnings on aborted/slower requests + promises.forEach(p => p.catch(() => {})); + successfulResult = await Promise.race(promises); parentController.abort(); } catch (err: any) { @@ -374,10 +513,18 @@ Do not include markdown blocks or text outside the JSON.`; } } - _state.limits = successfulResult?.rateLimits ?? null; + if (!successfulResult) { + throw lastError || new TempoAiError('All configured AI providers failed.', 500); + } + + _state.limits = successfulResult.rateLimits ?? null; + + const { parsedData, providerId } = successfulResult; + if (typeof parsedData?.rrule !== 'string' || !parsedData.rrule.trim()) { + throw new TempoAiError('Invalid recurrence response from AI provider: missing or empty rrule string.', 422); + } - const { parsedData, providerId } = successfulResult!; - const rruleStr = typeof parsedData?.rrule === 'string' ? parsedData.rrule.trim() : 'FREQ=DAILY'; + const rruleStr = parsedData.rrule.trim(); const summaryText = typeof parsedData?.summary === 'string' ? parsedData.summary : (typeof parsedData?.humanReadable === 'string' ? parsedData.humanReadable : input); const confidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : 0.9; const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined; diff --git a/packages/plugins/ai/test/recurrence.test.ts b/packages/plugins/ai/test/recurrence.test.ts index 97d26707..1478ba51 100644 --- a/packages/plugins/ai/test/recurrence.test.ts +++ b/packages/plugins/ai/test/recurrence.test.ts @@ -10,7 +10,7 @@ describe('AI Recurrence Plugin (recurrenceAI)', () => { }); afterEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); }); it('should detect raw RRULE strings and parse them natively without network calls', async () => { @@ -98,38 +98,91 @@ describe('AI Recurrence Plugin (recurrenceAI)', () => { expect(iterated).toHaveLength(5); }); - it('should support provider race and consensus execution modes in recurrenceAI', async () => { + it('should support provider race execution mode returning fastest provider and aborting slower requests', async () => { + let slowWasAborted = false; const fetchSpy = vi.spyOn(globalThis, 'fetch'); - fetchSpy.mockImplementation(async () => new Response(JSON.stringify({ - choices: [{ - message: { - content: JSON.stringify({ - rrule: 'FREQ=WEEKLY;BYDAY=FR', - summary: 'Every Friday', - confidence: 0.98 - }) + fetchSpy.mockImplementation(async (_url, init) => { + const body = JSON.parse(init?.body as string); + const signal = init?.signal as AbortSignal | undefined; + if (body.model === 'fast-model') { + return new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ rrule: 'FREQ=DAILY', summary: 'Fast Daily', confidence: 0.95 }) + } + }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return new Promise((_resolve, reject) => { + if (signal?.aborted) { + slowWasAborted = true; + reject(new DOMException('Aborted', 'AbortError')); + return; } - }] - }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + signal?.addEventListener('abort', () => { + slowWasAborted = true; + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + }); - const resRace = await recurrenceAI('Every Friday', { + const resRace = await recurrenceAI('Every day', { mode: 'race', providers: [ - { id: 'groq', key: 'key-1', url: 'https://api.groq.com/v1/chat/completions', model: 'llama-3.3-70b-versatile' }, - { id: 'openai', key: 'key-2', url: 'https://api.openai.com/v1/chat/completions', model: 'gpt-4o' } + { id: 'slow-provider', key: 'key-1', url: 'https://api.openai.com/v1/chat/completions', model: 'slow-model' }, + { id: 'fast-provider', key: 'key-2', url: 'https://api.groq.com/v1/chat/completions', model: 'fast-model' } ] }); - expect(resRace.rrule).toBe('FREQ=WEEKLY;BYDAY=FR'); - const resConsensus = await recurrenceAI('Every Friday', { + expect(resRace.rrule).toBe('FREQ=DAILY'); + expect(resRace.provider).toBe('fast-provider'); + expect(slowWasAborted).toBe(true); + }); + + it('should support consensus mode selecting highest confidence result when providers disagree', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ rrule: 'FREQ=WEEKLY;BYDAY=FR', summary: 'Every Friday', confidence: 0.85 }) + } + }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ rrule: 'FREQ=MONTHLY;BYDAY=-1FR', summary: 'Last Friday of month', confidence: 0.96 }) + } + }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const resConsensus = await recurrenceAI('Last Friday of the month', { mode: 'consensus', providers: [ - { id: 'groq', key: 'key-1', url: 'https://api.groq.com/v1/chat/completions', model: 'llama-3.3-70b-versatile' }, - { id: 'openai', key: 'key-2', url: 'https://api.openai.com/v1/chat/completions', model: 'gpt-4o' } + { id: 'p1', key: 'key-1', url: 'https://api.groq.com/v1/chat/completions', model: 'm1' }, + { id: 'p2', key: 'key-2', url: 'https://api.openai.com/v1/chat/completions', model: 'm2' } ] }); - expect(resConsensus.rrule).toBe('FREQ=WEEKLY;BYDAY=FR'); - expect(resConsensus.provider).toBe('consensus'); + + expect(resConsensus.rrule).toBe('FREQ=MONTHLY;BYDAY=-1FR'); + expect(resConsensus.confidence).toBe(0.96); + expect(resConsensus.provider).toBe('p2'); + }); + + it('should throw TempoAiError for invalid execution mode', async () => { + await expect(recurrenceAI('Every Friday', { mode: 'invalid_mode' as any })) + .rejects.toThrow(/invalid execution mode/i); + }); + + it('should throw TempoAiError if provider returns empty or missing rrule string', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: JSON.stringify({ summary: 'No rrule', confidence: 0.9 }) } }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + await expect(recurrenceAI('Unparseable prompt')) + .rejects.toThrow(/missing or empty rrule string/i); }); it('should honor minConfidence threshold and throw TempoAiError if confidence is low', async () => { @@ -152,19 +205,32 @@ describe('AI Recurrence Plugin (recurrenceAI)', () => { it('should evaluate RFC 5545 RRULE occurrences applying after and before windows', async () => { const rruleStr = 'FREQ=DAILY;COUNT=10'; - const anchor = new Tempo('2026-08-01T09:00:00Z'); + const anchor = new Tempo('2026-08-01T09:00:00'); const result = await recurrenceAI(rruleStr, { anchor, - after: '2026-08-03T00:00:00Z', - before: '2026-08-06T00:00:00Z' + after: '2026-08-03T00:00:00', + before: '2026-08-06T00:00:00' }); expect(result.isFinite).toBe(true); const items = result.take(10); + expect(items).toHaveLength(3); + expect(items[0].format('{yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')).toBe('2026-08-03 09:00:00'); + expect(items[1].format('{yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')).toBe('2026-08-04 09:00:00'); + expect(items[2].format('{yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')).toBe('2026-08-05 09:00:00'); + }); + + it('should correctly evaluate negative BYDAY ordinals such as -1FR and date-only UNTIL', async () => { + const rruleStr = 'FREQ=MONTHLY;BYDAY=-1FR;UNTIL=20261231'; + const anchor = new Tempo('2026-08-01T09:00:00Z'); + const result = await recurrenceAI(rruleStr, { anchor }); + + expect(result.isFinite).toBe(true); + const items = result.take(5); expect(items.length).toBeGreaterThan(0); - for (const item of items) { - expect(item >= new Tempo('2026-08-03T00:00:00Z')).toBe(true); - expect(item <= new Tempo('2026-08-06T00:00:00Z')).toBe(true); - } + // August 2026 last Friday is Aug 28th + expect(items[0].format('{yyyy}-{mm}-{dd}')).toBe('2026-08-28'); + // September 2026 last Friday is Sep 25th + expect(items[1].format('{yyyy}-{mm}-{dd}')).toBe('2026-09-25'); }); }); diff --git a/packages/tempo/doc/3-extending-tempo/tempo.modularity.md b/packages/tempo/doc/3-extending-tempo/tempo.modularity.md index 9410bf74..798d3d74 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.modularity.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.modularity.md @@ -117,9 +117,9 @@ declare module '@magmacomputing/tempo' { There is a subtle but important distinction between how features are activated in Core mode: * **`Tempo.extend(Module)`**: This is **Immediate and Explicit**. It applies the module to the class exactly when the line is executed. This is the recommended pattern for modular applications. -* **`Tempo.init()`**: Establishes global baseline configuration at application startup and accepts explicit plugin registrations via `Tempo.init({ plugins: [...] })`. +* **`Tempo.init()`**: Establishes global baseline configuration at application startup and registers plugins specified in the `plugins` array during initial startup discovery. ::: note -**Idempotent Initialization Lifecycle**: `Tempo.init()` is designed to establish baseline configuration once during startup. To register additional plugins dynamically after initialization, use `Tempo.extend(...)` rather than calling `Tempo.init()` again. +**Idempotent Initialization Lifecycle**: `Tempo.init()` is designed to establish baseline configuration once during initial startup. Built-in plugins are registered automatically via static imports in full Tempo (`@magmacomputing/tempo`), while explicit plugin lists are registered during `Tempo.init({ plugins: [...] })`. To register additional plugins dynamically after startup initialization, use `Tempo.extend(...)` rather than re-running `Tempo.init()`. ::: diff --git a/packages/tempo/doc/3-extending-tempo/tempo.plugin.md b/packages/tempo/doc/3-extending-tempo/tempo.plugin.md index ac5f6b73..a2a3c377 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.plugin.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.plugin.md @@ -104,8 +104,8 @@ Tempo.init({ const pulse = Tempo.ticker(1); ``` -> [!NOTE] Dynamic Extension -> `Tempo.init()` establishes baseline configuration at startup and accepts a `plugins` array for explicit registration. To dynamically register plugins loaded later at runtime, use `Tempo.extend(Plugin)` directly rather than re-running `Tempo.init()`. +> [!NOTE] Dynamic Extension vs Startup Registration +> `Tempo.init({ plugins: [...] })` establishes baseline configuration at startup and performs initial registration of plugins. In full Tempo (`@magmacomputing/tempo`), standard plugins are registered automatically upon import. To dynamically register custom plugins loaded later at runtime, use `Tempo.extend(Plugin)` directly rather than re-running `Tempo.init()`. --- diff --git a/packages/tempo/public/llms-full.txt b/packages/tempo/public/llms-full.txt index 87bd2667..82461b8d 100644 --- a/packages/tempo/public/llms-full.txt +++ b/packages/tempo/public/llms-full.txt @@ -2607,10 +2607,10 @@ declare module '@magmacomputing/tempo' { There is a subtle but important distinction between how features are activated in Core mode: * **`Tempo.extend(Module)`**: This is **Immediate and Explicit**. It applies the module to the class exactly when the line is executed. This is the recommended pattern for modular applications. -* **`Tempo.init()`**: Establishes global baseline configuration at application startup and accepts explicit plugin registrations via `Tempo.init({ plugins: [...] })`. +* **`Tempo.init()`**: Establishes global baseline configuration at application startup and registers plugins specified in the `plugins` array during initial startup discovery. ::: note -**Idempotent Initialization Lifecycle**: `Tempo.init()` is designed to establish baseline configuration once during startup. To register additional plugins dynamically after initialization, use `Tempo.extend(...)` rather than calling `Tempo.init()` again. +**Idempotent Initialization Lifecycle**: `Tempo.init()` is designed to establish baseline configuration once during initial startup. Built-in plugins are registered automatically via static imports in full Tempo (`@magmacomputing/tempo`), while explicit plugin lists are registered during `Tempo.init({ plugins: [...] })`. To register additional plugins dynamically after startup initialization, use `Tempo.extend(...)` rather than re-running `Tempo.init()`. ::: @@ -2930,8 +2930,8 @@ Tempo.init({ const pulse = Tempo.ticker(1); ``` -> [!NOTE] Dynamic Extension -> `Tempo.init()` establishes baseline configuration at startup and accepts a `plugins` array for explicit registration. To dynamically register plugins loaded later at runtime, use `Tempo.extend(Plugin)` directly rather than re-running `Tempo.init()`. +> [!NOTE] Dynamic Extension vs Startup Registration +> `Tempo.init({ plugins: [...] })` establishes baseline configuration at startup and performs initial registration of plugins. In full Tempo (`@magmacomputing/tempo`), standard plugins are registered automatically upon import. To dynamically register custom plugins loaded later at runtime, use `Tempo.extend(Plugin)` directly rather than re-running `Tempo.init()`. --- From 06fd92ce9bc6286e75c91ec2cbdf241426d6eaf3 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 7 Aug 2026 07:11:12 +1000 Subject: [PATCH 10/23] PR rrule.library --- packages/library/CHANGELOG.md | 5 + packages/library/package.json | 3 +- packages/library/src/common.index.ts | 1 + packages/library/src/common/rrule.library.ts | 260 ++++++++++++++++++ .../library/test/common/rrule_library.test.ts | 41 +++ packages/plugins/ai/package.json | 2 +- packages/plugins/ai/plan/v0.3.0-roadmap.md | 2 +- .../plugins/ai/src/functions/recurrence.ts | 244 +--------------- packages/plugins/ai/src/index.ts | 3 +- packages/tempo/package.json | 72 +++-- .../src/plugin/extend/extend.recurrence.ts | 22 ++ .../test/plugins/extend.recurrence.test.ts | 16 ++ 12 files changed, 406 insertions(+), 265 deletions(-) create mode 100644 packages/library/src/common/rrule.library.ts create mode 100644 packages/library/test/common/rrule_library.test.ts create mode 100644 packages/tempo/src/plugin/extend/extend.recurrence.ts create mode 100644 packages/tempo/test/plugins/extend.recurrence.test.ts diff --git a/packages/library/CHANGELOG.md b/packages/library/CHANGELOG.md index 1282f066..89ef09bc 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.11.1] - 2026-08-06 + +### Added +- **RRULE Support (`rrule.library`)**: Added standalone zero-dependency RFC 5545 recurrence rule utilities (`isRRuleString`, `parseRRule`, `getNextRRuleEpoch`) to `#library/rrule.library.js`. + ## [3.10.2] - 2026-07-25 ### Fixed diff --git a/packages/library/package.json b/packages/library/package.json index 0e3ec108..9ecbafb8 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -26,7 +26,8 @@ ".": { "types": "./dist/common.index.d.ts", "development": "./src/common.index.ts", - "import": "./dist/common.index.js" + "import": "./dist/common.index.js", + "default": "./dist/common.index.js" }, "./browser": { "types": "./dist/browser.index.d.ts", diff --git a/packages/library/src/common.index.ts b/packages/library/src/common.index.ts index 262b00d7..d3c031da 100644 --- a/packages/library/src/common.index.ts +++ b/packages/library/src/common.index.ts @@ -26,5 +26,6 @@ export * from './common/symbol.library.js'; export * from './common/type.library.js'; export * from './common/temporal.polyfill.js'; export * from './common/temporal.library.js'; +export * from './common/rrule.library.js'; export * from './common/utility.library.js'; export * from './common/webtoken.library.js'; diff --git a/packages/library/src/common/rrule.library.ts b/packages/library/src/common/rrule.library.ts new file mode 100644 index 00000000..f7f88ed2 --- /dev/null +++ b/packages/library/src/common/rrule.library.ts @@ -0,0 +1,260 @@ +import '#library/temporal.polyfill.js'; + +export function isRRuleString(input: string): boolean { + const trimmed = input.trim(); + return /^(RRULE:)?FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)/i.test(trimmed); +} + +export function checkIsFinite(rrule: string): boolean { + return /(UNTIL|COUNT)=/i.test(rrule); +} + +export const DAY_MAP: Record = { + MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 7 +}; + +export interface ParsedRRule { + freq: string; + interval: number; + count?: number | undefined; + untilMs?: number | undefined; + byMonth?: number[] | undefined; + byDay?: Array<{ nth?: number | undefined; day: string }> | undefined; + byHour?: number[] | undefined; + byMinute?: number[] | undefined; + bySetPos?: number[] | undefined; +} + +export function parseRRule(rrule: string): ParsedRRule { + const parts = rrule.split(';'); + let freq = 'DAILY'; + let interval = 1; + let count: number | undefined; + let untilMs: number | undefined; + let byMonth: number[] | undefined; + let byDay: Array<{ nth?: number | undefined; day: string }> | undefined; + let byHour: number[] | undefined; + let byMinute: number[] | undefined; + let bySetPos: number[] | undefined; + + for (const part of parts) { + const [key, val] = part.split('='); + if (!key || !val) continue; + const k = key.toUpperCase(); + const trimmedVal = val.trim(); + + if (k === 'FREQ') { + freq = trimmedVal.toUpperCase(); + } else if (k === 'INTERVAL') { + const parsed = parseInt(trimmedVal, 10); + interval = !isNaN(parsed) && parsed > 0 ? parsed : 1; + } else if (k === 'COUNT') { + const parsed = parseInt(trimmedVal, 10); + count = !isNaN(parsed) && parsed > 0 ? parsed : undefined; + } else if (k === 'UNTIL') { + if (/^\d{8}$/.test(trimmedVal)) { + const year = parseInt(trimmedVal.slice(0, 4), 10); + const month = parseInt(trimmedVal.slice(4, 6), 10); + const day = parseInt(trimmedVal.slice(6, 8), 10); + untilMs = Date.UTC(year, month - 1, day, 23, 59, 59, 999); + } else { + const uStr = trimmedVal.replace(/^(\d{4})(\d{2})(\d{2})T?(\d{2})?(\d{2})?(\d{2})?Z?$/, '$1-$2-$3T$4:$5:$6Z'); + const parsedDate = new Date(uStr); + untilMs = !isNaN(parsedDate.getTime()) ? parsedDate.getTime() : undefined; + } + } else if (k === 'BYMONTH') { + const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 1 && v <= 12); + if (items.length > 0) byMonth = items; + } else if (k === 'BYDAY') { + const items = trimmedVal.split(',').map(item => { + const m = item.match(/^([+-]?\d+)?([A-Z]{2})$/i); + const nthVal = m && m[1] ? parseInt(m[1], 10) : undefined; + const dayVal = m ? m[2].toUpperCase() : item.toUpperCase(); + return { nth: nthVal !== undefined && !isNaN(nthVal) ? nthVal : undefined, day: dayVal }; + }).filter(d => DAY_MAP[d.day] !== undefined); + if (items.length > 0) byDay = items; + } else if (k === 'BYHOUR') { + const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 0 && v <= 23); + if (items.length > 0) byHour = items; + } else if (k === 'BYMINUTE') { + const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 0 && v <= 59); + if (items.length > 0) byMinute = items; + } else if (k === 'BYSETPOS') { + const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v)); + if (items.length > 0) bySetPos = items; + } + } + + return { freq, interval, count, untilMs, byMonth, byDay, byHour, byMinute, bySetPos }; +} + +function getDaysInMonth(year: number, month: number): number { + return new Date(Date.UTC(year, month, 0)).getUTCDate(); +} + +/** + * Expands occurrences of an RRULE string into epoch millisecond numbers. + * Pure function operating strictly on epoch timestamps and Temporal/Date math. + */ +export function expandRRuleEpochs( + rruleStr: string, + anchorEpochMs: number, + options?: { count?: number | undefined; afterMs?: number | undefined; beforeMs?: number | undefined } +): number[] { + const rule = parseRRule(rruleStr); + const anchorDate = new Date(anchorEpochMs); + const results: number[] = []; + const maxToFetch = rule.count !== undefined ? rule.count : (options?.count ?? 100); + + let totalGeneratedFromAnchor = 0; + let resultsCount = 0; + let step = 0; + const MAX_STEPS = 1000; + + const anchorHours = anchorDate.getUTCHours(); + const anchorMinutes = anchorDate.getUTCMinutes(); + const anchorSeconds = anchorDate.getUTCSeconds(); + const anchorMs = anchorDate.getUTCMilliseconds(); + + while (resultsCount < maxToFetch && step < MAX_STEPS) { + let periodBases: Date[] = []; + + if (rule.freq === 'DAILY') { + const d = new Date(anchorEpochMs); + d.setUTCDate(d.getUTCDate() + step * rule.interval); + periodBases = [d]; + } else if (rule.freq === 'WEEKLY') { + const weekBase = new Date(anchorEpochMs); + weekBase.setUTCDate(weekBase.getUTCDate() + step * rule.interval * 7); + if (rule.byDay && rule.byDay.length > 0) { + periodBases = rule.byDay.map(bd => { + const targetDay = DAY_MAP[bd.day] ?? 1; + const currentDow = weekBase.getUTCDay() === 0 ? 7 : weekBase.getUTCDay(); + const diff = (targetDay - currentDow + 7) % 7; + const targetDate = new Date(weekBase.getTime()); + targetDate.setUTCDate(targetDate.getUTCDate() + diff); + return targetDate; + }); + } else { + periodBases = [weekBase]; + } + } else if (rule.freq === 'MONTHLY') { + const monthBase = new Date(anchorEpochMs); + monthBase.setUTCMonth(monthBase.getUTCMonth() + step * rule.interval); + const year = monthBase.getUTCFullYear(); + const month = monthBase.getUTCMonth() + 1; + const daysInMonth = getDaysInMonth(year, month); + + if (rule.byDay && rule.byDay.length > 0) { + const candidateDays: Date[] = []; + for (const bd of rule.byDay) { + const targetDow = DAY_MAP[bd.day] ?? 1; + const matchingDates: Date[] = []; + for (let dayNum = 1; dayNum <= daysInMonth; dayNum++) { + const d = new Date(Date.UTC(year, month - 1, dayNum, anchorHours, anchorMinutes, anchorSeconds, anchorMs)); + const dow = d.getUTCDay() === 0 ? 7 : d.getUTCDay(); + if (dow === targetDow) matchingDates.push(d); + } + + if (bd.nth !== undefined) { + if (bd.nth > 0 && bd.nth <= matchingDates.length) { + candidateDays.push(matchingDates[bd.nth - 1]); + } else if (bd.nth < 0 && Math.abs(bd.nth) <= matchingDates.length) { + candidateDays.push(matchingDates[matchingDates.length + bd.nth]); + } + } else { + candidateDays.push(...matchingDates); + } + } + periodBases = candidateDays; + } else { + periodBases = [monthBase]; + } + } else if (rule.freq === 'YEARLY') { + const yearBase = new Date(anchorEpochMs); + yearBase.setUTCFullYear(yearBase.getUTCFullYear() + step * rule.interval); + periodBases = [yearBase]; + } else { + const d = new Date(anchorEpochMs); + d.setUTCDate(d.getUTCDate() + step * rule.interval); + periodBases = [d]; + } + + if (rule.byMonth && rule.byMonth.length > 0) { + periodBases = periodBases.filter(b => rule.byMonth!.includes(b.getUTCMonth() + 1)); + } + + const periodCandidates: Date[] = []; + for (const base of periodBases) { + const hours = rule.byHour && rule.byHour.length > 0 ? rule.byHour : [base.getUTCHours()]; + const minutes = rule.byMinute && rule.byMinute.length > 0 ? rule.byMinute : [base.getUTCMinutes()]; + + for (const h of hours) { + for (const m of minutes) { + const cand = new Date(base.getTime()); + cand.setUTCHours(h, m, anchorSeconds, anchorMs); + periodCandidates.push(cand); + } + } + } + + let finalPeriodCandidates = periodCandidates; + if (rule.bySetPos && rule.bySetPos.length > 0 && periodCandidates.length > 0) { + finalPeriodCandidates = []; + for (const pos of rule.bySetPos) { + if (pos > 0 && pos <= periodCandidates.length) { + finalPeriodCandidates.push(periodCandidates[pos - 1]); + } else if (pos < 0 && Math.abs(pos) <= periodCandidates.length) { + finalPeriodCandidates.push(periodCandidates[periodCandidates.length + pos]); + } + } + } + + step++; + + let stopSeries = false; + for (const cand of finalPeriodCandidates) { + totalGeneratedFromAnchor++; + const candMs = cand.getTime(); + + if (rule.untilMs !== undefined && candMs > rule.untilMs) { + stopSeries = true; + break; + } + if (rule.count !== undefined && totalGeneratedFromAnchor > rule.count) { + stopSeries = true; + break; + } + if (options?.beforeMs !== undefined && candMs > options.beforeMs) { + stopSeries = true; + break; + } + if (options?.afterMs !== undefined && candMs <= options.afterMs) { + continue; + } + + results.push(candMs); + resultsCount++; + + if (resultsCount >= maxToFetch) { + stopSeries = true; + break; + } + } + + if (stopSeries) break; + } + + return results; +} + +/** + * Computes the single next RRULE occurrence epoch millisecond timestamp after `fromEpochMs`. + */ +export function getNextRRuleEpoch(rruleStr: string, fromEpochMs: number): number { + const expanded = expandRRuleEpochs(rruleStr, fromEpochMs, { count: 1, afterMs: fromEpochMs }); + if (expanded.length > 0) return expanded[0]; + + // Fallback to simple 1 day shift if rule has ended or yields no occurrences + return fromEpochMs + 86_400_000; +} diff --git a/packages/library/test/common/rrule_library.test.ts b/packages/library/test/common/rrule_library.test.ts new file mode 100644 index 00000000..c11566a1 --- /dev/null +++ b/packages/library/test/common/rrule_library.test.ts @@ -0,0 +1,41 @@ +import { isRRuleString, parseRRule, expandRRuleEpochs, getNextRRuleEpoch } from '../../src/common/rrule.library.js'; + +describe('rrule.library', () => { + test('isRRuleString identifies valid RRULE patterns', () => { + expect(isRRuleString('FREQ=DAILY')).toBe(true); + expect(isRRuleString('RRULE:FREQ=WEEKLY;BYDAY=MO')).toBe(true); + expect(isRRuleString('FREQ=MONTHLY;BYDAY=1MO,3MO')).toBe(true); + expect(isRRuleString('hello world')).toBe(false); + expect(isRRuleString('2026-08-07')).toBe(false); + }); + + test('parseRRule correctly parses RRULE components', () => { + const parsed = parseRRule('FREQ=WEEKLY;INTERVAL=2;COUNT=5;BYDAY=1MO,-1FR;BYHOUR=9,17;BYMINUTE=30'); + expect(parsed.freq).toBe('WEEKLY'); + expect(parsed.interval).toBe(2); + expect(parsed.count).toBe(5); + expect(parsed.byDay).toEqual([ + { nth: 1, day: 'MO' }, + { nth: -1, day: 'FR' } + ]); + expect(parsed.byHour).toEqual([9, 17]); + expect(parsed.byMinute).toEqual([30]); + }); + + test('expandRRuleEpochs generates correct occurrence timestamps', () => { + // 2026-08-07T00:00:00.000Z is Friday + const anchor = Date.UTC(2026, 7, 7, 0, 0, 0, 0); + const epochs = expandRRuleEpochs('FREQ=DAILY;INTERVAL=1', anchor, { count: 3 }); + + expect(epochs.length).toBe(3); + expect(new Date(epochs[0]).toISOString()).toBe('2026-08-07T00:00:00.000Z'); + expect(new Date(epochs[1]).toISOString()).toBe('2026-08-08T00:00:00.000Z'); + expect(new Date(epochs[2]).toISOString()).toBe('2026-08-09T00:00:00.000Z'); + }); + + test('getNextRRuleEpoch computes next occurrence', () => { + const anchor = Date.UTC(2026, 7, 7, 0, 0, 0, 0); + const nextMs = getNextRRuleEpoch('FREQ=DAILY;INTERVAL=1', anchor); + expect(new Date(nextMs).toISOString()).toBe('2026-08-08T00:00:00.000Z'); + }); +}); diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index ff5c79a1..b9bb48bf 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -25,7 +25,7 @@ "plan": "community" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.11.0" + "@magmacomputing/tempo": "^3.11.1" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1" diff --git a/packages/plugins/ai/plan/v0.3.0-roadmap.md b/packages/plugins/ai/plan/v0.3.0-roadmap.md index 3c08d420..c1729ce3 100644 --- a/packages/plugins/ai/plan/v0.3.0-roadmap.md +++ b/packages/plugins/ai/plan/v0.3.0-roadmap.md @@ -21,7 +21,7 @@ In v0.2.0, upcoming function handlers were scaffolded with `@internal` JSDoc tag ### 1.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. -### 1.5 `recurrenceAI(prompt: string, options?: AiOptions): Promise` +### 1.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)`). ### 1.6 `contextAI(text: string, options?: AiOptions): Promise` diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts index 9cb8e4f6..78280bf4 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -1,250 +1,20 @@ import { Tempo } from '@magmacomputing/tempo'; +import { isRRuleString, checkIsFinite, parseRRule, expandRRuleEpochs } from '@magmacomputing/library'; import { TempoAiError } from '../core/error.js'; import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; import { fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; import type { TempoRecurrenceOptions, TempoRecurrenceResult } from '../core/types.js'; -export function isRRuleString(input: string): boolean { - const trimmed = input.trim(); - return /^(RRULE:)?FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)/i.test(trimmed); -} - -function checkIsFinite(rrule: string): boolean { - return /(UNTIL|COUNT)=/i.test(rrule); -} - -const DAY_MAP: Record = { - MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 7 -}; - -interface ParsedRRule { - freq: string; - interval: number; - count?: number | undefined; - until?: Tempo | undefined; - byMonth?: number[] | undefined; - byDay?: Array<{ nth?: number | undefined; day: string }> | undefined; - byHour?: number[] | undefined; - byMinute?: number[] | undefined; - bySetPos?: number[] | undefined; -} - -function parseRRule(rrule: string): ParsedRRule { - const parts = rrule.split(';'); - let freq = 'DAILY'; - let interval = 1; - let count: number | undefined; - let until: Tempo | undefined; - let byMonth: number[] | undefined; - let byDay: Array<{ nth?: number | undefined; day: string }> | undefined; - let byHour: number[] | undefined; - let byMinute: number[] | undefined; - let bySetPos: number[] | undefined; - - for (const part of parts) { - const [key, val] = part.split('='); - if (!key || !val) continue; - const k = key.toUpperCase(); - const trimmedVal = val.trim(); - - if (k === 'FREQ') { - freq = trimmedVal.toUpperCase(); - } else if (k === 'INTERVAL') { - const parsed = parseInt(trimmedVal, 10); - interval = !isNaN(parsed) && parsed > 0 ? parsed : 1; - } else if (k === 'COUNT') { - const parsed = parseInt(trimmedVal, 10); - count = !isNaN(parsed) && parsed > 0 ? parsed : undefined; - } else if (k === 'UNTIL') { - if (/^\d{8}$/.test(trimmedVal)) { - const year = trimmedVal.slice(0, 4); - const month = trimmedVal.slice(4, 6); - const day = trimmedVal.slice(6, 8); - until = new Tempo(`${year}-${month}-${day}T23:59:59Z`); - } else { - const uStr = trimmedVal.replace(/^(\d{4})(\d{2})(\d{2})T?(\d{2})?(\d{2})?(\d{2})?Z?$/, '$1-$2-$3T$4:$5:$6'); - const t = new Tempo(uStr); - until = t.isValid ? t : undefined; - } - } else if (k === 'BYMONTH') { - const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 1 && v <= 12); - if (items.length > 0) byMonth = items; - } else if (k === 'BYDAY') { - const items = trimmedVal.split(',').map(item => { - const m = item.match(/^([+-]?\d+)?([A-Z]{2})$/i); - const nthVal = m && m[1] ? parseInt(m[1], 10) : undefined; - const dayVal = m ? m[2].toUpperCase() : item.toUpperCase(); - return { nth: nthVal !== undefined && !isNaN(nthVal) ? nthVal : undefined, day: dayVal }; - }).filter(d => DAY_MAP[d.day] !== undefined); - if (items.length > 0) byDay = items; - } else if (k === 'BYHOUR') { - const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 0 && v <= 23); - if (items.length > 0) byHour = items; - } else if (k === 'BYMINUTE') { - const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 0 && v <= 59); - if (items.length > 0) byMinute = items; - } else if (k === 'BYSETPOS') { - const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v)); - if (items.length > 0) bySetPos = items; - } - } - - return { freq, interval, count, until, byMonth, byDay, byHour, byMinute, bySetPos }; -} - -function getMonthNum(t: Tempo): number { - return parseInt(t.format('{mm}'), 10); -} - -function getHourNum(t: Tempo): number { - return parseInt(t.format('{hh}'), 10); -} - -function getMinuteNum(t: Tempo): number { - return parseInt(t.format('{mi}'), 10); -} - -function getDaysInMonth(year: number, month: number): number { - return new Date(Date.UTC(year, month, 0)).getUTCDate(); -} - function expandOccurrences(rrule: string, anchor: Tempo, options?: { count?: number; after?: any; before?: any }): Tempo[] { - const rule = parseRRule(rrule); const afterTempo = options?.after ? new Tempo(options.after) : undefined; const beforeTempo = options?.before ? new Tempo(options.before) : undefined; - - const results: Tempo[] = []; - const maxToFetch = rule.count !== undefined ? rule.count : (options?.count ?? 100); - - let totalGeneratedFromAnchor = 0; - let resultsCount = 0; - let step = 0; - const MAX_STEPS = 1000; - - while (resultsCount < maxToFetch && step < MAX_STEPS) { - let periodBases: Tempo[] = []; - - if (rule.freq === 'DAILY') { - periodBases = [anchor.add(`${step * rule.interval} days`)]; - } else if (rule.freq === 'WEEKLY') { - const weekBase = anchor.add(`${step * rule.interval} weeks`); - if (rule.byDay && rule.byDay.length > 0) { - periodBases = rule.byDay.map(bd => { - const targetDay = DAY_MAP[bd.day] ?? 1; - const diff = (targetDay - weekBase.dow + 7) % 7; - return weekBase.add(`${diff} days`); - }); - } else { - periodBases = [weekBase]; - } - } else if (rule.freq === 'MONTHLY') { - const monthBase = anchor.add(`${step * rule.interval} months`); - const yearStr = monthBase.format('{yyyy}'); - const monthStr = monthBase.format('{mm}'); - const daysInMonth = getDaysInMonth(parseInt(yearStr, 10), parseInt(monthStr, 10)); - - if (rule.byDay && rule.byDay.length > 0) { - const candidateDays: Tempo[] = []; - for (const bd of rule.byDay) { - const targetDow = DAY_MAP[bd.day] ?? 1; - const matchingDates: Tempo[] = []; - for (let d = 1; d <= daysInMonth; d++) { - const dateStr = `${yearStr}-${monthStr}-${String(d).padStart(2, '0')}T${anchor.format('{hh}:{mi}:{ss}')}`; - const t = new Tempo(dateStr); - if (t.dow === targetDow) { - matchingDates.push(t); - } - } - - if (bd.nth !== undefined) { - if (bd.nth > 0 && bd.nth <= matchingDates.length) { - candidateDays.push(matchingDates[bd.nth - 1]); - } else if (bd.nth < 0 && Math.abs(bd.nth) <= matchingDates.length) { - candidateDays.push(matchingDates[matchingDates.length + bd.nth]); - } - } else { - candidateDays.push(...matchingDates); - } - } - periodBases = candidateDays; - } else { - periodBases = [monthBase]; - } - } else if (rule.freq === 'YEARLY') { - periodBases = [anchor.add(`${step * rule.interval} years`)]; - } else { - periodBases = [anchor.add(`${step * rule.interval} days`)]; - } - - // Filter period bases by BYMONTH if specified - if (rule.byMonth && rule.byMonth.length > 0) { - periodBases = periodBases.filter(b => rule.byMonth!.includes(getMonthNum(b))); - } - - // Cartesian expansion for BYHOUR and BYMINUTE - const periodCandidates: Tempo[] = []; - for (const base of periodBases) { - const hours = rule.byHour && rule.byHour.length > 0 ? rule.byHour : [getHourNum(base)]; - const minutes = rule.byMinute && rule.byMinute.length > 0 ? rule.byMinute : [getMinuteNum(base)]; - - for (const h of hours) { - for (const m of minutes) { - periodCandidates.push(base.set({ hour: h, minute: m })); - } - } - } - - // Apply BYSETPOS if specified - let finalPeriodCandidates = periodCandidates; - if (rule.bySetPos && rule.bySetPos.length > 0 && periodCandidates.length > 0) { - finalPeriodCandidates = []; - for (const pos of rule.bySetPos) { - if (pos > 0 && pos <= periodCandidates.length) { - finalPeriodCandidates.push(periodCandidates[pos - 1]); - } else if (pos < 0 && Math.abs(pos) <= periodCandidates.length) { - finalPeriodCandidates.push(periodCandidates[periodCandidates.length + pos]); - } - } - } - - step++; - - // Evaluate candidates sequentially with COUNT and window filtering - let stopSeries = false; - for (const cand of finalPeriodCandidates) { - totalGeneratedFromAnchor++; - - if (rule.until && cand > rule.until) { - stopSeries = true; - break; - } - if (rule.count !== undefined && totalGeneratedFromAnchor > rule.count) { - stopSeries = true; - break; - } - if (beforeTempo && cand > beforeTempo) { - stopSeries = true; - break; - } - - if (afterTempo && cand < afterTempo) { - continue; - } - - results.push(cand); - resultsCount++; - - if (resultsCount >= maxToFetch) { - stopSeries = true; - break; - } - } - - if (stopSeries) break; - } - - return results; + const epochs = expandRRuleEpochs(rrule, anchor.epoch.ms, { + count: options?.count, + afterMs: afterTempo ? afterTempo.epoch.ms : undefined, + beforeMs: beforeTempo ? beforeTempo.epoch.ms : undefined + }); + return epochs.map(ms => new Tempo(ms, anchor.config)); } function createRecurrenceResult( diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts index caade9e8..e0e37eb3 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -11,7 +11,8 @@ export { initAI, clearAiCache, getAiRateLimits, getAiConfig } from './core/init. // AI Function Handlers export { parseAI } from './functions/parse.js'; -export { recurrenceAI, isRRuleString } from './functions/recurrence.js'; +export { recurrenceAI } from './functions/recurrence.js'; +export { isRRuleString } from '@magmacomputing/library'; /* * ============================================================================ diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 489ddf93..89544e84 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -120,100 +120,124 @@ "exports": { ".": { "types": "./dist/tempo.index.d.ts", - "import": "./dist/tempo.index.js" + "import": "./dist/tempo.index.js", + "default": "./dist/tempo.index.js" }, "./enums": { "types": "./dist/support/support.enum.d.ts", - "import": "./dist/support/support.enum.js" + "import": "./dist/support/support.enum.js", + "default": "./dist/support/support.enum.js" }, "./extend/*": { "types": "./dist/plugin/extend/extend.*.d.ts", - "import": "./dist/plugin/extend/extend.*.js" + "import": "./dist/plugin/extend/extend.*.js", + "default": "./dist/plugin/extend/extend.*.js" }, "./module/*": { "types": "./dist/module/module.*.d.ts", - "import": "./dist/module/module.*.js" + "import": "./dist/module/module.*.js", + "default": "./dist/module/module.*.js" }, "./term/*": { "types": "./dist/plugin/term/term.*.d.ts", - "import": "./dist/plugin/term/term.*.js" + "import": "./dist/plugin/term/term.*.js", + "default": "./dist/plugin/term/term.*.js" }, "./term/standard": { "types": "./dist/term/index.d.ts", - "import": "./dist/term/index.js" + "import": "./dist/term/index.js", + "default": "./dist/term/index.js" }, "./term/quarter": { "types": "./dist/term/term.quarter.d.ts", - "import": "./dist/term/term.quarter.js" + "import": "./dist/term/term.quarter.js", + "default": "./dist/term/term.quarter.js" }, "./term/season": { "types": "./dist/term/term.season.d.ts", - "import": "./dist/term/term.season.js" + "import": "./dist/term/term.season.js", + "default": "./dist/term/term.season.js" }, "./term/zodiac": { "types": "./dist/term/term.zodiac.d.ts", - "import": "./dist/term/term.zodiac.js" + "import": "./dist/term/term.zodiac.js", + "default": "./dist/term/term.zodiac.js" }, "./term/timeline": { "types": "./dist/term/term.timeline.d.ts", - "import": "./dist/term/term.timeline.js" + "import": "./dist/term/term.timeline.js", + "default": "./dist/term/term.timeline.js" }, "./plugin": { "types": "./dist/plugin/plugin.index.d.ts", - "import": "./dist/plugin/plugin.index.js" + "import": "./dist/plugin/plugin.index.js", + "default": "./dist/plugin/plugin.index.js" }, "./plugin/*": { "types": "./dist/plugin/*.d.ts", - "import": "./dist/plugin/*.js" + "import": "./dist/plugin/*.js", + "default": "./dist/plugin/*.js" }, "./duration": { "types": "./dist/module/module.duration.d.ts", - "import": "./dist/module/module.duration.js" + "import": "./dist/module/module.duration.js", + "default": "./dist/module/module.duration.js" }, "./mutate": { "types": "./dist/module/module.mutate.d.ts", - "import": "./dist/module/module.mutate.js" + "import": "./dist/module/module.mutate.js", + "default": "./dist/module/module.mutate.js" }, "./format": { "types": "./dist/module/module.format.d.ts", - "import": "./dist/module/module.format.js" + "import": "./dist/module/module.format.js", + "default": "./dist/module/module.format.js" }, "./ticker": { "types": "./dist/plugin/extend/extend.ticker.d.ts", - "import": "./dist/plugin/extend/extend.ticker.js" + "import": "./dist/plugin/extend/extend.ticker.js", + "default": "./dist/plugin/extend/extend.ticker.js" }, "./parse": { "types": "./dist/module/module.parse.d.ts", - "import": "./dist/module/module.parse.js" + "import": "./dist/module/module.parse.js", + "default": "./dist/module/module.parse.js" }, "./library": { "types": "./dist/library.index.d.ts", - "import": "./dist/library.index.js" + "import": "./dist/library.index.js", + "default": "./dist/library.index.js" }, "./core": { "types": "./dist/core.index.d.ts", - "import": "./dist/core.index.js" + "import": "./dist/core.index.js", + "default": "./dist/core.index.js" }, "./term": { "types": "./dist/plugin/term/term.index.d.ts", - "import": "./dist/plugin/term/term.index.js" + "import": "./dist/plugin/term/term.index.js", + "default": "./dist/plugin/term/term.index.js" }, "./bundle": { "types": "./dist/tempo.index.d.ts", - "import": "./dist/tempo.bundle.esm.js" + "import": "./dist/tempo.bundle.esm.js", + "default": "./dist/tempo.bundle.esm.js" }, "./global": { "types": "./dist/tempo.index.d.ts", "import": "./dist/tempo.bundle.js", - "script": "./dist/tempo.bundle.js" + "script": "./dist/tempo.bundle.js", + "default": "./dist/tempo.bundle.js" }, "./plugin-api": { "types": "./dist/plugin-api.index.d.ts", - "import": "./dist/plugin-api.index.js" + "import": "./dist/plugin-api.index.js", + "default": "./dist/plugin-api.index.js" }, "./support": { "types": "./dist/support/support.index.d.ts", - "import": "./dist/support/support.index.js" + "import": "./dist/support/support.index.js", + "default": "./dist/support/support.index.js" } }, "scripts": { diff --git a/packages/tempo/src/plugin/extend/extend.recurrence.ts b/packages/tempo/src/plugin/extend/extend.recurrence.ts new file mode 100644 index 00000000..26fd0a73 --- /dev/null +++ b/packages/tempo/src/plugin/extend/extend.recurrence.ts @@ -0,0 +1,22 @@ +import { Tempo } from '../../tempo.class.js'; +import { getNextRRuleEpoch, isRRuleString, isString } from '#library'; + +declare module '../../tempo.class.js' { + interface Tempo { + /** + * Computes the next occurrence of a recurrence rule (RRULE string) after this instant. + * + * @param rrule - The RFC 5545 RRULE string or object with an rrule property + * @returns A new Tempo instance at the next occurrence + */ + nextOccurrence(rrule: string | { rrule: string }): Tempo; + } +} + +Tempo.prototype.nextOccurrence = function (this: Tempo, rrule: string | { rrule: string }): Tempo { + const rruleStr = isString(rrule) ? rrule : rrule.rrule; + const nextMs = getNextRRuleEpoch(rruleStr, this.epoch.ms); + return new Tempo(nextMs, this.config); +} + +export { isRRuleString, getNextRRuleEpoch }; diff --git a/packages/tempo/test/plugins/extend.recurrence.test.ts b/packages/tempo/test/plugins/extend.recurrence.test.ts new file mode 100644 index 00000000..5a03fa2e --- /dev/null +++ b/packages/tempo/test/plugins/extend.recurrence.test.ts @@ -0,0 +1,16 @@ +import { Tempo } from '#tempo'; +import '#tempo/plugin/extend/extend.recurrence.js'; + +describe('extend.recurrence', () => { + test('Tempo.prototype.nextOccurrence returns next date matching RRULE string', () => { + const start = new Tempo('2026-08-07T00:00:00.000Z'); + const next = start.nextOccurrence('FREQ=DAILY;INTERVAL=1'); + expect(next.format('{yyyy}-{mm}-{dd}')).toBe('2026-08-08'); + }); + + test('Tempo.prototype.nextOccurrence accepts object with rrule property', () => { + const start = new Tempo('2026-08-07T00:00:00.000Z'); + const next = start.nextOccurrence({ rrule: 'FREQ=DAILY;INTERVAL=2' }); + expect(next.format('{yyyy}-{mm}-{dd}')).toBe('2026-08-09'); + }); +}); From b47ac2249b9a9620faf02302df1c05a8e0d51fad Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 7 Aug 2026 10:17:20 +1000 Subject: [PATCH 11/23] PR TimeZone Offset placement --- packages/library/src/common/rrule.library.ts | 299 ++++++++++++------ .../library/test/common/rrule_library.test.ts | 15 + .../plugins/ai/src/functions/recurrence.ts | 7 +- packages/tempo/CHANGELOG.md | 1 + .../tempo/doc/2-core-concepts/tempo.parse.md | 23 ++ packages/tempo/public/llms-full.txt | 7 + packages/tempo/src/engine/engine.lexer.ts | 15 +- packages/tempo/src/support/support.default.ts | 7 +- packages/tempo/src/support/support.enum.ts | 10 + .../test/discrete/standalone_parse.test.ts | 37 +++ 10 files changed, 309 insertions(+), 112 deletions(-) diff --git a/packages/library/src/common/rrule.library.ts b/packages/library/src/common/rrule.library.ts index f7f88ed2..3b817cde 100644 --- a/packages/library/src/common/rrule.library.ts +++ b/packages/library/src/common/rrule.library.ts @@ -1,30 +1,82 @@ -import '#library/temporal.polyfill.js'; +import { isDefined } from './assertion.library.js'; +/** + * Tests whether a string is a valid RFC 5545 Recurrence Rule (RRULE). + * + * @param input - The candidate string to inspect + * @returns `true` if the string matches an RRULE pattern starting with FREQ=, otherwise `false`. + */ export function isRRuleString(input: string): boolean { const trimmed = input.trim(); return /^(RRULE:)?FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)/i.test(trimmed); } -export function checkIsFinite(rrule: string): boolean { +/** + * Checks whether an RRULE string represents a finite (bounded) series. + * + * @param rrule - The RFC 5545 RRULE string + * @returns `true` if the rule contains an UNTIL or COUNT boundary clause, otherwise `false`. + */ +export function isFiniteRRule(rrule: string): boolean { return /(UNTIL|COUNT)=/i.test(rrule); } -export const DAY_MAP: Record = { - MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 7 -}; +/** + * Number of days in a standard week. + */ +export const DAYS_IN_WEEK = 7; + +/** + * Mapping of 2-letter ISO day abbreviations (MO..SU) to 1-indexed weekday numbers (1..7). + */ +export const DAY_MAP: Record = Object.freeze({ + MO: 1, + TU: 2, + WE: 3, + TH: 4, + FR: 5, + SA: 6, + SU: 7 +}); + +/** + * Mapping of 3-letter month abbreviations (JAN..DEC) to 1-indexed month numbers (1..12). + */ +export const MONTH_MAP: Record = Object.freeze({ + JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, + JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12 +}); +/** + * Parsed structure of an RFC 5545 Recurrence Rule string. + */ export interface ParsedRRule { + /** Frequency unit: DAILY, WEEKLY, MONTHLY, or YEARLY */ freq: string; + /** Inter-occurrence interval count (default: 1) */ interval: number; + /** Maximum number of occurrences to generate, if bounded by COUNT */ count?: number | undefined; + /** Bounded end timestamp in epoch milliseconds, if bounded by UNTIL */ untilMs?: number | undefined; + /** Filter months (1..12) from BYMONTH */ byMonth?: number[] | undefined; + /** Day-of-week specifications from BYDAY with optional nth occurrences */ byDay?: Array<{ nth?: number | undefined; day: string }> | undefined; + /** Hour filters (0..23) from BYHOUR */ byHour?: number[] | undefined; + /** Minute filters (0..59) from BYMINUTE */ byMinute?: number[] | undefined; + /** Set position selectors from BYSETPOS */ bySetPos?: number[] | undefined; } +/** + * Parses an RFC 5545 RRULE string into a structured {@link ParsedRRule} object. + * + * @param rrule - The raw RFC 5545 RRULE string to parse + * @returns Structured representation of the recurrence parameters + */ export function parseRRule(rrule: string): ParsedRRule { const parts = rrule.split(';'); let freq = 'DAILY'; @@ -43,45 +95,73 @@ export function parseRRule(rrule: string): ParsedRRule { const k = key.toUpperCase(); const trimmedVal = val.trim(); - if (k === 'FREQ') { - freq = trimmedVal.toUpperCase(); - } else if (k === 'INTERVAL') { - const parsed = parseInt(trimmedVal, 10); - interval = !isNaN(parsed) && parsed > 0 ? parsed : 1; - } else if (k === 'COUNT') { - const parsed = parseInt(trimmedVal, 10); - count = !isNaN(parsed) && parsed > 0 ? parsed : undefined; - } else if (k === 'UNTIL') { - if (/^\d{8}$/.test(trimmedVal)) { - const year = parseInt(trimmedVal.slice(0, 4), 10); - const month = parseInt(trimmedVal.slice(4, 6), 10); - const day = parseInt(trimmedVal.slice(6, 8), 10); - untilMs = Date.UTC(year, month - 1, day, 23, 59, 59, 999); - } else { - const uStr = trimmedVal.replace(/^(\d{4})(\d{2})(\d{2})T?(\d{2})?(\d{2})?(\d{2})?Z?$/, '$1-$2-$3T$4:$5:$6Z'); - const parsedDate = new Date(uStr); - untilMs = !isNaN(parsedDate.getTime()) ? parsedDate.getTime() : undefined; + switch (k) { + case 'FREQ': + freq = trimmedVal.toUpperCase(); + break; + case 'INTERVAL': { + const parsed = parseInt(trimmedVal, 10); + interval = !isNaN(parsed) && parsed > 0 ? parsed : 1; + break; + } + case 'COUNT': { + const parsed = parseInt(trimmedVal, 10); + count = !isNaN(parsed) && parsed > 0 ? parsed : undefined; + break; + } + case 'UNTIL': { + if (/^\d{8}$/.test(trimmedVal)) { + const year = parseInt(trimmedVal.slice(0, 4), 10); + const month = parseInt(trimmedVal.slice(4, 6), 10); + const day = parseInt(trimmedVal.slice(6, 8), 10); + untilMs = Date.UTC(year, month - 1, day, 23, 59, 59, 999); + } else { + const uStr = trimmedVal.replace(/^(\d{4})(\d{2})(\d{2})T?(\d{2})?(\d{2})?(\d{2})?Z?$/, '$1-$2-$3T$4:$5:$6Z'); + const parsedDate = new Date(uStr); + untilMs = !isNaN(parsedDate.getTime()) ? parsedDate.getTime() : undefined; + } + break; + } + case 'BYMONTH': { + const items = trimmedVal.split(',').map(v => { + const trimmed = v.trim(); + const num = parseInt(trimmed, 10); + if (!isNaN(num) && num >= 1 && num <= 12) return num; + const prefix = trimmed.slice(0, 3).toUpperCase(); + return MONTH_MAP[prefix]; + }).filter((v): v is number => isDefined(v)); + if (items.length > 0) byMonth = items; + break; + } + case 'BYDAY': { + const items = trimmedVal.split(',').map(item => { + const m = item.match(/^([+-]?\d+)?([A-Z]+)$/i); + const nthVal = m && m[1] ? parseInt(m[1], 10) : undefined; + const rawDay = m ? m[2] : item; + const canonicalDay = rawDay.slice(0, 2).toUpperCase(); + return { nth: isDefined(nthVal) && !isNaN(nthVal) ? nthVal : undefined, day: canonicalDay }; + }).filter(d => isDefined(DAY_MAP[d.day])); + if (items.length > 0) byDay = items; + break; + } + case 'BYHOUR': { + const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 0 && v <= 23); + if (items.length > 0) byHour = items; + break; + } + case 'BYMINUTE': { + const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 0 && v <= 59); + if (items.length > 0) byMinute = items; + break; + } + case 'BYSETPOS': { + const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v)); + if (items.length > 0) bySetPos = items; + break; } - } else if (k === 'BYMONTH') { - const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 1 && v <= 12); - if (items.length > 0) byMonth = items; - } else if (k === 'BYDAY') { - const items = trimmedVal.split(',').map(item => { - const m = item.match(/^([+-]?\d+)?([A-Z]{2})$/i); - const nthVal = m && m[1] ? parseInt(m[1], 10) : undefined; - const dayVal = m ? m[2].toUpperCase() : item.toUpperCase(); - return { nth: nthVal !== undefined && !isNaN(nthVal) ? nthVal : undefined, day: dayVal }; - }).filter(d => DAY_MAP[d.day] !== undefined); - if (items.length > 0) byDay = items; - } else if (k === 'BYHOUR') { - const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 0 && v <= 23); - if (items.length > 0) byHour = items; - } else if (k === 'BYMINUTE') { - const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v) && v >= 0 && v <= 59); - if (items.length > 0) byMinute = items; - } else if (k === 'BYSETPOS') { - const items = trimmedVal.split(',').map(v => parseInt(v, 10)).filter(v => !isNaN(v)); - if (items.length > 0) bySetPos = items; + default: + // Safely ignore unrecognized or extra RRULE parameters (e.g. WKST, EXDATE) + break; } } @@ -93,8 +173,13 @@ function getDaysInMonth(year: number, month: number): number { } /** - * Expands occurrences of an RRULE string into epoch millisecond numbers. - * Pure function operating strictly on epoch timestamps and Temporal/Date math. + * Expands occurrences of an RFC 5545 RRULE string into epoch millisecond numbers. + * Pure function operating strictly on primitive timestamps and UTC Date calculations. + * + * @param rruleStr - The RFC 5545 recurrence rule string to expand + * @param anchorEpochMs - Anchor timestamp in epoch milliseconds to evaluate occurrences from + * @param options - Optional bounds and count controls for the evaluation + * @returns Array of occurrence timestamps in epoch milliseconds */ export function expandRRuleEpochs( rruleStr: string, @@ -104,7 +189,7 @@ export function expandRRuleEpochs( const rule = parseRRule(rruleStr); const anchorDate = new Date(anchorEpochMs); const results: number[] = []; - const maxToFetch = rule.count !== undefined ? rule.count : (options?.count ?? 100); + const maxToFetch = isDefined(rule.count) ? rule.count : (options?.count ?? 100); let totalGeneratedFromAnchor = 0; let resultsCount = 0; @@ -118,71 +203,73 @@ export function expandRRuleEpochs( while (resultsCount < maxToFetch && step < MAX_STEPS) { let periodBases: Date[] = []; + const baseDate = new Date(anchorEpochMs); - if (rule.freq === 'DAILY') { - const d = new Date(anchorEpochMs); - d.setUTCDate(d.getUTCDate() + step * rule.interval); - periodBases = [d]; - } else if (rule.freq === 'WEEKLY') { - const weekBase = new Date(anchorEpochMs); - weekBase.setUTCDate(weekBase.getUTCDate() + step * rule.interval * 7); - if (rule.byDay && rule.byDay.length > 0) { - periodBases = rule.byDay.map(bd => { - const targetDay = DAY_MAP[bd.day] ?? 1; - const currentDow = weekBase.getUTCDay() === 0 ? 7 : weekBase.getUTCDay(); - const diff = (targetDay - currentDow + 7) % 7; - const targetDate = new Date(weekBase.getTime()); - targetDate.setUTCDate(targetDate.getUTCDate() + diff); - return targetDate; - }); - } else { - periodBases = [weekBase]; + switch (rule.freq) { + case 'WEEKLY': { + baseDate.setUTCDate(baseDate.getUTCDate() + step * rule.interval * DAYS_IN_WEEK); + if (rule.byDay && rule.byDay.length > 0) { + periodBases = rule.byDay.map(bd => { + const targetDay = DAY_MAP[bd.day] ?? 1; + const currentDow = baseDate.getUTCDay() === 0 ? DAY_MAP.SUN : baseDate.getUTCDay(); + const diff = (targetDay - currentDow + DAYS_IN_WEEK) % DAYS_IN_WEEK; + const targetDate = new Date(baseDate.getTime()); + targetDate.setUTCDate(targetDate.getUTCDate() + diff); + return targetDate; + }); + } else { + periodBases = [baseDate]; + } + break; } - } else if (rule.freq === 'MONTHLY') { - const monthBase = new Date(anchorEpochMs); - monthBase.setUTCMonth(monthBase.getUTCMonth() + step * rule.interval); - const year = monthBase.getUTCFullYear(); - const month = monthBase.getUTCMonth() + 1; - const daysInMonth = getDaysInMonth(year, month); + case 'MONTHLY': { + baseDate.setUTCMonth(baseDate.getUTCMonth() + step * rule.interval); + const year = baseDate.getUTCFullYear(); + const month = baseDate.getUTCMonth() + 1; + const daysInMonth = getDaysInMonth(year, month); - if (rule.byDay && rule.byDay.length > 0) { - const candidateDays: Date[] = []; - for (const bd of rule.byDay) { - const targetDow = DAY_MAP[bd.day] ?? 1; - const matchingDates: Date[] = []; - for (let dayNum = 1; dayNum <= daysInMonth; dayNum++) { - const d = new Date(Date.UTC(year, month - 1, dayNum, anchorHours, anchorMinutes, anchorSeconds, anchorMs)); - const dow = d.getUTCDay() === 0 ? 7 : d.getUTCDay(); - if (dow === targetDow) matchingDates.push(d); - } + if (rule.byDay && rule.byDay.length > 0) { + const candidateDays: Date[] = []; + for (const bd of rule.byDay) { + const targetDow = DAY_MAP[bd.day] ?? 1; + const matchingDates: Date[] = []; + for (let dayNum = 1; dayNum <= daysInMonth; dayNum++) { + const d = new Date(Date.UTC(year, month - 1, dayNum, anchorHours, anchorMinutes, anchorSeconds, anchorMs)); + const dow = d.getUTCDay() === 0 ? DAY_MAP.SUN : d.getUTCDay(); + if (dow === targetDow) matchingDates.push(d); + } - if (bd.nth !== undefined) { - if (bd.nth > 0 && bd.nth <= matchingDates.length) { - candidateDays.push(matchingDates[bd.nth - 1]); - } else if (bd.nth < 0 && Math.abs(bd.nth) <= matchingDates.length) { - candidateDays.push(matchingDates[matchingDates.length + bd.nth]); + if (isDefined(bd.nth)) { + if (bd.nth > 0 && bd.nth <= matchingDates.length) { + candidateDays.push(matchingDates[bd.nth - 1]); + } else if (bd.nth < 0 && Math.abs(bd.nth) <= matchingDates.length) { + candidateDays.push(matchingDates[matchingDates.length + bd.nth]); + } + } else { + candidateDays.push(...matchingDates); } - } else { - candidateDays.push(...matchingDates); } + periodBases = candidateDays; + } else { + periodBases = [baseDate]; } - periodBases = candidateDays; - } else { - periodBases = [monthBase]; + break; + } + case 'YEARLY': { + baseDate.setUTCFullYear(baseDate.getUTCFullYear() + step * rule.interval); + periodBases = [baseDate]; + break; + } + case 'DAILY': + default: { + baseDate.setUTCDate(baseDate.getUTCDate() + step * rule.interval); + periodBases = [baseDate]; + break; } - } else if (rule.freq === 'YEARLY') { - const yearBase = new Date(anchorEpochMs); - yearBase.setUTCFullYear(yearBase.getUTCFullYear() + step * rule.interval); - periodBases = [yearBase]; - } else { - const d = new Date(anchorEpochMs); - d.setUTCDate(d.getUTCDate() + step * rule.interval); - periodBases = [d]; } - if (rule.byMonth && rule.byMonth.length > 0) { + if (rule.byMonth && rule.byMonth.length > 0) periodBases = periodBases.filter(b => rule.byMonth!.includes(b.getUTCMonth() + 1)); - } const periodCandidates: Date[] = []; for (const base of periodBases) { @@ -217,19 +304,19 @@ export function expandRRuleEpochs( totalGeneratedFromAnchor++; const candMs = cand.getTime(); - if (rule.untilMs !== undefined && candMs > rule.untilMs) { + if (isDefined(rule.untilMs) && candMs > rule.untilMs) { stopSeries = true; break; } - if (rule.count !== undefined && totalGeneratedFromAnchor > rule.count) { + if (isDefined(rule.count) && totalGeneratedFromAnchor > rule.count) { stopSeries = true; break; } - if (options?.beforeMs !== undefined && candMs > options.beforeMs) { + if (isDefined(options?.beforeMs) && candMs > options.beforeMs) { stopSeries = true; break; } - if (options?.afterMs !== undefined && candMs <= options.afterMs) { + if (isDefined(options?.afterMs) && candMs <= options.afterMs) { continue; } @@ -250,6 +337,10 @@ export function expandRRuleEpochs( /** * Computes the single next RRULE occurrence epoch millisecond timestamp after `fromEpochMs`. + * + * @param rruleStr - The RFC 5545 recurrence rule string + * @param fromEpochMs - The baseline timestamp in epoch milliseconds + * @returns Epoch millisecond timestamp of the next occurrence */ export function getNextRRuleEpoch(rruleStr: string, fromEpochMs: number): number { const expanded = expandRRuleEpochs(rruleStr, fromEpochMs, { count: 1, afterMs: fromEpochMs }); diff --git a/packages/library/test/common/rrule_library.test.ts b/packages/library/test/common/rrule_library.test.ts index c11566a1..d831fdb8 100644 --- a/packages/library/test/common/rrule_library.test.ts +++ b/packages/library/test/common/rrule_library.test.ts @@ -22,6 +22,21 @@ describe('rrule.library', () => { expect(parsed.byMinute).toEqual([30]); }); + test('parseRRule supports 2-letter, 3-letter, and full weekday names and normalizes to standard RFC 2-letter codes', () => { + const parsed = parseRRule('FREQ=WEEKLY;BYDAY=Monday,FRI,Wed,2Thursday'); + expect(parsed.byDay).toEqual([ + { nth: undefined, day: 'MO' }, + { nth: undefined, day: 'FR' }, + { nth: undefined, day: 'WE' }, + { nth: 2, day: 'TH' } + ]); + }); + + test('parseRRule supports numeric, 3-letter, and full month names in BYMONTH', () => { + const parsed = parseRRule('FREQ=YEARLY;BYMONTH=1,Jan,December,AUG'); + expect(parsed.byMonth).toEqual([1, 1, 12, 8]); + }); + test('expandRRuleEpochs generates correct occurrence timestamps', () => { // 2026-08-07T00:00:00.000Z is Friday const anchor = Date.UTC(2026, 7, 7, 0, 0, 0, 0); diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts index 78280bf4..f6cee812 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -1,5 +1,5 @@ import { Tempo } from '@magmacomputing/tempo'; -import { isRRuleString, checkIsFinite, parseRRule, expandRRuleEpochs } from '@magmacomputing/library'; +import { isRRuleString, isFiniteRRule, parseRRule, expandRRuleEpochs } from '@magmacomputing/library'; import { TempoAiError } from '../core/error.js'; import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; @@ -14,6 +14,7 @@ function expandOccurrences(rrule: string, anchor: Tempo, options?: { count?: num afterMs: afterTempo ? afterTempo.epoch.ms : undefined, beforeMs: beforeTempo ? beforeTempo.epoch.ms : undefined }); + return epochs.map(ms => new Tempo(ms, anchor.config)); } @@ -28,7 +29,7 @@ function createRecurrenceResult( options?: TempoRecurrenceOptions ): TempoRecurrenceResult { const rule = parseRRule(rruleStr); - const isFinite = checkIsFinite(rruleStr) || Boolean(options?.before); + const isFinite = isFiniteRRule(rruleStr) || Boolean(options?.before); let sizeLimit: number; if (rule.count !== undefined) { sizeLimit = rule.count; @@ -224,7 +225,7 @@ Do not include markdown blocks or text outside the JSON.`; }); // Attach no-op rejection handler to suppress unhandled promise warnings on aborted/slower requests - promises.forEach(p => p.catch(() => {})); + promises.forEach(p => p.catch(() => { })); successfulResult = await Promise.race(promises); parentController.abort(); diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 905ecdee..a41e1fde 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.11.1] - 2026-08-03 ### Added +- **Timezone Abbreviation & Humanized Offset Parsing**: Upgraded `Token.tzd` snippet compilation and Master Guard scanning to natively support 3–4 letter timezone abbreviations (e.g. `AEST`, `PST`, `EST`, `CET`, `JST`) alongside `GMT`/`UTC` offset prefixes (e.g. `'Aug 6, 16:16 GMT+10'`, `'August 6, 16:16 AEST'`). Dynamically compiles `Token.tzd` from `DEFAULTS.TIMEZONE` and introduces `Match.offset` for clean structural offset matching with downstream `Temporal.ZonedDateTime` validation. - **Cache Serialization (`toJSON`)**: Added native `toJSON()` serialization support to `BoundedCache` and the `Tempo.cache` facade object. Calling `Tempo.cache.toJSON()` or `JSON.stringify(Tempo.cache)` now cleanly converts active, non-expired in-memory cache entries into a plain key-value JavaScript object. - **AI Context & IDE Integration (`llms.txt`)**: Published official standardized `llms.txt` and `llms-full.txt` context bundles at `https://tempo.magmacomputing.com.au` to provide full project context and enhance code-generation accuracy for IDE tools (Cursor, VS Code / GitHub Copilot, Antigravity) and web AI interfaces (ChatGPT, Claude, Gemini). - **Automated Doc Harvester**: Created `bin/generate-llms-txt.mjs` monorepo build script integrated into `npm run docs:build` to harvest all 56 markdown documentation files into a unified `llms-full.txt` corpus. diff --git a/packages/tempo/doc/2-core-concepts/tempo.parse.md b/packages/tempo/doc/2-core-concepts/tempo.parse.md index e4668e7b..b2382342 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.parse.md +++ b/packages/tempo/doc/2-core-concepts/tempo.parse.md @@ -126,6 +126,29 @@ Tempo.extend(ParseModule); Tempo uses your configuration to intelligently parse ambiguous dates and foreign languages. +### TimeZone & Offset Parsing +Tempo natively supports human-readable timezone abbreviations (e.g. `AEST`, `PST`, `EST`, `CET`, `JST`) as well as explicit `GMT` / `UTC` offset designators (e.g. `'Aug 6, 16:16 GMT+10'`, `'August 6, 16:16 AEST'`, `'UTC-5'`). + +> [!WARNING] +> **Default Layout Positioning & Collisions** +> By default, Tempo's built-in layouts expect timezone offsets and abbreviations to *follow* the date/time payload. This design prevents leading abbreviations from colliding with 3-letter month names (such as `MAR` for March vs. Marshall Islands Time). + +#### Registering a Custom Leading Timezone Layout +If your application processes custom log files or legacy data streams that position timezone designators at the *start* (e.g. `"PST 8 Aug 10:30"`), you can register a custom layout using `{tzd}` alongside `{dt}` and `{tm}`: + +```typescript +Tempo.init({ + registry: { + layouts: { + leadingTz: '{tzd}{sep}+{dt}{sep}+{tm}' + } + } +}); + +const t = new Tempo('PST 8 Aug 10:30'); +console.log(t.tz); // 'America/Los_Angeles' +``` + ### US-Style Dates (`MM/DD/YYYY`) If you parse a numeric string like `04012026`, Tempo uses your `timeZone` to decide if it means **April 1st** (US) or **4th of January** (UK/AU). diff --git a/packages/tempo/public/llms-full.txt b/packages/tempo/public/llms-full.txt index 82461b8d..6b1d7001 100644 --- a/packages/tempo/public/llms-full.txt +++ b/packages/tempo/public/llms-full.txt @@ -2005,6 +2005,13 @@ Tempo.extend(ParseModule); Tempo uses your configuration to intelligently parse ambiguous dates and foreign languages. +### TimeZone & Offset Parsing +Tempo natively supports human-readable timezone abbreviations (e.g. `AEST`, `PST`, `EST`, `CET`, `JST`) as well as explicit `GMT` / `UTC` offset designators (e.g. `'Aug 6, 16:16 GMT+10'`, `'August 6, 16:16 AEST'`, `'UTC-5'`). + +> [!WARNING] +> **TimeZone Offset Positioning & Collisions** +> Timezone offsets and abbreviations should be positioned after the date/time payload (`Aug 6, 16:16 AEST` or `Aug 6, 16:16 GMT+10`). Avoid placing unprefixed standalone timezone abbreviations at the very start of date strings (e.g., `"AEST 8 Aug 10:30"`), as leading abbreviations can collide with month names (e.g., `MAR` for March vs. Marshall Islands Time, `MAY` for May vs. Magadan Time). For non-standard log formats requiring leading timezone resolution, use `parseAI` from the `@magmacomputing/tempo-plugin-ai` package. + ### US-Style Dates (`MM/DD/YYYY`) If you parse a numeric string like `04012026`, Tempo uses your `timeZone` to decide if it means **April 1st** (US) or **4th of January** (UK/AU). diff --git a/packages/tempo/src/engine/engine.lexer.ts b/packages/tempo/src/engine/engine.lexer.ts index 65c4b7cc..e78ac03d 100644 --- a/packages/tempo/src/engine/engine.lexer.ts +++ b/packages/tempo/src/engine/engine.lexer.ts @@ -301,6 +301,12 @@ export function parseZone(groups: t.Groups, dateTime: Temporal.ZonedDateTime, co const brk = groups["brk"]?.replace(Match.zed, 'UTC'); let zone: string | undefined = brk || tzd; + if (zone && /^([+-]\d{1,2})$/.test(zone)) { + const sign = zone[0]; + const num = Math.abs(parseInt(zone, 10)); + zone = `${sign}${num.toString().padStart(2, '0')}:00`; + } + let cal = groups["cal"]; if (zone?.startsWith('u-ca=')) { cal = zone; @@ -309,8 +315,13 @@ export function parseZone(groups: t.Groups, dateTime: Temporal.ZonedDateTime, co const zdt = dateTime as any; if (zone && zone !== zdt.timeZoneId) { - if (config) config.timeZone = zone; - dateTime = zdt.toPlainDateTime().toZonedDateTime(zone); + const resolvedZone = enums.TIMEZONE[zone.toLowerCase()] ?? zone; + if (config) config.timeZone = resolvedZone; + try { + dateTime = zdt.toPlainDateTime().toZonedDateTime(resolvedZone); + } catch { + logWarn(`Unrecognized or invalid timezone identifier: '${zone}'`, config); + } } if (cal && cal !== (dateTime as any).calendarId) { const calendar = cal.startsWith('u-ca=') ? cal.substring(5) : cal; diff --git a/packages/tempo/src/support/support.default.ts b/packages/tempo/src/support/support.default.ts index 9a4669ec..c90fdcac 100644 --- a/packages/tempo/src/support/support.default.ts +++ b/packages/tempo/src/support/support.default.ts @@ -3,7 +3,7 @@ import { secure, proxify } from '#library/proxy.library.js'; import { getDateTimeFormat } from '#library/international.library.js'; import { LOG } from '#library/logger.class.js'; -import { NUMBER, MODE, MONTH_DAY } from './support.enum.js'; +import { NUMBER, TIMEZONE, MODE, MONTH_DAY } from './support.enum.js'; import { Token } from './support.symbol.js'; import type { Options, AliasContext, IntlOptions } from '../tempo.type.js'; @@ -34,6 +34,7 @@ export const Match = proxify({ /** slick shorthand-shifter (e.g. #qtr.>2q2) */ shorthand: /(?:(?:#[\w]+|[\w]+)\.(?:[\+\-\<\>]=?)?(?:[0-9]+)?(?:[\w]*))/, /** anchored version for shifter resolution */ slick: /^(?#[\w]+|[\w]+)\.(?[\+\-\<\>\=]=?)?(?-?[0-9]+)?(?[\w]*)$/, /** extracted value-only version of a slick shifter */ slickValue: /^(?[\+\-\<\>\=]=?)?(?-?[0-9]+)?(?[\w]*)$/, + /** numeric timezone offset (e.g. +10:00, +1000, -05:00, -0500, GMT+10, UTC-5) */ offset: /(?:[+-]\d{2}:\d{2}|[+-]\d{4}|(?<=\s|T|GMT|UTC)\s*[+-]?\d{1,2}(?::?\d{2})?)/, /** escape special regex characters in a string */ escape: (str: string) => String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), /** escape only dangerous quantifiers and anchors to prevent backtracking/injection while allowing basic regex */ safeAlias: (str: string) => String(str).replace(/[*+{}!^$\\]/g, '\\$&'), @@ -67,7 +68,7 @@ export const Snippet = looseIndex()({ [Token.mer]: /(\s*(?am|pm))/, // meridiem suffix (am,pm) [Token.sfx]: /((?:{sep}+|T)({tm}){tzd}?)/, // time-pattern suffix 'T {tm} Z'; NOTE: {tm} resolves via Layout fallback in compileRegExp (cross-registry dependency: Snippet → Layout) [Token.wkd]: /(?Mon(?:day)?|Tue(?:sday)?|Wed(?:nesday)?|Thu(?:rsday)?|Fri(?:day)?|Sat(?:urday)?|Sun(?:day)?)/, // day-name (abbrev or full) - [Token.tzd]: /(?Z|(?:\+(?:(?:0[0-9]|1[0-3]):?[0-5][0-9]|14:?00)|-(?:(?:0[0-9]|1[0-1]):?[0-5][0-9]|12:?00)))/, // time-zone offset +14:00 to -12:00; colon optional throughout (including boundary values UTC+14 and UTC-12) + [Token.tzd]: new RegExp(`\\s*(?:GMT|UTC)?\\s*(?Z|(?:${Object.keys(TIMEZONE).map(w => Match.escape(w.toUpperCase())).join('|')})|${Match.offset.source})`, 'i'), // time-zone offset or abbreviation with optional GMT/UTC prefix (e.g. GMT+10, UTC+10:00, +10:00, AEST, PST) [Token.nbr]: new RegExp(`(?[0-9]+|${Object.keys(NUMBER).map(w => Match.escape(w)).join('|')})`), // modifier count; number-word keys are regex-escaped at construction time (setPatterns() also re-escapes, but defence-in-depth) [Token.afx]: new RegExp(`((s)? (?${Match.modifier.source}))?{sep}?`), // affix optional plural 's' and (ago|hence) [Token.mod]: new RegExp(`((?${Match.modifier.source})? *)`), @@ -189,7 +190,7 @@ export type SLICK_KEYS = typeof SLICK_KEYS /** @internal Tempo Master Guard list */ export const Guard = [ - 'am', 'pm', 'ago', 'hence', 'this', 'next', 'prev', 'last', 'from', 'now', 'today', 'yesterday', 'tomorrow', 'start', 'mid', 'end', + 'am', 'pm', 'gmt', 'utc', 'ago', 'hence', 'this', 'next', 'prev', 'last', 'from', 'now', 'today', 'yesterday', 'tomorrow', 'start', 'mid', 'end', 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond', 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds', 'microseconds', 'nanoseconds', 'st', 'nd', 'rd', 'th', diff --git a/packages/tempo/src/support/support.enum.ts b/packages/tempo/src/support/support.enum.ts index 5504ba85..2c873f25 100644 --- a/packages/tempo/src/support/support.enum.ts +++ b/packages/tempo/src/support/support.enum.ts @@ -49,15 +49,25 @@ export const DEFAULTS = { 'utc': 'UTC', 'gmt': 'Europe/London', 'est': 'America/New_York', + 'edt': 'America/New_York', 'cst': 'America/Chicago', + 'cdt': 'America/Chicago', 'mst': 'America/Denver', + 'mdt': 'America/Denver', 'pst': 'America/Los_Angeles', + 'pdt': 'America/Los_Angeles', 'aest': 'Australia/Sydney', + 'aedt': 'Australia/Sydney', 'acst': 'Australia/Adelaide', + 'acdt': 'Australia/Adelaide', 'awst': 'Australia/Perth', 'nzt': 'Pacific/Auckland', + 'nzst': 'Pacific/Auckland', + 'nzdt': 'Pacific/Auckland', 'cet': 'Europe/Paris', + 'cest': 'Europe/Paris', 'eet': 'Europe/Helsinki', + 'eest': 'Europe/Helsinki', 'ist': 'Asia/Kolkata', 'npt': 'Asia/Kathmandu', 'jst': 'Asia/Tokyo', diff --git a/packages/tempo/test/discrete/standalone_parse.test.ts b/packages/tempo/test/discrete/standalone_parse.test.ts index f42d05d1..fe058e3f 100644 --- a/packages/tempo/test/discrete/standalone_parse.test.ts +++ b/packages/tempo/test/discrete/standalone_parse.test.ts @@ -59,3 +59,40 @@ test('standalone parse: timezone lookup', () => { const zdt = parse('2025-05-20 10:00', { timeZone: 'pst' }); expect(zdt.timeZoneId).toBe('America/Los_Angeles'); }); + +test('standalone parse: human date string with GMT/UTC timezone offset (e.g. Aug 6, 16:16 GMT+10)', () => { + const zdt = parse('Aug 6, 16:16 GMT+10'); + expect(zdt.month).toBe(8); + expect(zdt.day).toBe(6); + expect(zdt.hour).toBe(16); + expect(zdt.minute).toBe(16); + expect(zdt.offset).toBe('+10:00'); + + const zdt2 = parse('Aug 6, 16:16 UTC-5'); + expect(zdt2.month).toBe(8); + expect(zdt2.day).toBe(6); + expect(zdt2.hour).toBe(16); + expect(zdt2.minute).toBe(16); + expect(zdt2.offset).toBe('-05:00'); + + const t = new Tempo('Aug 6, 16:16 GMT+10'); + expect(t.mm).toBe(8); + expect(t.dd).toBe(6); + expect(t.hh).toBe(16); + expect(t.mi).toBe(16); + + const zdtAest = parse('August 6, 16:16 AEST'); + expect(zdtAest.month).toBe(8); + expect(zdtAest.day).toBe(6); + expect(zdtAest.hour).toBe(16); + expect(zdtAest.minute).toBe(16); + expect(zdtAest.timeZoneId).toBe('Australia/Sydney'); + + const zdtPst = parse('Aug 6, 16:16 PST'); + expect(zdtPst.month).toBe(8); + expect(zdtPst.day).toBe(6); + expect(zdtPst.hour).toBe(16); + expect(zdtPst.minute).toBe(16); + + expect(zdtPst.timeZoneId).toBe('America/Los_Angeles'); +}); From 65306fe0ad18b341bba1e64765b2e6e41cdbacb6 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sat, 8 Aug 2026 13:12:36 +1000 Subject: [PATCH 12/23] PR new scheduleAI fn --- packages/plugins/ai/CHANGELOG.md | 6 + packages/plugins/ai/src/core/config.ts | 21 +- packages/plugins/ai/src/core/init.ts | 2 +- packages/plugins/ai/src/core/manifest.ts | 2 +- packages/plugins/ai/src/core/support.ts | 2 +- packages/plugins/ai/src/functions/parse.ts | 58 +-- .../plugins/ai/src/functions/recurrence.ts | 2 +- packages/plugins/ai/src/functions/schedule.ts | 359 +++++++++++++++++- packages/plugins/ai/src/index.ts | 3 +- .../{core/types.ts => types/common.type.ts} | 81 +--- packages/plugins/ai/src/types/index.ts | 4 + packages/plugins/ai/src/types/parse.type.ts | 41 ++ .../plugins/ai/src/types/recurrence.type.ts | 42 ++ .../plugins/ai/src/types/schedule.type.ts | 112 ++++++ packages/plugins/ai/test/schedule.test.ts | 214 +++++++++++ packages/tempo/CHANGELOG.md | 4 + packages/tempo/src/engine/engine.composer.ts | 23 +- packages/tempo/src/interval.class.ts | 3 +- packages/tempo/src/module/module.parse.ts | 3 + packages/tempo/src/tempo.class.ts | 4 +- 20 files changed, 833 insertions(+), 153 deletions(-) rename packages/plugins/ai/src/{core/types.ts => types/common.type.ts} (62%) create mode 100644 packages/plugins/ai/src/types/index.ts create mode 100644 packages/plugins/ai/src/types/parse.type.ts create mode 100644 packages/plugins/ai/src/types/recurrence.type.ts create mode 100644 packages/plugins/ai/src/types/schedule.type.ts create mode 100644 packages/plugins/ai/test/schedule.test.ts diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index bba9a013..bd2e686c 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.3.0] - 2026-08-04 ### Added +- **Intelligent Calendar Scheduling (`scheduleAI`)**: Introduced natural language appointment scheduling with deterministic conflict detection and automated slot bumping powered by `Interval.overlaps()`. +- **RFC 5545 Recurrence Engine (`recurrenceAI`)**: Added full recurrence pattern expansion with Cartesian product support across `BYDAY`, `BYHOUR`, `BYMINUTE`, and `BYMONTH` rules, backed by lazy page-based iteration. +- **Deep-Immutability for Default Config**: Migrated `DEFAULT_PROVIDERS` to the `secure()` Proxy utility, enforcing zero-mutation safety across AI provider configurations without dictionary lookup overhead. - **3-Tier Timeout Resolution Hierarchy**: Introduced flexible, multi-level request timeout control for LLM API queries to prevent network hangs and ensure predictable SLAs: 1. *Call-site override*: `parseAI(input, { timeout: 3000 })` 2. *Provider-specific override*: `{ id: 'groq', options: { timeout: 2000 } }` @@ -20,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Dynamic Remote Provider Manifest (`loadRemoteManifest`)**: Lazily fetches remote provider defaults (`providers.v1.json`) on initialization with a 1500ms timeout and automatic air-gapped fallback to compiled `DEFAULT_PROVIDERS`. - **Cache Eviction Synchronization**: Enhanced `clearAiCache()` to flush matching keys and prefixes across both `Tempo.cache` and custom `AiCacheAdapter` storage engines, returning `Promise` to await async adapter eviction. +### Changed +- **Streamlined ISO Parsing**: Refactored internal date resolution in `scheduleAI` to delegate directly to core `Tempo` constructors (`new Tempo(str, { timeZone })`), removing redundant regex parsing layers and manual `Temporal.PlainDateTime` conversions. + ## [0.2.0] - 2026-07-30 ### Added diff --git a/packages/plugins/ai/src/core/config.ts b/packages/plugins/ai/src/core/config.ts index 947f42ec..a44de2bf 100644 --- a/packages/plugins/ai/src/core/config.ts +++ b/packages/plugins/ai/src/core/config.ts @@ -1,4 +1,5 @@ -import type { AiProvider } from './types.js'; +import { secure } from '@magmacomputing/library'; +import type { AiProvider } from '../types/index.js'; /** * ## AiMode @@ -23,25 +24,25 @@ export const RESERVED_PROVIDER_IDS: ReadonlySet = new Set(['native', 'ca /** * Built-in default endpoint and model configurations for popular providers. */ -export const DEFAULT_PROVIDERS: Readonly>>> = Object.freeze({ - groq: Object.freeze({ +export const DEFAULT_PROVIDERS: Record>> = secure({ + groq: { url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile', tokenParam: 'max_tokens' - }), - openai: Object.freeze({ + }, + openai: { url: 'https://api.openai.com/v1/chat/completions', model: 'gpt-5.4-mini', tokenParam: 'max_completion_tokens' - }), - gemini: Object.freeze({ + }, + gemini: { url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-3.6-flash', tokenParam: 'max_tokens' - }), - mistral: Object.freeze({ + }, + mistral: { url: 'https://api.mistral.ai/v1/chat/completions', model: 'mistral-small-latest', tokenParam: 'max_tokens' - }) + } }); diff --git a/packages/plugins/ai/src/core/init.ts b/packages/plugins/ai/src/core/init.ts index 4b6c589d..578d57dd 100644 --- a/packages/plugins/ai/src/core/init.ts +++ b/packages/plugins/ai/src/core/init.ts @@ -2,7 +2,7 @@ import { Tempo } from '@magmacomputing/tempo'; import { getResolvedProviderDefaults, loadRemoteManifest } from './manifest.js'; import { normalizeCacheInput, assertNoReservedProviderId } from './support.js'; -import type { AiConfig, AiRateLimits, AiProvider } from './types.js'; +import type { AiConfig, AiRateLimits, AiProvider } from '../types/index.js'; export const _state: { config: AiConfig; diff --git a/packages/plugins/ai/src/core/manifest.ts b/packages/plugins/ai/src/core/manifest.ts index 8d6fe129..7b69d5a9 100644 --- a/packages/plugins/ai/src/core/manifest.ts +++ b/packages/plugins/ai/src/core/manifest.ts @@ -1,5 +1,5 @@ import { DEFAULT_PROVIDERS } from './config.js'; -import type { AiProvider } from './types.js'; +import type { AiProvider } from '../types/index.js'; export const DEFAULT_REMOTE_MANIFEST_URL = 'https://tempo.magmacomputing.com.au/providers.v1.json'; export const DEFAULT_MANIFEST_TIMEOUT_MS = 1500; diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index 33e972a6..62f25954 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -2,7 +2,7 @@ import { Tempo } from '@magmacomputing/tempo'; import { TempoAiError } from './error.js'; import { RESERVED_PROVIDER_IDS } from './config.js'; import { updateRateLimitsFromResponse, _state } from './init.js'; -import type { AiProvider, TempoAiMeta } from './types.js'; +import type { AiProvider, TempoAiMeta } from '../types/index.js'; export function assertNoReservedProviderId(providers: Partial[]): void { for (const p of providers) { diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts index 507ac875..d14b4a00 100644 --- a/packages/plugins/ai/src/functions/parse.ts +++ b/packages/plugins/ai/src/functions/parse.ts @@ -1,9 +1,9 @@ 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'; +import type { AiParseOptions } from '../types/index.js'; async function parseSingleInput(str: string, options?: AiParseOptions): Promise { const isDebug = options?.debug ?? _state.config.debug ?? false; @@ -112,39 +112,39 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< assertNoReservedProviderId(availableProviders); - const mode = aiMode || _state.config.mode || AiMode.Fallback; - const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence; - let successfulResult: { parsedData: any; providerId: string; rateLimits?: any } | null = null; + const mode = aiMode || _state.config.mode || AiMode.Fallback; + const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence; + 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; + 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, undefined, callTimeout); - const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); - const parsedData = JSON.parse(cleanContent); + for (const provider of availableProviders) { + try { + const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug, undefined, callTimeout); + 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); + 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 (!bestCandidate || candidateConfidence > (bestCandidate.parsedData?.confidence ?? 0)) { + bestCandidate = { parsedData, providerId, rateLimits }; + } - if (effectiveMinConfidence === undefined || candidateConfidence >= effectiveMinConfidence) { - successfulResult = { parsedData, providerId, rateLimits }; - break; - } + if (effectiveMinConfidence === undefined || candidateConfidence >= effectiveMinConfidence) { + successfulResult = { parsedData, providerId, rateLimits }; + break; + } - if (isDebug) { - console.log(`[tempo-plugin-ai] Provider '${providerId}' confidence (${candidateConfidence}) below minConfidence (${effectiveMinConfidence}). Cascading to next provider...`); - } - } catch (err: any) { - lastError = err; - if (err instanceof TempoAiError && err.code === 422 && effectiveMinConfidence === undefined) break; - } - } + if (isDebug) { + console.log(`[tempo-plugin-ai] Provider '${providerId}' confidence (${candidateConfidence}) below minConfidence (${effectiveMinConfidence}). Cascading to next provider...`); + } + } catch (err: any) { + lastError = err; + if (err instanceof TempoAiError && err.code === 422 && effectiveMinConfidence === undefined) break; + } + } if (!successfulResult) { if (bestCandidate) { @@ -225,7 +225,7 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< const granularity = typeof parsedData?.granularity === 'string' ? parsedData.granularity : 'unknown'; const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined; - const isBelowMinConfidence = effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence; + const isBelowMinConfidence = effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence; if (rawIso === 'INVALID' || isBelowMinConfidence) { const invalidInstance = new Tempo('INVALID', { ...coreOptions, catch: true }); diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts index f6cee812..60e85606 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -4,7 +4,7 @@ import { TempoAiError } from '../core/error.js'; import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; import { fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; -import type { TempoRecurrenceOptions, TempoRecurrenceResult } from '../core/types.js'; +import type { TempoRecurrenceOptions, TempoRecurrenceResult } from '../types/index.js'; function expandOccurrences(rrule: string, anchor: Tempo, options?: { count?: number; after?: any; before?: any }): Tempo[] { const afterTempo = options?.after ? new Tempo(options.after) : undefined; diff --git a/packages/plugins/ai/src/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts index 0e41e9c1..f69537f8 100644 --- a/packages/plugins/ai/src/functions/schedule.ts +++ b/packages/plugins/ai/src/functions/schedule.ts @@ -1,27 +1,344 @@ -import type { Tempo } from '@magmacomputing/tempo'; +import { Tempo, Interval } from '@magmacomputing/tempo'; +import { isString, isNumber, isFunction } from '@magmacomputing/library/assertion.library.js'; +import { TempoAiError } from '../core/error.js'; +import { AiMode } from '../core/config.js'; +import { _state } from '../core/init.js'; +import { fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; +import type { TempoScheduleOptions, TempoScheduleResult, TempoWorkingHours, TempoInterval, TempoScheduleMeta, AiProvider } from '../types/index.js'; -export interface TempoInterval { - start: Tempo; - end: Tempo; +function normalizeBusyEvents(rawEvents?: any[], timeZone = 'UTC'): Array<{ start: Tempo; end: Tempo; title?: string | undefined }> { + if (!Array.isArray(rawEvents)) return []; + + const parsePoint = (val: any): Tempo => { + if (!val) return new Tempo({ timeZone }); + if (Tempo.isTempo(val)) return val; + return new Tempo(val, { timeZone }); + } + + return rawEvents.map(evt => { + let start: Tempo; + let end: Tempo; + let title = 'Busy'; + + if (evt && typeof evt === 'object') { + if ('start' in evt && 'end' in evt) { + start = parsePoint((evt as any).start); + end = parsePoint((evt as any).end); + if ('title' in evt) title = String((evt as any).title); + else if ('label' in evt) title = String((evt as any).label); + } else if (Array.isArray(evt) && evt.length >= 2) { + start = parsePoint(evt[0]); + end = parsePoint(evt[1]); + } else { + start = parsePoint(evt); + end = start.add('1 hour'); + } + } else { + start = parsePoint(evt); + end = start.add('1 hour'); + } + + return { start, end, title }; + }); +} + +function parseDurationMinutes(prompt: string, fallback?: number): number { + if (isNumber(fallback) && fallback > 0) return fallback; + const match = prompt.match(/(\d+)\s*(?:minutes?|mins?|m\b)/i); + if (match) return parseInt(match[1], 10); + const hourMatch = prompt.match(/(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|h\b)/i); + if (hourMatch) return Math.round(parseFloat(hourMatch[1]) * 60); + return 30; // default 30 minutes +} + +function buildContextPrompt( + anchorTempo: Tempo, + timeZone: string, + workingHours: TempoWorkingHours, + busyEvents: Array<{ start: Tempo; end: Tempo; title?: string | undefined }>, + durationMinutes: number +): string { + const daysMap = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const activeDays = (workingHours.days ?? [1, 2, 3, 4, 5]).map(d => daysMap[d] ?? d).join(', '); + const whStart = workingHours.start ?? '09:00'; + const whEnd = workingHours.end ?? '17:00'; + + let busySummary = 'None'; + if (busyEvents.length > 0) { + busySummary = busyEvents.map(b => + `- ${b.title || 'Busy'}: ${b.start.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')} to ${b.end.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')} [${b.start.tz}]` + ).join('\n'); + } + + return `Reference Anchor Time: ${anchorTempo.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}')} (${timeZone}) +Target TimeZone: ${timeZone} +Working Hours: ${whStart} to ${whEnd} (${activeDays}) in ${workingHours.timeZone || timeZone} +Required Slot Duration: ${durationMinutes} minutes +Existing Booked Busy Slots to Avoid: +${busySummary}`; +} + +const SCHEDULE_SYSTEM_PROMPT = `You are a high-precision calendar scheduling engine. +Your task is to analyze a natural language scheduling prompt and resolve the single optimal start and end time interval. + +Instructions: +- Calculate an available interval that falls strictly within working hours and active weekdays. +- Ensure the slot does NOT overlap with any existing booked busy slots. +- Output ONLY valid JSON containing: + "start": ISO 8601 string with timeZone offset (e.g. "2026-08-11T14:15:00-07:00") + "end": ISO 8601 string with timeZone offset (e.g. "2026-08-11T15:00:00-07:00") + "durationMinutes": number + "summary": string (human-friendly summary of the slot) + "reasoning": string (explanation of why this slot was selected) + "confidence": number between 0.0 and 1.0 + "alternatives": array of secondary { "start": "...", "end": "..." } options if available`; + +function wrapScheduleInterval( + interval: Interval, + meta: TempoScheduleMeta +): TempoScheduleResult { + const frozenMeta = Object.freeze(meta); + return new Proxy(interval, { + get(target, prop) { + if (prop in frozenMeta) + return (frozenMeta as any)[prop]; + + const val = Reflect.get(target, prop, target); + if (isFunction(val)) return val.bind(target); + return val; + }, + has(target, prop) { + if (prop in frozenMeta) return true; + return Reflect.has(target, prop); + }, + getOwnPropertyDescriptor(target, prop) { + if (prop in frozenMeta) { + return { + value: (frozenMeta as any)[prop], + writable: false, + configurable: true, + enumerable: true + }; + } + return Reflect.getOwnPropertyDescriptor(target, prop); + }, + ownKeys(target) { + const keys = Reflect.ownKeys(target); + for (const k of Object.keys(frozenMeta)) { + if (!keys.includes(k)) keys.push(k); + } + return keys; + } + }) as unknown as TempoScheduleResult; } /** - * @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]" - * ``` + * ## scheduleAI + * Resolves natural language scheduling prompts against working hours, existing calendar + * events, and timezones into an optimal `TempoScheduleResult` (implementing `Interval`). + * + * @param prompt - Natural language scheduling prompt + * @param options - Scheduling configuration including working hours, existing busy events, anchor date, and timeZone + * @returns Promise resolving to an Interval instance decorated with AI scheduling metadata */ -export async function scheduleAI(_prompt: string, _options?: Record): Promise { - throw new Error('scheduleAI is not yet implemented in tempo-plugin-ai.'); +export async function scheduleAI( + prompt: string, + options?: TempoScheduleOptions +): Promise { + if (!isString(prompt) || prompt.trim() === '') { + throw new TempoAiError('Invalid scheduling prompt provided to scheduleAI', 400); + } + + const state = _state; + const availableProviders = options?.providers ?? state.config.providers; + + if (!availableProviders || availableProviders.length === 0) { + throw new TempoAiError('No AI providers configured for scheduleAI. Call initAI() or supply providers in options.', 400); + } + + assertNoReservedProviderId(availableProviders); + + const anchorTempo = options?.anchor ? new Tempo(options.anchor) : new Tempo(); + const timeZone = options?.timeZone || anchorTempo.tz || 'UTC'; + const workingHours: TempoWorkingHours = { + start: options?.workingHours?.start ?? '09:00', + end: options?.workingHours?.end ?? '17:00', + days: options?.workingHours?.days ?? [1, 2, 3, 4, 5], + timeZone: options?.workingHours?.timeZone ?? timeZone + }; + + const rawBusy = options?.events ?? options?.intervals; + const busyEvents = normalizeBusyEvents(rawBusy, timeZone); + const durationMinutes = parseDurationMinutes(prompt, options?.durationMinutes); + + const contextString = buildContextPrompt(anchorTempo, timeZone, workingHours, busyEvents, durationMinutes); + const isDebug = Boolean(options?.debug ?? state.config.debug); + const mode = (options?.mode || state.config.mode || AiMode.Fallback).toLowerCase(); + const callTimeout = options?.timeout ?? state.config.timeout ?? 15000; + + const executeProviderCall = async (provider: AiProvider, signal?: AbortSignal) => { + const { rawContent, providerId, rateLimits } = await fetchFromProvider( + provider, + prompt, + contextString, + isDebug, + signal, + callTimeout, + SCHEDULE_SYSTEM_PROMPT + ) + + const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); + let parsed: any; + try { + parsed = JSON.parse(cleanContent); + } catch { + throw new TempoAiError(`Provider ${provider.id} returned invalid JSON payload.`, 422); + } + + if (!parsed.start || !parsed.end) { + throw new TempoAiError(`Provider ${provider.id} missing start or end ISO timestamp.`, 422); + } + + let finalStart: Tempo; + let finalEnd: Tempo; + try { + finalStart = new Tempo(parsed.start, { timeZone }); + finalEnd = new Tempo(parsed.end, { timeZone }); + } catch { + throw new TempoAiError(`Provider ${provider.id} returned unparseable start or end timestamp.`, 422); + } + + if (finalEnd.epoch.ms <= finalStart.epoch.ms) { + throw new TempoAiError(`Provider ${provider.id} proposed end time before or equal to start time.`, 422); + } + + return { + parsed, + startTempo: finalStart, + endTempo: finalEnd, + confidence: isNumber(parsed.confidence) ? parsed.confidence : 0.9, + providerId, + rateLimits, + summary: parsed.summary || `Scheduled slot ${finalStart.format('{yyyy}-{mm}-{dd} {hh}:{mi}')} to ${finalEnd.format('{hh}:{mi}')}`, + reasoning: parsed.reasoning || 'Resolved slot via AI scheduler.', + alternatives: Array.isArray(parsed.alternatives) ? parsed.alternatives : [] + }; + }; + + let selectedResult: any; + + if (mode === AiMode.Fallback || mode === 'fallback') { + let lastErr: any; + for (const provider of availableProviders) { + try { + selectedResult = await executeProviderCall(provider); + break; + } catch (err) { + lastErr = err; + } + } + if (!selectedResult) { + throw lastErr || new TempoAiError('All configured AI providers failed during scheduleAI execution.', 502); + } + } else if (mode === AiMode.Race || mode === 'race') { + const parentController = new AbortController(); + try { + const promises = availableProviders.map(p => executeProviderCall(p, parentController.signal)); + promises.forEach(p => p.catch(() => { })); + selectedResult = await Promise.race(promises); + parentController.abort(); + } catch (aggregateErr: any) { + parentController.abort(); + throw aggregateErr instanceof TempoAiError + ? aggregateErr + : new TempoAiError(`All providers failed in race mode: ${aggregateErr.message}`, 502); + } + } else if (mode === AiMode.Consensus || mode === 'consensus') { + const parentController = new AbortController(); + const results = await Promise.allSettled( + availableProviders.map(p => executeProviderCall(p, parentController.signal)) + ); + + const fulfilled = results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map(r => r.value); + + if (fulfilled.length === 0) { + const rejected = results.find(r => r.status === 'rejected') as PromiseRejectedResult; + throw rejected.reason instanceof TempoAiError + ? rejected.reason + : new TempoAiError('All providers failed in consensus mode during scheduleAI execution.', 502); + } + + fulfilled.sort((a, b) => b.confidence - a.confidence); + selectedResult = fulfilled[0]; + } else { + throw new TempoAiError(`Invalid execution mode '${options?.mode}' provided to scheduleAI.`, 400); + } + + _state.limits = selectedResult.rateLimits ?? null; + + const minConf = options?.minConfidence ?? state.config.minConfidence ?? 0.0; + if (selectedResult.confidence < minConf) { + throw new TempoAiError( + `scheduleAI confidence (${selectedResult.confidence}) is below the required threshold of ${minConf}`, + 422 + ); + } + + let finalStart = selectedResult.startTempo; + let finalEnd = selectedResult.endTempo; + let conflictBumped = false; + let originalSlot: TempoInterval | undefined; + + // Deterministic Conflict Validation using core Interval.overlaps() + const proposedInterval = new Interval(finalStart, finalEnd); + const conflictingEvent = busyEvents.find(b => { + const busyInt = new Interval(b.start, b.end); + const isOver = proposedInterval.overlaps(busyInt); + return isOver; + }); + + if (conflictingEvent) { + conflictBumped = true; + originalSlot = { start: finalStart, end: finalEnd }; + // Bump start to the end of the conflicting event + finalStart = conflictingEvent.end; + finalEnd = finalStart.add(`${durationMinutes} minutes`); + selectedResult.reasoning = `[Adjusted for conflict] Shifted slot past conflicting event "${conflictingEvent.title || 'Busy'}" to ${finalStart.format('{yyyy}-{mm}-{dd} {hh}:{mi}')}. ${selectedResult.reasoning}`; + } + + // Create actual Interval instance + const rawInterval = new Interval(finalStart, finalEnd); + + // Process alternative slots into Interval instances + const alternatives: TempoInterval[] = selectedResult.alternatives + .map((alt: any) => { + try { + const s = new Tempo(alt.start, { timeZone }); + const e = new Tempo(alt.end, { timeZone }); + return new Interval(s, e); + } catch { + return null; + } + }) + .filter((i: any): i is TempoInterval => i !== null); + + const actualDuration = Math.round((finalEnd.epoch.ms - finalStart.epoch.ms) / 60000); + + return wrapScheduleInterval(rawInterval, { + durationMinutes: actualDuration, + summary: selectedResult.summary, + reasoning: selectedResult.reasoning, + confidence: selectedResult.confidence, + provider: selectedResult.providerId, + alternatives, + ai: { + provider: selectedResult.providerId, + confidence: selectedResult.confidence, + conflictBumped, + originalSlot, + reasoning: selectedResult.reasoning + } + }); } diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts index e0e37eb3..e0f926e5 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -1,6 +1,6 @@ // Core Infrastructure & Configuration export { TempoAiError } from './core/error.js'; -export * from './core/types.js'; +export * from './types/index.js'; export * from './core/config.js'; // AI Manifest Support @@ -12,6 +12,7 @@ export { initAI, clearAiCache, getAiRateLimits, getAiConfig } from './core/init. // AI Function Handlers export { parseAI } from './functions/parse.js'; export { recurrenceAI } from './functions/recurrence.js'; +export { scheduleAI } from './functions/schedule.js'; export { isRRuleString } from '@magmacomputing/library'; /* diff --git a/packages/plugins/ai/src/core/types.ts b/packages/plugins/ai/src/types/common.type.ts similarity index 62% rename from packages/plugins/ai/src/core/types.ts rename to packages/plugins/ai/src/types/common.type.ts index e69e15ec..84e90ce3 100644 --- a/packages/plugins/ai/src/core/types.ts +++ b/packages/plugins/ai/src/types/common.type.ts @@ -1,5 +1,5 @@ import type { Tempo } from '@magmacomputing/tempo'; -import type { AiMode } from './config.js'; +import type { AiMode } from '../core/config.js'; /** * ## TempoAiMeta @@ -73,45 +73,6 @@ export interface AiProvider { 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; - /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */ - cacheAdapter?: AiCacheAdapter; - /** Optional TTL override in milliseconds for cached result */ - ttl?: number; - /** If true, logs prompt context and LLM payloads to console */ - debug?: boolean; - /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` or string literal) */ - mode?: AiMode; - /** Per-request provider configuration overrides */ - providers?: AiProvider[]; - /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */ - minConfidence?: number; - /** If true, places TempoAiError into array index position instead of rejecting batch */ - softErrors?: boolean; - /** Optional request timeout in milliseconds (overrides provider and global timeout) */ - timeout?: number; - /** Allow extra options */ - [key: string]: any; -} - /** * ## AiConfig * Configuration options for the AI parsing plugin. @@ -152,43 +113,3 @@ export interface AiRateLimits { /** A Tempo instance representing the exact time the limits reset, or null if unknown */ resetAt: Tempo | null; } - -/** - * ## TempoRecurrenceOptions - * Options passed to `recurrenceAI(input, options)`. - */ -export interface TempoRecurrenceOptions extends AiParseOptions { - /** Start date/time window for occurrence expansion */ - after?: any; - /** End date/time window for occurrence expansion */ - before?: any; - /** Number of occurrences to pull per batch (default: 5) */ - count?: number; - /** Preferred BCP 47 locale tag for summary output (e.g. 'en-US', 'fr-FR', 'es-ES') */ - locale?: string; -} - -/** - * ## TempoRecurrenceResult - * Structured multi-directional recurrence result returned by `recurrenceAI`. - */ -export interface TempoRecurrenceResult { - /** Standard RFC 5545 RRULE string (e.g. 'FREQ=WEEKLY;BYDAY=TU') */ - rrule: string; - /** Localized human-friendly summary of the schedule (e.g. 'Every Tuesday at 15:00') */ - summary: string; - /** True if schedule has an explicit end date or count limit; false if infinite */ - isFinite: boolean; - /** Total count of occurrences if finite, or Infinity (Number.POSITIVE_INFINITY) */ - size: number; - /** Advances cursor and returns the next batch of N Tempo instances (default: 5) */ - take(count?: number): Tempo[]; - /** Lazy generator yielding Tempo instances on demand */ - [Symbol.iterator](): Generator; - /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ - confidence: number; - /** Provider ID responsible for processing or 'rrule-parser' for native RRULE inputs */ - provider: string; - /** Reasoning / explanation of the recurrence pattern */ - reasoning?: string | undefined; -} diff --git a/packages/plugins/ai/src/types/index.ts b/packages/plugins/ai/src/types/index.ts new file mode 100644 index 00000000..37bd3e84 --- /dev/null +++ b/packages/plugins/ai/src/types/index.ts @@ -0,0 +1,4 @@ +export * from './common.type.js'; +export * from './parse.type.js'; +export * from './recurrence.type.js'; +export * from './schedule.type.js'; diff --git a/packages/plugins/ai/src/types/parse.type.ts b/packages/plugins/ai/src/types/parse.type.ts new file mode 100644 index 00000000..bfd6ff7f --- /dev/null +++ b/packages/plugins/ai/src/types/parse.type.ts @@ -0,0 +1,41 @@ +import type { AiMode } from '../core/config.js'; +import type { AiCacheAdapter, AiProvider } from './common.type.js'; + +/** + * ## 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; + /** Optional custom cache adapter engine (e.g. Redis, KV store) for this request */ + cacheAdapter?: AiCacheAdapter; + /** Optional TTL override in milliseconds for cached result */ + ttl?: number; + /** If true, logs prompt context and LLM payloads to console */ + debug?: boolean; + /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` or string literal) */ + mode?: AiMode; + /** Per-request provider configuration overrides */ + providers?: AiProvider[]; + /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */ + minConfidence?: number; + /** If true, places TempoAiError into array index position instead of rejecting batch */ + softErrors?: boolean; + /** Optional request timeout in milliseconds (overrides provider and global timeout) */ + timeout?: number; + /** Allow extra options */ + [key: string]: any; +} diff --git a/packages/plugins/ai/src/types/recurrence.type.ts b/packages/plugins/ai/src/types/recurrence.type.ts new file mode 100644 index 00000000..0b51db30 --- /dev/null +++ b/packages/plugins/ai/src/types/recurrence.type.ts @@ -0,0 +1,42 @@ +import type { Tempo } from '@magmacomputing/tempo'; +import type { AiParseOptions } from './parse.type.js'; + +/** + * ## TempoRecurrenceOptions + * Options passed to `recurrenceAI(input, options)`. + */ +export interface TempoRecurrenceOptions extends AiParseOptions { + /** Start date/time window for occurrence expansion */ + after?: any; + /** End date/time window for occurrence expansion */ + before?: any; + /** Number of occurrences to pull per batch (default: 5) */ + count?: number; + /** Preferred BCP 47 locale tag for summary output (e.g. 'en-US', 'fr-FR', 'es-ES') */ + locale?: string; +} + +/** + * ## TempoRecurrenceResult + * Structured multi-directional recurrence result returned by `recurrenceAI`. + */ +export interface TempoRecurrenceResult { + /** Standard RFC 5545 RRULE string (e.g. 'FREQ=WEEKLY;BYDAY=TU') */ + rrule: string; + /** Localized human-friendly summary of the schedule (e.g. 'Every Tuesday at 15:00') */ + summary: string; + /** True if schedule has an explicit end date or count limit; false if infinite */ + isFinite: boolean; + /** Total count of occurrences if finite, or Infinity (Number.POSITIVE_INFINITY) */ + size: number; + /** Advances cursor and returns the next batch of N Tempo instances (default: 5) */ + take(count?: number): Tempo[]; + /** Lazy generator yielding Tempo instances on demand */ + [Symbol.iterator](): Generator; + /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ + confidence: number; + /** Provider ID responsible for processing or 'rrule-parser' for native RRULE inputs */ + provider: string; + /** Reasoning / explanation of the recurrence pattern */ + reasoning?: string | undefined; +} diff --git a/packages/plugins/ai/src/types/schedule.type.ts b/packages/plugins/ai/src/types/schedule.type.ts new file mode 100644 index 00000000..aa6463f7 --- /dev/null +++ b/packages/plugins/ai/src/types/schedule.type.ts @@ -0,0 +1,112 @@ +import type { Tempo } from '@magmacomputing/tempo'; +import type { AiParseOptions } from './parse.type.js'; + +/** + * ## TempoWorkingHours + * Defines daily working hour boundaries and active weekdays for scheduling. + */ +export interface TempoWorkingHours { + /** Start time of working day in HH:mm format (default: '09:00') */ + start?: string; + /** End time of working day in HH:mm format (default: '17:00') */ + end?: string; + /** Active working weekdays (0 = Sunday, 1 = Monday, ... 6 = Saturday; default: [1, 2, 3, 4, 5]) */ + days?: number[]; + /** Target timeZone for working hours (defaults to anchor or options timeZone) */ + timeZone?: string; +} + +/** + * ## TempoInterval + * Continuous date-time interval boundary pair. + */ +export interface TempoInterval { + /** Start boundary as a Tempo instance */ + start: Tempo; + /** End boundary as a Tempo instance */ + end: Tempo; +} + +/** + * ## TempoScheduleOptions + * Options passed to `scheduleAI(prompt, options)`. + */ +export interface TempoScheduleOptions extends AiParseOptions { + /** Target slot duration in minutes (if not explicitly specified in prompt) */ + durationMinutes?: number; + /** Working hours configuration for slot resolution */ + workingHours?: TempoWorkingHours; + /** Existing booked events or busy intervals to avoid */ + events?: Array<{ start: any; end: any; title?: string }> | Array; + /** Alias for events */ + intervals?: Array<{ start: any; end: any; title?: string }> | Array; + /** Search window start constraint */ + after?: any; + /** Search window end constraint */ + before?: any; + /** Preferred slot positioning ('earliest' | 'latest' | 'morning' | 'afternoon' | string) */ + preference?: string; + /** Number of alternative slots to return if requesting multiple options */ + count?: number; +} + +/** + * ## TempoScheduleResult + * Structured scheduling result returned by `scheduleAI`. + */ +export interface TempoScheduleResult extends TempoInterval { + /** Resolved start boundary as a Tempo instance */ + start: Tempo; + /** Resolved end boundary as a Tempo instance */ + end: Tempo; + /** Target slot duration in minutes */ + durationMinutes: number; + /** Human-friendly summary of the scheduled slot */ + summary: string; + /** Reasoning / explanation of why this slot was selected */ + reasoning?: string | undefined; + /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ + confidence: number; + /** Provider ID responsible for processing or 'native-scheduler' */ + provider: string; + /** Alternative backup intervals identified during scheduling */ + alternatives?: TempoInterval[] | undefined; + /** Extended AI execution metadata */ + ai?: { + provider: string; + confidence: number; + conflictBumped?: boolean | undefined; + originalSlot?: TempoInterval | undefined; + reasoning?: string | undefined; + [key: string]: any; + } | undefined; +} + +/** + * ## TempoScheduleMeta + * Metadata overlay attached to the Interval instance by `wrapScheduleInterval`. + */ +export interface TempoScheduleMeta { + /** Target slot duration in minutes */ + durationMinutes: number; + /** Human-friendly summary of the scheduled slot */ + summary: string; + /** Reasoning / explanation of why this slot was selected */ + reasoning?: string | undefined; + /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ + confidence: number; + /** Provider ID responsible for processing or 'native-scheduler' */ + provider: string; + /** Alternative backup intervals identified during scheduling */ + alternatives?: TempoInterval[] | undefined; + /** Extended AI execution metadata */ + ai: { + provider: string; + confidence: number; + conflictBumped?: boolean | undefined; + originalSlot?: TempoInterval | undefined; + reasoning?: string | undefined; + [key: string]: any; + }; +} + diff --git a/packages/plugins/ai/test/schedule.test.ts b/packages/plugins/ai/test/schedule.test.ts new file mode 100644 index 00000000..5fa1fae6 --- /dev/null +++ b/packages/plugins/ai/test/schedule.test.ts @@ -0,0 +1,214 @@ +import { Tempo, Interval } from '@magmacomputing/tempo'; +import { ParseModule } from '@magmacomputing/tempo/parse'; +import { scheduleAI, initAI, TempoAiError } from '../src/index.js'; + +Tempo.extend(ParseModule); + +describe('AI Schedule Plugin (scheduleAI)', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => { }); + // vi.spyOn(console, 'error').mockImplementation(() => { }); + vi.spyOn(console, 'log').mockImplementation(() => { }); + initAI({ providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should resolve natural language scheduling prompt into an Interval instance with AI metadata', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + start: '2026-08-11T14:15:00-07:00', + end: '2026-08-11T15:00:00-07:00', + durationMinutes: 45, + summary: '45-minute meeting on Tuesday afternoon', + reasoning: 'Selected Tuesday Aug 11 14:15-15:00 PST outside lunch.', + confidence: 0.95, + alternatives: [ + { start: '2026-08-11T15:15:00-07:00', end: '2026-08-11T16:00:00-07:00' } + ] + }) + } + }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const prompt = 'Find 45 minutes next Tuesday afternoon after 2pm PST excluding lunch'; + const anchor = new Tempo('2026-08-07T10:00:00-07:00'); + + const slot = await scheduleAI(prompt, { + anchor, + timeZone: 'America/Los_Angeles', + workingHours: { start: '09:00', end: '17:00' } + }); + + expect(slot).toBeInstanceOf(Interval); + expect(slot.start).toBeInstanceOf(Tempo); + expect(slot.end).toBeInstanceOf(Tempo); + + expect(slot.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 14:15'); + expect(slot.end.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 15:00'); + expect(slot.durationMinutes).toBe(45); + expect(slot.confidence).toBe(0.95); + expect(slot.provider).toBe('groq'); + expect(slot.reasoning).toContain('Selected Tuesday Aug 11'); + + expect(slot.alternatives).toHaveLength(1); + expect(slot.alternatives![0]).toBeInstanceOf(Interval); + expect(slot.alternatives![0].start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 15:15'); + + // Assert system prompt includes reference anchor and working hours + expect(fetchSpy).toHaveBeenCalledTimes(1); + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + const systemPrompt = requestBody.messages[0].content; + expect(systemPrompt).toContain('You are a high-precision calendar scheduling engine.'); + expect(systemPrompt).toContain('Working Hours: 09:00 to 17:00'); + }); + + it('should accept busy events as core Interval instances and deterministically bump slot on conflict', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + // LLM hallucination: proposes a slot 14:00 - 14:45 that conflicts with busy1 (13:30 - 14:30) + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + start: '2026-08-11T14:00:00-07:00', + end: '2026-08-11T14:45:00-07:00', + summary: 'Conflicting proposed slot', + confidence: 0.90 + }) + } + }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const busy1 = new Interval( + new Tempo('2026-08-11 13:30:00', { timeZone: 'America/Los_Angeles' }), + new Tempo('2026-08-11 14:30:00', { timeZone: 'America/Los_Angeles' }) + ); + + const slot = await scheduleAI('Find 45 minutes next Tuesday afternoon', { + intervals: [busy1], + timeZone: 'America/Los_Angeles' + }); + + expect(slot).toBeInstanceOf(Interval); + // Deterministic check should have bumped start from 14:00 to 14:30 (end of busy1) + expect(slot.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 14:30'); + expect(slot.end.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 15:15'); + expect(slot.ai?.conflictBumped).toBe(true); + expect(slot.ai?.originalSlot?.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 14:00'); + expect(slot.reasoning).toContain('[Adjusted for conflict]'); + }); + + it('should support provider race execution mode', async () => { + let slowWasAborted = false; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockImplementation(async (_url, init) => { + const body = JSON.parse(init?.body as string); + const signal = init?.signal as AbortSignal | undefined; + if (body.model === 'fast-model') { + return new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + start: '2026-08-11T09:00:00Z', + end: '2026-08-11T10:00:00Z', + confidence: 0.95 + }) + } + }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return new Promise((_resolve, reject) => { + if (signal?.aborted) { + slowWasAborted = true; + reject(new DOMException('Aborted', 'AbortError')); + return; + } + signal?.addEventListener('abort', () => { + slowWasAborted = true; + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + }); + + const resRace = await scheduleAI('Schedule 1 hour slot', { + mode: 'race', + providers: [ + { id: 'slow-provider', key: 'key-1', url: 'https://api.openai.com/v1/chat/completions', model: 'slow-model' }, + { id: 'fast-provider', key: 'key-2', url: 'https://api.groq.com/v1/chat/completions', model: 'fast-model' } + ] + }); + + expect(resRace.provider).toBe('fast-provider'); + expect(resRace.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 09:00'); + expect(slowWasAborted).toBe(true); + }); + + it('should support provider consensus execution mode', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + start: '2026-08-11T10:00:00Z', + end: '2026-08-11T10:30:00Z', + confidence: 0.82 + }) + } + }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + start: '2026-08-11T11:00:00Z', + end: '2026-08-11T11:30:00Z', + confidence: 0.96 + }) + } + }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const resConsensus = await scheduleAI('Schedule 30 minute slot', { + mode: 'consensus', + providers: [ + { id: 'p1', key: 'key-1', url: 'https://api.groq.com/v1/chat/completions', model: 'm1' }, + { id: 'p2', key: 'key-2', url: 'https://api.openai.com/v1/chat/completions', model: 'm2' } + ] + }); + + expect(resConsensus.confidence).toBe(0.96); + expect(resConsensus.provider).toBe('p2'); + expect(resConsensus.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 11:00'); + }); + + it('should throw TempoAiError when confidence is below minConfidence threshold', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + start: '2026-08-11T10:00:00Z', + end: '2026-08-11T10:30:00Z', + confidence: 0.50 + }) + } + }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + await expect(scheduleAI('Low confidence prompt', { minConfidence: 0.8 })) + .rejects.toThrow(/scheduleAI confidence \(0.5\) is below the required threshold of 0.8/i); + }); + + it('should throw TempoAiError if prompt is empty or providers missing', async () => { + await expect(scheduleAI('')).rejects.toThrow(/invalid scheduling prompt/i); + + initAI({ providers: [] }); + await expect(scheduleAI('Schedule meeting')).rejects.toThrow(/no AI providers configured/i); + }); +}); diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index a41e1fde..10107442 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Automated Doc Harvester**: Created `bin/generate-llms-txt.mjs` monorepo build script integrated into `npm run docs:build` to harvest all 56 markdown documentation files into a unified `llms-full.txt` corpus. - **AI Documentation Guide**: Added a dedicated `AI & IDE Integration` guide (`doc/1-getting-started/ai-integration.md`) featured directly in the primary VitePress navigation sidebar under Getting Started. +### Changed & Hardened +- **Hardened String-to-Temporal Composer (`engine.composer.ts`)**: Upgraded raw string fallback resolution to utilize a lookahead boundary regex (`/^(\d{4}-\d{2}-\d{2})\s+(?=\d{2}:\d{2})/`) and whitespace stripper (`/\s+(?=[Zz]|[+-]\d{2}|\[)/`). This automatically normalizes SQL/space-delimited timestamps (`2026-08-08 10:30`) to ISO 8601 (`2026-08-08T10:30`), collapses multiple whitespace runs, and strips spaces before UTC markers (`Z`), offsets (`+10:00`), and timezone brackets (`[Australia/Sydney]`), while ensuring timezone names containing internal spaces (e.g., `[America/Port of Spain]`) remain uncorrupted. +- **Master Guard Fast-Path Safety Valve (`module.parse.ts`)**: Added a zero-cost bypass for standard ISO date strings (`YYYY-MM-DD`), preventing false-negative token scanner rejections before reaching the layout resolution engine. + ## [3.11.0] - 2026-07-31 ### Added diff --git a/packages/tempo/src/engine/engine.composer.ts b/packages/tempo/src/engine/engine.composer.ts index 873e16d7..f157efd4 100644 --- a/packages/tempo/src/engine/engine.composer.ts +++ b/packages/tempo/src/engine/engine.composer.ts @@ -15,7 +15,7 @@ const UNIT_LOOKUP: Record = { ms: { scale: 1_000_000n, matchName: 'Milliseconds' }, us: { scale: 1_000n, matchName: 'Microseconds' }, ns: { scale: 1n, matchName: 'Nanoseconds' }, -}; +} /** * Logic to compose various input types into a Temporal.ZonedDateTime. @@ -41,6 +41,13 @@ export function compose( let dateTime: Temporal.ZonedDateTime | undefined; switch (type) { + case 'Temporal.ZonedDateTime': + case 'Temporal.Instant': + case 'Temporal.PlainDateTime': + case 'Temporal.PlainDate': + temporal = value; + break; + case 'Void': case 'Empty': case 'Undefined': @@ -51,12 +58,16 @@ export function compose( case 'String': try { 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); + const normValue = value + .trim() + .replace(/^(\d{4}-\d{2}-\d{2})\s+(?=\d{2}:\d{2})/, '$1T') + .replace(/\s+(?=[Zz]|[+-]\d{2}|\[)/, ''); + if (normValue.includes('[')) { + zdt = Temporal.ZonedDateTime.from(normValue); + } else if (/Z$|[+-]\d{2}:?\d{2}/i.test(normValue)) { + zdt = Temporal.Instant.from(normValue).toZonedDateTimeISO(tz); } else { - zdt = Temporal.PlainDateTime.from(value, { overflow: 'constrain' }).toZonedDateTime(tz); + zdt = Temporal.PlainDateTime.from(normValue, { overflow: 'constrain' }).toZonedDateTime(tz); } timeZone = getTemporalIds(zdt)[0]; temporal = zdt; diff --git a/packages/tempo/src/interval.class.ts b/packages/tempo/src/interval.class.ts index f2520713..5e1df0df 100644 --- a/packages/tempo/src/interval.class.ts +++ b/packages/tempo/src/interval.class.ts @@ -1,4 +1,5 @@ import type { Tempo } from './tempo.class.js'; +import { isInteger } from '#library/assertion.library.js'; import { Immutable } from '#library/class.library.js'; export type TemporalPoint = Tempo | { epochNanoseconds: bigint }; @@ -7,7 +8,7 @@ export type TemporalPoint = Tempo | { epochNanoseconds: bigint }; function getNs(point: TemporalPoint | unknown): bigint { const ns = (point as any)?.epoch?.ns ?? (point as any)?.epochNanoseconds; - if (typeof ns === 'bigint') return ns; + if (isInteger(ns)) return ns; throw new TypeError('Invalid TemporalPoint: missing epoch.ns or epochNanoseconds'); } diff --git a/packages/tempo/src/module/module.parse.ts b/packages/tempo/src/module/module.parse.ts index 57a9ddd6..3d891562 100644 --- a/packages/tempo/src/module/module.parse.ts +++ b/packages/tempo/src/module/module.parse.ts @@ -260,6 +260,9 @@ const _ParseEngine = { let guard = (TempoClass as any)?.[sym.$guard]?.test(trim) ?? true; + if (!guard && /^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}/.test(trim)) + guard = true; + // 🛡️ Bypass the strict global guard if the current instance is using localized parsing if (!guard && (!isEmpty(state.parse.monthMap) || !isEmpty(state.parse.weekdayMap))) guard = true; diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 9b2aa8ac..c939ae15 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1732,6 +1732,9 @@ export class Tempo { logError(msg, this.#local.config); return undefined as any; } + if (isObject(res) && 'value' in res) { + return (res as any).value ?? (undefined as any); + } return res; } @@ -1836,7 +1839,6 @@ export namespace Tempo { export type Set = t.MutateSet; export type Add = t.MutateAdd; - export type Formats = t.Formats; export type Format = t.Format; export type FormatRegistry = t.FormatRegistry; From ac6a275221018441510485562e90aedeaa7c3381 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 10 Aug 2026 11:09:18 +1000 Subject: [PATCH 13/23] PR scheduleAI 1st review --- .github/workflows/deploy-docs.yml | 2 +- packages/library/CHANGELOG.md | 3 +- packages/library/README.md | 1 + packages/library/src/common.index.ts | 3 +- .../library/src/common/calendar.library.ts | 191 +++++++ ...rrule.library.ts => recurrence.library.ts} | 192 ++++--- .../test/common/calendar.library.test.ts | 228 ++++++++ ...e-secure.test.ts => class.library.test.ts} | 0 ...library.test.ts => number.library.test.ts} | 0 .../test/common/recurrence.library.test.ts | 189 +++++++ ...ary.test.ts => reflection.library.test.ts} | 0 .../library/test/common/rrule_library.test.ts | 56 -- ...mbol.test.ts => serialize.library.test.ts} | 0 ...library.test.ts => string.library.test.ts} | 0 ...brary.test.ts => temporal.library.test.ts} | 0 packages/plugins/.bin/check-branch-diff.sh | 15 +- packages/plugins/.bin/check-versions.sh | 18 +- packages/plugins/ai/CHANGELOG.md | 9 +- packages/plugins/ai/README.md | 22 +- packages/plugins/ai/doc/architecture.md | 6 +- packages/plugins/ai/doc/index.md | 14 +- packages/plugins/ai/doc/init.md | 4 + packages/plugins/ai/doc/rate-limits.md | 2 +- packages/plugins/ai/doc/recurrence.md | 3 + packages/plugins/ai/plan/v0.3.0-roadmap.md | 28 +- packages/plugins/ai/src/core/config.ts | 2 +- packages/plugins/ai/src/core/init.ts | 160 ++++-- packages/plugins/ai/src/core/manifest.ts | 8 +- packages/plugins/ai/src/core/mode.ts | 217 ++++++++ packages/plugins/ai/src/core/support.ts | 17 +- packages/plugins/ai/src/functions/parse.ts | 499 ++++++++---------- .../plugins/ai/src/functions/recurrence.ts | 193 ++----- packages/plugins/ai/src/functions/schedule.ts | 321 ++++++----- packages/plugins/ai/src/index.ts | 6 +- packages/plugins/ai/src/types/common.type.ts | 31 +- packages/plugins/ai/src/types/parse.type.ts | 27 +- .../plugins/ai/src/types/schedule.type.ts | 47 +- packages/plugins/ai/test/cache.test.ts | 42 +- packages/plugins/ai/test/manifest.test.ts | 36 +- packages/plugins/ai/test/mode.test.ts | 102 ++++ packages/plugins/ai/test/parse.test.ts | 66 ++- packages/plugins/ai/test/recurrence.test.ts | 34 +- packages/plugins/ai/test/schedule.test.ts | 159 +++++- packages/plugins/vitest.shared.ts | 1 + packages/tempo/CHANGELOG.md | 5 +- packages/tempo/README.md | 19 +- .../doc/1-getting-started/ai-integration.md | 4 +- .../tempo/doc/2-core-concepts/tempo.parse.md | 2 +- .../doc/3-extending-tempo/tempo.layout.md | 2 +- packages/tempo/public/esm_sh.index.html | 8 +- packages/tempo/public/llms.txt | 1 + packages/tempo/public/providers.v1.json | 2 +- packages/tempo/src/engine/engine.lexer.ts | 10 +- packages/tempo/src/library.index.ts | 11 +- packages/tempo/src/module/module.parse.ts | 14 +- .../src/plugin/extend/extend.recurrence.ts | 4 +- packages/tempo/src/support/support.default.ts | 2 +- .../test/discrete/standalone_parse.test.ts | 17 +- .../test/plugins/extend.recurrence.test.ts | 22 +- 59 files changed, 2142 insertions(+), 935 deletions(-) create mode 100644 packages/library/src/common/calendar.library.ts rename packages/library/src/common/{rrule.library.ts => recurrence.library.ts} (69%) create mode 100644 packages/library/test/common/calendar.library.test.ts rename packages/library/test/common/{decorator.immutable-secure.test.ts => class.library.test.ts} (100%) rename packages/library/test/common/{number_library.test.ts => number.library.test.ts} (100%) create mode 100644 packages/library/test/common/recurrence.library.test.ts rename packages/library/test/common/{reflection_library.test.ts => reflection.library.test.ts} (100%) delete mode 100644 packages/library/test/common/rrule_library.test.ts rename packages/library/test/common/{serialize_symbol.test.ts => serialize.library.test.ts} (100%) rename packages/library/test/common/{string_library.test.ts => string.library.test.ts} (100%) rename packages/library/test/common/{temporal_library.test.ts => temporal.library.test.ts} (100%) create mode 100644 packages/plugins/ai/src/core/mode.ts create mode 100644 packages/plugins/ai/test/mode.test.ts diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 9229287b..50c183c8 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -79,7 +79,7 @@ jobs: uses: actions/deploy-pages@v4 - name: Trigger tempo-workspace AI Context Deploy - uses: peter-evans/repository-dispatch@v3 + uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0 with: token: ${{ secrets.TEMPO_WORKSPACE_DISPATCH_TOKEN }} repository: magmacomputing/tempo-workspace diff --git a/packages/library/CHANGELOG.md b/packages/library/CHANGELOG.md index 89ef09bc..b3ac0840 100644 --- a/packages/library/CHANGELOG.md +++ b/packages/library/CHANGELOG.md @@ -8,7 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.11.1] - 2026-08-06 ### Added -- **RRULE Support (`rrule.library`)**: Added standalone zero-dependency RFC 5545 recurrence rule utilities (`isRRuleString`, `parseRRule`, `getNextRRuleEpoch`) to `#library/rrule.library.js`. +- **Calendar & Time Math (`calendar.library`)**: Added standalone date/calendar constants and helpers (`ISO_WEEKDAY_NAMES`, `DAY_MAP`, `MONTH_MAP`, `getDaysInMonth`, `getUtcParts`, `DayKey`, `MonthKey`, `IsoWeekdayNames`) in `#library/calendar.library.js`. +- **Recurrence Engine (`recurrence.library`)**: Added standalone zero-dependency RFC 5545 recurrence rule utilities (`isRRuleString`, `isFiniteRRule`, `parseRRule`, `getNextRRuleEpoch`, `expandRRuleEpochs`, `ParsedRRule`) to `#library/recurrence.library.js`. ## [3.10.2] - 2026-07-25 diff --git a/packages/library/README.md b/packages/library/README.md index 27f28657..ce00846f 100644 --- a/packages/library/README.md +++ b/packages/library/README.md @@ -25,6 +25,7 @@ The library is organized into specialized modules, each designed for maximum eff | **Pledge** | A robust wrapper for native Promises with settled-state tracking and timeout support. | | **Reflection** | Clean access to own-properties, values, and entries without prototype pollution. | | **Temporal** | Lightweight helpers and polyfill integration for the native `Temporal` API. | +| **Recurrence** | Zero-dependency RFC 5545 recurrence rule parsing, expansion, and finiteness evaluation. | --- diff --git a/packages/library/src/common.index.ts b/packages/library/src/common.index.ts index d3c031da..44d73521 100644 --- a/packages/library/src/common.index.ts +++ b/packages/library/src/common.index.ts @@ -26,6 +26,7 @@ export * from './common/symbol.library.js'; export * from './common/type.library.js'; export * from './common/temporal.polyfill.js'; export * from './common/temporal.library.js'; -export * from './common/rrule.library.js'; +export * from './common/calendar.library.js'; +export * from './common/recurrence.library.js'; export * from './common/utility.library.js'; export * from './common/webtoken.library.js'; diff --git a/packages/library/src/common/calendar.library.ts b/packages/library/src/common/calendar.library.ts new file mode 100644 index 00000000..e112a44d --- /dev/null +++ b/packages/library/src/common/calendar.library.ts @@ -0,0 +1,191 @@ +/** + * Number of days in a standard week. + */ +export const DAYS_IN_WEEK = 7; + +/** + * Mapping of 2-letter and 3-letter ISO day abbreviations to 1-indexed weekday numbers (1..7, Monday=1, Sunday=7). + */ +export const DAY_MAP = Object.freeze({ + MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 7, + MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6, SUN: 7, +} as const); + +/** Type representing the DAY_MAP mapping structure. */ +export type DayMap = typeof DAY_MAP; + +/** Union type of all valid 2-letter and 3-letter day abbreviation keys ('MO' | 'TU' | ... | 'SUN'). */ +export type DayKey = keyof DayMap; + +/** Union type of all valid ISO weekday numeric values (1..7). */ +export type DayValue = DayMap[DayKey]; + +/** + * Mapping of 3-letter month abbreviations (JAN..DEC) to 1-indexed month numbers (1..12). + */ +export const MONTH_MAP = Object.freeze({ + JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, + JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12, +} as const); + +/** Type representing the MONTH_MAP mapping structure. */ +export type MonthMap = typeof MONTH_MAP; + +/** Union type of all valid 3-letter month abbreviation keys ('JAN' | 'FEB' | ... | 'DEC'). */ +export type MonthKey = keyof MonthMap; + +/** Union type of all valid 1-indexed month numeric values (1..12). */ +export type MonthValue = MonthMap[MonthKey]; + +/** + * Mapping of 1-indexed ISO weekday numbers (1..7, Monday=1, Sunday=7) to full English weekday names. + */ +export const ISO_WEEKDAY_NAMES = Object.freeze({ + 1: 'Monday', + 2: 'Tuesday', + 3: 'Wednesday', + 4: 'Thursday', + 5: 'Friday', + 6: 'Saturday', + 7: 'Sunday', +} as const); + +/** Type representing the ISO_WEEKDAY_NAMES mapping structure. */ +export type IsoWeekdayNames = typeof ISO_WEEKDAY_NAMES; + +/** Union type of all valid ISO weekday numbers (1 | 2 | 3 | 4 | 5 | 6 | 7). */ +export type IsoWeekdayNumber = keyof IsoWeekdayNames; + +/** Union type of all full English weekday names ('Monday' | 'Tuesday' | ... | 'Sunday'). */ +export type IsoWeekdayName = IsoWeekdayNames[IsoWeekdayNumber]; + +/** + * Structured UTC calendar and clock components. + */ +export interface UtcParts { + year: number; + month: number; // 1-indexed (1..12) + day: number; // 1-indexed (1..31) + weekday: number; // 1-indexed ISO weekday (1..7, Monday=1, Sunday=7) + hours: number; // 0..23 + minutes: number; // 0..59 + seconds: number; // 0..59 + milliseconds: number; // 0..999 +} + +/** + * Extracts all UTC calendar and clock components from a Date instance as a structured object. + * Month and weekday are 1-indexed (1 for January, 1 for Monday through 7 for Sunday). + * Defaults to current timestamp (`new Date()`) if no date is provided. + * + * @param date - Optional Date instance to inspect (defaults to `new Date()`) + * @returns An object containing `{ year, month, day, weekday, hours, minutes, seconds, milliseconds }` + */ +export function getUtcParts(date: Date = new Date()): UtcParts { + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + weekday: date.getUTCDay() || DAY_MAP.SUN, + hours: date.getUTCHours(), + minutes: date.getUTCMinutes(), + seconds: date.getUTCSeconds(), + milliseconds: date.getUTCMilliseconds(), + } +} + +/** + * Returns the total number of days in the specified month of a given year (1-indexed month, 1..12). + * + * @param year - The full calendar year (e.g. 2026) + * @param month - The 1-indexed month number (1 for January through 12 for December) + * @returns Total number of days in that month (28..31) + */ +export function getDaysInMonth(year: number, month: number): number { + return new Date(Date.UTC(year, month, 0)).getUTCDate(); +} + +/** + * Determines whether a given calendar year is a leap year in the Gregorian calendar. + * + * @param year - The full calendar year to test (e.g. 2024) + * @returns `true` if the year is a leap year, otherwise `false` + */ +export function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +/** + * Structured calendar and clock options for creating a UTC Date instance. + */ +export interface UtcPartsOptions { + year: number; + month: number; // 1-indexed (1..12) + day: number; // 1-indexed (1..31) + hours?: number; // 0..23 (default: 0) + minutes?: number; // 0..59 (default: 0) + seconds?: number; // 0..59 (default: 0) + milliseconds?: number; // 0..999 (default: 0) +} + +/** Type alias for UtcPartsOptions. */ +export type UtcDateOptions = UtcPartsOptions; + +/** + * Creates a UTC Date instance from structured 1-indexed calendar components and optional clock values. + * Inverse of `getUtcParts`. + * + * @param parts - Structured calendar components containing year, month, day, and optional clock components + * @returns A new UTC Date instance + */ +export function fromUtcParts(parts: UtcPartsOptions): Date { + const { + year, + month, + day, + hours = 0, + minutes = 0, + seconds = 0, + milliseconds = 0, + } = parts; + return new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds, milliseconds)); +} + +/** + * Validates whether a given year, 1-indexed month, and day constitute a valid calendar date in the Gregorian calendar. + * + * @param year - The full calendar year (e.g. 2026) + * @param month - The 1-indexed month number (1..12) + * @param day - The 1-indexed day of the month (1..31) + * @returns `true` if the year, month, and day represent a valid date, otherwise `false` + */ +export function isValidDate(year: number, month: number, day: number): boolean { + return month >= 1 && month <= 12 && day >= 1 && day <= getDaysInMonth(year, month); +} + +/** + * Adds an integer number of days to a Date in UTC, returning a new Date instance. + * + * @param date - The baseline Date instance + * @param days - Number of days to add (can be negative) + * @returns A new Date instance offset by the specified number of days + */ +export function addUtcDays(date: Date, days: number): Date { + const result = new Date(date.getTime()); + result.setUTCDate(result.getUTCDate() + days); + return result; +} + +/** + * Returns a new Date instance with updated UTC calendar or clock fields applied to the given date. + * Non-mutating (pure copy-on-write). + * + * @param date - The source Date instance + * @param parts - Partial UTC parts to overwrite on the source date + * @returns A new UTC Date instance with the specified parts updated + */ +export function withUtcParts(date: Date, parts: Partial): Date { + const current = getUtcParts(date); + return fromUtcParts({ ...current, ...parts }); +} + diff --git a/packages/library/src/common/rrule.library.ts b/packages/library/src/common/recurrence.library.ts similarity index 69% rename from packages/library/src/common/rrule.library.ts rename to packages/library/src/common/recurrence.library.ts index 3b817cde..f9874b07 100644 --- a/packages/library/src/common/rrule.library.ts +++ b/packages/library/src/common/recurrence.library.ts @@ -1,4 +1,23 @@ import { isDefined } from './assertion.library.js'; +import { + DAYS_IN_WEEK, + DAY_MAP, + MONTH_MAP, + getDaysInMonth, + getUtcParts, + fromUtcParts, + isValidDate, + addUtcDays, + withUtcParts, + type DayKey, + type MonthKey, +} from './calendar.library.js'; + +export { + DAYS_IN_WEEK, + DAY_MAP, + MONTH_MAP +}; /** * Tests whether a string is a valid RFC 5545 Recurrence Rule (RRULE). @@ -21,32 +40,6 @@ export function isFiniteRRule(rrule: string): boolean { return /(UNTIL|COUNT)=/i.test(rrule); } -/** - * Number of days in a standard week. - */ -export const DAYS_IN_WEEK = 7; - -/** - * Mapping of 2-letter ISO day abbreviations (MO..SU) to 1-indexed weekday numbers (1..7). - */ -export const DAY_MAP: Record = Object.freeze({ - MO: 1, - TU: 2, - WE: 3, - TH: 4, - FR: 5, - SA: 6, - SU: 7 -}); - -/** - * Mapping of 3-letter month abbreviations (JAN..DEC) to 1-indexed month numbers (1..12). - */ -export const MONTH_MAP: Record = Object.freeze({ - JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, - JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12 -}); - /** * Parsed structure of an RFC 5545 Recurrence Rule string. */ @@ -128,7 +121,7 @@ export function parseRRule(rrule: string): ParsedRRule { const num = parseInt(trimmed, 10); if (!isNaN(num) && num >= 1 && num <= 12) return num; const prefix = trimmed.slice(0, 3).toUpperCase(); - return MONTH_MAP[prefix]; + return prefix in MONTH_MAP ? MONTH_MAP[prefix as MonthKey] : undefined; }).filter((v): v is number => isDefined(v)); if (items.length > 0) byMonth = items; break; @@ -140,7 +133,7 @@ export function parseRRule(rrule: string): ParsedRRule { const rawDay = m ? m[2] : item; const canonicalDay = rawDay.slice(0, 2).toUpperCase(); return { nth: isDefined(nthVal) && !isNaN(nthVal) ? nthVal : undefined, day: canonicalDay }; - }).filter(d => isDefined(DAY_MAP[d.day])); + }).filter(d => d.day in DAY_MAP); if (items.length > 0) byDay = items; break; } @@ -168,10 +161,6 @@ export function parseRRule(rrule: string): ParsedRRule { return { freq, interval, count, untilMs, byMonth, byDay, byHour, byMinute, bySetPos }; } -function getDaysInMonth(year: number, month: number): number { - return new Date(Date.UTC(year, month, 0)).getUTCDate(); -} - /** * Expands occurrences of an RFC 5545 RRULE string into epoch millisecond numbers. * Pure function operating strictly on primitive timestamps and UTC Date calculations. @@ -189,53 +178,66 @@ export function expandRRuleEpochs( const rule = parseRRule(rruleStr); const anchorDate = new Date(anchorEpochMs); const results: number[] = []; - const maxToFetch = isDefined(rule.count) ? rule.count : (options?.count ?? 100); + const maxToFetch = isDefined(rule.count) && isDefined(options?.count) + ? Math.min(rule.count, options.count) + : (rule.count ?? options?.count ?? 100); let totalGeneratedFromAnchor = 0; let resultsCount = 0; let step = 0; const MAX_STEPS = 1000; - const anchorHours = anchorDate.getUTCHours(); - const anchorMinutes = anchorDate.getUTCMinutes(); - const anchorSeconds = anchorDate.getUTCSeconds(); - const anchorMs = anchorDate.getUTCMilliseconds(); + const { + year: anchorYear, + month: anchorMonth, + day: anchorDay, + hours: anchorHours, + minutes: anchorMinutes, + seconds: anchorSeconds, + milliseconds: anchorMs, + } = getUtcParts(anchorDate); while (resultsCount < maxToFetch && step < MAX_STEPS) { let periodBases: Date[] = []; - const baseDate = new Date(anchorEpochMs); switch (rule.freq) { case 'WEEKLY': { - baseDate.setUTCDate(baseDate.getUTCDate() + step * rule.interval * DAYS_IN_WEEK); + const steppedDate = addUtcDays(anchorDate, step * rule.interval * DAYS_IN_WEEK); if (rule.byDay && rule.byDay.length > 0) { periodBases = rule.byDay.map(bd => { - const targetDay = DAY_MAP[bd.day] ?? 1; - const currentDow = baseDate.getUTCDay() === 0 ? DAY_MAP.SUN : baseDate.getUTCDay(); + const targetDay = bd.day in DAY_MAP ? DAY_MAP[bd.day as DayKey] : 1; + const currentDow = getUtcParts(steppedDate).weekday; const diff = (targetDay - currentDow + DAYS_IN_WEEK) % DAYS_IN_WEEK; - const targetDate = new Date(baseDate.getTime()); - targetDate.setUTCDate(targetDate.getUTCDate() + diff); - return targetDate; + return addUtcDays(steppedDate, diff); }); } else { - periodBases = [baseDate]; + periodBases = [steppedDate]; } break; } + case 'MONTHLY': { - baseDate.setUTCMonth(baseDate.getUTCMonth() + step * rule.interval); - const year = baseDate.getUTCFullYear(); - const month = baseDate.getUTCMonth() + 1; - const daysInMonth = getDaysInMonth(year, month); + const totalMonths = (anchorYear * 12 + (anchorMonth - 1)) + step * rule.interval; + const targetYear = Math.floor(totalMonths / 12); + const targetMonth = (totalMonths % 12) + 1; if (rule.byDay && rule.byDay.length > 0) { + const daysInMonth = getDaysInMonth(targetYear, targetMonth); const candidateDays: Date[] = []; for (const bd of rule.byDay) { - const targetDow = DAY_MAP[bd.day] ?? 1; + const targetDow = bd.day in DAY_MAP ? DAY_MAP[bd.day as DayKey] : 1; const matchingDates: Date[] = []; for (let dayNum = 1; dayNum <= daysInMonth; dayNum++) { - const d = new Date(Date.UTC(year, month - 1, dayNum, anchorHours, anchorMinutes, anchorSeconds, anchorMs)); - const dow = d.getUTCDay() === 0 ? DAY_MAP.SUN : d.getUTCDay(); + const d = fromUtcParts({ + year: targetYear, + month: targetMonth, + day: dayNum, + hours: anchorHours, + minutes: anchorMinutes, + seconds: anchorSeconds, + milliseconds: anchorMs, + }); + const dow = getUtcParts(d).weekday; if (dow === targetDow) matchingDates.push(d); } @@ -251,40 +253,104 @@ export function expandRRuleEpochs( } periodBases = candidateDays; } else { - periodBases = [baseDate]; + if (isValidDate(targetYear, targetMonth, anchorDay)) { + periodBases = [fromUtcParts({ + year: targetYear, + month: targetMonth, + day: anchorDay, + hours: anchorHours, + minutes: anchorMinutes, + seconds: anchorSeconds, + milliseconds: anchorMs, + })]; + } } break; } + case 'YEARLY': { - baseDate.setUTCFullYear(baseDate.getUTCFullYear() + step * rule.interval); - periodBases = [baseDate]; + const targetYear = anchorYear + step * rule.interval; + const months = rule.byMonth && rule.byMonth.length > 0 ? rule.byMonth : [anchorMonth]; + const candidateDays: Date[] = []; + + for (const m of months) { + if (rule.byDay && rule.byDay.length > 0) { + const daysInMonth = getDaysInMonth(targetYear, m); + for (const bd of rule.byDay) { + const targetDow = bd.day in DAY_MAP ? DAY_MAP[bd.day as DayKey] : 1; + const matchingDates: Date[] = []; + for (let dayNum = 1; dayNum <= daysInMonth; dayNum++) { + const d = fromUtcParts({ + year: targetYear, + month: m, + day: dayNum, + hours: anchorHours, + minutes: anchorMinutes, + seconds: anchorSeconds, + milliseconds: anchorMs, + }); + const dow = getUtcParts(d).weekday; + if (dow === targetDow) matchingDates.push(d); + } + + if (isDefined(bd.nth)) { + if (bd.nth > 0 && bd.nth <= matchingDates.length) { + candidateDays.push(matchingDates[bd.nth - 1]); + } else if (bd.nth < 0 && Math.abs(bd.nth) <= matchingDates.length) { + candidateDays.push(matchingDates[matchingDates.length + bd.nth]); + } + } else { + candidateDays.push(...matchingDates); + } + } + } else { + if (isValidDate(targetYear, m, anchorDay)) { + candidateDays.push(fromUtcParts({ + year: targetYear, + month: m, + day: anchorDay, + hours: anchorHours, + minutes: anchorMinutes, + seconds: anchorSeconds, + milliseconds: anchorMs, + })); + } + } + } + periodBases = candidateDays; break; } + case 'DAILY': default: { - baseDate.setUTCDate(baseDate.getUTCDate() + step * rule.interval); - periodBases = [baseDate]; + periodBases = [addUtcDays(anchorDate, step * rule.interval)]; break; } } - if (rule.byMonth && rule.byMonth.length > 0) + if (rule.byMonth && rule.byMonth.length > 0 && rule.freq !== 'YEARLY') periodBases = periodBases.filter(b => rule.byMonth!.includes(b.getUTCMonth() + 1)); const periodCandidates: Date[] = []; for (const base of periodBases) { - const hours = rule.byHour && rule.byHour.length > 0 ? rule.byHour : [base.getUTCHours()]; - const minutes = rule.byMinute && rule.byMinute.length > 0 ? rule.byMinute : [base.getUTCMinutes()]; + const { hours: baseHours, minutes: baseMinutes } = getUtcParts(base); + const hours = rule.byHour && rule.byHour.length > 0 ? rule.byHour : [baseHours]; + const minutes = rule.byMinute && rule.byMinute.length > 0 ? rule.byMinute : [baseMinutes]; for (const h of hours) { for (const m of minutes) { - const cand = new Date(base.getTime()); - cand.setUTCHours(h, m, anchorSeconds, anchorMs); - periodCandidates.push(cand); + periodCandidates.push(withUtcParts(base, { + hours: h, + minutes: m, + seconds: anchorSeconds, + milliseconds: anchorMs, + })); } } } + periodCandidates.sort((a, b) => a.getTime() - b.getTime()); + let finalPeriodCandidates = periodCandidates; if (rule.bySetPos && rule.bySetPos.length > 0 && periodCandidates.length > 0) { finalPeriodCandidates = []; diff --git a/packages/library/test/common/calendar.library.test.ts b/packages/library/test/common/calendar.library.test.ts new file mode 100644 index 00000000..350e98ae --- /dev/null +++ b/packages/library/test/common/calendar.library.test.ts @@ -0,0 +1,228 @@ +import { + DAYS_IN_WEEK, + DAY_MAP, + MONTH_MAP, + ISO_WEEKDAY_NAMES, + getUtcParts, + getDaysInMonth, + isLeapYear, + fromUtcParts, + isValidDate, + addUtcDays, + withUtcParts, +} from '../../src/common/calendar.library.js'; +import type { + UtcPartsOptions, + DayKey, + DayValue, + MonthKey, + MonthValue, + IsoWeekdayNumber, + IsoWeekdayName +} from '../../src/common/calendar.library.js'; + +describe('calendar.library', () => { + test('DAYS_IN_WEEK constant is 7', () => { + expect(DAYS_IN_WEEK).toBe(7); + }); + + test('DAY_MAP maps 2-letter and 3-letter weekday abbreviations to ISO 1..7 indices', () => { + expect(DAY_MAP.MO).toBe(1); + expect(DAY_MAP.TU).toBe(2); + expect(DAY_MAP.WE).toBe(3); + expect(DAY_MAP.TH).toBe(4); + expect(DAY_MAP.FR).toBe(5); + expect(DAY_MAP.SA).toBe(6); + expect(DAY_MAP.SU).toBe(7); + + expect(DAY_MAP.MON).toBe(1); + expect(DAY_MAP.TUE).toBe(2); + expect(DAY_MAP.WED).toBe(3); + expect(DAY_MAP.THU).toBe(4); + expect(DAY_MAP.FRI).toBe(5); + expect(DAY_MAP.SAT).toBe(6); + expect(DAY_MAP.SUN).toBe(7); + + // Type assertions + const sampleDayKey: DayKey = 'MON'; + const sampleDayVal: DayValue = DAY_MAP[sampleDayKey]; + expect(sampleDayVal).toBe(1); + }); + + test('MONTH_MAP maps 3-letter month abbreviations to 1..12 indices', () => { + expect(MONTH_MAP.JAN).toBe(1); + expect(MONTH_MAP.FEB).toBe(2); + expect(MONTH_MAP.MAR).toBe(3); + expect(MONTH_MAP.APR).toBe(4); + expect(MONTH_MAP.MAY).toBe(5); + expect(MONTH_MAP.JUN).toBe(6); + expect(MONTH_MAP.JUL).toBe(7); + expect(MONTH_MAP.AUG).toBe(8); + expect(MONTH_MAP.SEP).toBe(9); + expect(MONTH_MAP.OCT).toBe(10); + expect(MONTH_MAP.NOV).toBe(11); + expect(MONTH_MAP.DEC).toBe(12); + + // Type assertions + const sampleMonthKey: MonthKey = 'AUG'; + const sampleMonthVal: MonthValue = MONTH_MAP[sampleMonthKey]; + expect(sampleMonthVal).toBe(8); + }); + + test('ISO_WEEKDAY_NAMES maps ISO indices 1..7 to full English weekday names', () => { + expect(ISO_WEEKDAY_NAMES[1]).toBe('Monday'); + expect(ISO_WEEKDAY_NAMES[2]).toBe('Tuesday'); + expect(ISO_WEEKDAY_NAMES[3]).toBe('Wednesday'); + expect(ISO_WEEKDAY_NAMES[4]).toBe('Thursday'); + expect(ISO_WEEKDAY_NAMES[5]).toBe('Friday'); + expect(ISO_WEEKDAY_NAMES[6]).toBe('Saturday'); + expect(ISO_WEEKDAY_NAMES[7]).toBe('Sunday'); + + // Type assertions + const sampleNum: IsoWeekdayNumber = 7; + const sampleName: IsoWeekdayName = ISO_WEEKDAY_NAMES[sampleNum]; + expect(sampleName).toBe('Sunday'); + }); + + test('getUtcParts extracts all components with 1-indexed month and weekday', () => { + // Sunday 2026-08-09T14:35:45.123Z + const date = new Date(Date.UTC(2026, 7, 9, 14, 35, 45, 123)); + const parts = getUtcParts(date); + + expect(parts.year).toBe(2026); + expect(parts.month).toBe(8); + expect(parts.day).toBe(9); + expect(parts.weekday).toBe(7); + expect(parts.hours).toBe(14); + expect(parts.minutes).toBe(35); + expect(parts.seconds).toBe(45); + expect(parts.milliseconds).toBe(123); + }); + + test('getUtcParts defaults to current date when no argument is provided', () => { + const now = new Date(); + const parts = getUtcParts(); + + expect(parts.year).toBe(now.getUTCFullYear()); + expect(parts.month).toBe(now.getUTCMonth() + 1); + expect(parts.day).toBe(now.getUTCDate()); + expect(parts.weekday).toBeGreaterThanOrEqual(1); + expect(parts.weekday).toBeLessThanOrEqual(7); + expect(typeof parts.hours).toBe('number'); + expect(typeof parts.minutes).toBe('number'); + expect(typeof parts.seconds).toBe('number'); + expect(typeof parts.milliseconds).toBe('number'); + }); + + test('getUtcParts.weekday converts JS Date Sunday (0) to ISO Sunday (7)', () => { + // Sunday Aug 9, 2026 + const sunday = new Date(Date.UTC(2026, 7, 9)); + expect(sunday.getUTCDay()).toBe(0); + expect(getUtcParts(sunday).weekday).toBe(7); + + // Monday Aug 10, 2026 + const monday = new Date(Date.UTC(2026, 7, 10)); + expect(monday.getUTCDay()).toBe(1); + expect(getUtcParts(monday).weekday).toBe(1); + + // Friday Aug 7, 2026 + const friday = new Date(Date.UTC(2026, 7, 7)); + expect(friday.getUTCDay()).toBe(5); + expect(getUtcParts(friday).weekday).toBe(5); + + // Saturday Aug 8, 2026 + const saturday = new Date(Date.UTC(2026, 7, 8)); + expect(saturday.getUTCDay()).toBe(6); + expect(getUtcParts(saturday).weekday).toBe(6); + }); + + test('getDaysInMonth returns correct days for each month including leap year February', () => { + // Non-leap year 2026 + expect(getDaysInMonth(2026, 1)).toBe(31); // Jan + expect(getDaysInMonth(2026, 2)).toBe(28); // Feb + expect(getDaysInMonth(2026, 3)).toBe(31); // Mar + expect(getDaysInMonth(2026, 4)).toBe(30); // Apr + expect(getDaysInMonth(2026, 5)).toBe(31); // May + expect(getDaysInMonth(2026, 6)).toBe(30); // Jun + expect(getDaysInMonth(2026, 7)).toBe(31); // Jul + expect(getDaysInMonth(2026, 8)).toBe(31); // Aug + expect(getDaysInMonth(2026, 9)).toBe(30); // Sep + expect(getDaysInMonth(2026, 10)).toBe(31); // Oct + expect(getDaysInMonth(2026, 11)).toBe(30); // Nov + expect(getDaysInMonth(2026, 12)).toBe(31); // Dec + + // Leap year 2024 + expect(getDaysInMonth(2024, 2)).toBe(29); // Feb leap + }); + + test('isLeapYear correctly determines leap years', () => { + expect(isLeapYear(2024)).toBe(true); + expect(isLeapYear(2000)).toBe(true); + expect(isLeapYear(2026)).toBe(false); + expect(isLeapYear(1900)).toBe(false); + expect(isLeapYear(2100)).toBe(false); + }); + + test('fromUtcParts creates Date instances with 1-indexed month and clock values', () => { + const options: UtcPartsOptions = { + year: 2026, + month: 8, + day: 9, + hours: 10, + minutes: 30, + seconds: 45, + milliseconds: 500, + } + const date1 = fromUtcParts(options); + expect(date1.toISOString()).toBe('2026-08-09T10:30:45.500Z'); + + // Default clock values are 0 + const date2 = fromUtcParts({ year: 2026, month: 1, day: 15 }); + expect(date2.toISOString()).toBe('2026-01-15T00:00:00.000Z'); + + // Round-trip symmetry with getUtcParts + const original = new Date('2026-11-25T17:45:30.250Z'); + const parts = getUtcParts(original); + const roundtrip = fromUtcParts(parts); + expect(roundtrip.getTime()).toBe(original.getTime()); + }); + + test('isValidDate checks calendar validity without date allocation or overflow', () => { + expect(isValidDate(2026, 1, 31)).toBe(true); + expect(isValidDate(2026, 2, 28)).toBe(true); + expect(isValidDate(2026, 2, 29)).toBe(false); // Non-leap year + expect(isValidDate(2024, 2, 29)).toBe(true); // Leap year + expect(isValidDate(2026, 4, 30)).toBe(true); + expect(isValidDate(2026, 4, 31)).toBe(false); // April only has 30 days + expect(isValidDate(2026, 0, 15)).toBe(false); // Month < 1 + expect(isValidDate(2026, 13, 15)).toBe(false); // Month > 12 + expect(isValidDate(2026, 5, 0)).toBe(false); // Day < 1 + }); + + test('addUtcDays adds or subtracts days without mutating original date', () => { + const base = new Date('2026-02-28T12:00:00.000Z'); + const next = addUtcDays(base, 1); + expect(next.toISOString()).toBe('2026-03-01T12:00:00.000Z'); + expect(base.toISOString()).toBe('2026-02-28T12:00:00.000Z'); // Unmutated + + const prev = addUtcDays(base, -7); + expect(prev.toISOString()).toBe('2026-02-21T12:00:00.000Z'); + }); + + test('withUtcParts updates specific calendar or clock parts while preserving others', () => { + const base = new Date('2026-05-15T14:30:45.123Z'); + + // Date update + const updatedDate = withUtcParts(base, { month: 10, day: 31 }); + expect(updatedDate.toISOString()).toBe('2026-10-31T14:30:45.123Z'); + expect(base.toISOString()).toBe('2026-05-15T14:30:45.123Z'); // Unmutated + + // Clock update + const updatedTime = withUtcParts(base, { hours: 9, minutes: 0 }); + expect(updatedTime.toISOString()).toBe('2026-05-15T09:00:45.123Z'); + + // Combined update + const fullUpdate = withUtcParts(base, { year: 2028, hours: 23, minutes: 59, seconds: 59, milliseconds: 999 }); + expect(fullUpdate.toISOString()).toBe('2028-05-15T23:59:59.999Z'); + }); +}); diff --git a/packages/library/test/common/decorator.immutable-secure.test.ts b/packages/library/test/common/class.library.test.ts similarity index 100% rename from packages/library/test/common/decorator.immutable-secure.test.ts rename to packages/library/test/common/class.library.test.ts diff --git a/packages/library/test/common/number_library.test.ts b/packages/library/test/common/number.library.test.ts similarity index 100% rename from packages/library/test/common/number_library.test.ts rename to packages/library/test/common/number.library.test.ts diff --git a/packages/library/test/common/recurrence.library.test.ts b/packages/library/test/common/recurrence.library.test.ts new file mode 100644 index 00000000..1e598142 --- /dev/null +++ b/packages/library/test/common/recurrence.library.test.ts @@ -0,0 +1,189 @@ +import { + isRRuleString, + isFiniteRRule, + parseRRule, + expandRRuleEpochs, + getNextRRuleEpoch, + DAY_MAP +} from '../../src/common/recurrence.library.js'; + +describe('recurrence.library', () => { + test('isRRuleString identifies valid RRULE patterns', () => { + expect(isRRuleString('FREQ=DAILY')).toBe(true); + expect(isRRuleString('RRULE:FREQ=WEEKLY;BYDAY=MO')).toBe(true); + expect(isRRuleString('FREQ=MONTHLY;BYDAY=1MO,3MO')).toBe(true); + expect(isRRuleString('hello world')).toBe(false); + expect(isRRuleString('2026-08-07')).toBe(false); + }); + + test('isFiniteRRule identifies bounded vs infinite series', () => { + expect(isFiniteRRule('FREQ=DAILY;COUNT=5')).toBe(true); + expect(isFiniteRRule('FREQ=WEEKLY;UNTIL=20261231T235959Z')).toBe(true); + expect(isFiniteRRule('FREQ=MONTHLY;UNTIL=20261231')).toBe(true); + expect(isFiniteRRule('FREQ=DAILY')).toBe(false); + expect(isFiniteRRule('FREQ=WEEKLY;BYDAY=MO,WE,FR')).toBe(false); + }); + + test('DAY_MAP provides correct weekday mapping and Sunday aliases', () => { + expect(DAY_MAP.MO).toBe(1); + expect(DAY_MAP.TU).toBe(2); + expect(DAY_MAP.WE).toBe(3); + expect(DAY_MAP.TH).toBe(4); + expect(DAY_MAP.FR).toBe(5); + expect(DAY_MAP.SA).toBe(6); + expect(DAY_MAP.SU).toBe(7); + expect(DAY_MAP.SUN).toBe(7); + }); + + test('parseRRule correctly parses RRULE components', () => { + const parsed = parseRRule('FREQ=WEEKLY;INTERVAL=2;COUNT=5;BYDAY=1MO,-1FR;BYHOUR=9,17;BYMINUTE=30'); + expect(parsed.freq).toBe('WEEKLY'); + expect(parsed.interval).toBe(2); + expect(parsed.count).toBe(5); + expect(parsed.byDay).toEqual([ + { nth: 1, day: 'MO' }, + { nth: -1, day: 'FR' } + ]); + expect(parsed.byHour).toEqual([9, 17]); + expect(parsed.byMinute).toEqual([30]); + }); + + test('parseRRule supports 2-letter, 3-letter, and full weekday names and normalizes to standard RFC 2-letter codes', () => { + const parsed = parseRRule('FREQ=WEEKLY;BYDAY=Monday,FRI,Wed,2Thursday,Sunday'); + expect(parsed.byDay).toEqual([ + { nth: undefined, day: 'MO' }, + { nth: undefined, day: 'FR' }, + { nth: undefined, day: 'WE' }, + { nth: 2, day: 'TH' }, + { nth: undefined, day: 'SU' } + ]); + }); + + test('parseRRule supports numeric, 3-letter, and full month names in BYMONTH', () => { + const parsed = parseRRule('FREQ=YEARLY;BYMONTH=1,Jan,December,AUG'); + expect(parsed.byMonth).toEqual([1, 1, 12, 8]); + }); + + test('expandRRuleEpochs generates correct daily occurrence timestamps', () => { + // 2026-08-07T00:00:00.000Z is Friday + const anchor = Date.UTC(2026, 7, 7, 0, 0, 0, 0); + const epochs = expandRRuleEpochs('FREQ=DAILY;INTERVAL=1', anchor, { count: 3 }); + + expect(epochs.length).toBe(3); + expect(new Date(epochs[0]).toISOString()).toBe('2026-08-07T00:00:00.000Z'); + expect(new Date(epochs[1]).toISOString()).toBe('2026-08-08T00:00:00.000Z'); + expect(new Date(epochs[2]).toISOString()).toBe('2026-08-09T00:00:00.000Z'); + }); + + test('expandRRuleEpochs handles Sunday BYDAY correctly for WEEKLY', () => { + // Friday, Aug 7, 2026 + const anchor = Date.UTC(2026, 7, 7, 10, 0, 0, 0); + const epochs = expandRRuleEpochs('FREQ=WEEKLY;BYDAY=SU', anchor, { count: 2 }); + + expect(epochs.length).toBe(2); + // First Sunday on or after Friday Aug 7 is Sunday Aug 9 + expect(new Date(epochs[0]).toISOString()).toBe('2026-08-09T10:00:00.000Z'); + // Next Sunday is Aug 16 + expect(new Date(epochs[1]).toISOString()).toBe('2026-08-16T10:00:00.000Z'); + }); + + test('expandRRuleEpochs handles Sunday and positive/negative nth selectors for MONTHLY', () => { + // August 2026: 1st is Saturday, 2nd is Sunday, last day is 31st (Monday), last Sunday is 30th + const anchor = Date.UTC(2026, 7, 1, 9, 0, 0, 0); + + // 1st Sunday of August 2026 -> Aug 2 + const firstSunday = expandRRuleEpochs('FREQ=MONTHLY;BYDAY=1SU', anchor, { count: 1 }); + expect(new Date(firstSunday[0]).toISOString()).toBe('2026-08-02T09:00:00.000Z'); + + // Last Sunday of August 2026 -> Aug 30 + const lastSunday = expandRRuleEpochs('FREQ=MONTHLY;BYDAY=-1SU', anchor, { count: 1 }); + expect(new Date(lastSunday[0]).toISOString()).toBe('2026-08-30T09:00:00.000Z'); + + // 2nd Tuesday of August 2026 -> Aug 11 + const secondTuesday = expandRRuleEpochs('FREQ=MONTHLY;BYDAY=2TU', anchor, { count: 1 }); + expect(new Date(secondTuesday[0]).toISOString()).toBe('2026-08-11T09:00:00.000Z'); + }); + + test('expandRRuleEpochs handles YEARLY with BYMONTH', () => { + const anchor = Date.UTC(2026, 7, 7, 12, 0, 0, 0); + const epochs = expandRRuleEpochs('FREQ=YEARLY;BYMONTH=12', anchor, { count: 2 }); + + expect(epochs.length).toBe(2); + expect(new Date(epochs[0]).toISOString()).toBe('2026-12-07T12:00:00.000Z'); + expect(new Date(epochs[1]).toISOString()).toBe('2027-12-07T12:00:00.000Z'); + + // BYMONTH=1 produces January candidates + const janEpochs = expandRRuleEpochs('FREQ=YEARLY;BYMONTH=1', anchor, { count: 2 }); + expect(janEpochs.length).toBe(2); + expect(new Date(janEpochs[0]).toISOString()).toBe('2026-01-07T12:00:00.000Z'); + expect(new Date(janEpochs[1]).toISOString()).toBe('2027-01-07T12:00:00.000Z'); + + // BYMONTH with BYDAY (e.g. 4th Thursday of November) + // November 2026 Thursdays: Nov 5, 12, 19, 26 (4th is Nov 26) + const thanksgivingEpochs = expandRRuleEpochs('FREQ=YEARLY;BYMONTH=11;BYDAY=4TH', anchor, { count: 1 }); + expect(thanksgivingEpochs.length).toBe(1); + expect(new Date(thanksgivingEpochs[0]).toISOString()).toBe('2026-11-26T12:00:00.000Z'); + }); + + test('expandRRuleEpochs terminates correctly with UNTIL and COUNT clauses', () => { + const anchor = Date.UTC(2026, 7, 1, 0, 0, 0, 0); + + // COUNT=3 + const countEpochs = expandRRuleEpochs('FREQ=DAILY;COUNT=3', anchor); + expect(countEpochs.length).toBe(3); + + // UNTIL=20260804 + const untilEpochs = expandRRuleEpochs('FREQ=DAILY;UNTIL=20260804', anchor); + expect(untilEpochs.length).toBe(4); + expect(new Date(untilEpochs[3]).toISOString()).toBe('2026-08-04T00:00:00.000Z'); + }); + + test('expandRRuleEpochs filters correctly with BYSETPOS', () => { + // August 2026 weekdays: Aug 3 (1st weekday), Aug 31 (last weekday) + const anchor = Date.UTC(2026, 7, 1, 8, 0, 0, 0); + + // 1st weekday of the month + const firstWeekday = expandRRuleEpochs('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=1', anchor, { count: 1 }); + expect(new Date(firstWeekday[0]).toISOString()).toBe('2026-08-03T08:00:00.000Z'); + + // Last weekday of the month + const lastWeekday = expandRRuleEpochs('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1', anchor, { count: 1 }); + expect(new Date(lastWeekday[0]).toISOString()).toBe('2026-08-31T08:00:00.000Z'); + }); + + test('getNextRRuleEpoch computes next occurrence including Sunday handling', () => { + const anchor = Date.UTC(2026, 7, 7, 0, 0, 0, 0); // Friday Aug 7 + const nextDaily = getNextRRuleEpoch('FREQ=DAILY;INTERVAL=1', anchor); + expect(new Date(nextDaily).toISOString()).toBe('2026-08-08T00:00:00.000Z'); + + // Next occurrence after Friday Aug 7 for Sunday rule is Sunday Aug 9 + const nextSunday = getNextRRuleEpoch('FREQ=WEEKLY;BYDAY=SU', anchor); + expect(new Date(nextSunday).toISOString()).toBe('2026-08-09T00:00:00.000Z'); + }); + + test('expandRRuleEpochs respects smaller of rule.count and options.count', () => { + const anchor = Date.UTC(2026, 7, 7, 0, 0, 0, 0); + // rule.count = 10, options.count = 2 -> yields 2 + const epochs1 = expandRRuleEpochs('FREQ=DAILY;COUNT=10', anchor, { count: 2 }); + expect(epochs1.length).toBe(2); + + // rule.count = 2, options.count = 10 -> yields 2 + const epochs2 = expandRRuleEpochs('FREQ=DAILY;COUNT=2', anchor, { count: 10 }); + expect(epochs2.length).toBe(2); + + // neither count provided -> defaults to up to 100 + const epochs3 = expandRRuleEpochs('FREQ=DAILY;INTERVAL=1', anchor); + expect(epochs3.length).toBe(100); + }); + + test('expandRRuleEpochs skips months that do not have the anchor day for MONTHLY recurrence without BYDAY', () => { + // Jan 31, 2026 -> Feb has 28 days (skipped), Mar has 31 days (included), Apr has 30 days (skipped), May has 31 days (included) + const anchor = Date.UTC(2026, 0, 31, 10, 0, 0, 0); + const epochs = expandRRuleEpochs('FREQ=MONTHLY;INTERVAL=1', anchor, { count: 3 }); + + expect(epochs.length).toBe(3); + expect(new Date(epochs[0]).toISOString()).toBe('2026-01-31T10:00:00.000Z'); + expect(new Date(epochs[1]).toISOString()).toBe('2026-03-31T10:00:00.000Z'); + expect(new Date(epochs[2]).toISOString()).toBe('2026-05-31T10:00:00.000Z'); + }); +}); diff --git a/packages/library/test/common/reflection_library.test.ts b/packages/library/test/common/reflection.library.test.ts similarity index 100% rename from packages/library/test/common/reflection_library.test.ts rename to packages/library/test/common/reflection.library.test.ts diff --git a/packages/library/test/common/rrule_library.test.ts b/packages/library/test/common/rrule_library.test.ts deleted file mode 100644 index d831fdb8..00000000 --- a/packages/library/test/common/rrule_library.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { isRRuleString, parseRRule, expandRRuleEpochs, getNextRRuleEpoch } from '../../src/common/rrule.library.js'; - -describe('rrule.library', () => { - test('isRRuleString identifies valid RRULE patterns', () => { - expect(isRRuleString('FREQ=DAILY')).toBe(true); - expect(isRRuleString('RRULE:FREQ=WEEKLY;BYDAY=MO')).toBe(true); - expect(isRRuleString('FREQ=MONTHLY;BYDAY=1MO,3MO')).toBe(true); - expect(isRRuleString('hello world')).toBe(false); - expect(isRRuleString('2026-08-07')).toBe(false); - }); - - test('parseRRule correctly parses RRULE components', () => { - const parsed = parseRRule('FREQ=WEEKLY;INTERVAL=2;COUNT=5;BYDAY=1MO,-1FR;BYHOUR=9,17;BYMINUTE=30'); - expect(parsed.freq).toBe('WEEKLY'); - expect(parsed.interval).toBe(2); - expect(parsed.count).toBe(5); - expect(parsed.byDay).toEqual([ - { nth: 1, day: 'MO' }, - { nth: -1, day: 'FR' } - ]); - expect(parsed.byHour).toEqual([9, 17]); - expect(parsed.byMinute).toEqual([30]); - }); - - test('parseRRule supports 2-letter, 3-letter, and full weekday names and normalizes to standard RFC 2-letter codes', () => { - const parsed = parseRRule('FREQ=WEEKLY;BYDAY=Monday,FRI,Wed,2Thursday'); - expect(parsed.byDay).toEqual([ - { nth: undefined, day: 'MO' }, - { nth: undefined, day: 'FR' }, - { nth: undefined, day: 'WE' }, - { nth: 2, day: 'TH' } - ]); - }); - - test('parseRRule supports numeric, 3-letter, and full month names in BYMONTH', () => { - const parsed = parseRRule('FREQ=YEARLY;BYMONTH=1,Jan,December,AUG'); - expect(parsed.byMonth).toEqual([1, 1, 12, 8]); - }); - - test('expandRRuleEpochs generates correct occurrence timestamps', () => { - // 2026-08-07T00:00:00.000Z is Friday - const anchor = Date.UTC(2026, 7, 7, 0, 0, 0, 0); - const epochs = expandRRuleEpochs('FREQ=DAILY;INTERVAL=1', anchor, { count: 3 }); - - expect(epochs.length).toBe(3); - expect(new Date(epochs[0]).toISOString()).toBe('2026-08-07T00:00:00.000Z'); - expect(new Date(epochs[1]).toISOString()).toBe('2026-08-08T00:00:00.000Z'); - expect(new Date(epochs[2]).toISOString()).toBe('2026-08-09T00:00:00.000Z'); - }); - - test('getNextRRuleEpoch computes next occurrence', () => { - const anchor = Date.UTC(2026, 7, 7, 0, 0, 0, 0); - const nextMs = getNextRRuleEpoch('FREQ=DAILY;INTERVAL=1', anchor); - expect(new Date(nextMs).toISOString()).toBe('2026-08-08T00:00:00.000Z'); - }); -}); diff --git a/packages/library/test/common/serialize_symbol.test.ts b/packages/library/test/common/serialize.library.test.ts similarity index 100% rename from packages/library/test/common/serialize_symbol.test.ts rename to packages/library/test/common/serialize.library.test.ts diff --git a/packages/library/test/common/string_library.test.ts b/packages/library/test/common/string.library.test.ts similarity index 100% rename from packages/library/test/common/string_library.test.ts rename to packages/library/test/common/string.library.test.ts diff --git a/packages/library/test/common/temporal_library.test.ts b/packages/library/test/common/temporal.library.test.ts similarity index 100% rename from packages/library/test/common/temporal_library.test.ts rename to packages/library/test/common/temporal.library.test.ts diff --git a/packages/plugins/.bin/check-branch-diff.sh b/packages/plugins/.bin/check-branch-diff.sh index 3a2b1e93..7b19673d 100755 --- a/packages/plugins/.bin/check-branch-diff.sh +++ b/packages/plugins/.bin/check-branch-diff.sh @@ -44,7 +44,20 @@ function compare(a, b) { if (pa.patch !== pb.patch) return pa.patch > pb.patch; if (!pa.prerelease && pb.prerelease) return true; if (pa.prerelease && !pb.prerelease) return false; - return pa.prerelease > pb.prerelease; + if (!pa.prerelease && !pb.prerelease) return false; + const aParts = pa.prerelease.split("."); + const bParts = pb.prerelease.split("."); + const len = Math.min(aParts.length, bParts.length); + for (let i = 0; i < len; i++) { + const ap = aParts[i], bp = bParts[i]; + if (ap === bp) continue; + const aNum = /^\d+$/.test(ap), bNum = /^\d+$/.test(bp); + if (aNum && bNum) return parseInt(ap, 10) > parseInt(bp, 10); + if (aNum && !bNum) return false; + if (!aNum && bNum) return true; + return ap > bp; + } + return aParts.length > bParts.length; } console.log(compare(process.argv[1], process.argv[2]) ? "true" : "false"); ' "${branch_version}" "${main_version}") diff --git a/packages/plugins/.bin/check-versions.sh b/packages/plugins/.bin/check-versions.sh index 05747642..85ea4022 100755 --- a/packages/plugins/.bin/check-versions.sh +++ b/packages/plugins/.bin/check-versions.sh @@ -19,13 +19,23 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" printf "%-38s | %-16s | %-16s | %-12s\n" "Package Name" "Published (NPM)" "Local Workspace" "Status" printf "%-38s-+-%-16s-+-%-16s-+-%-12s\n" "--------------------------------------" "----------------" "----------------" "------------" +has_error=0 + for entry in "${packages[@]}"; do pkg_name="${entry%%:*}" rel_path="${entry#*:}" full_npm_name="@magmacomputing/${pkg_name}" # Fetch published version from NPM registry - published_ver=$(npm view "${full_npm_name}" version 2>/dev/null || echo "not published") + npm_out=$(npm view "${full_npm_name}" version 2>&1) && npm_code=0 || npm_code=$? + if [ "${npm_code}" -eq 0 ]; then + published_ver="${npm_out}" + elif echo "${npm_out}" | grep -q -E "E404|404 Not Found"; then + published_ver="not published" + else + published_ver="lookup failed" + has_error=1 + fi # Read local version from package.json local_ver="unknown" @@ -33,11 +43,17 @@ for entry in "${packages[@]}"; do if [ -f "${target_json}" ]; then local_ver=$(node --input-type=module -e "import fs from 'fs'; console.log(JSON.parse(fs.readFileSync(process.argv[2], 'utf8')).version)" dummy "${target_json}" 2>/dev/null || echo "unknown") fi + if [ "${local_ver}" = "unknown" ]; then + has_error=1 + fi status="Up to date" if [ "${published_ver}" != "${local_ver}" ]; then status="Out of sync" + has_error=1 fi printf "%-38s | %-16s | %-16s | %-12s\n" "${full_npm_name}" "${published_ver}" "${local_ver}" "${status}" done + +exit ${has_error} diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index bd2e686c..735bf1af 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to the `@magmacomputing/tempo-plugin-ai` project will be doc The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.3.0] - 2026-08-04 +## [0.3.0] - 2026-08-10 ### Added - **Intelligent Calendar Scheduling (`scheduleAI`)**: Introduced natural language appointment scheduling with deterministic conflict detection and automated slot bumping powered by `Interval.overlaps()`. @@ -23,8 +23,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Dynamic Remote Provider Manifest (`loadRemoteManifest`)**: Lazily fetches remote provider defaults (`providers.v1.json`) on initialization with a 1500ms timeout and automatic air-gapped fallback to compiled `DEFAULT_PROVIDERS`. - **Cache Eviction Synchronization**: Enhanced `clearAiCache()` to flush matching keys and prefixes across both `Tempo.cache` and custom `AiCacheAdapter` storage engines, returning `Promise` to await async adapter eviction. -### Changed +### Changed & Hardened +- **Deterministic Conflict Bumping (`scheduleAI`)**: Enhanced the scheduling engine's conflict-adjustment logic to iteratively shift proposed intervals against conflicting event bounds, re-verifying against all `busyEvents` and `workingHours` with finite loop guards to guarantee deterministic non-overlapping slots. - **Streamlined ISO Parsing**: Refactored internal date resolution in `scheduleAI` to delegate directly to core `Tempo` constructors (`new Tempo(str, { timeZone })`), removing redundant regex parsing layers and manual `Temporal.PlainDateTime` conversions. +- **Streamlined Public API Surface**: Removed redundant RFC 5545 utility exports (`isFiniteRule`, `parseRRule`) from the AI plugin entry point in favor of direct imports from `@magmacomputing/tempo/library`. +- **Test Lifecycle & Mock Isolation**: Upgraded all test suite cleanup hooks (`afterEach`) to utilize `vi.restoreAllMocks()` instead of `vi.clearAllMocks()`, ensuring network fetch and console mocks are completely reverted between test cases. Standardized `beforeEach` hooks to run asynchronously and pin `remoteConfigUrl: false` to ensure isolated, deterministic CI test runs. +- **Revision-Guarded State Initialization**: Implemented revision tracking in `initAI` to prevent background remote manifest network resolutions from overwriting newer local initialization configurations. +- **Context Propagation in Recurrence**: Hardened `recurrenceAI` to resolve and propagate full `Tempo` context (`timeZone`, `calendar`, `locale`, `sphere`) across anchor initialization and subsequent recurrence occurrence expansions. ## [0.2.0] - 2026-07-30 diff --git a/packages/plugins/ai/README.md b/packages/plugins/ai/README.md index 3b5b1ebb..2eeb6af8 100644 --- a/packages/plugins/ai/README.md +++ b/packages/plugins/ai/README.md @@ -25,21 +25,33 @@ npm install @magmacomputing/tempo-plugin-ai ### 🎯 Usage ```typescript -import { parseAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; +import { parseAI, scheduleAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; // Initialize with your BYOK API keys -initAI({ +await initAI({ providers: [ { id: 'groq', key: process.env.GROQ_API_KEY! } ] }); -// Parse natural language into a Tempo instance! +// 1. Parse natural language into a Tempo instance! const dt = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); console.log(dt.format('{yyyy}-{mm}-{dd}')); // 2026-11-17 -console.log(dt.ai?.confidence); // 0.98 -console.log(dt.ai?.provider); // 'groq' +console.log(dt.ai?.confidence); // 0.98 +console.log(dt.ai?.provider); // 'groq' + +// 2. Schedule appointment slots around busy events +const booking = await scheduleAI("45 min sync next Wednesday afternoon", { + events: [{ start: "2026-08-12 14:00", end: "2026-08-12 15:00", title: "Team standup" }] +}); + +console.log(booking.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')); // 2026-08-12 15:00 +console.log(booking.end.format('{yyyy}-{mm}-{dd} {hh}:{mi}')); // 2026-08-12 15:45 +console.log(booking.durationMinutes); // 45 +console.log(booking.slot); // Interval +console.log(booking.alternatives); // Interval[] +console.log(booking.ai?.conflictBumped); // true // Evict cached resolution clearAiCache("The penultimate Tuesday before Thanksgiving in 2026"); diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md index 7195fe11..8f7e7428 100644 --- a/packages/plugins/ai/doc/architecture.md +++ b/packages/plugins/ai/doc/architecture.md @@ -47,10 +47,14 @@ initAI({ }); ``` -### Dynamic Provider Manifests & Air-Gapped Fallback +### Dynamic Provider Manifests & Remote Endpoint Trust By default, `@magmacomputing/tempo-plugin-ai` lazily fetches provider defaults (model IDs, endpoints, token parameter keys) from `https://tempo.magmacomputing.com.au/providers.v1.json` once per application lifecycle. +- **Remote Manifest Trust & Endpoint Enforcement**: + - `remoteConfigUrl` is restricted to fixed trusted hosts (`tempo.magmacomputing.com.au` or trusted internal HTTPS endpoints). + - Any dynamic `provider.url` values received from the manifest or dynamically returned via `fetchDefaults` are strictly validated against an enforced provider host allowlist (or must use verified HTTPS/localhost origins) before `getResolvedProviderDefaults()` merges them into runtime provider configurations. Untrusted or unauthenticated endpoints are rejected and stripped before merging. +- **Validation on `fetchDefaults` Hook**: The exact same host allowlist and HTTPS origin verification is enforced when resolving custom provider options via the `fetchDefaults` callback. Any dynamic hook attempting to return unauthenticated or disallowed host URLs will have the `url` property safely discarded. - **Async Resolution & Promise Lifecycle**: `initAI()` returns a `Promise`. - **Synchronous Fire-and-Forget**: Calling `initAI(...)` synchronously without `await` immediately initializes system state with compiled local provider defaults (`DEFAULT_PROVIDERS`). You can execute `parseAI()` immediately on the next line without blocking. The remote manifest is fetched in the background and transparently updates provider defaults once received. - **Guaranteed Remote Resolution**: If your application strictly requires remote provider defaults to be resolved before executing your first AI request, you can `await initAI(...)`: diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md index 8b6e2cae..df45c8ea 100644 --- a/packages/plugins/ai/doc/index.md +++ b/packages/plugins/ai/doc/index.md @@ -39,12 +39,14 @@ console.log(dt.format('{yyyy}-{mm}-{dd}')); // 2026-11-17 ``` ## AI Function Catalog - -| Function | Input | Output | Guide | -| :--- | :--- | :--- | :--- | -| **`initAI`** | Configuration object | `Promise` | [Initialization & Provider Farm Guide](./init.md) | -| **`parseAI`** | Unstructured text string | `Promise` | [Point-in-Time Parsing Guide](./parse.md) | -| **`recurrenceAI`** | Natural language schedule OR RFC 5545 RRULE string | `Promise` | [Recurrence & Schedules Guide](./recurrence.md) | +All AI functions return a standard ES Promise wrapped object. + +| Function | Input | Returns (`Promise<...>`) | What You Get | Guide | +| :--- | :--- | :--- | :--- | :--- | +| **`initAI`** | Provider config & API keys | `void` | Configured AI provider farm | [Initialization & Provider Farm Guide](./init.md) | +| **`parseAI`** | Natural language text string(s) | `Tempo` \| `Tempo[]` | Single point-in-time `Tempo` instance (or batch array) | [Point-in-Time Parsing Guide](./parse.md) | +| **`recurrenceAI`** | Natural language pattern or RRULE string | `TempoRecurrenceResult` | **Iterable series of `Tempo` dates** (with `.take(n)`, `.next`, & RRULE string) | [Recurrence & Schedules Guide](./recurrence.md) | +| **`scheduleAI`** | Booking prompt + busy constraints | `TempoScheduleResult` | **Resolved appointment slot** (`startTempo`, `endTempo`, conflict-bumped) | [Recurrence & Schedules Guide](./recurrence.md) | ## Architecture & Infrastructure Guides diff --git a/packages/plugins/ai/doc/init.md b/packages/plugins/ai/doc/init.md index a3e48f1c..bebba3dd 100644 --- a/packages/plugins/ai/doc/init.md +++ b/packages/plugins/ai/doc/init.md @@ -86,5 +86,9 @@ export interface AiConfig { cache?: Map; /** Custom cache adapter for distributed storage (e.g. Redis, KV) */ cacheAdapter?: AiCacheAdapter; + /** Global default time-to-live in milliseconds for cache adapters */ + ttl?: number; + /** URL for dynamic remote provider manifest updates, or `false` to disable */ + remoteConfigUrl?: string | false; } ``` diff --git a/packages/plugins/ai/doc/rate-limits.md b/packages/plugins/ai/doc/rate-limits.md index 0b8270b1..d8d10562 100644 --- a/packages/plugins/ai/doc/rate-limits.md +++ b/packages/plugins/ai/doc/rate-limits.md @@ -139,7 +139,7 @@ const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPST const redisAdapter: AiCacheAdapter = { get: async (key) => (await redis.get(`tempo:ai:${key}`)) ?? undefined, set: async (key, value, ttlMs) => { - if (ttlMs) await redis.set(`tempo:ai:${key}`, value, { px: ttlMs }); + if (ttlMs !== undefined) await redis.set(`tempo:ai:${key}`, value, { px: ttlMs }); else await redis.set(`tempo:ai:${key}`, value); }, delete: async (key) => { diff --git a/packages/plugins/ai/doc/recurrence.md b/packages/plugins/ai/doc/recurrence.md index 2e14072f..e2127cf8 100644 --- a/packages/plugins/ai/doc/recurrence.md +++ b/packages/plugins/ai/doc/recurrence.md @@ -86,6 +86,9 @@ export interface TempoRecurrenceResult { /** Localized human-friendly schedule summary */ summary: string; + /** Reasoning / explanation of how the recurrence pattern was parsed */ + reasoning?: string; + /** True if schedule has an explicit end date or count limit; false if infinite */ isFinite: boolean; diff --git a/packages/plugins/ai/plan/v0.3.0-roadmap.md b/packages/plugins/ai/plan/v0.3.0-roadmap.md index c1729ce3..aa42aad4 100644 --- a/packages/plugins/ai/plan/v0.3.0-roadmap.md +++ b/packages/plugins/ai/plan/v0.3.0-roadmap.md @@ -4,27 +4,31 @@ This document captures the planned feature set, architectural requirements, and --- -## 1. Implementation of Scaffolded AI Function Handlers +## 1. AI Function Handler Implementations (v0.3.0 Status) -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: +### 1.1 ✅ `scheduleAI(prompt: string, options?: TempoScheduleOptions): Promise` +* Resolves natural language scheduling prompts against working hours, existing calendar events, and timezones into an optimal start/end `TempoScheduleResult` interval (`slot`, `alternatives`, `ai.conflictBumped`). -### 1.1 `formatAI(tempo: Tempo, prompt: string, options?: AiOptions): Promise` +### 1.2 ✅ `recurrenceAI(prompt: string, options?: TempoRecurrenceOptions): Promise` +* Translates complex natural language repeating schedule descriptions into standard RFC 5545 RRULE strings and stateful `Tempo` date batches (`rule.take(count)`). + +--- + +## 2. Upcoming AI Function Handlers (Post-v0.3.0 Roadmap) + +The following functions remain scaffolded for upcoming releases: + +### 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)"`. -### 1.2 `extractAI(text: string, options?: AiOptions): Promise` +### 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`). -### 1.3 `diffAI(start: Tempo, end: Tempo, prompt?: string, options?: AiOptions): Promise` +### 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)"`). -### 1.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. - -### 1.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)`). - -### 1.6 `contextAI(text: string, options?: AiOptions): Promise` +### 2.4 `contextAI(text: string, options?: AiOptions): Promise` * Infers `timeZone`, `locale`, and preferred `calendar` system from ambiguous location descriptions or user bios. --- diff --git a/packages/plugins/ai/src/core/config.ts b/packages/plugins/ai/src/core/config.ts index a44de2bf..a1aff7f2 100644 --- a/packages/plugins/ai/src/core/config.ts +++ b/packages/plugins/ai/src/core/config.ts @@ -1,4 +1,4 @@ -import { secure } from '@magmacomputing/library'; +import { secure } from '@magmacomputing/tempo/library'; import type { AiProvider } from '../types/index.js'; /** diff --git a/packages/plugins/ai/src/core/init.ts b/packages/plugins/ai/src/core/init.ts index 578d57dd..cfa715a8 100644 --- a/packages/plugins/ai/src/core/init.ts +++ b/packages/plugins/ai/src/core/init.ts @@ -1,9 +1,13 @@ import { Tempo } from '@magmacomputing/tempo'; -import { getResolvedProviderDefaults, loadRemoteManifest } from './manifest.js'; +import { getResolvedProviderDefaults, loadRemoteManifest, resetManifestCache } from './manifest.js'; import { normalizeCacheInput, assertNoReservedProviderId } from './support.js'; import type { AiConfig, AiRateLimits, AiProvider } from '../types/index.js'; +/** + * Internal singleton state container for the AI plugin. + * @internal + */ export const _state: { config: AiConfig; limits: AiRateLimits | null; @@ -14,6 +18,21 @@ export const _state: { revision: 0, } +/** + * Initializes the Tempo AI plugin with the specified global configuration. + * Configures AI provider credentials, models, timeouts, caching options, + * and asynchronously resolves provider defaults against remote manifests. + * + * @param config - Global AI plugin configuration object + * @returns A Promise that resolves once initial configuration and background manifest synchronization is scheduled + * @example + * ```ts + * await initAI({ + * providers: [{ id: 'groq', key: 'gsk_...' }], + * mode: AiMode.Consensus + * }); + * ``` + */ export function initAI(config: AiConfig): Promise { if (config.providers) assertNoReservedProviderId(config.providers); @@ -44,38 +63,64 @@ export function initAI(config: AiConfig): Promise { Tempo.init({ cache: config.cache, silent: true }); } - return (async () => { - if (remoteUrl !== false) { - try { - await loadRemoteManifest(remoteUrl, undefined, config.debug ?? _state.config.debug); - } catch { } - } + return (async () => { + if (remoteUrl !== false) { + try { + await loadRemoteManifest(remoteUrl, undefined, config.debug ?? _state.config.debug); + } catch { } + } - if (_state.revision !== currentRevision) return; - - if (config.fetchDefaults && config.providers) { - const asyncProviders = await Promise.all(config.providers.map(async p => { - const normalizedId = p.id?.toLowerCase() ?? ''; - const defaults = getResolvedProviderDefaults(normalizedId, remoteUrl, config.debug ?? _state.config.debug); - let hookOptions: Partial | null = null; - try { - hookOptions = await config.fetchDefaults!(normalizedId); - } catch { } - return { - ...defaults, - ...(hookOptions ?? {}), - ...p - } as AiProvider; - })); - if (_state.revision === currentRevision) - _state.config.providers = asyncProviders; - } else if (config.providers) { - if (_state.revision === currentRevision) - _state.config.providers = resolveSyncProviders(config.providers); - } - })(); + if (_state.revision !== currentRevision) return; + + const fetchDefaults = config.fetchDefaults ?? _state.config.fetchDefaults; + const currentProviders = config.providers ?? _state.config.providers; + + if (fetchDefaults && currentProviders) { + const asyncProviders = await Promise.all(currentProviders.map(async p => { + const normalizedId = p.id?.toLowerCase() ?? ''; + const defaults = getResolvedProviderDefaults(normalizedId, remoteUrl, config.debug ?? _state.config.debug); + let hookOptions: Partial | null = null; + try { + hookOptions = await fetchDefaults(normalizedId); + } catch { } + return { + ...defaults, + ...(hookOptions ?? {}), + ...p, + } as AiProvider; + })); + if (_state.revision === currentRevision) + _state.config.providers = asyncProviders; + } else if (config.providers) { + if (_state.revision === currentRevision) + _state.config.providers = resolveSyncProviders(config.providers); + } + })(); +} + +/** + * Resets the global AI state, cached rate limits, and remote manifest cache. + * Useful for test isolation and clean lifecycle teardown. + */ +export function resetAI(): void { + _state.config = {}; + _state.limits = null; + _state.revision = 0; + resetManifestCache(); } +/** + * Clears AI parsing results from the in-memory cache and any external storage adapters. + * If specific input strings or keys are provided, selectively purges only those entries. + * + * @param input - Optional string key, date string, or array of strings to purge from the cache + * @returns A Promise that resolves once cache eviction is completed + * @example + * ```ts + * await clearAiCache('next tuesday'); + * await clearAiCache(); // Clears all cached AI entries + * ``` + */ export async function clearAiCache(input?: string | string[]): Promise { const adapter = _state.config.cacheAdapter; @@ -115,24 +160,42 @@ export async function clearAiCache(input?: string | string[]): Promise { } } +/** + * Retrieves the latest observed rate limits across all provider responses. + * + * @returns The current rate limits snapshot containing remaining requests/tokens and reset timestamp, or `null` if none recorded + */ export function getAiRateLimits(): AiRateLimits | null { - return _state.limits; + return _state.limits; } +/** + * Returns an immutable, sanitized snapshot of the active AI configuration. + * Sensitive provider API keys are redacted for safety. + * + * @returns A frozen, read-only copy of the active AI configuration with redacted API keys + */ export function getAiConfig(): Readonly { - const sanitizedProviders: AiProvider[] = _state.config.providers?.map(p => { - const clone = { ...p }; - if (clone.key) - clone.key = '[REDACTED]'; - return clone; - }) ?? []; - - return Object.freeze({ - ..._state.config, - providers: Object.freeze(sanitizedProviders) as unknown as AiProvider[] - }); + const sanitizedProviders: AiProvider[] = _state.config.providers?.map(p => { + const clone = { ...p }; + if (clone.key) + clone.key = '[REDACTED]'; + return clone; + }) ?? []; + + return Object.freeze({ + ..._state.config, + providers: Object.freeze(sanitizedProviders) as unknown as AiProvider[] + }); } +/** + * Parses HTTP rate-limit reset headers into a `Tempo` instance. + * Supports raw seconds, UNIX timestamps, compound durations (e.g. '1m30s'), and HTTP-date strings. + * + * @param resetHeader - The raw reset header string value (e.g. from `Retry-After` or `x-ratelimit-reset-*`) + * @returns A `Tempo` instance pointing to the reset time, or `null` if unparseable + */ export function parseResetHeaderToTempo(resetHeader: string): Tempo | null { const trimmed = resetHeader.trim(); if (!trimmed) return null; @@ -193,6 +256,13 @@ export function parseResetHeaderToTempo(resetHeader: string): Tempo | null { return null; } +/** + * Extracts and parses rate-limiting metadata from an HTTP response's headers. + * Inspects `x-ratelimit-remaining-requests`, `x-ratelimit-remaining-tokens`, and `retry-after`/`reset` headers. + * + * @param response - The Fetch `Response` object to inspect + * @returns An `AiRateLimits` structure, or `null` if no rate-limit headers are present + */ 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'); @@ -220,6 +290,12 @@ export function parseRateLimitsFromResponse(response: Response): AiRateLimits | } } +/** + * Inspects an HTTP response and extracts updated rate-limit statistics. + * + * @param response - The Fetch `Response` object to inspect + * @returns An `AiRateLimits` structure, or `null` if no rate-limit headers are present + */ export function updateRateLimitsFromResponse(response: Response): AiRateLimits | null { return parseRateLimitsFromResponse(response); } diff --git a/packages/plugins/ai/src/core/manifest.ts b/packages/plugins/ai/src/core/manifest.ts index 7b69d5a9..25b9ea9e 100644 --- a/packages/plugins/ai/src/core/manifest.ts +++ b/packages/plugins/ai/src/core/manifest.ts @@ -41,17 +41,16 @@ export async function loadRemoteManifest( } const fetchPromise = (async () => { + let timer: ReturnType | undefined; try { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + timer = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(targetUrl, { signal: controller.signal, - headers: { Accept: 'application/json' } + headers: { Accept: 'application/json' }, }); - clearTimeout(timer); - if (!response.ok) { if (debug) { console.warn(`[tempo-plugin-ai] Remote manifest fetch failed with status ${response.status}`); @@ -83,6 +82,7 @@ export async function loadRemoteManifest( _cachedManifestMap.set(targetUrl, empty); return null; } finally { + if (timer !== undefined) clearTimeout(timer); _fetchPromiseMap.delete(targetUrl); } })(); diff --git a/packages/plugins/ai/src/core/mode.ts b/packages/plugins/ai/src/core/mode.ts new file mode 100644 index 00000000..5b44fee8 --- /dev/null +++ b/packages/plugins/ai/src/core/mode.ts @@ -0,0 +1,217 @@ +import { TempoAiError } from './error.js'; +import { AiMode } from './config.js'; +import type { AiProvider } from '../types/index.js'; + +/** + * ## ModeCandidate + * Represents the normalized execution result returned by a single AI provider task. + * + * @template T - The domain-specific payload type (e.g. parsed date components, RRULE structure, schedule slot). + */ +export interface ModeCandidate { + /** The domain-specific result payload returned by the provider. */ + data: T; + /** The identifier of the AI provider that produced this candidate. */ + providerId: string; + /** Confidence score between 0.0 (unusable) and 1.0 (certain). */ + confidence?: number | undefined; + /** Rate limit metadata headers extracted from provider response. */ + rateLimits?: any; + /** + * Unique token or key used to determine consensus between different providers + * (e.g. normalized ISO string in parse, RRULE string in recurrence, timestamp pair in schedule). + */ + consensusKey?: string | undefined; + /** Whether the candidate was flagged as ambiguous or had conflicting alternatives. */ + ambiguous?: boolean | undefined; +} + +/** + * ## ProviderTask + * Async closure that executes a provider-specific AI request. + * + * @template T - The domain-specific payload type. + * @param provider - The target AI provider configuration to call. + * @param signal - Optional AbortSignal for aborting in-flight requests during race conditions. + * @returns Promise resolving to a normalized `ModeCandidate`. + */ +export type ProviderTask = (provider: AiProvider, signal?: AbortSignal | undefined) => Promise>; + +/** + * ## ExecuteModeOptions + * Optional configuration overrides passed to the mode execution orchestrator. + */ +export interface ExecuteModeOptions { + /** Minimum confidence threshold (0.0 to 1.0) required to accept a result without cascading. */ + minConfidence?: number | undefined; + /** Whether to emit debug telemetry and provider fallback warnings to stdout/stderr. */ + debug?: boolean | undefined; + /** Logging tag prefix for debugging output (e.g. 'tempo-plugin-ai:parse'). */ + tag?: string | undefined; +} + +/** + * Normalizes and extracts meaningful error details from multi-provider execution failures. + * Unwraps `AggregateError` instances (from `Promise.any`), preserving internal `TempoAiError` + * status codes and messages. + * + * @internal + * @param err - The raw error caught from the provider execution promise. + * @param fallbackPrefix - Contextual error prefix for generic error wrappers. + * @returns Normalized `Error` instance. + */ +function unwrapExecutionError(err: unknown, fallbackPrefix: string): Error { + if (err instanceof TempoAiError) return err; + if (err instanceof AggregateError) { + const firstTempoError = err.errors.find(e => e instanceof TempoAiError); + if (firstTempoError) return firstTempoError; + return new TempoAiError(`${fallbackPrefix}: ${err.message}`, 500); + } + const message = err instanceof Error ? err.message : String(err); + return new TempoAiError(`${fallbackPrefix}: ${message}`, 500); +} + +/** + * Executes providers sequentially in configured order. + * - Resolves immediately if a candidate meets or exceeds `minConfidence`. + * - If no candidate meets the threshold, falls back to the candidate with the highest confidence score. + * - Throws if all configured providers fail. + * + * @internal + */ +async function executeFallbackMode( + providers: AiProvider[], + task: ProviderTask, + options?: ExecuteModeOptions, +): Promise> { + let lastError: any = null; + let bestCandidate: ModeCandidate | null = null; + + for (const provider of providers) { + try { + const candidate = await task(provider); + const confidence = typeof candidate.confidence === 'number' ? candidate.confidence : 1.0; + + if (!bestCandidate || confidence > (bestCandidate.confidence ?? 0)) + bestCandidate = candidate; + + if (options?.minConfidence === undefined || confidence >= options.minConfidence) + return candidate; + + if (options?.debug) + console.log(`[${options.tag || 'tempo-plugin-ai'}] Provider '${candidate.providerId}' confidence (${confidence}) below minConfidence (${options.minConfidence}). Cascading to next provider...`); + + } catch (err: any) { + lastError = err; + if (err instanceof TempoAiError && err.code === 422 && options?.minConfidence === undefined) break; + if (options?.debug) + console.warn(`[${options.tag || 'tempo-plugin-ai'}] Provider '${provider.id}' failed:`, err); + } + } + + if (bestCandidate) return bestCandidate; + throw lastError || new TempoAiError('All configured AI providers failed.', 500); +} + +/** + * Executes providers concurrently, resolving with the fastest successful response. + * Uses an `AbortController` to cancel in-flight requests once a winner emerges. + * + * @internal + */ +async function executeRaceMode( + providers: AiProvider[], + task: ProviderTask, +): Promise> { + const controller = new AbortController(); + try { + const promises = providers.map(p => task(p, controller.signal)); + const winner = await Promise.any(promises); + controller.abort(); + return winner; + } catch (err: any) { + controller.abort(); + throw unwrapExecutionError(err, 'Provider race failed'); + } +} + +/** + * Dispatches concurrent requests across all providers and evaluates consensus: + * - If all responding providers yield the same `consensusKey`, confidence is elevated to 1.0 (unanimous). + * - If candidates disagree, selects the candidate with highest confidence and flags it as `ambiguous: true`. + * - Throws if all providers reject. + * + * @internal + */ +async function executeConsensusMode( + providers: AiProvider[], + task: ProviderTask, +): Promise> { + const controller = new AbortController(); + const promises = providers.map(p => task(p, controller.signal)); + const settled = await Promise.allSettled(promises); + + const fulfilled = settled + .filter((s): s is PromiseFulfilledResult> => 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) return fulfilled[0]; + + const firstKey = fulfilled[0].consensusKey; + const allMatch = firstKey !== undefined && fulfilled.every(f => f.consensusKey === firstKey); + + if (allMatch) { + return { + ...fulfilled[0], + confidence: 1.0, + ambiguous: false, + providerId: AiMode.Consensus, + }; + } + + const sorted = [...fulfilled].sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0)); + return { + ...sorted[0], + ambiguous: true, + }; +} + +/** + * ## executeWithMode + * Central multi-provider execution orchestrator for Tempo AI plugins. + * Routes task execution through the configured multi-provider strategy: + * - `Fallback`: Sequential cascade through providers until confidence threshold is met or highest confidence is found. + * - `Race`: Concurrent speculative requests with AbortSignal cancellation returning the fastest resolution. + * - `Consensus`: Dispatches concurrent calls across all providers and resolves matching/highest-confidence consensus. + * + * @internal + * @template T - The domain-specific payload type. + * @param mode - Execution strategy (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus`). + * @param providers - Array of available configured `AiProvider` definitions. + * @param task - Async task closure executed per provider. + * @param options - Execution options including `minConfidence`, `debug`, and logging `tag`. + * @returns Promise resolving to the winning `ModeCandidate`. + * @throws {TempoAiError} When the mode is invalid (400) or all providers fail (500/502). + */ +export async function executeWithMode( + mode: AiMode, + providers: AiProvider[], + task: ProviderTask, + options?: ExecuteModeOptions, +): Promise> { + switch (mode) { + case AiMode.Fallback: + return executeFallbackMode(providers, task, options); + case AiMode.Race: + return executeRaceMode(providers, task); + case AiMode.Consensus: + return executeConsensusMode(providers, task); + default: + throw new TempoAiError(`Invalid execution mode: '${mode}'. Supported modes: ${Object.values(AiMode).map(m => `'${m}'`).join(', ')}.`, 400); + } +} diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index 62f25954..53d96eb8 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -147,9 +147,10 @@ Do not include markdown blocks or any text outside the JSON.`; if (!response.ok) { const errorText = await response.text(); + const boundedError = errorText.length > 500 ? `${errorText.slice(0, 500)}... (truncated)` : errorText; const resetTime = limits?.resetAt ?? undefined; _state.limits = limits; - throw new TempoAiError(`Provider ${provider.id} failed with status ${response.status}. Details: ${errorText}`, response.status, resetTime); + throw new TempoAiError(`Provider ${provider.id} failed with status ${response.status}. Details: ${boundedError}`, response.status, resetTime); } const data = await response.json(); @@ -162,11 +163,11 @@ Do not include markdown blocks or any text outside the JSON.`; console.log(`[tempo-plugin-ai] Received response from '${provider.id}' in ${elapsed}ms`); } - return { rawContent: rawContent.trim(), providerId: provider.id, rateLimits: limits }; - } finally { - clearTimeout(timeoutId); - if (parentSignal) { - parentSignal.removeEventListener('abort', onParentAbort); - } - } + 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/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts index d14b4a00..b5180297 100644 --- a/packages/plugins/ai/src/functions/parse.ts +++ b/packages/plugins/ai/src/functions/parse.ts @@ -2,293 +2,234 @@ import { Tempo } from '@magmacomputing/tempo'; import { TempoAiError } from '../core/error.js'; import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; +import { executeWithMode } from '../core/mode.js'; import { normalizeCacheInput, attachAiMeta, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; import type { AiParseOptions } from '../types/index.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, timeout: callTimeout, ...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}`; - const adapter = options?.cacheAdapter ?? _state.config.cacheAdapter; - - let cachedIso: string | undefined; - if (!force && aiCacheOption !== false) { - if (adapter) { - try { - const val1 = await adapter.get(cacheKey); - if (val1) { - cachedIso = val1; - } else { - const val2 = await adapter.get(normalizedStr); - if (val2) { - cachedIso = val2; - } else { - const val3 = await adapter.get(str); - if (val3) cachedIso = val3; - } - } - } catch (err: any) { - if (isDebug) console.log('[tempo-plugin-ai] Cache adapter read error:', err?.message); - } - } - - if (!cachedIso) { - 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: "${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(`[tempo-plugin-ai] 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 || _state.config.mode || AiMode.Fallback; - const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence; - 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, undefined, callTimeout); - 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 (effectiveMinConfidence === undefined || candidateConfidence >= effectiveMinConfidence) { - successfulResult = { parsedData, providerId, rateLimits }; - break; - } - - if (isDebug) { - console.log(`[tempo-plugin-ai] Provider '${providerId}' confidence (${candidateConfidence}) below minConfidence (${effectiveMinConfidence}). Cascading to next provider...`); - } - } catch (err: any) { - lastError = err; - if (err instanceof TempoAiError && err.code === 422 && effectiveMinConfidence === 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, callTimeout); - 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, undefined, callTimeout); - 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, rateLimits } = 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 = effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence; - - 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, - limits: rateLimits ?? undefined - }); - } - - const parsedIso = `${rawIso.replace(/Z$/i, '')}[${tz}]`; - - // Determine TTL hierarchy: options.ttl > provider.ttl > global config.ttl > 3600000 (1 hour) - const winningProvider = availableProviders.find(p => p.id === providerId); - const resolvedTtl = options?.ttl ?? winningProvider?.ttl ?? _state.config.ttl ?? 3600000; - - if (aiCacheOption !== false) { - if (adapter) { - try { - const res = adapter.set(cacheKey, parsedIso, resolvedTtl); - if (res instanceof Promise) await res; - } catch (err: any) { - if (isDebug) console.log('[tempo-plugin-ai] Cache adapter write error:', err?.message); - } - } - 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, - limits: rateLimits ?? undefined - }); + const isDebug = options?.debug ?? _state.config.debug ?? false; + const normalizedStr = normalizeCacheInput(str); + + const { force, debug, mode: aiMode, providers, minConfidence, softErrors, cache: aiCacheOption, timeout: callTimeout, ttl, cacheAdapter, anchor, ...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}`; + const adapter = options?.cacheAdapter ?? _state.config.cacheAdapter; + + let cachedIso: string | undefined; + if (!force && aiCacheOption !== false) { + if (adapter) { + try { + const val1 = await adapter.get(cacheKey); + if (val1) { + cachedIso = val1; + } else { + const val2 = await adapter.get(normalizedStr); + if (val2) { + cachedIso = val2; + } else { + const val3 = await adapter.get(str); + if (val3) cachedIso = val3; + } + } + } catch (err: any) { + if (isDebug) console.log('[tempo-plugin-ai] Cache adapter read error:', err?.message); + } + } + + if (!cachedIso) { + 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: "${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(`[tempo-plugin-ai] 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 || _state.config.mode || AiMode.Fallback; + const effectiveMinConfidence = minConfidence ?? _state.config.minConfidence; + + const winningCandidate = await executeWithMode( + mode, + availableProviders, + async (provider, signal) => { + const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug, signal, callTimeout); + const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); + const parsedData = JSON.parse(cleanContent); + const confidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : (parsedData?.iso === 'INVALID' ? 0.0 : 1.0); + + return { + data: parsedData, + providerId, + rateLimits, + confidence, + consensusKey: parsedData?.iso, + ambiguous: Boolean(parsedData?.ambiguous || parsedData?.iso === 'INVALID'), + }; + }, + { minConfidence: effectiveMinConfidence, debug: isDebug, tag: 'tempo-plugin-ai:parse' }, + ); + + _state.limits = winningCandidate.rateLimits ?? null; + + const { data: parsedData, providerId, rateLimits, ambiguous: modeAmbiguous } = winningCandidate; + const rawIso = typeof parsedData?.iso === 'string' ? parsedData.iso : 'INVALID'; + const confidence = typeof winningCandidate.confidence === 'number' ? winningCandidate.confidence : (rawIso === 'INVALID' ? 0.0 : 1.0); + const ambiguous = Boolean(modeAmbiguous || parsedData?.ambiguous || rawIso === 'INVALID'); + const granularity = typeof parsedData?.granularity === 'string' ? parsedData.granularity : 'unknown'; + const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined; + + const isBelowMinConfidence = effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence; + + 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, + limits: rateLimits ?? undefined, + }); + } + + const parsedIso = `${rawIso.replace(/Z$/i, '')}[${tz}]`; + + // Determine TTL hierarchy: options.ttl > provider.ttl > global config.ttl > 3600000 (1 hour) + const winningProvider = availableProviders.find(p => p.id === providerId); + const resolvedTtl = options?.ttl ?? winningProvider?.ttl ?? _state.config.ttl ?? 3600000; + + if (aiCacheOption !== false) { + if (adapter) { + try { + const res = adapter.set(cacheKey, parsedIso, resolvedTtl); + if (res instanceof Promise) await res; + } catch (err: any) { + if (isDebug) console.log('[tempo-plugin-ai] Cache adapter write error:', err?.message); + } + } + 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, + limits: rateLimits ?? undefined, + }); } +/** + * ## parseAI + * Parses natural language temporal expressions, relative dates, and unstructured prompt strings + * into high-precision `Tempo` instances using configured LLM providers. + * + * Supports single strings or batch arrays, hierarchical TTL/caching, execution modes (Fallback, Race, Consensus), + * and attached AI metadata (confidence score, provider info, reasoning, and ambiguity flags). + * + * @param input - A natural language date/time prompt string or an array of prompt strings for batch resolution + * @param options - Optional parsing configuration including provider overrides, timeout, caching, and execution modes + * @returns Promise resolving to a Tempo instance (or an array of Tempo instances / TempoAiErrors for batch requests) + * @example + * ```ts + * const tempo = await parseAI('the Friday after Thanksgiving next year'); + * console.log(tempo.format('{yyyy}-{mm}-{dd}')); + * console.log(tempo.ai?.provider); // 'groq' + * console.log(tempo.ai?.confidence); // 0.98 + * ``` + */ 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 + 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); + 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 index 60e85606..dc8805af 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -1,18 +1,19 @@ import { Tempo } from '@magmacomputing/tempo'; -import { isRRuleString, isFiniteRRule, parseRRule, expandRRuleEpochs } from '@magmacomputing/library'; +import { isRRuleString, isFiniteRRule, parseRRule, expandRRuleEpochs } from '@magmacomputing/tempo/library'; import { TempoAiError } from '../core/error.js'; import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; +import { executeWithMode } from '../core/mode.js'; import { fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; import type { TempoRecurrenceOptions, TempoRecurrenceResult } from '../types/index.js'; function expandOccurrences(rrule: string, anchor: Tempo, options?: { count?: number; after?: any; before?: any }): Tempo[] { - const afterTempo = options?.after ? new Tempo(options.after) : undefined; - const beforeTempo = options?.before ? new Tempo(options.before) : undefined; + const afterTempo = options?.after ? new Tempo(options.after, anchor.config) : undefined; + const beforeTempo = options?.before ? new Tempo(options.before, anchor.config) : undefined; const epochs = expandRRuleEpochs(rrule, anchor.epoch.ms, { count: options?.count, afterMs: afterTempo ? afterTempo.epoch.ms : undefined, - beforeMs: beforeTempo ? beforeTempo.epoch.ms : undefined + beforeMs: beforeTempo ? beforeTempo.epoch.ms : undefined, }); return epochs.map(ms => new Tempo(ms, anchor.config)); @@ -29,9 +30,13 @@ function createRecurrenceResult( options?: TempoRecurrenceOptions ): TempoRecurrenceResult { const rule = parseRRule(rruleStr); + const hasWindow = Boolean(options?.after || options?.before); const isFinite = isFiniteRRule(rruleStr) || Boolean(options?.before); let sizeLimit: number; - if (rule.count !== undefined) { + if (hasWindow && isFinite) { + const windowOccurrences = expandOccurrences(rruleStr, anchorTempo, { count: rule.count ?? 1000, after: options?.after, before: options?.before }); + sizeLimit = rule.count !== undefined ? Math.min(rule.count, windowOccurrences.length) : windowOccurrences.length; + } else if (rule.count !== undefined) { sizeLimit = rule.count; } else if (isFinite) { sizeLimit = expandOccurrences(rruleStr, anchorTempo, { count: 1000, after: options?.after, before: options?.before }).length; @@ -45,16 +50,17 @@ function createRecurrenceResult( const ensureCached = (neededCount: number): void => { if (fullyExpanded || cachedOccurrences.length >= neededCount) return; + const missingCount = neededCount - cachedOccurrences.length; + const fetchCount = Math.max(missingCount, cachedOccurrences.length, defaultBatchSize); + const lastOccurrence = cachedOccurrences.length > 0 ? cachedOccurrences[cachedOccurrences.length - 1] : undefined; const fresh = expandOccurrences(rruleStr, anchorTempo, { - count: neededCount, - after: options?.after, - before: options?.before + count: fetchCount, + after: lastOccurrence ?? options?.after, + before: options?.before, }); - cachedOccurrences.length = 0; cachedOccurrences.push(...fresh); - if (fresh.length < neededCount) { + if (fresh.length < fetchCount) fullyExpanded = true; - } }; const take = (count?: number): Tempo[] => { @@ -103,20 +109,21 @@ function createRecurrenceResult( */ export async function recurrenceAI( input: string, - options?: TempoRecurrenceOptions + options?: TempoRecurrenceOptions, ): Promise { const isDebug = options?.debug ?? _state.config.debug ?? false; const isRRule = isRRuleString(input); - const anchorTempo = options?.anchor ? new Tempo(options.anchor) : new Tempo(); - const defaultBatchSize = options?.count ?? 5; - // Resolve full Tempo context hierarchy const tz = options?.timeZone || (options?.anchor instanceof Tempo ? options.anchor.config.timeZone : undefined) || Tempo.options.timeZone; const cal = options?.calendar || (options?.anchor instanceof Tempo ? options.anchor.config.calendar : undefined) || Tempo.options.calendar; const loc = options?.locale || (options?.anchor instanceof Tempo ? options.anchor.config.locale : undefined) || Tempo.options.locale; const sph = options?.sphere || (options?.anchor instanceof Tempo ? options.anchor.config.sphere : undefined) || Tempo.options.sphere; + const contextConfig = { timeZone: tz, calendar: cal, locale: loc, sphere: sph }; + const anchorTempo = options?.anchor ? new Tempo(options.anchor, contextConfig) : new Tempo(contextConfig); + const defaultBatchSize = options?.count ?? 5; + if (isRRule) { const cleanRRule = input.trim().replace(/^RRULE:/i, ''); if (isDebug) @@ -130,7 +137,7 @@ export async function recurrenceAI( 'Parsed natively from RFC 5545 RRULE string input.', anchorTempo, defaultBatchSize, - options + options, ); } @@ -141,10 +148,6 @@ export async function recurrenceAI( assertNoReservedProviderId(availableProviders); const mode = options?.mode || _state.config.mode || AiMode.Fallback; - if (mode !== AiMode.Fallback && mode !== AiMode.Race && mode !== AiMode.Consensus) { - throw new TempoAiError(`Invalid execution mode: '${mode}'. Supported modes are 'fallback', 'race', 'consensus'.`, 400); - } - const effectiveMinConfidence = options?.minConfidence ?? _state.config.minConfidence; const callTimeout = options?.timeout; const contextString = `Current Time: ${anchorTempo.format('{wkd}, {yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${loc}, Hemisphere: ${sph}.`; @@ -162,156 +165,56 @@ Rules: - "confidence": Float score between 0.0 (unparseable) and 1.0 (certain). Do not include markdown blocks or text outside the JSON.`; - let successfulResult: { parsedData: any; providerId: string; rateLimits?: any } | null = null; - let lastError: any = null; - let bestCandidate: { parsedData: any; providerId: string; rateLimits?: any } | null = null; - - if (mode === AiMode.Fallback) { - for (const provider of availableProviders) { - try { - const { rawContent, providerId, rateLimits } = await fetchFromProvider( - provider, - input, - contextString, - isDebug, - undefined, - callTimeout, - systemPrompt - ); - const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); - const parsedData = JSON.parse(cleanContent); - const candidateConfidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : 0.9; - - if (!bestCandidate || candidateConfidence > (bestCandidate.parsedData?.confidence ?? 0)) { - bestCandidate = { parsedData, providerId, rateLimits }; - } - - if (effectiveMinConfidence === undefined || candidateConfidence >= effectiveMinConfidence) { - successfulResult = { parsedData, providerId, rateLimits }; - break; - } - - if (isDebug) - console.log(`[tempo-plugin-ai:recurrence] Provider '${providerId}' confidence (${candidateConfidence}) below minConfidence (${effectiveMinConfidence}). Cascading to next provider...`); - } catch (err: any) { - lastError = err; - if (isDebug) - console.warn(`[tempo-plugin-ai:recurrence] Provider ${provider.id} failed:`, err); - } - } - - 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, - input, - contextString, - isDebug, - parentController.signal, - callTimeout, - systemPrompt - ); - const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); - return { parsedData: JSON.parse(cleanContent), providerId, rateLimits }; - }); - - // Attach no-op rejection handler to suppress unhandled promise warnings on aborted/slower requests - promises.forEach(p => p.catch(() => { })); - - 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 winningCandidate = await executeWithMode( + mode, + availableProviders, + async (provider, signal) => { const { rawContent, providerId, rateLimits } = await fetchFromProvider( provider, input, contextString, isDebug, - undefined, + signal, callTimeout, - systemPrompt + systemPrompt, ); 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 firstRrule = fulfilled[0].parsedData?.rrule; - const allMatch = fulfilled.every(f => f.parsedData?.rrule === firstRrule); - - if (allMatch) { - successfulResult = { - parsedData: { - ...fulfilled[0].parsedData, - confidence: 1.0 - }, - 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, - providerId: sorted[0].providerId, - rateLimits: sorted[0].rateLimits - }; - } - } - } - - if (!successfulResult) { - throw lastError || new TempoAiError('All configured AI providers failed.', 500); - } + const parsedData = JSON.parse(cleanContent); + const confidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : 0.9; + + return { + data: parsedData, + providerId, + rateLimits, + confidence, + consensusKey: parsedData?.rrule, + }; + }, + { minConfidence: effectiveMinConfidence, debug: isDebug, tag: 'tempo-plugin-ai:recurrence' }, + ); - _state.limits = successfulResult.rateLimits ?? null; + _state.limits = winningCandidate.rateLimits ?? null; - const { parsedData, providerId } = successfulResult; - if (typeof parsedData?.rrule !== 'string' || !parsedData.rrule.trim()) { + const { data: parsedData, providerId } = winningCandidate; + if (typeof parsedData?.rrule !== 'string' || !parsedData.rrule.trim()) throw new TempoAiError('Invalid recurrence response from AI provider: missing or empty rrule string.', 422); - } const rruleStr = parsedData.rrule.trim(); const summaryText = typeof parsedData?.summary === 'string' ? parsedData.summary : (typeof parsedData?.humanReadable === 'string' ? parsedData.humanReadable : input); const confidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : 0.9; const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined; - if (effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence) { + if (effectiveMinConfidence !== undefined && confidence < effectiveMinConfidence) throw new TempoAiError(`Recurrence rule confidence (${confidence}) is below the required threshold of ${effectiveMinConfidence}.`, 422); - } return createRecurrenceResult( rruleStr, summaryText, confidence, providerId, - isDebug ? reasoning : undefined, + reasoning, anchorTempo, defaultBatchSize, - options + options, ); } diff --git a/packages/plugins/ai/src/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts index f69537f8..00976a75 100644 --- a/packages/plugins/ai/src/functions/schedule.ts +++ b/packages/plugins/ai/src/functions/schedule.ts @@ -1,8 +1,9 @@ import { Tempo, Interval } from '@magmacomputing/tempo'; -import { isString, isNumber, isFunction } from '@magmacomputing/library/assertion.library.js'; +import { isString, isNumber, isFunction, DAY_MAP, ISO_WEEKDAY_NAMES, type DayKey } from '@magmacomputing/tempo/library'; import { TempoAiError } from '../core/error.js'; import { AiMode } from '../core/config.js'; import { _state } from '../core/init.js'; +import { executeWithMode } from '../core/mode.js'; import { fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; import type { TempoScheduleOptions, TempoScheduleResult, TempoWorkingHours, TempoInterval, TempoScheduleMeta, AiProvider } from '../types/index.js'; @@ -24,8 +25,10 @@ function normalizeBusyEvents(rawEvents?: any[], timeZone = 'UTC'): Array<{ start if ('start' in evt && 'end' in evt) { start = parsePoint((evt as any).start); end = parsePoint((evt as any).end); - if ('title' in evt) title = String((evt as any).title); - else if ('label' in evt) title = String((evt as any).label); + if ('title' in evt && (evt as any).title !== undefined && String((evt as any).title).trim().length > 0) + title = String((evt as any).title); + else if ('label' in evt && (evt as any).label !== undefined && String((evt as any).label).trim().length > 0) + title = String((evt as any).label); } else if (Array.isArray(evt) && evt.length >= 2) { start = parsePoint(evt[0]); end = parsePoint(evt[1]); @@ -51,6 +54,19 @@ function parseDurationMinutes(prompt: string, fallback?: number): number { return 30; // default 30 minutes } +function formatActiveDays(days?: Array): string { + const active = days ?? [1, 2, 3, 4, 5]; + return active.map(d => { + if (typeof d === 'number' && (ISO_WEEKDAY_NAMES as any)[d]) return (ISO_WEEKDAY_NAMES as any)[d]; + if (typeof d === 'string') { + const upper = d.toUpperCase() as DayKey; + const num = (DAY_MAP as any)[upper]; + if (num && (ISO_WEEKDAY_NAMES as any)[num]) return (ISO_WEEKDAY_NAMES as any)[num]; + } + return String(d); + }).join(', '); +} + function buildContextPrompt( anchorTempo: Tempo, timeZone: string, @@ -58,8 +74,7 @@ function buildContextPrompt( busyEvents: Array<{ start: Tempo; end: Tempo; title?: string | undefined }>, durationMinutes: number ): string { - const daysMap = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - const activeDays = (workingHours.days ?? [1, 2, 3, 4, 5]).map(d => daysMap[d] ?? d).join(', '); + const activeDays = formatActiveDays(workingHours.days); const whStart = workingHours.start ?? '09:00'; const whEnd = workingHours.end ?? '17:00'; @@ -93,14 +108,11 @@ Instructions: "confidence": number between 0.0 and 1.0 "alternatives": array of secondary { "start": "...", "end": "..." } options if available`; -function wrapScheduleInterval( - interval: Interval, - meta: TempoScheduleMeta -): TempoScheduleResult { +function wrapScheduleInterval(interval: Interval, meta: TempoScheduleMeta): TempoScheduleResult { const frozenMeta = Object.freeze(meta); return new Proxy(interval, { get(target, prop) { - if (prop in frozenMeta) + if (Object.hasOwn(frozenMeta, prop)) return (frozenMeta as any)[prop]; const val = Reflect.get(target, prop, target); @@ -108,16 +120,16 @@ function wrapScheduleInterval( return val; }, has(target, prop) { - if (prop in frozenMeta) return true; + if (Object.hasOwn(frozenMeta, prop)) return true; return Reflect.has(target, prop); }, getOwnPropertyDescriptor(target, prop) { - if (prop in frozenMeta) { + if (Object.hasOwn(frozenMeta, prop)) { return { value: (frozenMeta as any)[prop], writable: false, configurable: true, - enumerable: true + enumerable: true, }; } return Reflect.getOwnPropertyDescriptor(target, prop); @@ -128,7 +140,7 @@ function wrapScheduleInterval( if (!keys.includes(k)) keys.push(k); } return keys; - } + }, }) as unknown as TempoScheduleResult; } @@ -152,9 +164,8 @@ export async function scheduleAI( const state = _state; const availableProviders = options?.providers ?? state.config.providers; - if (!availableProviders || availableProviders.length === 0) { + if (!availableProviders || availableProviders.length === 0) throw new TempoAiError('No AI providers configured for scheduleAI. Call initAI() or supply providers in options.', 400); - } assertNoReservedProviderId(availableProviders); @@ -164,8 +175,8 @@ export async function scheduleAI( start: options?.workingHours?.start ?? '09:00', end: options?.workingHours?.end ?? '17:00', days: options?.workingHours?.days ?? [1, 2, 3, 4, 5], - timeZone: options?.workingHours?.timeZone ?? timeZone - }; + timeZone: options?.workingHours?.timeZone ?? timeZone, + } const rawBusy = options?.events ?? options?.intervals; const busyEvents = normalizeBusyEvents(rawBusy, timeZone); @@ -173,146 +184,172 @@ export async function scheduleAI( const contextString = buildContextPrompt(anchorTempo, timeZone, workingHours, busyEvents, durationMinutes); const isDebug = Boolean(options?.debug ?? state.config.debug); - const mode = (options?.mode || state.config.mode || AiMode.Fallback).toLowerCase(); + const mode = options?.mode || state.config.mode || AiMode.Fallback; const callTimeout = options?.timeout ?? state.config.timeout ?? 15000; - const executeProviderCall = async (provider: AiProvider, signal?: AbortSignal) => { - const { rawContent, providerId, rateLimits } = await fetchFromProvider( - provider, - prompt, - contextString, - isDebug, - signal, - callTimeout, - SCHEDULE_SYSTEM_PROMPT - ) - - const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); - let parsed: any; - try { - parsed = JSON.parse(cleanContent); - } catch { - throw new TempoAiError(`Provider ${provider.id} returned invalid JSON payload.`, 422); - } - - if (!parsed.start || !parsed.end) { - throw new TempoAiError(`Provider ${provider.id} missing start or end ISO timestamp.`, 422); - } - - let finalStart: Tempo; - let finalEnd: Tempo; - try { - finalStart = new Tempo(parsed.start, { timeZone }); - finalEnd = new Tempo(parsed.end, { timeZone }); - } catch { - throw new TempoAiError(`Provider ${provider.id} returned unparseable start or end timestamp.`, 422); - } - - if (finalEnd.epoch.ms <= finalStart.epoch.ms) { - throw new TempoAiError(`Provider ${provider.id} proposed end time before or equal to start time.`, 422); - } - - return { - parsed, - startTempo: finalStart, - endTempo: finalEnd, - confidence: isNumber(parsed.confidence) ? parsed.confidence : 0.9, - providerId, - rateLimits, - summary: parsed.summary || `Scheduled slot ${finalStart.format('{yyyy}-{mm}-{dd} {hh}:{mi}')} to ${finalEnd.format('{hh}:{mi}')}`, - reasoning: parsed.reasoning || 'Resolved slot via AI scheduler.', - alternatives: Array.isArray(parsed.alternatives) ? parsed.alternatives : [] - }; - }; + const winningCandidate = await executeWithMode( + mode, + availableProviders, + async (provider, signal) => { + const { rawContent, providerId, rateLimits } = await fetchFromProvider( + provider, + prompt, + contextString, + isDebug, + signal, + callTimeout, + SCHEDULE_SYSTEM_PROMPT, + ); + + const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); + let parsed: any; + try { + parsed = JSON.parse(cleanContent); + } catch { + throw new TempoAiError(`Provider ${provider.id} returned invalid JSON payload.`, 422); + } - let selectedResult: any; + if (!parsed.start || !parsed.end) + throw new TempoAiError(`Provider ${provider.id} missing start or end ISO timestamp.`, 422); - if (mode === AiMode.Fallback || mode === 'fallback') { - let lastErr: any; - for (const provider of availableProviders) { + let finalStart: Tempo; + let finalEnd: Tempo; try { - selectedResult = await executeProviderCall(provider); - break; - } catch (err) { - lastErr = err; + finalStart = new Tempo(parsed.start, { timeZone }); + finalEnd = new Tempo(parsed.end, { timeZone }); + } catch { + throw new TempoAiError(`Provider ${provider.id} returned unparseable start or end timestamp.`, 422); } - } - if (!selectedResult) { - throw lastErr || new TempoAiError('All configured AI providers failed during scheduleAI execution.', 502); - } - } else if (mode === AiMode.Race || mode === 'race') { - const parentController = new AbortController(); - try { - const promises = availableProviders.map(p => executeProviderCall(p, parentController.signal)); - promises.forEach(p => p.catch(() => { })); - selectedResult = await Promise.race(promises); - parentController.abort(); - } catch (aggregateErr: any) { - parentController.abort(); - throw aggregateErr instanceof TempoAiError - ? aggregateErr - : new TempoAiError(`All providers failed in race mode: ${aggregateErr.message}`, 502); - } - } else if (mode === AiMode.Consensus || mode === 'consensus') { - const parentController = new AbortController(); - const results = await Promise.allSettled( - availableProviders.map(p => executeProviderCall(p, parentController.signal)) - ); - - const fulfilled = results - .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') - .map(r => r.value); - - if (fulfilled.length === 0) { - const rejected = results.find(r => r.status === 'rejected') as PromiseRejectedResult; - throw rejected.reason instanceof TempoAiError - ? rejected.reason - : new TempoAiError('All providers failed in consensus mode during scheduleAI execution.', 502); - } - fulfilled.sort((a, b) => b.confidence - a.confidence); - selectedResult = fulfilled[0]; - } else { - throw new TempoAiError(`Invalid execution mode '${options?.mode}' provided to scheduleAI.`, 400); - } + if (finalEnd.epoch.ms <= finalStart.epoch.ms) + throw new TempoAiError(`Provider ${provider.id} proposed end time before or equal to start time.`, 422); + + const startKey = finalStart.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}'); + const endKey = finalEnd.format('{yyyy}-{mm}-{dd}T{hh}:{mi}:{ss}'); + + return { + data: { + parsed, + startTempo: finalStart, + endTempo: finalEnd, + summary: parsed.summary || `Scheduled slot ${finalStart.format('{yyyy}-{mm}-{dd} {hh}:{mi}')} to ${finalEnd.format('{hh}:{mi}')}`, + reasoning: parsed.reasoning || 'Resolved slot via AI scheduler.', + alternatives: Array.isArray(parsed.alternatives) ? parsed.alternatives : [], + }, + providerId, + rateLimits, + confidence: isNumber(parsed.confidence) ? parsed.confidence : 0.9, + consensusKey: `${startKey}::${endKey}`, + }; + }, + { minConfidence: options?.minConfidence ?? state.config.minConfidence, debug: isDebug, tag: 'tempo-plugin-ai:schedule' }, + ); + + _state.limits = winningCandidate.rateLimits ?? null; - _state.limits = selectedResult.rateLimits ?? null; + const { data: scheduleData, providerId } = winningCandidate; + const confidence = typeof winningCandidate.confidence === 'number' ? winningCandidate.confidence : 0.9; const minConf = options?.minConfidence ?? state.config.minConfidence ?? 0.0; - if (selectedResult.confidence < minConf) { - throw new TempoAiError( - `scheduleAI confidence (${selectedResult.confidence}) is below the required threshold of ${minConf}`, - 422 - ); - } + if (confidence < minConf) + throw new TempoAiError(`scheduleAI confidence (${confidence}) is below the required threshold of ${minConf}`, 422); - let finalStart = selectedResult.startTempo; - let finalEnd = selectedResult.endTempo; + let finalStart = scheduleData.startTempo; + let finalEnd = scheduleData.endTempo; let conflictBumped = false; let originalSlot: TempoInterval | undefined; - // Deterministic Conflict Validation using core Interval.overlaps() - const proposedInterval = new Interval(finalStart, finalEnd); - const conflictingEvent = busyEvents.find(b => { - const busyInt = new Interval(b.start, b.end); - const isOver = proposedInterval.overlaps(busyInt); - return isOver; + const whStartStr = workingHours.start ?? '09:00'; + const whEndStr = workingHours.end ?? '17:00'; + const [whStartH, whStartM] = whStartStr.split(':').map(v => parseInt(v, 10) || 0); + const [whEndH, whEndM] = whEndStr.split(':').map(v => parseInt(v, 10) || 0); + const whTz = workingHours.timeZone || timeZone; + + const activeDaysList = (workingHours.days ?? [1, 2, 3, 4, 5]).map(d => { + if (typeof d === 'number') return d; + if (typeof d === 'string') { + const upper = d.toUpperCase() as DayKey; + if (upper in DAY_MAP) return (DAY_MAP as any)[upper]; + const n = parseInt(d, 10); + if (!isNaN(n)) return n; + } + return typeof d === 'number' ? d : 1; }); + const activeDaysSet = new Set(activeDaysList.length > 0 ? activeDaysList : [1, 2, 3, 4, 5]); + const advanceToNextActiveDay = (curZdt: Temporal.ZonedDateTime): Temporal.ZonedDateTime => { + let next = curZdt.add({ days: 1 }).startOfDay().add({ hours: whStartH, minutes: whStartM }); + while (!activeDaysSet.has(next.dayOfWeek)) { + next = next.add({ days: 1 }); + } + return next; + }; + + // Deterministic Conflict Validation using core Interval.overlaps() + let proposedInterval = new Interval(finalStart, finalEnd); + let conflictingEvent = busyEvents.find(b => proposedInterval.overlaps(new Interval(b.start, b.end))); + + let reasoning = scheduleData.reasoning; if (conflictingEvent) { conflictBumped = true; originalSlot = { start: finalStart, end: finalEnd }; - // Bump start to the end of the conflicting event - finalStart = conflictingEvent.end; - finalEnd = finalStart.add(`${durationMinutes} minutes`); - selectedResult.reasoning = `[Adjusted for conflict] Shifted slot past conflicting event "${conflictingEvent.title || 'Busy'}" to ${finalStart.format('{yyyy}-{mm}-{dd} {hh}:{mi}')}. ${selectedResult.reasoning}`; + + const MAX_ADJUSTMENT_ITERATIONS = 50; + let iterations = 0; + let lastConflictingEvent = conflictingEvent; + + // Continue bumping and re-checking until slot is valid against busyEvents and workingHours + while (iterations < MAX_ADJUSTMENT_ITERATIONS) { + iterations++; + + // Bump start to the end of the conflicting event + finalStart = new Tempo(lastConflictingEvent.end, { timeZone }); + finalEnd = finalStart.add(`${durationMinutes} minutes`); + + // Validate and adjust against working hours and active days + const startZdt = finalStart.toDateTime().withTimeZone(whTz); + const endZdt = finalEnd.toDateTime().withTimeZone(whTz); + + if (!activeDaysSet.has(startZdt.dayOfWeek)) { + const nextZdt = advanceToNextActiveDay(startZdt); + finalStart = new Tempo(nextZdt, { timeZone }); + finalEnd = finalStart.add(`${durationMinutes} minutes`); + } else { + const dayStart = startZdt.startOfDay().add({ hours: whStartH, minutes: whStartM }); + const dayEnd = startZdt.startOfDay().add({ hours: whEndH, minutes: whEndM }); + + if (startZdt.epochNanoseconds < dayStart.epochNanoseconds) { + finalStart = new Tempo(dayStart, { timeZone }); + finalEnd = finalStart.add(`${durationMinutes} minutes`); + } else if (endZdt.epochNanoseconds > dayEnd.epochNanoseconds) { + const nextZdt = advanceToNextActiveDay(startZdt); + finalStart = new Tempo(nextZdt, { timeZone }); + finalEnd = finalStart.add(`${durationMinutes} minutes`); + } + } + + // Re-check resulting interval against all busyEvents + const currentInterval = new Interval(finalStart, finalEnd); + const nextConflict = busyEvents.find(b => currentInterval.overlaps(new Interval(b.start, b.end))); + + if (nextConflict) { + lastConflictingEvent = nextConflict; + continue; + } + + // Slot is valid against all busyEvents and workingHours + break; + } + + const conflictTitle = lastConflictingEvent?.title || 'Busy'; + reasoning = `[Adjusted for conflict] Shifted slot past conflicting event "${conflictTitle}" to ${finalStart.format('{yyyy}-{mm}-{dd} {hh}:{mi}')}. ${scheduleData.reasoning}`; } // Create actual Interval instance const rawInterval = new Interval(finalStart, finalEnd); // Process alternative slots into Interval instances - const alternatives: TempoInterval[] = selectedResult.alternatives + const alternatives: Array> = scheduleData.alternatives .map((alt: any) => { try { const s = new Tempo(alt.start, { timeZone }); @@ -322,23 +359,23 @@ export async function scheduleAI( return null; } }) - .filter((i: any): i is TempoInterval => i !== null); + .filter((i: any): i is Interval => i !== null); const actualDuration = Math.round((finalEnd.epoch.ms - finalStart.epoch.ms) / 60000); return wrapScheduleInterval(rawInterval, { durationMinutes: actualDuration, - summary: selectedResult.summary, - reasoning: selectedResult.reasoning, - confidence: selectedResult.confidence, - provider: selectedResult.providerId, + summary: scheduleData.summary, + reasoning, + confidence, + provider: providerId, alternatives, ai: { - provider: selectedResult.providerId, - confidence: selectedResult.confidence, + provider: providerId, + confidence, conflictBumped, originalSlot, - reasoning: selectedResult.reasoning - } + reasoning, + }, }); } diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts index e0f926e5..11f8d446 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -7,13 +7,12 @@ export * from './core/config.js'; export { loadRemoteManifest, resetManifestCache, DEFAULT_REMOTE_MANIFEST_URL } from './core/manifest.js'; // AI Core Functions -export { initAI, clearAiCache, getAiRateLimits, getAiConfig } from './core/init.js'; +export { initAI, resetAI, clearAiCache, getAiRateLimits, getAiConfig } from './core/init.js'; // AI Function Handlers export { parseAI } from './functions/parse.js'; export { recurrenceAI } from './functions/recurrence.js'; export { scheduleAI } from './functions/schedule.js'; -export { isRRuleString } from '@magmacomputing/library'; /* * ============================================================================ @@ -32,8 +31,5 @@ export { isRRuleString } from '@magmacomputing/library'; // /** 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'; - // /** 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/src/types/common.type.ts b/packages/plugins/ai/src/types/common.type.ts index 84e90ce3..c6855ec6 100644 --- a/packages/plugins/ai/src/types/common.type.ts +++ b/packages/plugins/ai/src/types/common.type.ts @@ -2,37 +2,26 @@ import type { Tempo } from '@magmacomputing/tempo'; import type { AiMode } from '../core/config.js'; /** - * ## TempoAiMeta - * Frozen metadata object attached to Tempo instances produced by `parseAI`. + * ## TempoBaseAiMeta + * Fundamental AI resolution telemetry and metadata shared across all AI functions. */ -export interface TempoAiMeta { +export interface TempoBaseAiMeta { /** 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) */ + /** Whether the result was retrieved from cache */ + readonly cached?: boolean | undefined; + /** Step-by-step reasoning or justification provided by the engine/LLM */ readonly reasoning?: string | undefined; + /** Rate limit snapshot returned by the provider HTTP headers for this request */ + readonly limits?: AiRateLimits | undefined; /** Raw prompt input (only included when debug: true) */ readonly rawPrompt?: string | undefined; /** Normalized prompt input (only included when debug: true) */ readonly normalizedPrompt?: string | undefined; - /** Rate limit snapshot returned by the provider HTTP headers for this request */ - readonly limits?: AiRateLimits | undefined; -} - -declare module '@magmacomputing/tempo' { - interface Tempo { - /** Frozen AI resolution metadata attached when parsed via parseAI */ - ai?: TempoAiMeta | undefined; - } + /** Arbitrary provider-specific extra metadata */ + readonly [key: string]: any; } /** diff --git a/packages/plugins/ai/src/types/parse.type.ts b/packages/plugins/ai/src/types/parse.type.ts index bfd6ff7f..ef3f560f 100644 --- a/packages/plugins/ai/src/types/parse.type.ts +++ b/packages/plugins/ai/src/types/parse.type.ts @@ -1,5 +1,30 @@ import type { AiMode } from '../core/config.js'; -import type { AiCacheAdapter, AiProvider } from './common.type.js'; +import type { AiCacheAdapter, AiProvider, TempoBaseAiMeta } from './common.type.js'; + +declare module '@magmacomputing/tempo' { + interface Tempo { + /** Frozen AI resolution metadata attached when parsed via parseAI */ + ai?: TempoParseAiMeta | undefined; + } +} + +/** + * ## TempoParseAiMeta + * Frozen AI resolution metadata attached to Tempo instances produced by `parseAI`. + */ +export interface TempoParseAiMeta extends TempoBaseAiMeta { + /** Whether the result was retrieved from cache */ + readonly cached: boolean; + /** 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; +} + +/** Backward-compatible alias for TempoParseAiMeta */ +export type TempoAiMeta = TempoParseAiMeta; /** * ## AiParseOptions diff --git a/packages/plugins/ai/src/types/schedule.type.ts b/packages/plugins/ai/src/types/schedule.type.ts index aa6463f7..e38a94ff 100644 --- a/packages/plugins/ai/src/types/schedule.type.ts +++ b/packages/plugins/ai/src/types/schedule.type.ts @@ -1,4 +1,6 @@ -import type { Tempo } from '@magmacomputing/tempo'; +import type { Tempo, Interval } from '@magmacomputing/tempo'; +import type { DayKey } from '@magmacomputing/tempo/library'; +import type { TempoBaseAiMeta } from './common.type.js'; import type { AiParseOptions } from './parse.type.js'; /** @@ -10,8 +12,8 @@ export interface TempoWorkingHours { start?: string; /** End time of working day in HH:mm format (default: '17:00') */ end?: string; - /** Active working weekdays (0 = Sunday, 1 = Monday, ... 6 = Saturday; default: [1, 2, 3, 4, 5]) */ - days?: number[]; + /** Active working weekdays (1 = Monday, ... 7 = Sunday; or tokens like 'MO', 'MON'; default: [1, 2, 3, 4, 5]) */ + days?: Array; /** Target timeZone for working hours (defaults to anchor or options timeZone) */ timeZone?: string; } @@ -37,9 +39,9 @@ export interface TempoScheduleOptions extends AiParseOptions { /** Working hours configuration for slot resolution */ workingHours?: TempoWorkingHours; /** Existing booked events or busy intervals to avoid */ - events?: Array<{ start: any; end: any; title?: string }> | Array; + events?: Array<{ start: any; end: any; title?: string }> | Array>; /** Alias for events */ - intervals?: Array<{ start: any; end: any; title?: string }> | Array; + intervals?: Array<{ start: any; end: any; title?: string }> | Array>; /** Search window start constraint */ after?: any; /** Search window end constraint */ @@ -50,11 +52,22 @@ export interface TempoScheduleOptions extends AiParseOptions { count?: number; } +/** + * ## TempoScheduleAiMeta + * Extended AI execution metadata attached to scheduling results and intervals. + */ +export interface TempoScheduleAiMeta extends TempoBaseAiMeta { + /** Whether this slot was bumped due to a conflict */ + readonly conflictBumped?: boolean | undefined; + /** The original un-bumped candidate slot if conflict bumping occurred */ + readonly originalSlot?: TempoInterval | Interval | undefined; +} + /** * ## TempoScheduleResult * Structured scheduling result returned by `scheduleAI`. */ -export interface TempoScheduleResult extends TempoInterval { +export interface TempoScheduleResult extends Interval { /** Resolved start boundary as a Tempo instance */ start: Tempo; /** Resolved end boundary as a Tempo instance */ @@ -70,16 +83,9 @@ export interface TempoScheduleResult extends TempoInterval { /** Provider ID responsible for processing or 'native-scheduler' */ provider: string; /** Alternative backup intervals identified during scheduling */ - alternatives?: TempoInterval[] | undefined; + alternatives?: Interval[] | undefined; /** Extended AI execution metadata */ - ai?: { - provider: string; - confidence: number; - conflictBumped?: boolean | undefined; - originalSlot?: TempoInterval | undefined; - reasoning?: string | undefined; - [key: string]: any; - } | undefined; + ai?: TempoScheduleAiMeta | undefined; } /** @@ -98,15 +104,8 @@ export interface TempoScheduleMeta { /** Provider ID responsible for processing or 'native-scheduler' */ provider: string; /** Alternative backup intervals identified during scheduling */ - alternatives?: TempoInterval[] | undefined; + alternatives?: Interval[] | undefined; /** Extended AI execution metadata */ - ai: { - provider: string; - confidence: number; - conflictBumped?: boolean | undefined; - originalSlot?: TempoInterval | undefined; - reasoning?: string | undefined; - [key: string]: any; - }; + ai: TempoScheduleAiMeta; } diff --git a/packages/plugins/ai/test/cache.test.ts b/packages/plugins/ai/test/cache.test.ts index 7f081381..b5155484 100644 --- a/packages/plugins/ai/test/cache.test.ts +++ b/packages/plugins/ai/test/cache.test.ts @@ -2,12 +2,12 @@ import { parseAI, initAI, clearAiCache, type AiCacheAdapter } from '../src/index import { Tempo } from '@magmacomputing/tempo'; describe('Advanced Cache TTL & Async Storage Adapters', () => { - beforeEach(() => { + beforeEach(async () => { vi.restoreAllMocks(); Tempo.cache.clear(); - initAI({ + await initAI({ providers: [{ id: 'groq', key: 'mock-test-key' }], - remoteConfigUrl: false + remoteConfigUrl: false, }); }); @@ -31,20 +31,20 @@ describe('Advanced Cache TTL & Async Storage Adapters', () => { }), clear: vi.fn(async () => { store.clear(); - }) - }; + }), + } - initAI({ + await initAI({ providers: [{ id: 'groq', key: 'mock-test-key' }], cacheAdapter: mockAdapter, - ttl: 120000 + ttl: 120000, }); // Mock LLM fetch response for first call vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"Custom adapter test", "iso":"2026-11-26T00:00:00", "confidence":0.95}' } }] - }), { status: 200 }) + choices: [{ message: { content: '{"reasoning":"Custom adapter test", "iso":"2026-11-26T00:00:00", "confidence":0.95}' } }], + }), { status: 200 }), ); const result1 = await parseAI('Thanksgiving 2026'); @@ -68,19 +68,19 @@ describe('Advanced Cache TTL & Async Storage Adapters', () => { get: vi.fn(() => undefined), set: vi.fn((_key: string, _val: string, ttlMs?: number) => { if (ttlMs) setTtlLogs.push(ttlMs); - }) + }), }; - initAI({ + await initAI({ providers: [{ id: 'groq', key: 'mock-key', ttl: 60000 }], cacheAdapter: mockAdapter, - ttl: 300000 + ttl: 300000, }); vi.spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve( new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"TTL test", "iso":"2026-12-25T00:00:00", "confidence":0.95}' } }] - }), { status: 200 }) + choices: [{ message: { content: '{"reasoning":"TTL test", "iso":"2026-12-25T00:00:00", "confidence":0.95}' } }], + }), { status: 200 }), )); // Call 1: Inherits provider.ttl (60000) @@ -99,18 +99,18 @@ describe('Advanced Cache TTL & Async Storage Adapters', () => { }), set: vi.fn(async () => { throw new Error('Redis write error'); - }) + }), }; - initAI({ + await initAI({ providers: [{ id: 'groq', key: 'mock-key' }], - cacheAdapter: faultyAdapter + cacheAdapter: faultyAdapter, }); vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"Faulty adapter test", "iso":"2026-07-04T00:00:00", "confidence":0.95}' } }] - }), { status: 200 }) + choices: [{ message: { content: '{"reasoning":"Faulty adapter test", "iso":"2026-07-04T00:00:00", "confidence":0.95}' } }], + }), { status: 200 }), ); // parseAI should NOT throw Redis error; it should fail open to LLM fetch @@ -124,10 +124,10 @@ describe('Advanced Cache TTL & Async Storage Adapters', () => { get: vi.fn(), set: vi.fn(), delete: vi.fn(), - clear: vi.fn() + clear: vi.fn(), }; - initAI({ cacheAdapter: mockAdapter }); + await initAI({ cacheAdapter: mockAdapter }); clearAiCache('Easter 2026'); expect(mockAdapter.delete).toHaveBeenCalled(); diff --git a/packages/plugins/ai/test/manifest.test.ts b/packages/plugins/ai/test/manifest.test.ts index 453d0eb4..3895207d 100644 --- a/packages/plugins/ai/test/manifest.test.ts +++ b/packages/plugins/ai/test/manifest.test.ts @@ -1,19 +1,20 @@ import { initAI, + resetAI, + getAiConfig, loadRemoteManifest, - resetManifestCache, DEFAULT_REMOTE_MANIFEST_URL, DEFAULT_PROVIDERS } from '../src/index.js'; describe('Remote Provider Manifest & Dynamic Defaults', () => { beforeEach(() => { - resetManifestCache(); + resetAI(); vi.restoreAllMocks(); }); afterEach(() => { - resetManifestCache(); + resetAI(); vi.restoreAllMocks(); }); @@ -111,10 +112,10 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { providers: [{ id: 'groq', key: 'test-key' }] }); - // Check resolved providers in init state - const { _state } = await import('../src/core/init.js'); - expect(_state.config.providers).toHaveLength(1); - expect(_state.config.providers?.[0].model).toBe('remote-llama-model'); + // Check resolved providers in init state via getAiConfig + const config = getAiConfig(); + expect(config.providers).toHaveLength(1); + expect(config.providers?.[0].model).toBe('remote-llama-model'); }); it('should fallback to compiled DEFAULT_PROVIDERS if remote manifest is missing provider ID', async () => { @@ -131,8 +132,8 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { providers: [{ id: 'openai', key: 'test-key' }] }); - const { _state } = await import('../src/core/init.js'); - expect(_state.config.providers?.[0].model).toBe(DEFAULT_PROVIDERS.openai.model); + const config = getAiConfig(); + expect(config.providers?.[0].model).toBe(DEFAULT_PROVIDERS.openai.model); }); it('should retain fetchDefaults hook results alongside remote manifest resolution', async () => { @@ -152,10 +153,10 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { fetchDefaults: async () => ({ timeout: 5000, ttl: 9999 }) }); - const { _state } = await import('../src/core/init.js'); - expect(_state.config.providers?.[0].model).toBe('remote-manifest-groq-model'); - expect(_state.config.providers?.[0].timeout).toBe(5000); - expect(_state.config.providers?.[0].ttl).toBe(9999); + const config = getAiConfig(); + expect(config.providers?.[0].model).toBe('remote-manifest-groq-model'); + expect(config.providers?.[0].timeout).toBe(5000); + expect(config.providers?.[0].ttl).toBe(9999); }); it('should prevent older async initAI invocation from overwriting newer provider state via revision tracking', async () => { @@ -170,24 +171,23 @@ describe('Remote Provider Manifest & Dynamic Defaults', () => { // Start invocation 1 (which hangs on manifest resolution) const initPromise1 = initAI({ remoteConfigUrl: 'https://tempo.magmacomputing.com.au/manifest-1.json', - providers: [{ id: 'groq', key: 'key-invocation-1' }] + providers: [{ id: 'groq', key: 'key-invocation-1', model: 'invocation-1-model' }] }); // Synchronously start invocation 2 (newer) const initPromise2 = initAI({ remoteConfigUrl: 'https://tempo.magmacomputing.com.au/manifest-2.json', - providers: [{ id: 'groq', key: 'key-invocation-2' }] + providers: [{ id: 'groq', key: 'key-invocation-2', model: 'invocation-2-model' }] }); await initPromise2; - const { _state } = await import('../src/core/init.js'); - expect(_state.config.providers?.[0].key).toBe('key-invocation-2'); + expect(getAiConfig().providers?.[0].model).toBe('invocation-2-model'); // Resolve slow invocation 1 resolveManifest1!(new Response(JSON.stringify({ providers: { groq: { model: 'stale-model' } } }), { status: 200 })); await initPromise1; // Verify state was NOT overwritten by stale invocation 1 - expect(_state.config.providers?.[0].key).toBe('key-invocation-2'); + expect(getAiConfig().providers?.[0].model).toBe('invocation-2-model'); }); }); diff --git a/packages/plugins/ai/test/mode.test.ts b/packages/plugins/ai/test/mode.test.ts new file mode 100644 index 00000000..ab4f942b --- /dev/null +++ b/packages/plugins/ai/test/mode.test.ts @@ -0,0 +1,102 @@ +import { executeWithMode } from '../src/core/mode.js'; +import { AiMode } from '../src/core/config.js'; +import { TempoAiError } from '../src/core/error.js'; +import type { AiProvider } from '../src/types/index.js'; + +describe('AI Mode Execution Helper (executeWithMode)', () => { + const mockProviders: AiProvider[] = [ + { id: 'provider-a', key: 'key-a' }, + { id: 'provider-b', key: 'key-b' }, + ]; + + it('should execute Fallback mode and stop on first provider meeting minConfidence', async () => { + const task = vi.fn() + .mockResolvedValueOnce({ data: { val: 1 }, providerId: 'provider-a', confidence: 0.7 }) + .mockResolvedValueOnce({ data: { val: 2 }, providerId: 'provider-b', confidence: 0.95 }); + + const winner = await executeWithMode( + AiMode.Fallback, + mockProviders, + task, + { minConfidence: 0.8 }, + ); + + expect(task).toHaveBeenCalledTimes(2); + expect(winner.providerId).toBe('provider-b'); + expect(winner.data).toEqual({ val: 2 }); + }); + + it('should return best candidate in Fallback mode if none meet minConfidence', async () => { + const task = vi.fn() + .mockResolvedValueOnce({ data: { val: 1 }, providerId: 'provider-a', confidence: 0.6 }) + .mockResolvedValueOnce({ data: { val: 2 }, providerId: 'provider-b', confidence: 0.75 }); + + const winner = await executeWithMode( + AiMode.Fallback, + mockProviders, + task, + { minConfidence: 0.9 }, + ); + + expect(task).toHaveBeenCalledTimes(2); + expect(winner.providerId).toBe('provider-b'); + expect(winner.data).toEqual({ val: 2 }); + }); + + it('should execute Race mode and return fastest provider result', async () => { + const task = vi.fn().mockImplementation(async (provider: AiProvider) => { + if (provider.id === 'provider-a') { + await new Promise(resolve => setTimeout(resolve, 50)); + return { data: { fast: false }, providerId: 'provider-a', confidence: 0.8 }; + } + return { data: { fast: true }, providerId: 'provider-b', confidence: 0.9 }; + }); + + const winner = await executeWithMode( + AiMode.Race, + mockProviders, + task, + ); + + expect(winner.providerId).toBe('provider-b'); + expect(winner.data).toEqual({ fast: true }); + }); + + it('should execute Consensus mode and mark consensus when keys match', async () => { + const task = vi.fn() + .mockResolvedValueOnce({ data: { rrule: 'FREQ=DAILY' }, providerId: 'provider-a', consensusKey: 'FREQ=DAILY', confidence: 0.9 }) + .mockResolvedValueOnce({ data: { rrule: 'FREQ=DAILY' }, providerId: 'provider-b', consensusKey: 'FREQ=DAILY', confidence: 0.9 }); + + const winner = await executeWithMode( + AiMode.Consensus, + mockProviders, + task, + ); + + expect(winner.providerId).toBe(AiMode.Consensus); + expect(winner.confidence).toBe(1.0); + expect(winner.ambiguous).toBe(false); + }); + + it('should sort by confidence in Consensus mode when keys differ', async () => { + const task = vi.fn() + .mockResolvedValueOnce({ data: { rrule: 'FREQ=DAILY' }, providerId: 'provider-a', consensusKey: 'FREQ=DAILY', confidence: 0.8 }) + .mockResolvedValueOnce({ data: { rrule: 'FREQ=WEEKLY' }, providerId: 'provider-b', consensusKey: 'FREQ=WEEKLY', confidence: 0.95 }); + + const winner = await executeWithMode( + AiMode.Consensus, + mockProviders, + task, + ); + + expect(winner.providerId).toBe('provider-b'); + expect(winner.confidence).toBe(0.95); + expect(winner.ambiguous).toBe(true); + }); + + it('should throw TempoAiError with status 400 for invalid mode', async () => { + const task = vi.fn(); + await expect(executeWithMode('unsupported' as any, mockProviders, task)) + .rejects.toThrow(TempoAiError); + }); +}); diff --git a/packages/plugins/ai/test/parse.test.ts b/packages/plugins/ai/test/parse.test.ts index a7923913..aa9664b0 100644 --- a/packages/plugins/ai/test/parse.test.ts +++ b/packages/plugins/ai/test/parse.test.ts @@ -7,28 +7,31 @@ describe('AI Parsing Plugin (parseAI)', () => { const liveProviderId = process.env.GROQ_API_KEY ? 'groq' : 'openai'; const isLiveTest = Boolean(process.env.LIVE_AI_TEST && liveApiKey); - beforeEach(() => { + beforeEach(async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(console, 'log').mockImplementation(() => {}); if (isLiveTest) { - initAI({ + return initAI({ + remoteConfigUrl: false, providers: [{ id: liveProviderId, key: liveApiKey! }] }); } else { - initAI({ + return initAI({ + remoteConfigUrl: false, providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] }); } }); afterEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); }); - it('should return current runtime configuration via getAiConfig', () => { - initAI({ + it('should return current runtime configuration via getAiConfig', async () => { + await initAI({ + remoteConfigUrl: false, providers: [{ id: 'groq', key: 'test-key-123' }], mode: AiMode.Fallback, timeout: 3000, @@ -58,12 +61,13 @@ describe('AI Parsing Plugin (parseAI)', () => { }); 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); + expect(() => initAI({ remoteConfigUrl: false, providers: [{ id: 'native', key: '123' }] })).toThrow(TempoAiError); + expect(() => initAI({ remoteConfigUrl: false, providers: [{ id: 'cache', key: '123' }] })).toThrow(TempoAiError); }); it('should canonicalize Gemini provider ID and use gemini-3.6-flash model by default', async () => { - initAI({ + await initAI({ + remoteConfigUrl: false, providers: [{ id: 'Gemini', key: 'mock-gemini-key' }] }); const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ @@ -77,11 +81,23 @@ describe('AI Parsing Plugin (parseAI)', () => { }); it('should throw TempoAiError if no key is configured and AI is needed', async () => { - initAI({ providers: [] }); + await initAI({ remoteConfigUrl: false, providers: [] }); await expect(parseAI('Next Thanksgiving')).rejects.toThrow(TempoAiError); await expect(parseAI('Next Thanksgiving')).rejects.toThrow('No AI providers configured.'); }); + it('should throw TempoAiError with status 400 if an invalid mode is specified in parseAI', async () => { + await initAI({ remoteConfigUrl: false, providers: [{ id: 'openai', key: 'test-key' }] }); + try { + await parseAI('Next Thanksgiving', { mode: 'invalid-mode' as any, force: true }); + expect.unreachable('Should have thrown TempoAiError'); + } catch (err: any) { + expect(err).toBeInstanceOf(TempoAiError); + expect(err.code).toBe(400); + expect(err.message).toContain("Invalid execution mode: 'invalid-mode'"); + } + }); + it('should parse natural language successfully and attach secured .ai metadata', async () => { if (!isLiveTest) { vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ @@ -190,7 +206,8 @@ describe('AI Parsing Plugin (parseAI)', () => { }); it('should cascade from low-confidence local provider to high-confidence online provider in Fallback mode', async () => { - initAI({ + await initAI({ + remoteConfigUrl: false, 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' } @@ -214,7 +231,8 @@ describe('AI Parsing Plugin (parseAI)', () => { }); it('should short-circuit and stop looking to other providers when a provider meets minConfidence', async () => { - initAI({ + await initAI({ + remoteConfigUrl: false, 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' } @@ -258,7 +276,8 @@ describe('AI Parsing Plugin (parseAI)', () => { }); it('should support softErrors in array batch processing', async () => { - initAI({ + await initAI({ + remoteConfigUrl: false, providers: [{ id: 'groq', key: 'test-key' }] }); @@ -335,7 +354,7 @@ describe('AI Parsing Plugin (parseAI)', () => { 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' }] }); + await initAI({ remoteConfigUrl: false, providers: [{ id: 'openai', key: 'bad_key' }] }); vi.spyOn(global, 'fetch').mockResolvedValueOnce(new Response(null, { status: 401, @@ -353,7 +372,8 @@ describe('AI Parsing Plugin (parseAI)', () => { }); it('should seamlessly fallback to the next provider if the first hits a 429 Exhausted Key rate limit', async () => { - initAI({ + await initAI({ + remoteConfigUrl: false, providers: [ { id: 'openai', key: 'exhausted_key' }, { id: 'openai', key: 'good_key' } @@ -415,12 +435,12 @@ describe('AI Parsing Plugin (parseAI)', () => { expect(Array.from(cache.keys())).not.toContain('tempKey'); }); - it('should preserve clearAiCache functionality', () => { + it('should preserve clearAiCache functionality', async () => { const cache = new BoundedCache(100); cache.set('Thanksgiving::2026-05-10', '2026-11-26T00:00:00Z'); cache.set('Christmas::2026-05-10', '2026-12-25T00:00:00Z'); - initAI({ cache }); + await initAI({ remoteConfigUrl: false, cache }); clearAiCache('Thanksgiving'); expect(cache.has('Thanksgiving::2026-05-10')).toBe(false); @@ -432,7 +452,7 @@ describe('AI Parsing Plugin (parseAI)', () => { ['my_custom_company_glossary_term', '2026-11-01T00:00:00Z'] ]); - initAI({ cache: glossary }); + await initAI({ remoteConfigUrl: false, cache: glossary }); const fetchSpy = vi.spyOn(globalThis, 'fetch'); @@ -446,7 +466,7 @@ describe('AI Parsing Plugin (parseAI)', () => { 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' }] }); + await initAI({ remoteConfigUrl: false, providers: [{ id: 'openai', key: 'test-key' }] }); const fetchSpy = vi.spyOn(globalThis, 'fetch'); fetchSpy.mockResolvedValueOnce(new Response(null, { @@ -472,7 +492,7 @@ describe('AI Parsing Plugin (parseAI)', () => { }); it('should replace rather than retain prior rate-limit state when subsequent response has no headers', async () => { - initAI({ providers: [{ id: 'openai', key: 'test-key' }] }); + await initAI({ remoteConfigUrl: false, providers: [{ id: 'openai', key: 'test-key' }] }); const fetchSpy = vi.spyOn(globalThis, 'fetch'); fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ @@ -499,7 +519,7 @@ describe('AI Parsing Plugin (parseAI)', () => { }); it('should parse HTTP-date format Retry-After header strings into valid Tempo resetAt', async () => { - initAI({ providers: [{ id: 'openai', key: 'test-key' }] }); + await initAI({ remoteConfigUrl: false, providers: [{ id: 'openai', key: 'test-key' }] }); const fetchSpy = vi.spyOn(globalThis, 'fetch'); const httpDateStr = 'Wed, 21 Oct 2026 07:28:00 GMT'; @@ -518,7 +538,7 @@ describe('AI Parsing Plugin (parseAI)', () => { }); it('should attach limits snapshot directly to the returned Tempo instance .ai property', async () => { - initAI({ providers: [{ id: 'groq', key: 'test-key' }] }); + await initAI({ remoteConfigUrl: false, providers: [{ id: 'groq', key: 'test-key' }] }); const fetchSpy = vi.spyOn(globalThis, 'fetch'); fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ @@ -538,7 +558,7 @@ describe('AI Parsing Plugin (parseAI)', () => { }); it('should ignore invalid or malformed duration strings without throwing or crashing', async () => { - initAI({ providers: [{ id: 'openai', key: 'test-key' }] }); + await initAI({ remoteConfigUrl: false, providers: [{ id: 'openai', key: 'test-key' }] }); const fetchSpy = vi.spyOn(globalThis, 'fetch'); fetchSpy.mockResolvedValueOnce(new Response(null, { diff --git a/packages/plugins/ai/test/recurrence.test.ts b/packages/plugins/ai/test/recurrence.test.ts index 1478ba51..7943a2ac 100644 --- a/packages/plugins/ai/test/recurrence.test.ts +++ b/packages/plugins/ai/test/recurrence.test.ts @@ -1,12 +1,13 @@ import { Tempo } from '@magmacomputing/tempo'; -import { recurrenceAI, isRRuleString, initAI } from '../src/index.js'; +import { isRRuleString } from '@magmacomputing/tempo/library'; +import { recurrenceAI, initAI } from '../src/index.js'; describe('AI Recurrence Plugin (recurrenceAI)', () => { - beforeEach(() => { + beforeEach(async () => { vi.spyOn(console, 'warn').mockImplementation(() => { }); vi.spyOn(console, 'error').mockImplementation(() => { }); vi.spyOn(console, 'log').mockImplementation(() => { }); - initAI({ providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] }); + await initAI({ remoteConfigUrl: false, providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] }); }); afterEach(() => { @@ -226,11 +227,36 @@ describe('AI Recurrence Plugin (recurrenceAI)', () => { const result = await recurrenceAI(rruleStr, { anchor }); expect(result.isFinite).toBe(true); + expect(result.size).toBe(5); const items = result.take(5); - expect(items.length).toBeGreaterThan(0); + expect(items).toHaveLength(5); // August 2026 last Friday is Aug 28th expect(items[0].format('{yyyy}-{mm}-{dd}')).toBe('2026-08-28'); // September 2026 last Friday is Sep 25th expect(items[1].format('{yyyy}-{mm}-{dd}')).toBe('2026-09-25'); + // October 2026 last Friday is Oct 30th + expect(items[2].format('{yyyy}-{mm}-{dd}')).toBe('2026-10-30'); + // November 2026 last Friday is Nov 27th + expect(items[3].format('{yyyy}-{mm}-{dd}')).toBe('2026-11-27'); + // December 2026 last Friday is Dec 25th + expect(items[4].format('{yyyy}-{mm}-{dd}')).toBe('2026-12-25'); + }); + + it('should propagate resolved context options (tz, cal, loc, sph) to generated Tempo instances and expandOccurrences', async () => { + const rruleStr = 'FREQ=DAILY;COUNT=3'; + const result = await recurrenceAI(rruleStr, { + anchor: '2026-08-01T09:00:00', + timeZone: 'Australia/Sydney', + calendar: 'iso8601', + locale: 'en-AU', + sphere: 'southern', + }); + + const items = result.take(3); + expect(items).toHaveLength(3); + expect(items[0].config.timeZone).toBe('Australia/Sydney'); + expect(items[0].config.calendar).toBe('iso8601'); + expect(items[0].config.locale).toBe('en-AU'); + expect(items[0].config.sphere).toBe('southern'); }); }); diff --git a/packages/plugins/ai/test/schedule.test.ts b/packages/plugins/ai/test/schedule.test.ts index 5fa1fae6..59b59b85 100644 --- a/packages/plugins/ai/test/schedule.test.ts +++ b/packages/plugins/ai/test/schedule.test.ts @@ -1,15 +1,14 @@ import { Tempo, Interval } from '@magmacomputing/tempo'; import { ParseModule } from '@magmacomputing/tempo/parse'; -import { scheduleAI, initAI, TempoAiError } from '../src/index.js'; +import { scheduleAI, initAI } from '../src/index.js'; Tempo.extend(ParseModule); describe('AI Schedule Plugin (scheduleAI)', () => { - beforeEach(() => { + beforeEach(async () => { vi.spyOn(console, 'warn').mockImplementation(() => { }); - // vi.spyOn(console, 'error').mockImplementation(() => { }); vi.spyOn(console, 'log').mockImplementation(() => { }); - initAI({ providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] }); + await initAI({ remoteConfigUrl: false, providers: [{ id: 'groq', key: 'mock-key-for-unit-testing' }] }); }); afterEach(() => { @@ -58,7 +57,7 @@ describe('AI Schedule Plugin (scheduleAI)', () => { expect(slot.alternatives).toHaveLength(1); expect(slot.alternatives![0]).toBeInstanceOf(Interval); - expect(slot.alternatives![0].start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 15:15'); + expect(slot.alternatives![0].start?.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 15:15'); // Assert system prompt includes reference anchor and working hours expect(fetchSpy).toHaveBeenCalledTimes(1); @@ -99,10 +98,87 @@ describe('AI Schedule Plugin (scheduleAI)', () => { expect(slot.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 14:30'); expect(slot.end.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 15:15'); expect(slot.ai?.conflictBumped).toBe(true); - expect(slot.ai?.originalSlot?.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 14:00'); + expect(slot.ai?.originalSlot?.start?.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 14:00'); expect(slot.reasoning).toContain('[Adjusted for conflict]'); }); + it('should iteratively bump through multiple consecutive busy events', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + start: '2026-08-11T14:00:00-07:00', + end: '2026-08-11T14:45:00-07:00', + summary: 'Initial slot candidate', + confidence: 0.90, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const busy1 = new Interval( + new Tempo('2026-08-11 13:30:00', { timeZone: 'America/Los_Angeles' }), + new Tempo('2026-08-11 14:30:00', { timeZone: 'America/Los_Angeles' }) + ); + const busy2 = new Interval( + new Tempo('2026-08-11 14:30:00', { timeZone: 'America/Los_Angeles' }), + new Tempo('2026-08-11 15:00:00', { timeZone: 'America/Los_Angeles' }) + ); + + const slot = await scheduleAI('Find 45 minutes next Tuesday afternoon', { + intervals: [busy1, busy2], + timeZone: 'America/Los_Angeles', + }); + + expect(slot).toBeInstanceOf(Interval); + // Should have bumped past busy1 (to 14:30) and then past busy2 (to 15:00) + expect(slot.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 15:00'); + expect(slot.end.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 15:45'); + expect(slot.ai?.conflictBumped).toBe(true); + expect(slot.ai?.originalSlot?.start?.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-11 14:00'); + }); + + it('should wrap slot to next active working day when conflict bump pushes slot past working hours end', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + // Slot proposed at 16:15 - 17:00 on Friday (2026-08-14) + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + start: '2026-08-14T16:15:00-07:00', + end: '2026-08-14T17:00:00-07:00', + summary: 'Late Friday slot', + confidence: 0.90, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + // Busy event from 16:00 to 16:45 on Friday + const busyFriday = new Interval( + new Tempo('2026-08-14 16:00:00', { timeZone: 'America/Los_Angeles' }), + new Tempo('2026-08-14 16:45:00', { timeZone: 'America/Los_Angeles' }) + ); + + const slot = await scheduleAI('Find 45 minutes Friday afternoon', { + intervals: [busyFriday], + timeZone: 'America/Los_Angeles', + workingHours: { + start: '09:00', + end: '17:00', + days: [1, 2, 3, 4, 5], + }, + }); + + expect(slot).toBeInstanceOf(Interval); + // Bump past 16:45 pushes 45m slot to 17:30 (exceeding 17:00), wrapping to Monday Aug 17 09:00 + expect(slot.start.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-17 09:00'); + expect(slot.end.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-17 09:45'); + expect(slot.ai?.conflictBumped).toBe(true); + expect(slot.ai?.originalSlot?.start?.format('{yyyy}-{mm}-{dd} {hh}:{mi}')).toBe('2026-08-14 16:15'); + }); + it('should support provider race execution mode', async () => { let slowWasAborted = false; const fetchSpy = vi.spyOn(globalThis, 'fetch'); @@ -205,10 +281,79 @@ describe('AI Schedule Plugin (scheduleAI)', () => { .rejects.toThrow(/scheduleAI confidence \(0.5\) is below the required threshold of 0.8/i); }); + it('should format ISO weekday numbers (including Sunday=7) and string tokens correctly in workingHours prompt', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + start: '2026-08-09T10:00:00Z', + end: '2026-08-09T11:00:00Z', + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + await scheduleAI('Schedule Sunday weekend session', { + workingHours: { + days: [7, 'SA', 'MON'], + start: '10:00', + end: '16:00', + }, + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + const systemPrompt = requestBody.messages[0].content; + expect(systemPrompt).toContain('Working Hours: 10:00 to 16:00 (Sunday, Saturday, Monday)'); + }); + it('should throw TempoAiError if prompt is empty or providers missing', async () => { await expect(scheduleAI('')).rejects.toThrow(/invalid scheduling prompt/i); - initAI({ providers: [] }); + await initAI({ remoteConfigUrl: false, providers: [] }); await expect(scheduleAI('Schedule meeting')).rejects.toThrow(/no AI providers configured/i); }); + + it('should preserve Interval prototype behavior, own-property metadata, and toString conversion', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + start: '2026-08-11T14:00:00Z', + end: '2026-08-11T15:00:00Z', + summary: '1-hour review session', + confidence: 0.95, + }), + }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + const slot = await scheduleAI('Schedule 1 hour review session'); + + expect(slot).toBeInstanceOf(Interval); + expect(slot.constructor).toBe(Interval); + expect(Object.prototype.toString.call(slot)).toBe('[object Tempo.Interval]'); + expect(typeof slot.toString).toBe('function'); + expect(typeof slot.valueOf).toBe('function'); + + // Metadata keys exist as own properties / proxy traps + expect('durationMinutes' in slot).toBe(true); + expect('summary' in slot).toBe(true); + expect('confidence' in slot).toBe(true); + expect(slot.summary).toBe('1-hour review session'); + + // Interval prototype methods operate normally + const testPoint = new Tempo('2026-08-11T14:30:00Z'); + expect(slot.contains(testPoint)).toBe(true); + }); + + it('should throw TempoAiError with status 400 for invalid mode in scheduleAI', async () => { + await initAI({ remoteConfigUrl: false, providers: [{ id: 'openai', key: 'test-key' }] }); + await expect(scheduleAI('Schedule meeting', { mode: 'invalid-mode' as any })) + .rejects.toThrow(/Invalid execution mode: 'invalid-mode'/); + }); }); + diff --git a/packages/plugins/vitest.shared.ts b/packages/plugins/vitest.shared.ts index fcc0ad19..19c90850 100644 --- a/packages/plugins/vitest.shared.ts +++ b/packages/plugins/vitest.shared.ts @@ -40,6 +40,7 @@ export default defineConfig({ { find: /^#tempo\/(.*)$/, replacement: resolve(__dirname, '../tempo/src/$1.ts') }, { find: /^#tempo$/, replacement: resolve(__dirname, '../tempo/src/tempo.index.ts') }, { find: /^@magmacomputing\/tempo\/plugin-api$/, replacement: resolve(__dirname, '../tempo/src/plugin-api.index.ts') }, + { find: /^@magmacomputing\/tempo\/library$/, replacement: resolve(__dirname, '../tempo/src/library.index.ts') }, { find: /^@magmacomputing\/tempo$/, replacement: resolve(__dirname, '../tempo/src/tempo.index.ts') }, { find: /^@magmacomputing\/tempo\/(parse|format|mutate|duration)$/, replacement: resolve(__dirname, '../tempo/src/module/module.$1.ts') }, { find: /^@magmacomputing\/tempo\/core$/, replacement: resolve(__dirname, '../tempo/src/core.index.ts') }, diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 10107442..2b6d9375 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,7 +6,7 @@ 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.1] - 2026-08-03 +## [3.11.1] - 2026-08-10 ### Added - **Timezone Abbreviation & Humanized Offset Parsing**: Upgraded `Token.tzd` snippet compilation and Master Guard scanning to natively support 3–4 letter timezone abbreviations (e.g. `AEST`, `PST`, `EST`, `CET`, `JST`) alongside `GMT`/`UTC` offset prefixes (e.g. `'Aug 6, 16:16 GMT+10'`, `'August 6, 16:16 AEST'`). Dynamically compiles `Token.tzd` from `DEFAULTS.TIMEZONE` and introduces `Match.offset` for clean structural offset matching with downstream `Temporal.ZonedDateTime` validation. @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **AI Documentation Guide**: Added a dedicated `AI & IDE Integration` guide (`doc/1-getting-started/ai-integration.md`) featured directly in the primary VitePress navigation sidebar under Getting Started. ### Changed & Hardened +- **Timezone Offset Normalization (`engine.lexer.ts`)**: Upgraded `parseZone` to normalize signed hour-and-minute offsets (e.g. `+5:30`, `-8:30`, `+05:30`, `+530`) to canonical ISO-8601 `±HH:MM` format before calling `toZonedDateTime`, enabling seamless parsing for half-hour and quarter-hour timezones. +- **Safe Timezone Configuration Mutation**: Hardened `parseZone` so that `config.timeZone` is updated only when `toZonedDateTime` completes successfully without throwing, preventing state mutation on invalid timezone identifiers. +- **Remote Provider Manifest Defaults (`providers.v1.json`)**: Updated the Groq provider default model from the retiring `llama-3.3-70b-versatile` to `openai/gpt-oss-120b`. - **Hardened String-to-Temporal Composer (`engine.composer.ts`)**: Upgraded raw string fallback resolution to utilize a lookahead boundary regex (`/^(\d{4}-\d{2}-\d{2})\s+(?=\d{2}:\d{2})/`) and whitespace stripper (`/\s+(?=[Zz]|[+-]\d{2}|\[)/`). This automatically normalizes SQL/space-delimited timestamps (`2026-08-08 10:30`) to ISO 8601 (`2026-08-08T10:30`), collapses multiple whitespace runs, and strips spaces before UTC markers (`Z`), offsets (`+10:00`), and timezone brackets (`[Australia/Sydney]`), while ensuring timezone names containing internal spaces (e.g., `[America/Port of Spain]`) remain uncorrupted. - **Master Guard Fast-Path Safety Valve (`module.parse.ts`)**: Added a zero-cost bypass for standard ISO date strings (`YYYY-MM-DD`), preventing false-negative token scanner rejections before reaching the layout resolution engine. diff --git a/packages/tempo/README.md b/packages/tempo/README.md index 000076f2..332e14d9 100644 --- a/packages/tempo/README.md +++ b/packages/tempo/README.md @@ -93,7 +93,7 @@ For standard usage natively in the browser, use the pre-optimized **Global ESM B --- -## ✨ Why Tempo? +## ⏳ Why Tempo? While the native Temporal API gives you perfect primitives (`ZonedDateTime`, `PlainDate`), it doesn't give you business logic. Tempo bridges that gap. @@ -106,16 +106,10 @@ While the native Temporal API gives you perfect primitives (`ZonedDateTime`, `Pl ### The Missing Domain Layer -* **🏗️ Future Standard**: Built natively on the TC39 `Temporal` proposal. Inherit the reliability of the future standard. -* **🧩 Premium Ecosystem**: Don't build temporal math from scratch. Drop in our plugins for Astronomical seasons (`astro`) and atomic state syncing (`sync`). -* **🌍 Zero-Bundle Localization**: Best-in-class multi-language parsing and formatting powered natively by the `Intl` API—no massive static locale dictionaries required. -* **🗣️ Natural Language**: Resolve complex terms like "two days ago" with zero configuration. -* **🧠 Functional Aliases**: Extend the parser with custom logic using a powerful resolution context for relative date math. -* **🔄 Cycle Persistence**: Shift by semantic terms (Quarters, Seasons) while preserving your relative day-of-period offset. -* **⚡ Zero-Cost Parsing**: Lazy evaluation and smart matching ensure instantiation overhead is near-zero. -* **🛡️ Monorepo Resilient**: Built for stability in complex environments with proxy-protected registries. -* **📦 Tree-Shakable**: Keep your bundle light. Only load what you need—from Fiscal calendars to high-performance Tickers. -* **🪶 Ultra Lightweight**: Tempo itself is incredibly lean. While the required `Temporal` polyfill adds weight today, it can be dropped entirely the moment JavaScript environments natively adopt the Stage 4 standard. +* **🗣️ Natural Language & Smart Parsing**: Parse natural language phrases (e.g. "next Friday 3pm", "two days ago") with zero-cost lazy evaluation and functional aliases. +* **🌍 Zero-Bundle Localization**: Multi-language date parsing and formatting powered natively by ECMAScript `Intl`—no heavy static locale dictionaries required. +* **🧩 Extensible & AI-Ready**: Modular plugin ecosystem for astronomical cycles (`astro`), financial quarters (`finance`), schedulers (`ticker`), and LLM-powered parsing (`ai`). +* **🏗️ Future-Proof & Ultra-Lightweight**: Built natively on the TC39 Stage 4 `Temporal` foundation with zero legacy runtime baggage and full tree-shaking support. --- @@ -135,8 +129,9 @@ Tempo is the core library, but the ecosystem extends further: | Package | Description | Resources | | :--- | :--- | :--- | | **[`@magmacomputing/tempo`](https://www.npmjs.com/package/@magmacomputing/tempo)** | Core library — parsing, formatting, natural-language engine | [![Docs](https://img.shields.io/badge/Docs-VitePress-brightgreen?logo=vitepress&style=flat-square)](https://magmacomputing.github.io/magma/) | -| **[`@magmacomputing/tempo-fns`](https://www.npmjs.com/package/@magmacomputing/tempo-fns)** | Pure functional utilities built on native Temporal & Tempo — tree-shakeable helpers | [![Docs](https://img.shields.io/badge/Docs-VitePress-brightgreen?logo=vitepress&style=flat-square)](https://magmacomputing.github.io/magma/functions/) | +| **[`@magmacomputing/tempo-plugin-ai`](https://www.npmjs.com/package/@magmacomputing/tempo-plugin-ai)** | LLM-powered natural language parsing, recurrence expansion & smart scheduling | [![Docs](https://img.shields.io/badge/Docs-AI%20Plugin-blueviolet?logo=vitepress&style=flat-square)](https://magmacomputing.github.io/magma/doc/9-plugins/ai/index.html) | | **[`@magmacomputing/tempo-plugin-*`](https://www.npmjs.com/search?q=%40magmacomputing%2Ftempo-plugin)** | Premium & community plugins — Ticker, Astro, Finance, Sync, Snap and more | [![Ecosystem](https://img.shields.io/badge/Browse-Plugin%20Ecosystem-blueviolet?logo=npm&style=flat-square)](https://magmacomputing.github.io/magma/doc/3-extending-tempo/ecosystem) | +| **[`@magmacomputing/tempo-fns`](https://www.npmjs.com/package/@magmacomputing/tempo-fns)** | Pure functional utilities built on native Temporal & Tempo — tree-shakeable helpers | [![Docs](https://img.shields.io/badge/Docs-VitePress-brightgreen?logo=vitepress&style=flat-square)](https://magmacomputing.github.io/magma/functions/) | --- diff --git a/packages/tempo/doc/1-getting-started/ai-integration.md b/packages/tempo/doc/1-getting-started/ai-integration.md index b02b06a0..add7abb3 100644 --- a/packages/tempo/doc/1-getting-started/ai-integration.md +++ b/packages/tempo/doc/1-getting-started/ai-integration.md @@ -22,7 +22,7 @@ Add Tempo to Cursor's native documentation index: --- ### 2. VS Code & GitHub Copilot -In VS Code, configure GitHub Copilot Chat by adding a `.github/copilot-instructions.md` file (or `.cursorrules`) to the root of your workspace: +In VS Code, configure GitHub Copilot Chat by adding a `.github/copilot-instructions.md` file to the root of your workspace: ```markdown # Tempo AI Rules @@ -53,7 +53,7 @@ For web-based LLM interfaces, reference or copy-paste the full, un-truncated doc ## 🛠️ Prompting AI for Custom Layout Extensions -When asking AI assistants to generate custom layout patterns for parsing unique date-time formats, instruct the model to use Tempo's configuration syntax (`Tempo.init({ registry: { layouts: { ... } } })`) and named capture tokens (`{yy}`, `{mon}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`). +When asking AI assistants to generate custom layout patterns for parsing unique date-time formats, instruct the model to use Tempo's configuration syntax (`Tempo.init({ registry: { layouts: { ... } } })`) and named capture tokens (`{yy}`, `{mm}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`). ### Sample Prompt: > *"Using the rules from https://tempo.magmacomputing.com.au/llms.txt, register a custom Tempo layout for fiscal quarters (e.g., 'Q3 2026') using `Tempo.init({ registry: { layouts: { ... } } })` and parse a date using `Tempo.parse()`."* diff --git a/packages/tempo/doc/2-core-concepts/tempo.parse.md b/packages/tempo/doc/2-core-concepts/tempo.parse.md index b2382342..d2afb4a6 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.parse.md +++ b/packages/tempo/doc/2-core-concepts/tempo.parse.md @@ -134,7 +134,7 @@ Tempo natively supports human-readable timezone abbreviations (e.g. `AEST`, `PST > By default, Tempo's built-in layouts expect timezone offsets and abbreviations to *follow* the date/time payload. This design prevents leading abbreviations from colliding with 3-letter month names (such as `MAR` for March vs. Marshall Islands Time). #### Registering a Custom Leading Timezone Layout -If your application processes custom log files or legacy data streams that position timezone designators at the *start* (e.g. `"PST 8 Aug 10:30"`), you can register a custom layout using `{tzd}` alongside `{dt}` and `{tm}`: +If your application processes custom log files or legacy data streams that position timezone designators at the *start* (e.g. `"PST 8 Aug 10:30"`), you can register a custom layout using `{tzd}` alongside `{dt}` and `{tm}`. The `{tzd}` token accepts both timezone offset designators (`+10:00`, `Z`, `GMT+10`) and registered timezone abbreviations (such as `AEST`, `PST`, `EST`): ```typescript Tempo.init({ diff --git a/packages/tempo/doc/3-extending-tempo/tempo.layout.md b/packages/tempo/doc/3-extending-tempo/tempo.layout.md index b5ac390c..98ae8d0a 100644 --- a/packages/tempo/doc/3-extending-tempo/tempo.layout.md +++ b/packages/tempo/doc/3-extending-tempo/tempo.layout.md @@ -106,7 +106,7 @@ console.log(regex.source); When prompting AI assistants (Cursor, GitHub Copilot, ChatGPT, Claude) to write custom `Tempo` regular expression snippets and layout extensions: 1. **Ingest AI Rules**: Provide the assistant with our official `llms.txt` rules by referencing `@https://tempo.magmacomputing.com.au/llms.txt` in Cursor or pasting `llms.txt` context into ChatGPT. -2. **Explicit Token Request**: Ask the LLM to use Tempo's standard snippet tokens (`{yy}`, `{mon}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`) rather than raw, un-anchored regular expressions. +2. **Explicit Token Request**: Ask the LLM to use Tempo's standard snippet tokens (`{yy}`, `{mm}`, `{dd}`, `{hh}`, `{mi}`, `{ss}`, `{tzd}`) rather than raw, un-anchored regular expressions. 3. **Example AI Prompt**: ```text "Using https://tempo.magmacomputing.com.au/llms.txt, register a custom layout for strings like 'Q3 2026' using Tempo.init({ registry: { layouts: { ... } } }) and snippet tokens." diff --git a/packages/tempo/public/esm_sh.index.html b/packages/tempo/public/esm_sh.index.html index 9323c9a3..31659a74 100644 --- a/packages/tempo/public/esm_sh.index.html +++ b/packages/tempo/public/esm_sh.index.html @@ -216,7 +216,7 @@

Tempo

Result -
Initializing Temporal...
+
Initializing Temporal...