From 3bb5dc9a82d570801d408c286b1dded331cad521 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 26 Jul 2026 13:29:55 +1000 Subject: [PATCH 1/8] support for Magma AI Proxy --- package.json | 2 +- packages/library/package.json | 2 +- packages/tempo/package.json | 2 +- .../src/plugin/license/license.manager.ts | 32 ++++- packages/tempo/src/support/support.index.ts | 2 +- packages/tempo/src/support/support.symbol.ts | 3 +- packages/tempo/src/tempo.class.ts | 9 +- .../tempo/test/plugins/license.phase1.test.ts | 130 ++++++++++++++++++ 8 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 packages/tempo/test/plugins/license.phase1.test.ts diff --git a/package.json b/package.json index 1b330e4a..24f98315 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.10.2", + "version": "3.10.3", "private": true, "engines": { "node": ">=20.0.0" diff --git a/packages/library/package.json b/packages/library/package.json index 045c08b1..0f85b9b8 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.10.2", + "version": "3.10.3", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 8bc38c57..2e04bda9 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.10.2", + "version": "3.10.3", "engines": { "node": ">=20.0.0" }, diff --git a/packages/tempo/src/plugin/license/license.manager.ts b/packages/tempo/src/plugin/license/license.manager.ts index 259066df..34146b9b 100644 --- a/packages/tempo/src/plugin/license/license.manager.ts +++ b/packages/tempo/src/plugin/license/license.manager.ts @@ -47,6 +47,33 @@ export function getLicenseSnapshot(state: Internal.State): Internal.LicenseState return secure(snapshot); } +export function updateScopeStatus(state: Internal.State, scopeKey: string, status: string, error?: string): void { + const license = getLicenseState(state); + if (!license || !license.scopes) return; + + const scopes = { ...license.scopes } as Record; + if (scopes[scopeKey]) { + scopes[scopeKey] = { + ...scopes[scopeKey], + status, + ...(error ? { error } : {}) + } + } else { + scopes[scopeKey] = { + status, + ...(error ? { error } : {}) + } + } + + license.scopes = scopes; + + const activeScopes = Object.values(scopes).filter((s: any) => s && s.status !== LICENSE.Revoked && s.status !== 'revoked'); + if (activeScopes.length === 0 && Object.keys(scopes).length > 0) { + license.status = LICENSE.Revoked; + if (error) license.error = error; + } +} + function disposePendingLicense(license?: Internal.LicenseState): void { const jws = license?.jws as any; if (jws && jws.isPending) { @@ -102,7 +129,10 @@ export function setLicense(state: Internal.State, key: string): void { else warnIfExpiringSoon(license, state.config); - if (res.revocationPromise) { + const scopesList = Object.values(license.scopes || {}); + const shouldSkipRevocation = scopesList.length > 0 && scopesList.every((s: any) => s?.skipRevocationCheck === true); + + if (res.revocationPromise && !shouldSkipRevocation) { res.revocationPromise.then((isRevoked: boolean) => { if (isRevoked && license.jti === initialJti && license.key === initialKey) { license.status = LICENSE.Revoked; diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index 77952a5f..07a7ef61 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -34,7 +34,7 @@ export { export { markConfig } from '#library/symbol.library.js'; export { sym, isTempo, Token, TermError, type TempoBrand } from './support.symbol.js'; -export { $Tempo, $Register, $Interpreter, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Identity, $LogConfig, $Discover, $ImmutableSkip } from './support.symbol.js'; +export { $Tempo, $Register, $Interpreter, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Identity, $LogConfig, $Discover, $ImmutableSkip, $updateScopeStatus } from './support.symbol.js'; export { registryUpdate, registryReset, onRegistryReset } from './support.register.js'; export { getRuntime, resetRuntime, TempoRuntime } from './support.runtime.js'; export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './support.default.js'; diff --git a/packages/tempo/src/support/support.symbol.ts b/packages/tempo/src/support/support.symbol.ts index 945119e7..392145c9 100644 --- a/packages/tempo/src/support/support.symbol.ts +++ b/packages/tempo/src/support/support.symbol.ts @@ -35,12 +35,13 @@ export const TermError: unique symbol = Symbol.for('magmacomputing/tempo/termErr /** @internal static alias builder */ export const $setAliases: unique symbol = Symbol.for('magmacomputing/tempo/setAliases') as any; /** @internal static guard builder */ export const $buildGuard: unique symbol = Symbol.for('magmacomputing/tempo/buildGuard') as any; /** @internal static base class marker */ export const $IsBase: unique symbol = Symbol.for('magmacomputing/tempo/isBase') as any; +/** @internal static license scope status mutator */ export const $updateScopeStatus: unique symbol = Symbol.for('magmacomputing/tempo/updateScopeStatus') as any; /** @internal Tempo Symbol Registry (Local Keys) */ const local = { $Tempo, $Register, $Interpreter, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $License, $setConfig, $setDiscovery, - $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $PluginType + $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $PluginType, $updateScopeStatus } as const; /** @internal Unified Symbol Registry (Inherits from #library via Prototype Chain) */ diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 6219f543..b30b5ece 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -26,11 +26,11 @@ import { PatternCompiler } from './engine/engine.pattern.js'; import { createMasterGuard } from './engine/engine.guard.js'; import { DEFAULT_LAYOUT_CLASS, resolveLayoutOrder, getLayoutOrder } from './engine/engine.layout.js'; -import { validateLicenseState, getLicenseSnapshot, setLicense, getLicenseState } from './plugin/license/license.manager.js'; +import { validateLicenseState, getLicenseSnapshot, setLicense, getLicenseState, updateScopeStatus } from './plugin/license/license.manager.js'; import { resolveMonthDay, setProperty, proto, hasOwn, resolveDisplayStatus } from './support/support.util.js'; import { datePattern } from './support/support.default.js'; -import { sym, markConfig, TermError, getRuntime, init, extendState, setPatterns, isTempo, registryUpdate, registryReset, onRegistryReset, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, LICENSE, DISCOVERY, $Internal, $setConfig, $Identity, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Tempo, $Register, $errored, $guard, $Discover, $setDiscovery, $LogConfig, $ImmutableSkip, logError, logDebug, logWarn, logTempo, setLogLevel } from '#tempo/support'; +import { sym, markConfig, TermError, getRuntime, init, extendState, setPatterns, isTempo, registryUpdate, registryReset, onRegistryReset, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, LICENSE, DISCOVERY, $Internal, $setConfig, $Identity, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Tempo, $Register, $errored, $guard, $Discover, $setDiscovery, $LogConfig, $ImmutableSkip, $updateScopeStatus, logError, logDebug, logWarn, logTempo, setLogLevel } from '#tempo/support'; import { TEMPO_VERSION } from './tempo.version.js'; import { Interval } from './interval.class.js'; import * as t from './tempo.type.js'; // namespaced types (Tempo.*) @@ -143,6 +143,11 @@ export class Tempo { ...(isNumber(raw.issuedAt) && { issuedAt: new Tempo(raw.issuedAt, ss).fmt.weekTime }), }); } + + /** @internal programmatically update status of a license scope (e.g. when network response determines revocation) */ + static [$updateScopeStatus](scopeKey: string, status: string, error?: string): void { + updateScopeStatus(this[$Internal](), scopeKey, status, error); + } /** mapping of terms to their resolved values */ static #termMap: Map = new Map(); /** @internal Master Guard predicate (implements RegExp-like interface) */static get [$guard]() { return (this[$Internal]() as any)[$guard] ?? { test: () => true }; } diff --git a/packages/tempo/test/plugins/license.phase1.test.ts b/packages/tempo/test/plugins/license.phase1.test.ts new file mode 100644 index 00000000..f4f374a1 --- /dev/null +++ b/packages/tempo/test/plugins/license.phase1.test.ts @@ -0,0 +1,130 @@ +import { Tempo } from '#tempo'; +import { LICENSE } from '#tempo/support/support.enum.js'; +import { getRuntime, resetRuntime } from '#tempo/support/support.runtime.js'; +import { $updateScopeStatus } from '#tempo/support/support.symbol.js'; +import { encodeBase64 } from '#library'; + +const { licenseModulePath, mockFactory, setMockResult } = vi.hoisted(() => { + const path = require('node:path') as typeof import('node:path'); + const licenseModulePath = path.resolve(__dirname, '../../src/plugin/license/license.validator.ts'); + + let mockResult: any = { status: 'active', scopes: {} }; + + const setMockResult = (res: any) => { + mockResult = res; + }; + + const mockFactory = () => { + const Validator = vi.fn().mockImplementation(function () { + return { + verify: vi.fn().mockImplementation(async () => mockResult) + } + }); + return { Validator }; + }; + + return { licenseModulePath, mockFactory, setMockResult }; +}); + +vi.mock('#tempo/license', mockFactory); +vi.mock(licenseModulePath, mockFactory); + +describe('Phase 1 Core Tempo Licensing Engine Enhancements', () => { + beforeEach(() => { + resetRuntime(); + vi.clearAllMocks(); + setMockResult({ status: 'active', scopes: {} }); + }); + + test('Tempo[$updateScopeStatus] symbol method programmatically mutates scope status and is protected from public static access', () => { + // 1. Verify public static access is undefined + expect((Tempo as any).updateScopeStatus).toBeUndefined(); + + const payload = { + iss: 'Magma Computing', + scopes: { + 'tempo-plugin-ai': { exp: 2000000000 }, + 'tempo-plugin-ticker': { exp: 2000000000 } + }, + jti: 'test-ai-jwt-1' + } + const mockToken = `header.${encodeBase64(JSON.stringify(payload))}.sig`; + + Tempo.init({ license: mockToken }); + const rt = getRuntime(); + rt.license.status = LICENSE.Active; + + expect((Tempo.license as any).scopes['tempo-plugin-ai'].status).toBeUndefined(); + + // Update AI scope to revoked via internal symbol + (Tempo as any)[$updateScopeStatus]('tempo-plugin-ai', 'revoked', 'Quota exceeded or token revoked'); + + const snapshot = Tempo.license as any; + expect(snapshot.scopes['tempo-plugin-ai'].status).toBe('revoked'); + expect(snapshot.scopes['tempo-plugin-ai'].error).toBe('Quota exceeded or token revoked'); + expect(snapshot.scopes['tempo-plugin-ticker'].status).toBeUndefined(); + // Top-level status should remain Active because ticker is still active + expect(rt.license.status).toBe(LICENSE.Active); + + // Now update ticker to revoked + (Tempo as any)[$updateScopeStatus]('tempo-plugin-ticker', 'revoked', 'Ticker scope revoked'); + + // Now all scopes are revoked -> top-level license state transitions to Revoked + expect(rt.license.status).toBe(LICENSE.Revoked); + }); + + test('bypasses background revocation promise when ALL scopes have skipRevocationCheck: true', async () => { + const payload = { + iss: 'Magma Computing', + scopes: { + 'tempo-plugin-ai': { exp: 2000000000, skipRevocationCheck: true } + }, + jti: 'ai-only-jwt' + } + const mockToken = `h.${encodeBase64(JSON.stringify(payload))}.s`; + + const revocationFn = vi.fn().mockResolvedValue(true); + setMockResult({ + status: 'active', + scopes: payload.scopes, + revocationPromise: revocationFn() + }); + + Tempo.init({ license: mockToken }); + const rt = getRuntime(); + + await rt.license.jws; + await new Promise(r => setTimeout(r, 20)); + + // Revocation function should NOT have triggered license status mutation to Revoked because skipRevocationCheck is true + expect(rt.license.status).toBe(LICENSE.Active); + }); + + test('preserves background revocation promise when ANY scope lacks skipRevocationCheck', async () => { + const payload = { + iss: 'Magma Computing', + scopes: { + 'tempo-plugin-ai': { exp: 2000000000, skipRevocationCheck: true }, + 'tempo-plugin-ticker': { exp: 2000000000 } // Lacks skipRevocationCheck! + }, + jti: 'hybrid-jwt' + } + const mockToken = `h.${encodeBase64(JSON.stringify(payload))}.s`; + + setMockResult({ + status: 'active', + scopes: payload.scopes, + revocationPromise: Promise.resolve(true) // Revoked background response! + }); + + Tempo.init({ license: mockToken }); + const rt = getRuntime(); + + await rt.license.jws; + await vi.waitFor(() => expect(rt.license.status).toBe(LICENSE.Revoked)); + + // Revocation promise SHOULD run because ticker scope requires client-side background polling! + expect(rt.license.status).toBe(LICENSE.Revoked); + expect(rt.license.error).toContain('revoked'); + }); +}); From 96a93c3e922e6c40525ff3cbf0d806c4b907d702 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 26 Jul 2026 13:43:33 +1000 Subject: [PATCH 2/8] PR 1st review --- .../src/plugin/license/license.manager.ts | 34 ++++++++++++------- .../tempo/test/plugins/license.phase1.test.ts | 15 ++++---- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/tempo/src/plugin/license/license.manager.ts b/packages/tempo/src/plugin/license/license.manager.ts index 34146b9b..6803717b 100644 --- a/packages/tempo/src/plugin/license/license.manager.ts +++ b/packages/tempo/src/plugin/license/license.manager.ts @@ -132,19 +132,27 @@ export function setLicense(state: Internal.State, key: string): void { const scopesList = Object.values(license.scopes || {}); const shouldSkipRevocation = scopesList.length > 0 && scopesList.every((s: any) => s?.skipRevocationCheck === true); - if (res.revocationPromise && !shouldSkipRevocation) { - res.revocationPromise.then((isRevoked: boolean) => { - if (isRevoked && license.jti === initialJti && license.key === initialKey) { - license.status = LICENSE.Revoked; - license.error = 'License has been revoked by the issuer.'; - logWarn(`⚠️ ${logMessage} ${license.error}`, state.config); - } else { - warnIfExpiringSoon(license, state.config); - } - }).catch((err: unknown) => { - const { message } = asError(err); - logDebug(`${logMessage} Background revocation check failed for JTI ${initialJti} - ${message}`, state.config); - }); + if (!shouldSkipRevocation && (res.revocationPromise || res.getRevocationPromise)) { + const promise = typeof res.getRevocationPromise === 'function' + ? res.getRevocationPromise() + : typeof res.revocationPromise === 'function' + ? res.revocationPromise() + : res.revocationPromise; + + if (promise && typeof promise.then === 'function') { + promise.then((isRevoked: boolean) => { + if (isRevoked && license.jti === initialJti && license.key === initialKey) { + license.status = LICENSE.Revoked; + license.error = 'License has been revoked by the issuer.'; + logWarn(`⚠️ ${logMessage} ${license.error}`, state.config); + } else { + warnIfExpiringSoon(license, state.config); + } + }).catch((err: unknown) => { + const { message } = asError(err); + logDebug(`${logMessage} Background revocation check failed for JTI ${initialJti} - ${message}`, state.config); + }); + } } }, onReject: (err: unknown) => { diff --git a/packages/tempo/test/plugins/license.phase1.test.ts b/packages/tempo/test/plugins/license.phase1.test.ts index f4f374a1..af1cdb64 100644 --- a/packages/tempo/test/plugins/license.phase1.test.ts +++ b/packages/tempo/test/plugins/license.phase1.test.ts @@ -73,7 +73,7 @@ describe('Phase 1 Core Tempo Licensing Engine Enhancements', () => { expect(rt.license.status).toBe(LICENSE.Revoked); }); - test('bypasses background revocation promise when ALL scopes have skipRevocationCheck: true', async () => { + test('bypasses background revocation promise factory when ALL scopes have skipRevocationCheck: true', async () => { const payload = { iss: 'Magma Computing', scopes: { @@ -83,11 +83,11 @@ describe('Phase 1 Core Tempo Licensing Engine Enhancements', () => { } const mockToken = `h.${encodeBase64(JSON.stringify(payload))}.s`; - const revocationFn = vi.fn().mockResolvedValue(true); + const revocationSpy = vi.fn().mockResolvedValue(true); setMockResult({ status: 'active', scopes: payload.scopes, - revocationPromise: revocationFn() + getRevocationPromise: revocationSpy }); Tempo.init({ license: mockToken }); @@ -96,7 +96,8 @@ describe('Phase 1 Core Tempo Licensing Engine Enhancements', () => { await rt.license.jws; await new Promise(r => setTimeout(r, 20)); - // Revocation function should NOT have triggered license status mutation to Revoked because skipRevocationCheck is true + // Lazy revocation factory spy should NOT be invoked because skipRevocationCheck is true + expect(revocationSpy).not.toHaveBeenCalled(); expect(rt.license.status).toBe(LICENSE.Active); }); @@ -111,10 +112,11 @@ describe('Phase 1 Core Tempo Licensing Engine Enhancements', () => { } const mockToken = `h.${encodeBase64(JSON.stringify(payload))}.s`; + const revocationSpy = vi.fn().mockResolvedValue(true); setMockResult({ status: 'active', scopes: payload.scopes, - revocationPromise: Promise.resolve(true) // Revoked background response! + getRevocationPromise: revocationSpy }); Tempo.init({ license: mockToken }); @@ -123,7 +125,8 @@ describe('Phase 1 Core Tempo Licensing Engine Enhancements', () => { await rt.license.jws; await vi.waitFor(() => expect(rt.license.status).toBe(LICENSE.Revoked)); - // Revocation promise SHOULD run because ticker scope requires client-side background polling! + // Revocation factory spy SHOULD be invoked because ticker scope requires client-side background polling! + expect(revocationSpy).toHaveBeenCalledTimes(1); expect(rt.license.status).toBe(LICENSE.Revoked); expect(rt.license.error).toContain('revoked'); }); From 71e019162eb11db088b32002dee1a147cd0b7d1d Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 29 Jul 2026 17:14:09 +1000 Subject: [PATCH 3/8] Registry work on JWT --- package-lock.json | 48 +++++++++---------- package.json | 8 ++-- packages/plugins/{parseAI => ai}/CHANGELOG.md | 0 packages/plugins/{parseAI => ai}/LICENSE | 0 packages/plugins/ai/README.md | 46 ++++++++++++++++++ .../{parseAI => ai}/doc/architecture.md | 0 .../plugins/{parseAI => ai}/doc/context.md | 6 +-- packages/plugins/{parseAI => ai}/doc/index.md | 23 +++++++-- .../{parseAI => ai}/doc/rate-limits.md | 8 ++-- packages/plugins/{parseAI => ai}/package.json | 0 packages/plugins/{parseAI => ai}/src/cache.ts | 0 packages/plugins/{parseAI => ai}/src/error.ts | 0 packages/plugins/{parseAI => ai}/src/index.ts | 18 +++---- .../{parseAI => ai}/src/parseAI.type.ts | 0 .../{parseAI => ai}/test/index.spec.ts | 18 +++---- .../{parseAI => ai}/test/tsconfig.json | 0 .../plugins/{parseAI => ai}/tsconfig.json | 0 .../plugins/{parseAI => ai}/tsup.config.ts | 0 packages/plugins/parseAI/README.md | 44 ----------------- .../tempo/.vitepress/theme/data/catalog.json | 2 +- packages/tempo/CHANGELOG.md | 8 ++++ .../doc/4-advanced-reference/tempo.locale.md | 17 ++++--- packages/tempo/src/tempo.class.ts | 36 +++++++------- packages/tempo/src/tempo.version.ts | 2 +- .../tempo/test/discrete/parse.locale.test.ts | 23 +++++++++ 25 files changed, 181 insertions(+), 126 deletions(-) rename packages/plugins/{parseAI => ai}/CHANGELOG.md (100%) rename packages/plugins/{parseAI => ai}/LICENSE (100%) create mode 100644 packages/plugins/ai/README.md rename packages/plugins/{parseAI => ai}/doc/architecture.md (100%) rename packages/plugins/{parseAI => ai}/doc/context.md (91%) rename packages/plugins/{parseAI => ai}/doc/index.md (61%) rename packages/plugins/{parseAI => ai}/doc/rate-limits.md (86%) rename packages/plugins/{parseAI => ai}/package.json (100%) rename packages/plugins/{parseAI => ai}/src/cache.ts (100%) rename packages/plugins/{parseAI => ai}/src/error.ts (100%) rename packages/plugins/{parseAI => ai}/src/index.ts (94%) rename packages/plugins/{parseAI => ai}/src/parseAI.type.ts (100%) rename packages/plugins/{parseAI => ai}/test/index.spec.ts (91%) rename packages/plugins/{parseAI => ai}/test/tsconfig.json (100%) rename packages/plugins/{parseAI => ai}/tsconfig.json (100%) rename packages/plugins/{parseAI => ai}/tsup.config.ts (100%) delete mode 100644 packages/plugins/parseAI/README.md diff --git a/package-lock.json b/package-lock.json index 07e70235..e4c8ee24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "3.10.2", + "version": "3.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "3.10.2", + "version": "3.10.3", "workspaces": [ "packages/*", "packages/plugins/*" @@ -17,10 +17,10 @@ "devDependencies": { "@js-temporal/polyfill": "^0.5.1", "@rollup/plugin-node-resolve": "^16.0.3", - "@types/google.maps": "^3.65.2", + "@types/google.maps": "^3.65.3", "@types/hammerjs": "^2.0.46", "@types/jquery": "^4.0.0", - "@types/node": "^26.0.1", + "@types/node": "^26.1.2", "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/browser-webdriverio": "^4.1.8", @@ -1135,7 +1135,7 @@ "link": true }, "node_modules/@magmacomputing/tempo-plugin-ai": { - "resolved": "packages/plugins/parseAI", + "resolved": "packages/plugins/ai", "link": true }, "node_modules/@magmacomputing/tempo-plugin-astro": { @@ -2725,9 +2725,9 @@ "license": "MIT" }, "node_modules/@types/google.maps": { - "version": "3.65.2", - "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.65.2.tgz", - "integrity": "sha512-e52bmOhGCQSNabFpL48iQlwJybq6rfns8NUVJ20MR7CdPlHQ2RmSCnPbJfrUYJfogrE4OiHQTZ4LXpop+eer1w==", + "version": "3.65.3", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.65.3.tgz", + "integrity": "sha512-tqbbx7MUtoDk+RwpZMymPDj6Skez0FhqDZNhLhS5UDmCx4D1dgP+BJOHTebiO/rhjJLa1f8te2p6mJJeFKVFOQ==", "dev": true, "license": "MIT" }, @@ -2798,9 +2798,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", - "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "dev": true, "license": "MIT", "dependencies": { @@ -11045,7 +11045,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "3.10.2", + "version": "3.10.3", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -11061,6 +11061,17 @@ "magma-cli": "index.js" } }, + "packages/plugins/ai": { + "name": "@magmacomputing/tempo-plugin-ai", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, + "peerDependencies": { + "@magmacomputing/tempo": "^3.10.2" + } + }, "packages/plugins/astro": { "name": "@magmacomputing/tempo-plugin-astro", "version": "2.1.3", @@ -11361,17 +11372,6 @@ } } }, - "packages/plugins/parseAI": { - "name": "@magmacomputing/tempo-plugin-ai", - "version": "0.1.0", - "license": "MIT", - "devDependencies": { - "@js-temporal/polyfill": "^0.5.1" - }, - "peerDependencies": { - "@magmacomputing/tempo": "^3.10.2" - } - }, "packages/plugins/snap": { "name": "@magmacomputing/tempo-plugin-snap", "version": "1.3.2", @@ -11392,7 +11392,7 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "3.10.2", + "version": "3.10.3", "license": "MIT", "dependencies": { "tslib": "^2.8.1" diff --git a/package.json b/package.json index 24f98315..eebf8954 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,10 @@ "devDependencies": { "@js-temporal/polyfill": "^0.5.1", "@rollup/plugin-node-resolve": "^16.0.3", - "@types/google.maps": "^3.65.2", + "@types/google.maps": "^3.65.3", "@types/hammerjs": "^2.0.46", "@types/jquery": "^4.0.0", - "@types/node": "^26.0.1", + "@types/node": "^26.1.2", "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/browser-webdriverio": "^4.1.8", @@ -63,9 +63,9 @@ "allowScripts": { "edgedriver@6.3.0": true, "geckodriver@6.1.0": true, - "@swc/core@1.15.41": true, "esbuild@0.28.1": true, - "esbuild@0.21.5": true + "esbuild@0.21.5": true, + "@swc/core@1.15.43": true }, "dependencies": { "typescript-7": "npm:typescript@^7.0.2" diff --git a/packages/plugins/parseAI/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md similarity index 100% rename from packages/plugins/parseAI/CHANGELOG.md rename to packages/plugins/ai/CHANGELOG.md diff --git a/packages/plugins/parseAI/LICENSE b/packages/plugins/ai/LICENSE similarity index 100% rename from packages/plugins/parseAI/LICENSE rename to packages/plugins/ai/LICENSE diff --git a/packages/plugins/ai/README.md b/packages/plugins/ai/README.md new file mode 100644 index 00000000..152c7b30 --- /dev/null +++ b/packages/plugins/ai/README.md @@ -0,0 +1,46 @@ +![Tempo Plugin](https://raw.githubusercontent.com/magmacomputing/magma/main/packages/tempo/public/plugin-logo.svg) + +# @magmacomputing/tempo-plugin-ai + +

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

+ +Tempo community plugin for LLM-powered natural language parsing. + +This plugin bridges the gap between deterministic date-math and unstructured NLP inputs, utilizing large language models (like Gemini, Groq, or OpenAI) to safely and asynchronously parse complex natural language expressions into `Tempo` instances. + +> **CRITICAL SECURITY WARNING**: Raw LLM API keys must **never** be exposed in a client-side browser bundle. BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, you must use a proxy service. + +> **LLM Output Disclaimer**: Large Language Models are probabilistic text generators, not deterministic calculators. Magma Computing Solutions and Tempo core maintainers provide `@magmacomputing/tempo-plugin-ai` "as-is". Developers and organizations are solely responsible for validating AI-generated date and time outputs before relying on them in financial, legal, medical, or time-critical production systems. + +## Installation + +```bash +npm install @magmacomputing/tempo-plugin-ai +``` + +## Setup & Usage + +```typescript +import { parseAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; + +// Initialize with your BYOK API Key +initAI({ + providers: [ + { id: 'groq', key: process.env.GROQ_API_KEY }, + ] +}); + +// Parse a complex natural language string! +const dt = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); + +// Evict bad parses from the cache +clearAiCache("The penultimate Tuesday before Thanksgiving in 2026"); +``` + +Full documentation is available at [https://magmacomputing.github.io/magma/doc/9-plugins/ai.index.html](https://magmacomputing.github.io/magma/doc/9-plugins/ai.index.html). + +## Licensing + +This is a **Community** plugin. It is completely free and open-source for personal and commercial use. No license token is required. diff --git a/packages/plugins/parseAI/doc/architecture.md b/packages/plugins/ai/doc/architecture.md similarity index 100% rename from packages/plugins/parseAI/doc/architecture.md rename to packages/plugins/ai/doc/architecture.md diff --git a/packages/plugins/parseAI/doc/context.md b/packages/plugins/ai/doc/context.md similarity index 91% rename from packages/plugins/parseAI/doc/context.md rename to packages/plugins/ai/doc/context.md index 5615d50b..63eeaed7 100644 --- a/packages/plugins/parseAI/doc/context.md +++ b/packages/plugins/ai/doc/context.md @@ -15,11 +15,11 @@ Along with your string, the plugin passes a hidden context payload to the LLM: You can explicitly override any of these global settings on a per-request basis by passing an `options` object as the second argument, identical to how you pass options to a standard `new Tempo()` constructor: ```typescript -// Explicitly evaluate this relative query from the perspective of September 1st -const dt = await parseAI("Next Friday at 5pm", { anchor: '2026-09-01T00:00:00Z' }); +// Explicitly evaluate this complex query from the perspective of September 1st +const dt = await parseAI("The penultimate Tuesday before Thanksgiving", { anchor: '2026-09-01T00:00:00Z' }); // Explicitly parse assuming a Japanese locale and timezone -const tokyoDt = await parseAI("The day after tomorrow", { locale: 'ja-JP', timeZone: 'Asia/Tokyo' }); +const tokyoDt = await parseAI("The second Sunday of May", { locale: 'ja-JP', timeZone: 'Asia/Tokyo' }); ``` ### Why Locale is Critical diff --git a/packages/plugins/parseAI/doc/index.md b/packages/plugins/ai/doc/index.md similarity index 61% rename from packages/plugins/parseAI/doc/index.md rename to packages/plugins/ai/doc/index.md index a61254f9..e7f93423 100644 --- a/packages/plugins/parseAI/doc/index.md +++ b/packages/plugins/ai/doc/index.md @@ -2,14 +2,17 @@ # @magmacomputing/tempo-plugin-ai -[![npm version](https://img.shields.io/npm/v/@magmacomputing/tempo-plugin-ai?style=flat-square)](https://www.npmjs.com/package/@magmacomputing/tempo-plugin-ai) -[![npm peer dependency version](https://img.shields.io/npm/dependency-version/@magmacomputing/tempo-plugin-ai/peer/@magmacomputing/tempo?style=flat-square)](https://www.npmjs.com/package/@magmacomputing/tempo) -[![License](https://img.shields.io/npm/l/@magmacomputing/tempo-plugin-ai?style=flat-square)](https://www.npmjs.com/package/@magmacomputing/tempo-plugin-ai) +

+ 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 Issues](https://github.com/magmacomputing/magma/issues) page! +> [!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 complex natural language expressions into `Tempo` instances. @@ -18,6 +21,16 @@ This plugin bridges the gap between deterministic date-math and unstructured NLP > > **CRITICAL SECURITY WARNING**: Raw LLM API keys must **never** be exposed in a client-side browser bundle. BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, you must use a proxy service. +## Ideal Use-Cases + +Good `parseAI` candidates 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 @@ -56,10 +69,10 @@ When building your LLM queries, it is often useful to see exactly how `parseAI` Passing `debug: true` into `initAI` is intended for **development environments only**. It will globally log system prompts, localized context, and raw LLM responses to the console. Because prompts, context, and responses may contain user-supplied or sensitive data, disable `debug: true` or redact sensitive logs in production. **Forced Evaluation** -If a relative query (like `"Next Friday"`) is perfectly intercepted by the native `Tempo` layout engine, but the anchor context inheritance is returning an undesired timezone, you can forcefully bypass the deterministic engine and the cache by passing `force: true`: +If a relative query (like `"The Friday after Thanksgiving"`) is intercepted by the native `Tempo` layout engine, but the anchor context inheritance is returning an undesired timezone, you can forcefully bypass the deterministic engine and the cache by passing `force: true`: ```typescript -const dt = await parseAI("Next Friday at 5pm", { +const dt = await parseAI("The Friday after Thanksgiving", { anchor: '2026-09-01T00:00:00Z', force: true, // Bypasses native parsers & cache; forces a network LLM request! debug: true // Overrides the global debug flag for this specific request diff --git a/packages/plugins/parseAI/doc/rate-limits.md b/packages/plugins/ai/doc/rate-limits.md similarity index 86% rename from packages/plugins/parseAI/doc/rate-limits.md rename to packages/plugins/ai/doc/rate-limits.md index 6a0815a1..98b5212f 100644 --- a/packages/plugins/parseAI/doc/rate-limits.md +++ b/packages/plugins/ai/doc/rate-limits.md @@ -53,14 +53,14 @@ This is by design for three critical reasons: 3. **Deterministic Safety**: LLMs are language models, not arrays. If you pass 50 strings, smaller models often hallucinate and return 49 strings, completely breaking your array indexing. By querying sequentially, we guarantee a strict 1:1 mapping and ensure one invalid string doesn't crash the entire batch. > [!WARNING] -> **Granular Time Gotcha**: The cache key is automatically salted with the **calendar date** (`yyyy-mm-dd`) of execution. This brilliantly protects relative day queries (like `"tomorrow"`) because the cache automatically misses as soon as midnight strikes! However, if you are parsing granular, time-relative phrases (like `"in 5 minutes"` or `"next hour"`), the calendar date salt is not enough to prevent staleness on a long-running server. +> **Granular Time Gotcha**: The cache key is automatically salted with the **calendar date** (`yyyy-mm-dd`) of execution. This brilliantly protects relative day queries (like `"The Friday after Thanksgiving"`) because the cache automatically misses as soon as midnight strikes! However, if you pass `force: true` for granular time-relative phrases, the calendar date salt is not enough to prevent staleness on a long-running server. -### Bypassing Cache for Relative Times -If you are intentionally parsing highly granular relative times (like `"in 5 minutes"`) and your server is long-running, you should explicitly disable caching for that specific query to ensure it is evaluated against real-world time: +### Bypassing Cache for Dynamic Queries +If you are intentionally parsing dynamic phrases and your server is long-running, you should explicitly disable caching for that specific query to ensure it is re-evaluated: ```typescript // The LLM will ALWAYS be queried, and the result will NOT be cached -const dt = await parseAI("in 5 minutes", { cache: false }); +const dt = await parseAI("The last Friday before Christmas", { cache: false }); ``` ### Evicting Bad Parses diff --git a/packages/plugins/parseAI/package.json b/packages/plugins/ai/package.json similarity index 100% rename from packages/plugins/parseAI/package.json rename to packages/plugins/ai/package.json diff --git a/packages/plugins/parseAI/src/cache.ts b/packages/plugins/ai/src/cache.ts similarity index 100% rename from packages/plugins/parseAI/src/cache.ts rename to packages/plugins/ai/src/cache.ts diff --git a/packages/plugins/parseAI/src/error.ts b/packages/plugins/ai/src/error.ts similarity index 100% rename from packages/plugins/parseAI/src/error.ts rename to packages/plugins/ai/src/error.ts diff --git a/packages/plugins/parseAI/src/index.ts b/packages/plugins/ai/src/index.ts similarity index 94% rename from packages/plugins/parseAI/src/index.ts rename to packages/plugins/ai/src/index.ts index 279a1226..e091aa9c 100644 --- a/packages/plugins/parseAI/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -144,7 +144,7 @@ export async function parseAI( const native = new Tempo(str, { ...options, silent: true }); if (native.isValid) { if (isDebug) console.log(`[parseAI] Resolved natively: "${str}"`); - results.push(new Tempo(str, options)); + results.push(native); continue; } } catch { @@ -161,7 +161,7 @@ export async function parseAI( sph = options!.sphere || options!.anchor.config.sphere; anchorStr = options!.anchor.toString(); } else { - const resolvedConfig = new Tempo().config; + const resolvedConfig = Tempo.config; tz = options?.timeZone || resolvedConfig.timeZone; cal = options?.calendar || resolvedConfig.calendar; loc = options?.locale || resolvedConfig.locale; @@ -169,22 +169,24 @@ export async function parseAI( anchorStr = options?.anchor || new Tempo().toString(); } + // Establish single anchor Tempo instance for cache salting and context prompt + const anchorTempo = new Tempo(anchorStr, { ...options, timeZone: tz, calendar: cal, locale: loc, sphere: sph }); + // The cache key salts the normalized string with the anchor's Calendar Date and resolved context (TZ/Cal/Loc/Sph). // This allows "tomorrow" to hit the cache all day, but cleanly miss when midnight strikes or context changes! const normalizedStr = normalizeCacheInput(str); - const cacheSalt = new Tempo(anchorStr, { ...options, timeZone: tz, calendar: cal, locale: loc, sphere: sph }).format('{yyyy}-{mm}-{dd}'); + const cacheSalt = anchorTempo.format('{yyyy}-{mm}-{dd}'); const cacheKey = `${normalizedStr}::${cacheSalt}::${tz}::${cal}::${loc}::${sph}`; // 3. Check Cache if (!options?.force && options?.cache !== false && _state.cache.has(cacheKey)) { const cachedIso = _state.cache.get(cacheKey)!; - if (isDebug) console.log(`[parseAI] Cache hit for "${str}":`, cachedIso); + if (isDebug) console.log(`[tempo-plugin-ai] Cache hit for "${str}":`, cachedIso); results.push(new Tempo(cachedIso, options)); continue; } // 4. Construct LLM Context - const anchorTempo = new Tempo(anchorStr, { ...options, timeZone: tz, calendar: cal, locale: loc, sphere: sph }); let contextString = `Current Time: ${anchorTempo.format('{yyyy}-{mm}-{dd} ({wkd}) {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${loc}, Hemisphere: ${sph}.`; // 5. LLM Network Fetch with Fallback Loop @@ -213,7 +215,7 @@ Ambiguity Rules: Do not include markdown blocks, explanations, or any text outside the JSON.`; if (isDebug) - console.log(`[parseAI] Sending to ${provider.id}:`, { system: `${systemPrompt}\n${contextString}`, user: str }); + 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) @@ -248,7 +250,7 @@ Do not include markdown blocks, explanations, or any text outside the JSON.`; }); } catch (fetchErr: any) { lastError = fetchErr; - if (isDebug) console.warn(`[parseAI] Provider ${provider.id} fetch failed or timed out:`, fetchErr?.message || fetchErr); + if (isDebug) console.warn(`[tempo-plugin-ai] Provider ${provider.id} fetch failed or timed out:`, fetchErr?.message || fetchErr); continue; } finally { clearTimeout(timeoutId); @@ -290,7 +292,7 @@ Do not include markdown blocks, explanations, or any text outside the JSON.`; const content = rawContent.trim(); if (isDebug) - console.log(`[parseAI] Received from ${provider.id}:`, content); + console.log(`[tempo-plugin-ai] Received from ${provider.id}:`, content); let parsedData: any; try { diff --git a/packages/plugins/parseAI/src/parseAI.type.ts b/packages/plugins/ai/src/parseAI.type.ts similarity index 100% rename from packages/plugins/parseAI/src/parseAI.type.ts rename to packages/plugins/ai/src/parseAI.type.ts diff --git a/packages/plugins/parseAI/test/index.spec.ts b/packages/plugins/ai/test/index.spec.ts similarity index 91% rename from packages/plugins/parseAI/test/index.spec.ts rename to packages/plugins/ai/test/index.spec.ts index e1511008..b8c14bdb 100644 --- a/packages/plugins/parseAI/test/index.spec.ts +++ b/packages/plugins/ai/test/index.spec.ts @@ -42,7 +42,7 @@ describe('AI Parsing Plugin', () => { it('should parse natural language successfully', async () => { if (!isLiveTest) { vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"Two days after December 1st", "iso":"2026-12-03T00:00:00"}' } }] + choices: [{ message: { content: '{"reasoning":"The Friday after Thanksgiving", "iso":"2026-11-27T00:00:00"}' } }] }), { status: 200, headers: new Headers({ @@ -54,32 +54,32 @@ describe('AI Parsing Plugin', () => { // Provide a strict anchor so we can assert the result deterministically const anchorDate = '2026-05-10T12:00:00Z'; - const result = await parseAI('Two days after December 1st', { anchor: anchorDate, timeZone: 'UTC' }); + const result = await parseAI('The Friday after Thanksgiving', { anchor: anchorDate, timeZone: 'UTC' }); expect(result).toBeInstanceOf(Tempo); expect(result.isValid).toBe(true); - expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-12-03'); + expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-27'); }); it('should cache the result', async () => { const anchorDate = '2026-05-10T12:00:00Z'; // Clear cache first - clearAiCache('Two days after December 1st'); + clearAiCache('The Friday after Thanksgiving'); const fetchSpy = vi.spyOn(globalThis, 'fetch'); if (!isLiveTest) { fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"Two days after December 1st", "iso":"2026-12-03T00:00:00"}' } }] + choices: [{ message: { content: '{"reasoning":"The Friday after Thanksgiving", "iso":"2026-11-27T00:00:00"}' } }] }), { status: 200 })); } // First parse (hits network or mock) - const dt1 = await parseAI('Two days after December 1st', { anchor: anchorDate, timeZone: 'UTC' }); - expect(dt1.format('{yyyy}-{mm}-{dd}')).toBe('2026-12-03'); + const dt1 = await parseAI('The Friday after Thanksgiving', { anchor: anchorDate, timeZone: 'UTC' }); + expect(dt1.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-27'); // Second parse (hits cache instantly) - const dt2 = await parseAI('Two days after December 1st', { anchor: anchorDate, timeZone: 'UTC' }); - expect(dt2.format('{yyyy}-{mm}-{dd}')).toBe('2026-12-03'); + const dt2 = await parseAI('The Friday after Thanksgiving', { anchor: anchorDate, timeZone: 'UTC' }); + expect(dt2.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-27'); if (!isLiveTest) { expect(fetchSpy).toHaveBeenCalledTimes(1); diff --git a/packages/plugins/parseAI/test/tsconfig.json b/packages/plugins/ai/test/tsconfig.json similarity index 100% rename from packages/plugins/parseAI/test/tsconfig.json rename to packages/plugins/ai/test/tsconfig.json diff --git a/packages/plugins/parseAI/tsconfig.json b/packages/plugins/ai/tsconfig.json similarity index 100% rename from packages/plugins/parseAI/tsconfig.json rename to packages/plugins/ai/tsconfig.json diff --git a/packages/plugins/parseAI/tsup.config.ts b/packages/plugins/ai/tsup.config.ts similarity index 100% rename from packages/plugins/parseAI/tsup.config.ts rename to packages/plugins/ai/tsup.config.ts diff --git a/packages/plugins/parseAI/README.md b/packages/plugins/parseAI/README.md deleted file mode 100644 index 4b880d68..00000000 --- a/packages/plugins/parseAI/README.md +++ /dev/null @@ -1,44 +0,0 @@ -![Tempo Plugin](https://raw.githubusercontent.com/magmacomputing/magma/main/packages/tempo/public/plugin-logo.svg) - -# @magmacomputing/tempo-plugin-ai - -[![npm version](https://img.shields.io/npm/v/@magmacomputing/tempo-plugin-ai?style=flat-square)](https://www.npmjs.com/package/@magmacomputing/tempo-plugin-ai) -[![npm peer dependency version](https://img.shields.io/npm/dependency-version/@magmacomputing/tempo-plugin-ai/peer/@magmacomputing/tempo?style=flat-square)](https://www.npmjs.com/package/@magmacomputing/tempo) -[![License](https://img.shields.io/npm/l/@magmacomputing/tempo-plugin-ai?style=flat-square)](https://www.npmjs.com/package/@magmacomputing/tempo-plugin-ai) - -Tempo community plugin for LLM-powered natural language parsing. - -This plugin bridges the gap between deterministic date-math and unstructured NLP inputs, utilizing large language models (like Gemini, Groq, or OpenAI) to safely and asynchronously parse complex natural language expressions into `Tempo` instances. - -> **CRITICAL SECURITY WARNING**: Raw LLM API keys must **never** be exposed in a client-side browser bundle. BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, you must use a proxy service. - -## Installation - -```bash -npm install @magmacomputing/tempo-plugin-ai -``` - -## Setup & Usage - -```typescript -import { parseAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; - -// Initialize with your BYOK API Key -initAI({ - providers: [ - { id: 'groq', key: process.env.GROQ_API_KEY }, - ] -}); - -// Parse a complex natural language string! -const dt = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); - -// Evict bad parses from the cache -clearAiCache("The penultimate Tuesday before Thanksgiving in 2026"); -``` - -Full documentation is available at [https://magmacomputing.github.io/magma/doc/9-plugins/ai.index.html](https://magmacomputing.github.io/magma/doc/9-plugins/ai.index.html). - -## Licensing - -This is a **Community** plugin. It is completely free and open-source for personal and commercial use. No license token is required. diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json index 8e560242..1c976349 100644 --- a/packages/tempo/.vitepress/theme/data/catalog.json +++ b/packages/tempo/.vitepress/theme/data/catalog.json @@ -51,7 +51,7 @@ "packageName": "@magmacomputing/tempo-plugin-ai", "plan": "community", "status": "experimental", - "version": "0.1.0" + "version": "" }, { "id": "ticker", diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 5edb1004..9108a5dc 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.10.3] - 2026-07-29 + +### Performance +- **Zero-Overhead Instantiation (~40–60% Speedup)**: Re-architected `Tempo` instance construction by introducing lazy evaluation for core private properties: + - **Lazy Instant (`#now`)**: Deferred system clock acquisition (`Temporal.Instant.fromEpochNanoseconds`) so that `instant()` is only fetched when relative duration math or parsing fallbacks require it. Passing explicit date strings or objects skips system clock calls entirely. + - **Lazy Delegators (`#fmt` & `#term`)**: Deferred creation of Proxy delegator objects until `.fmt` or `.term` properties are explicitly accessed. + - **`Interval` Acceleration**: `Tempo.Interval` and boundary set operations (`overlaps`, `contains`, `intersection`, `union`) automatically benefit from the reduced instantiation overhead. + ## [3.10.2] - 2026-07-25 ### Added diff --git a/packages/tempo/doc/4-advanced-reference/tempo.locale.md b/packages/tempo/doc/4-advanced-reference/tempo.locale.md index d6d54c36..97d016dd 100644 --- a/packages/tempo/doc/4-advanced-reference/tempo.locale.md +++ b/packages/tempo/doc/4-advanced-reference/tempo.locale.md @@ -29,19 +29,24 @@ By default, Tempo parses structural English abbreviations (e.g., `Jan`, `Feb`, ` When you nominate a non-English `locale` (or an array of locales like `['fr-FR', 'es-ES']`): - Tempo asks the native ECMAScript `Intl` API how to spell months, weekdays, and relative events (like "tomorrow" or "yesterday") in the specified languages. -- It dynamically compiles new, high-performance Regular Expressions containing these localized abbreviations. -- It injects these new patterns into its lexer, allowing Tempo to instantly understand strings like `'15 Janvier 2024'` or `'el próximo lunes'`. +- It dynamically compiles new, high-performance Regular Expressions containing these localized abbreviations (and automatically handles accent variations, matching both `próximo` and `proximo`). +- It injects these patterns into its lexer, allowing Tempo to instantly understand strings like `'15 Janvier 2024'` or `'15 febrero 2024'`. ```typescript import { Tempo } from '@magmacomputing/tempo'; -// Tempo learns French and Spanish at runtime! +// Tempo learns French and Spanish months & weekdays at runtime! +// Custom relative modifier keywords ('próximo') and noise articles ('el') can be registered in the registry. Tempo.init({ - locale: ['fr-FR', 'es-ES'] + locale: ['fr-FR', 'es-ES'], + registry: { + modifiers: { '+': ['próximo', 'proximo', 'siguiente'] }, + ignores: ['el', 'la', 'los', 'las'] + } }); -const a = new Tempo('15 janvier 2024'); // Matches French -const b = new Tempo('el próximo lunes'); // Matches Spanish +const a = new Tempo('15 janvier 2024'); // Matches French +const b = new Tempo('el próximo lunes'); // Matches Spanish ("next Monday") ``` *For more details on setting up and optimizing international parsing, see [Internationalized Parsing](../2-core-concepts/tempo.parse.md#internationalized-parsing-locales).* diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index b30b5ece..c8fd2a93 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -118,12 +118,11 @@ export class Tempo { /** the version of this Tempo build (stamped at build-time from package.json) */ static get version() { return Tempo.#versions['Tempo']; } - /** mutable list of registered term plugins */ static get #terms(): TermPlugin[] { return this[$Internal]().pluginsDb.terms } - /** @internal raw license state — sandbox-aware: reads sandbox-local license if present, otherwise global */ - static get #license() { return getLicenseState(this[$Internal]()); } - /** human-readable formatted license state */ static get license() { - const { jws, key, ...raw } = getLicenseSnapshot(this[$Internal]()); // omit internal Pledge and JWT string from user-facing snapshot - const ss = { timeStamp: 'ss' } as const; // JWT timestamps are always in seconds (RFC 7519) + /** mutable list of registered term plugins */ static get #terms(): TermPlugin[] { return Tempo[$Internal]().pluginsDb.terms } + /** @internal format raw license snapshot into human-readable license object */ + static #formatLicense(state: Internal.State) { + const { jws, key, ...raw } = getLicenseSnapshot(state); + const ss = { timeStamp: 'ss' } as const; const scopesSource = (raw.scopes && isObject(raw.scopes)) ? raw.scopes : {}; const scopes = Object.fromEntries( Object.entries(scopesSource).map(([key, scope]) => { @@ -144,6 +143,11 @@ export class Tempo { }); } + /** human-readable formatted license state */ + static get license() { + return Tempo.#formatLicense(this[$Internal]()); + } + /** @internal programmatically update status of a license scope (e.g. when network response determines revocation) */ static [$updateScopeStatus](scopeKey: string, status: string, error?: string): void { updateScopeStatus(this[$Internal](), scopeKey, status, error); @@ -1064,11 +1068,11 @@ export class Tempo { /** static units since Unix epoch */ static get epoch() { - return this.#getEpoch(instant()); + return Tempo.#getEpoch(instant()); } /** get the current system Instant */ - static get instant() { return Temporal.Instant.fromEpochNanoseconds(this.now()) } + static get instant() { return Temporal.Instant.fromEpochNanoseconds(Tempo.now()) } /** static Tempo.terms (registry) */ static get terms(): Secure & Record { @@ -1202,14 +1206,14 @@ export class Tempo { /** constructor tempo */ #tempo?: t.DateTime; /** constructor options */ #options = {} as t.Options; - /** instantiation Temporal Instant */ #now: Temporal.Instant; + /** instantiation Temporal Instant */ #instant?: Temporal.Instant; /** underlying Temporal ZonedDateTime */ #zdt!: Temporal.ZonedDateTime; /** memoized TimeZone ID */ #tz?: string; /** memoized Calendar ID */ #cal?: string; /** indicator that the instance failed to parse */ #errored = false; /** temporary anchor used during parsing */ #anchor: Temporal.ZonedDateTime | undefined; - /** prebuilt formats, for convenience */ #fmt!: Record; - /** mapping of terms to their resolved values */ #term!: any; + /** prebuilt formats, for convenience */ #fmt?: Record; + /** mapping of terms to their resolved values */ #term?: any; /** a collection of parse rule-matches */ #matches: Internal.MatchResult[] | undefined; /** current parsing depth to manage state isolation */ #parseDepth = 0; /** current mutation depth to manage infinite recursion */#mutateDepth = 0; @@ -1277,7 +1281,7 @@ export class Tempo { /** iterate over instance formats */ [Symbol.iterator]() { - return ownEntries(this.#fmt, true)[Symbol.iterator](); // instance Iterator over tuple of FormatType[] + return ownEntries(this.fmt, true)[Symbol.iterator](); // instance Iterator over tuple of FormatType[] } get [Symbol.toStringTag](): 'Tempo' { // default string description @@ -1298,7 +1302,6 @@ export class Tempo { */ constructor(tempo: t.DateTime, options?: t.Options); constructor(tempo?: t.DateTime | t.Options, options: t.Options = {}) { - this.#now = instant(); // stash current Instant [this.#tempo, this.#options] = this.#swap(tempo, options);// swap arguments around if (isZonedDateTime(this.#tempo)) this.#zdt = this.#tempo; @@ -1318,8 +1321,6 @@ export class Tempo { else if (isString(this.#tempo) && !isEmpty(input) && guard.test(trimAll(input))) this.#local.parse.lazy = true; // auto-switch to lazy-mode for valid strings - this.#fmt = this.#setDelegator('fmt'); // initialize the format-delegator - this.#term = this.#setDelegator('term'); // initialize the term-delegator this.#anchor = this.#options.anchor; // 🧬 Unified State Hand-off (from clone / mutate) @@ -1627,8 +1628,8 @@ export class Tempo { return out as t.Internal.Parse; } - /** Keyed results for all resolved terms */ get term(): TempoTermRegistry { return this.#term } - /** Formatted results for all pre-defined format codes */ get fmt(): Record { return this.#fmt } + /** Keyed results for all resolved terms */ get term(): TempoTermRegistry { return this.#term ??= this.#setDelegator('term'); } + /** Formatted results for all pre-defined format codes */ get fmt(): Record { return this.#fmt ??= this.#setDelegator('fmt'); } /** units since epoch for this date-time instance */ get epoch() { return Tempo.#getEpoch(this.toDateTime()); } /** @@ -1638,6 +1639,7 @@ export class Tempo { * rather than using `new Tempo(..)`. */ /** @internal */ get #Tempo() { return this.constructor as typeof Tempo; } + /** @internal */ get #now(): Temporal.Instant { return this.#instant ??= instant(); } /** apply a custom format. */ format(fmt?: any, options?: any): string { return this.#resolve(() => interpret(this, 'FormatModule', () => `{${String(fmt)}}`, false, fmt, options)) as string; } /** time duration until another date-time */ diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index 7d22d5f3..c3fdb730 100644 --- a/packages/tempo/src/tempo.version.ts +++ b/packages/tempo/src/tempo.version.ts @@ -5,4 +5,4 @@ * ⚠️ This file is auto-updated by `npm run build:version` (see `bin/update-version.mjs`). * Do NOT edit manually — your changes will be overwritten on the next build. */ -export const TEMPO_VERSION = '3.10.2'; +export const TEMPO_VERSION = '3.10.3'; diff --git a/packages/tempo/test/discrete/parse.locale.test.ts b/packages/tempo/test/discrete/parse.locale.test.ts index 69188125..f99b6bfb 100644 --- a/packages/tempo/test/discrete/parse.locale.test.ts +++ b/packages/tempo/test/discrete/parse.locale.test.ts @@ -78,4 +78,27 @@ describe('Localized Parsing', () => { expect(t2.isValid).toBe(true); expect(t2.mm).toBe(1); }); + + it('test Spanish el próximo lunes', () => { + Tempo.init({ + locale: ['fr-FR', 'es-ES'], + registry: { + modifiers: { + '+': ['próximo', 'proximo', 'siguiente'] + }, + ignores: ['el', 'la', 'los', 'las'] + } + }); + const t1 = new Tempo('lunes'); + expect(t1.isValid).toBe(true); + + const t2 = new Tempo('próximo lunes'); + expect(t2.isValid).toBe(true); + + const t3 = new Tempo('el próximo lunes'); + expect(t3.isValid).toBe(true); + + const t4 = new Tempo('el proximo lunes'); + expect(t4.isValid).toBe(true); + }); }); From 72e7fd03aec7af02f2e98fcae9cb5b9ed00113af Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 31 Jul 2026 09:52:10 +1000 Subject: [PATCH 4/8] pre Cache --- packages/plugins/ai/CHANGELOG.md | 29 ++++++++++++++++++--- packages/plugins/ai/package.json | 4 +-- packages/plugins/ai/src/cache.ts | 28 ++++++++++++++++----- packages/plugins/ai/src/index.ts | 16 +++++++++--- packages/plugins/ai/test/index.spec.ts | 35 ++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 15 deletions(-) diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index 30a2780b..2170a21a 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -1,6 +1,27 @@ -# @magmacomputing/tempo-plugin-ai +# Changelog -## 0.1.0 +All notable changes to the `@magmacomputing/tempo-plugin-ai` project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2026-07-30 + +### Added +- **`BoundedCache` LRU & TTL Eviction**: Implemented a memory-safe, capacity-bounded Map cache enforcing maximum size limits (`maxCacheSize`, default 1000) and time-to-live expiration (`cacheTtl`, default 24h) with automatic inline eviction during reads and iterations. +- **Multi-Provider Fallback Routing**: Robust failover loop across configured LLM providers (`groq`, `openai`, `gemini`, `mistral`, or custom endpoints). Supports custom `tokenParam` mappings (`max_tokens` vs `max_completion_tokens`) and request timeout control via `AbortController`. +- **Rate Limit Tracking (`getAiRateLimits`)**: Inspects provider HTTP response headers (`x-ratelimit-remaining-requests`, `x-ratelimit-remaining-tokens`, `x-ratelimit-reset-tokens`) and exposes real-time quota status via `getAiRateLimits()`, including a `resetAt` `Tempo` timestamp. +- **Structured Error Handling (`TempoAiError`)**: Custom error class providing HTTP status codes (`code`) and optional `retryAt` `Tempo` timestamps. Features a circuit-breaker that immediately stops provider failovers when an LLM returns an explicit `INVALID` parse result (422 status). +- **Documentation & Spec Suite**: Complete architectural guides (`architecture.md`, `context.md`, `rate-limits.md`, `index.md`) and a full Vitest test suite (`test/index.spec.ts`) covering live and mocked provider workflows. + +### Changed & Performance +- **Silent Native Pre-Parsing**: `parseAI` attempts fast, zero-latency native `Tempo` resolution before initiating LLM network calls (unless `force: true` is set). +- **Anchor Instance Reuse & Cache Salting**: Reuses anchor `Tempo` instances to minimize memory allocations and salts cache keys with the anchor's date and system context (`timeZone`, `calendar`, `locale`, `sphere`), preventing stale cache hits across midnight boundaries or context shifts. +- **Resilient Cache Invalidation**: Normalized cache key input (whitespace trimming and case insensitivity) for `clearAiCache` and internal lookups. + +## [0.1.0] - 2026-07-26 + +### Added - Initial scaffolding of the AI natural language parsing plugin. -- Added functional exports for `parseAI`, `initAI`, and `clearAiCache`. -- Drafted initial fallback-routing logic (mocked proxy). +- Functional exports for `parseAI`, `initAI`, and `clearAiCache`. +- Initial fallback-routing logic (mocked proxy). diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index ef26a586..aef15b67 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo-plugin-ai", - "version": "0.1.0", + "version": "0.2.0", "description": "Tempo community plugin for LLM-powered natural language parsing.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -25,7 +25,7 @@ "plan": "community" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.10.2" + "@magmacomputing/tempo": "^3.10.3" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1" diff --git a/packages/plugins/ai/src/cache.ts b/packages/plugins/ai/src/cache.ts index c5704c84..d44b1c2e 100644 --- a/packages/plugins/ai/src/cache.ts +++ b/packages/plugins/ai/src/cache.ts @@ -4,6 +4,7 @@ */ export class BoundedCache extends Map { #timestamps = new Map(); + #staticKeys = new Set(); maxSize: number; ttl: number; @@ -14,6 +15,7 @@ export class BoundedCache extends Map { } #isExpired(key: K): boolean { + if (this.#staticKeys.has(key)) return false; const time = this.#timestamps.get(key); if (time === undefined) return false; return Date.now() - time > this.ttl; @@ -22,11 +24,19 @@ export class BoundedCache extends Map { evictExpired(): void { const now = Date.now(); for (const [key, time] of this.#timestamps.entries()) { - if (now - time > this.ttl) + if (!this.#staticKeys.has(key) && now - time > this.ttl) this.delete(key); } } + setStatic(key: K, value: V): this { + if (super.has(key)) super.delete(key); + super.set(key, value); + this.#timestamps.delete(key); + this.#staticKeys.add(key); + return this; + } + override get(key: K): V | undefined { if (this.#isExpired(key)) { this.delete(key); @@ -56,15 +66,19 @@ export class BoundedCache extends Map { super.delete(key); super.set(key, value); + this.#staticKeys.delete(key); this.#timestamps.set(key, Date.now()); while (this.size > this.maxSize) { - const oldestKey = super.keys().next().value; - if (oldestKey !== undefined) { - this.delete(oldestKey); - } else { - break; + let evicted = false; + for (const k of super.keys()) { + if (!this.#staticKeys.has(k)) { + this.delete(k); + evicted = true; + break; + } } + if (!evicted) break; } return this; @@ -72,11 +86,13 @@ export class BoundedCache extends Map { override delete(key: K): boolean { this.#timestamps.delete(key); + this.#staticKeys.delete(key); return super.delete(key); } override clear(): void { this.#timestamps.clear(); + this.#staticKeys.clear(); super.clear(); } diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts index e091aa9c..63074076 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -178,9 +178,19 @@ export async function parseAI( const cacheSalt = anchorTempo.format('{yyyy}-{mm}-{dd}'); const cacheKey = `${normalizedStr}::${cacheSalt}::${tz}::${cal}::${loc}::${sph}`; - // 3. Check Cache - if (!options?.force && options?.cache !== false && _state.cache.has(cacheKey)) { - const cachedIso = _state.cache.get(cacheKey)!; + // 3. Check Cache (Two-Tier Lookup: Date-Salted Key first, then Un-Salted Normalized Key) + let cachedIso: string | undefined; + if (!options?.force && options?.cache !== false) { + if (_state.cache.has(cacheKey)) { + cachedIso = _state.cache.get(cacheKey); + } else if (_state.cache.has(normalizedStr)) { + cachedIso = _state.cache.get(normalizedStr); + } else if (_state.cache.has(str)) { + cachedIso = _state.cache.get(str); + } + } + + if (cachedIso) { if (isDebug) console.log(`[tempo-plugin-ai] Cache hit for "${str}":`, cachedIso); results.push(new Tempo(cachedIso, options)); continue; diff --git a/packages/plugins/ai/test/index.spec.ts b/packages/plugins/ai/test/index.spec.ts index b8c14bdb..52ce5fd4 100644 --- a/packages/plugins/ai/test/index.spec.ts +++ b/packages/plugins/ai/test/index.spec.ts @@ -230,6 +230,41 @@ describe('AI Parsing Plugin', () => { clearAiCache(' THANKSGIVING '); expect(cache.has('thanksgiving::2026-05-10')).toBe(false); }); + + it('should resolve static un-salted user glossary terms without hitting network or expiring', async () => { + const glossary = new Map([ + ['easter sunday 2026', '2026-04-05T00:00:00Z'], + ['q4 freeze 2026', '2026-11-01T00:00:00Z'] + ]); + + initAI({ cache: glossary }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + // Two-tier lookup hits un-salted normalized static key directly + const result = await parseAI('Easter Sunday 2026'); + expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-04-05'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should protect static un-salted keys set via setStatic from TTL and LRU maxCacheSize eviction in BoundedCache', async () => { + const cache = new BoundedCache(2, 50); // maxSize 2, TTL 50ms + cache.setStatic('easter sunday 2026', '2026-04-05T00:00:00Z'); // Static key + cache.set('temp1::2026-05-10', '2026-05-10T00:00:00Z'); // Salted key + cache.set('temp2::2026-05-10', '2026-05-10T00:00:00Z'); // Salted key, pushes total to 3 + + // LRU capacity check: should evict oldest salted key ('temp1::2026-05-10'), preserving static 'easter sunday 2026' + expect(cache.has('easter sunday 2026')).toBe(true); + expect(cache.has('temp1::2026-05-10')).toBe(false); + + // Wait for TTL expiration + await new Promise(resolve => setTimeout(resolve, 60)); + + // Salted key expires, static key remains intact + expect(cache.has('temp2::2026-05-10')).toBe(false); + expect(cache.has('easter sunday 2026')).toBe(true); + expect(cache.get('easter sunday 2026')).toBe('2026-04-05T00:00:00Z'); + }); }); describe('Configurable Token Parameter', () => { From a023e7dd7b845f38306ad504b27994ab15aa543f Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 31 Jul 2026 12:59:33 +1000 Subject: [PATCH 5/8] 1st pass at Caching --- package-lock.json | 12 +- package.json | 2 +- packages/library/package.json | 2 +- packages/plugins/ai/CHANGELOG.md | 6 +- packages/plugins/ai/package.json | 2 +- packages/plugins/ai/src/cache.ts | 118 --------- packages/plugins/ai/src/index.ts | 41 ++-- packages/plugins/ai/src/parseAI.type.ts | 4 - packages/plugins/ai/test/index.spec.ts | 9 +- packages/tempo/.vitepress/config.ts | 1 + .../tempo/.vitepress/theme/data/catalog.json | 2 +- packages/tempo/CHANGELOG.md | 10 + .../tempo/doc/2-core-concepts/tempo.cache.md | 94 +++++++ packages/tempo/package.json | 6 +- packages/tempo/src/engine/engine.composer.ts | 9 +- packages/tempo/src/module/module.parse.ts | 39 ++- packages/tempo/src/support/support.cache.ts | 231 ++++++++++++++++++ packages/tempo/src/support/support.enum.ts | 11 +- packages/tempo/src/support/support.index.ts | 4 +- packages/tempo/src/support/support.init.ts | 57 ++++- packages/tempo/src/tempo.class.ts | 20 +- packages/tempo/src/tempo.type.ts | 2 + packages/tempo/src/tempo.version.ts | 2 +- packages/tempo/test/support/cache.test.ts | 104 ++++++++ 24 files changed, 605 insertions(+), 183 deletions(-) delete mode 100644 packages/plugins/ai/src/cache.ts create mode 100644 packages/tempo/doc/2-core-concepts/tempo.cache.md create mode 100644 packages/tempo/src/support/support.cache.ts create mode 100644 packages/tempo/test/support/cache.test.ts diff --git a/package-lock.json b/package-lock.json index e4c8ee24..69b2f698 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "3.10.3", + "version": "3.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "3.10.3", + "version": "3.11.0", "workspaces": [ "packages/*", "packages/plugins/*" @@ -11045,7 +11045,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "3.10.3", + "version": "3.11.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -11063,13 +11063,13 @@ }, "packages/plugins/ai": { "name": "@magmacomputing/tempo-plugin-ai", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@js-temporal/polyfill": "^0.5.1" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.10.2" + "@magmacomputing/tempo": "^3.10.3" } }, "packages/plugins/astro": { @@ -11392,7 +11392,7 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "3.10.3", + "version": "3.11.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" diff --git a/package.json b/package.json index eebf8954..a960cd8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.10.3", + "version": "3.11.0", "private": true, "engines": { "node": ">=20.0.0" diff --git a/packages/library/package.json b/packages/library/package.json index 0f85b9b8..312b3090 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.10.3", + "version": "3.11.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index 2170a21a..3e05b9cb 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -8,14 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.2.0] - 2026-07-30 ### Added -- **`BoundedCache` LRU & TTL Eviction**: Implemented a memory-safe, capacity-bounded Map cache enforcing maximum size limits (`maxCacheSize`, default 1000) and time-to-live expiration (`cacheTtl`, default 24h) with automatic inline eviction during reads and iterations. +- **Centralized Caching Integration**: Powered by core Tempo's centralized `BoundedCache` singleton (`Tempo.cache`) enforcing memory safety, capacity-bounded LRU eviction (`maxSize`), time-to-live expiration (`ttl`), and static immortal glossary isolation. - **Multi-Provider Fallback Routing**: Robust failover loop across configured LLM providers (`groq`, `openai`, `gemini`, `mistral`, or custom endpoints). Supports custom `tokenParam` mappings (`max_tokens` vs `max_completion_tokens`) and request timeout control via `AbortController`. - **Rate Limit Tracking (`getAiRateLimits`)**: Inspects provider HTTP response headers (`x-ratelimit-remaining-requests`, `x-ratelimit-remaining-tokens`, `x-ratelimit-reset-tokens`) and exposes real-time quota status via `getAiRateLimits()`, including a `resetAt` `Tempo` timestamp. - **Structured Error Handling (`TempoAiError`)**: Custom error class providing HTTP status codes (`code`) and optional `retryAt` `Tempo` timestamps. Features a circuit-breaker that immediately stops provider failovers when an LLM returns an explicit `INVALID` parse result (422 status). - **Documentation & Spec Suite**: Complete architectural guides (`architecture.md`, `context.md`, `rate-limits.md`, `index.md`) and a full Vitest test suite (`test/index.spec.ts`) covering live and mocked provider workflows. ### Changed & Performance -- **Silent Native Pre-Parsing**: `parseAI` attempts fast, zero-latency native `Tempo` resolution before initiating LLM network calls (unless `force: true` is set). +- **Centralized Caching Architecture**: Delegated all cache capacity and TTL parameters directly to core `Tempo.init()`, allowing `initAI` to focus strictly on LLM provider registration. +- **Non-Destructive Glossary Appending**: Custom glossaries provided via `initAI({ cache })` are safely appended to `Tempo.cache` as static immortal terms without destructive overrides. +- **Silent Native Pre-Parsing & Cache Controls**: `parseAI` attempts fast, zero-latency native `Tempo` resolution and checks `Tempo.cache` before initiating LLM network calls. Supports `cache: false` to bypass cache lookups and `force: true` to force a fresh LLM API request. - **Anchor Instance Reuse & Cache Salting**: Reuses anchor `Tempo` instances to minimize memory allocations and salts cache keys with the anchor's date and system context (`timeZone`, `calendar`, `locale`, `sphere`), preventing stale cache hits across midnight boundaries or context shifts. - **Resilient Cache Invalidation**: Normalized cache key input (whitespace trimming and case insensitivity) for `clearAiCache` and internal lookups. diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index aef15b67..4c0dc1cb 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -25,7 +25,7 @@ "plan": "community" }, "peerDependencies": { - "@magmacomputing/tempo": "^3.10.3" + "@magmacomputing/tempo": "^3.11.0" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1" diff --git a/packages/plugins/ai/src/cache.ts b/packages/plugins/ai/src/cache.ts deleted file mode 100644 index d44b1c2e..00000000 --- a/packages/plugins/ai/src/cache.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * ## BoundedCache - * Map implementation enforcing maximum capacity (LRU) and TTL eviction. - */ -export class BoundedCache extends Map { - #timestamps = new Map(); - #staticKeys = new Set(); - maxSize: number; - ttl: number; - - constructor(maxSize = 1000, ttl = 24 * 60 * 60 * 1000) { - super(); - this.maxSize = maxSize; - this.ttl = ttl; - } - - #isExpired(key: K): boolean { - if (this.#staticKeys.has(key)) return false; - const time = this.#timestamps.get(key); - if (time === undefined) return false; - return Date.now() - time > this.ttl; - } - - evictExpired(): void { - const now = Date.now(); - for (const [key, time] of this.#timestamps.entries()) { - if (!this.#staticKeys.has(key) && now - time > this.ttl) - this.delete(key); - } - } - - setStatic(key: K, value: V): this { - if (super.has(key)) super.delete(key); - super.set(key, value); - this.#timestamps.delete(key); - this.#staticKeys.add(key); - return this; - } - - override get(key: K): V | undefined { - if (this.#isExpired(key)) { - this.delete(key); - return undefined; - } - if (super.has(key)) { - const val = super.get(key) as V; - super.delete(key); - super.set(key, val); - return val; - } - return undefined; - } - - override has(key: K): boolean { - if (this.#isExpired(key)) { - this.delete(key); - return false; - } - return super.has(key); - } - - override set(key: K, value: V): this { - this.evictExpired(); - - if (super.has(key)) - super.delete(key); - - super.set(key, value); - this.#staticKeys.delete(key); - this.#timestamps.set(key, Date.now()); - - while (this.size > this.maxSize) { - let evicted = false; - for (const k of super.keys()) { - if (!this.#staticKeys.has(k)) { - this.delete(k); - evicted = true; - break; - } - } - if (!evicted) break; - } - - return this; - } - - override delete(key: K): boolean { - this.#timestamps.delete(key); - this.#staticKeys.delete(key); - return super.delete(key); - } - - override clear(): void { - this.#timestamps.clear(); - this.#staticKeys.clear(); - super.clear(); - } - - override keys(): MapIterator { - this.evictExpired(); - return super.keys(); - } - - override values(): MapIterator { - this.evictExpired(); - return super.values(); - } - - override entries(): MapIterator<[K, V]> { - this.evictExpired(); - return super.entries(); - } - - override[Symbol.iterator](): MapIterator<[K, V]> { - this.evictExpired(); - return super[Symbol.iterator](); - } -} diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts index 63074076..b9eef3e5 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -1,23 +1,17 @@ import { Tempo } from '@magmacomputing/tempo'; -import type * as t from '@magmacomputing/tempo'; import { TempoAiError } from './error.js'; export { TempoAiError } from './error.js'; -import { BoundedCache } from './cache.js'; -export { BoundedCache } from './cache.js'; - export * from './parseAI.type.js'; import type { AiConfig, AiRateLimits, AiProvider } from './parseAI.type.js'; // Global module state const _state: { config: AiConfig; - cache: Map; limits: AiRateLimits | null; } = { config: {}, - cache: new BoundedCache(), limits: null, } @@ -52,25 +46,22 @@ const DEFAULT_PROVIDERS: Record> = { * @param config - The plugin configuration (providers and optional cache) */ export function initAI(config: AiConfig): void { - const resolvedProviders = (config.providers || []).map(p => { + const resolvedProviders = config.providers ? config.providers.map(p => { const defaults = DEFAULT_PROVIDERS[p.id] || DEFAULT_PROVIDERS.openai; return { ...defaults, ...p } as AiProvider; - }); + }) : _state.config.providers; _state.config = { ..._state.config, ...config, - providers: resolvedProviders + providers: resolvedProviders || [] }; if (config.cache) { - _state.cache = config.cache; - } else if (_state.cache instanceof BoundedCache) { - if (config.maxCacheSize !== undefined) _state.cache.maxSize = config.maxCacheSize; - if (config.cacheTtl !== undefined) _state.cache.ttl = config.cacheTtl; + Tempo.init({ cache: config.cache as any }); } } @@ -83,7 +74,7 @@ function normalizeCacheInput(input: string): string { /** * ## clearAiCache - * Explicitly evicts a natural language key or array of keys from the local AI cache. + * Explicitly evicts a natural language key or array of keys from the Tempo cache. * Useful for purging incorrectly parsed strings. * * @param input - The raw natural language string(s) to remove from the cache @@ -93,11 +84,9 @@ export function clearAiCache(input: string | string[]): void { for (const i of inputs) { const normalized = normalizeCacheInput(i); const prefix = `${normalized}::`; - for (const key of _state.cache.keys()) { - if (key.toLowerCase().startsWith(prefix) || key.toLowerCase() === normalized || key === i /* legacy fallback */) { - _state.cache.delete(key); - } - } + Tempo.cache.delete(normalized); + Tempo.cache.delete(i); + Tempo.cache.deletePrefix(prefix); } } @@ -181,12 +170,12 @@ export async function parseAI( // 3. Check Cache (Two-Tier Lookup: Date-Salted Key first, then Un-Salted Normalized Key) let cachedIso: string | undefined; if (!options?.force && options?.cache !== false) { - if (_state.cache.has(cacheKey)) { - cachedIso = _state.cache.get(cacheKey); - } else if (_state.cache.has(normalizedStr)) { - cachedIso = _state.cache.get(normalizedStr); - } else if (_state.cache.has(str)) { - cachedIso = _state.cache.get(str); + 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); } } @@ -337,7 +326,7 @@ Do not include markdown blocks, explanations, or any text outside the JSON.`; // 6. Cache result and push if (options?.cache !== false) - _state.cache.set(cacheKey, parsedIso); + Tempo.cache.set(cacheKey, parsedIso); results.push(new Tempo(parsedIso, options)); } diff --git a/packages/plugins/ai/src/parseAI.type.ts b/packages/plugins/ai/src/parseAI.type.ts index f3573e54..b2f4f62f 100644 --- a/packages/plugins/ai/src/parseAI.type.ts +++ b/packages/plugins/ai/src/parseAI.type.ts @@ -28,10 +28,6 @@ export interface AiConfig { providers?: AiProvider[] | undefined; /** Optional custom cache implementation for storing parsed strings */ cache?: Map | undefined; - /** Maximum number of entries allowed in the default cache (default: 1000) */ - maxCacheSize?: number | undefined; - /** Time to live in milliseconds for default cache entries (default: 24 hours) */ - cacheTtl?: number | undefined; /** If true, logs the spoon-fed LLM context prompt and raw LLM response to the console */ debug?: boolean | undefined; } diff --git a/packages/plugins/ai/test/index.spec.ts b/packages/plugins/ai/test/index.spec.ts index 52ce5fd4..4e6361d8 100644 --- a/packages/plugins/ai/test/index.spec.ts +++ b/packages/plugins/ai/test/index.spec.ts @@ -1,4 +1,5 @@ -import { parseAI, initAI, clearAiCache, getAiRateLimits, TempoAiError, BoundedCache } from '../src/index.js'; +import { parseAI, initAI, clearAiCache, getAiRateLimits, TempoAiError } from '../src/index.js'; +import { BoundedCache } from '@magmacomputing/tempo/support'; import { Tempo } from '@magmacomputing/tempo'; describe('AI Parsing Plugin', () => { @@ -209,15 +210,15 @@ describe('AI Parsing Plugin', () => { expect(cache.has('Christmas::2026-05-10')).toBe(true); }); - it('should update BoundedCache options via initAI', () => { + it('should update BoundedCache options via Tempo.init', () => { const cache = new BoundedCache(1000, 3600000); initAI({ cache }); - initAI({ maxCacheSize: 50, cacheTtl: 5000 }); + Tempo.init({ cache: { maxSize: 50, ttl: 5000 } }); expect(cache.maxSize).toBe(50); expect(cache.ttl).toBe(5000); - initAI({ maxCacheSize: 5, cacheTtl: 100 }); + Tempo.init({ cache: { maxSize: 5, ttl: 100 } }); expect(cache.maxSize).toBe(5); expect(cache.ttl).toBe(100); }); diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 7fbeb239..3ced2f8e 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -45,6 +45,7 @@ export default defineConfig({ text: 'Core Concepts', items: [ { text: 'Configuration', link: '/doc/2-core-concepts/tempo.config' }, + { text: 'Cache Management', link: '/doc/2-core-concepts/tempo.cache' }, { text: 'Core Getters', link: '/doc/2-core-concepts/tempo.getters' }, { text: 'Smart Parsing', link: '/doc/2-core-concepts/tempo.parse' }, { text: 'Smart Formatting', link: '/doc/2-core-concepts/tempo.format' }, diff --git a/packages/tempo/.vitepress/theme/data/catalog.json b/packages/tempo/.vitepress/theme/data/catalog.json index 1c976349..3fae8a86 100644 --- a/packages/tempo/.vitepress/theme/data/catalog.json +++ b/packages/tempo/.vitepress/theme/data/catalog.json @@ -51,7 +51,7 @@ "packageName": "@magmacomputing/tempo-plugin-ai", "plan": "community", "status": "experimental", - "version": "" + "version": "0.2.0" }, { "id": "ticker", diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 9108a5dc..7c124fe0 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.11.0] - 2026-07-31 + +### Added +- **Centralized Cache Engine (`Tempo.cache`)**: Introduced a unified `BoundedCache` singleton managing date resolution caching with LRU capacity eviction (`maxSize`), TTL expiration (`ttl`), and static immortal glossary isolation. +- **Glossary Seeding**: `Tempo.init({ cache: customMap })` appends pre-resolved terms as static immortal keys exempt from LRU and TTL eviction. +- **Cache Management Documentation**: Scaffolded the `tempo.cache.md` Core Concepts guide detailing cache topology, non-destructive appending, decision matrix (Glossary vs Aliases vs Layouts), opt-in vs automatic behavior, and AI plugin integration. + +### Changed +- **Decoupled Plugin Cache Topology**: Cleaned up `parseAI` cache configuration by delegating capacity and TTL settings to `Tempo.init()`, establishing `Tempo.cache` as the single source of truth across the monorepo. + ## [3.10.3] - 2026-07-29 ### Performance diff --git a/packages/tempo/doc/2-core-concepts/tempo.cache.md b/packages/tempo/doc/2-core-concepts/tempo.cache.md new file mode 100644 index 00000000..e23e4a31 --- /dev/null +++ b/packages/tempo/doc/2-core-concepts/tempo.cache.md @@ -0,0 +1,94 @@ +# Cache Management Guide + +**Tempo** includes a centralized, high-performance **`BoundedCache`** singleton accessible via `Tempo.cache`. It provides dual-layer resolution for dynamic relative dates (with LRU eviction and TTL expiration) and static business glossaries (immortal keys). + +--- + +## 🏛️ Centralized Cache Architecture + +All date resolution caching—whether triggered by core `Tempo` parsing or plugins like `parseAI`—is managed centrally by `Tempo.cache`. + +::: info Cache Behavior: Core Tempo vs. parseAI +* **Core Tempo**: Caching is **opt-in**. Core date parsing executes at sub-microsecond speeds using standard regex matching. `Tempo.cache` is consulted when you seed a static glossary or enable caching. +* **`parseAI` Plugin**: Caching is **automatic**. To eliminate network latency (~500ms+) and avoid redundant LLM API billing, `parseAI` automatically checks `Tempo.cache` before sending requests and caches every successful LLM resolution. +::: + +### Cache Topology & Configuration + +You can configure global cache parameters using `Tempo.init()`: + +```typescript +import { Tempo } from '@magmacomputing/tempo'; + +Tempo.init({ + cache: { + maxSize: 1000, // Maximum number of entries before LRU eviction (default: 1000) + ttl: 24 * 60 * 60 * 1000 // Time-to-live in milliseconds (default: 24 hours) + } +}); +``` + +* **Capacity Management (LRU):** When the cache reaches `maxSize`, the Least Recently Used dynamic entry is automatically evicted. +* **TTL Expiration:** Dynamic entries older than `ttl` are automatically purged upon lookup. +* **Static Glossary Isolation:** Static entries added to the glossary are **exempt** from both LRU eviction and TTL expiration. + +--- + +## 📖 Seeding & Appending Glossaries + +You can seed static business terms into `Tempo.cache` using a native JavaScript `Map` or via `Tempo.init({ cache: map })`: + +```typescript +const businessGlossary = new Map([ + ['fiscal year start 2026', '2026-07-01T00:00:00Z'], + ['q3 board review', '2026-09-15T09:00:00Z'] +]); + +// Appends entries to Tempo.cache as static, immortal terms +Tempo.init({ cache: businessGlossary }); +``` + +::: tip Non-Destructive Appending +Passing a `Map` or custom key-value pairs to `Tempo.init({ cache })` or `initAI({ cache })` **appends** to the existing cache without clearing previously cached terms or resetting cache capacity settings. +::: + +--- + +## 💡 When to Use What: Glossary vs. Alias vs. Snippet/Layout + +Tempo provides multiple mechanisms for augmenting parsing intelligence. Choosing the right pattern depends on whether your logic is static, dynamic, structural, or string replacement: + +| Mechanism | Tier / Location | Evaluation Model | Best Used For... | +| :--- | :--- | :--- | :--- | +| **Glossary** (`Tempo.cache`) | Core Engine | Zero-cost `O(1)` Map lookup | Pre-calculated static ISO date/time strings or exact business dates. | +| **Aliases / Events / Periods** (`registry.events` / `periods`) | Registry Engine | Dynamic function or target string | Computing dynamic business dates (e.g. `'deadline' => () => this.add({ days: 30 })`). | +| **Snippet / Layouts** (`registry.snippets` / `layouts`) | Parser Planner | Regex pattern matcher | Structural natural language formats (e.g. `yyyy/mm/dd` or custom date tokens). | + +### Decision Tree + +1. **Use a Glossary (`Tempo.cache`)** when you have fixed, pre-resolved ISO dates for specific terms (e.g., `'eoy 2026'` -> `'2026-12-31T23:59:59Z'`). It offers instant `O(1)` resolution without invoking the regex parser. +2. **Use an Alias (`registry.events` / `periods`)** when you need dynamic rules calculated relative to the current date/time (e.g., `'market-close'` -> `'16:00'` or `'deadline'` -> `30 days from now`). +3. **Use a Snippet or Layout (`registry.snippets` / `layouts`)** when parsing custom input structures with variable numbers or tokens (e.g. `"2026-W05"` or `"Quarter 3, 2026"`). + +--- + +## 🤖 `parseAI` Plugin Cache Integration + +The `@magmacomputing/tempo-plugin-ai` plugin works hand-in-hand with `Tempo.cache` to reduce LLM API calls and costs: + +```typescript +import { Tempo } from '@magmacomputing/tempo'; +import { parseAI, initAI } from '@magmacomputing/tempo-plugin-ai'; + +initAI({ providers: [...] }); + +// First lookup: Triggers LLM call -> Stores ISO result in Tempo.cache +const t1 = await parseAI("1st Tuesday in March 2026 at 3pm"); + +// Second lookup: Instantly resolves from Tempo.cache (O(1) local hit, $0 cost) +const t2 = new Tempo("1st Tuesday in March 2026 at 3pm"); +``` + +### Two-Tier Resolution Architecture +1. **Date-Salted Relative Cache**: Relative queries (e.g. `"next Tuesday"`) are salted with the anchor date so cached entries remain valid for the given day. +2. **Static Glossary Fallback**: Business glossary terms seeded via `initAI({ cache })` or `Tempo.init({ cache })` are checked first, providing zero-latency resolution without ever contacting the LLM. diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 2e04bda9..03168496 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.10.3", + "version": "3.11.0", "engines": { "node": ">=20.0.0" }, @@ -210,6 +210,10 @@ "./plugin-api": { "types": "./dist/plugin-api.index.d.ts", "import": "./dist/plugin-api.index.js" + }, + "./support": { + "types": "./dist/support/support.index.d.ts", + "import": "./dist/support/support.index.js" } }, "scripts": { diff --git a/packages/tempo/src/engine/engine.composer.ts b/packages/tempo/src/engine/engine.composer.ts index d0900e51..4d4a167a 100644 --- a/packages/tempo/src/engine/engine.composer.ts +++ b/packages/tempo/src/engine/engine.composer.ts @@ -51,7 +51,14 @@ export function compose( case 'String': try { const str = value.replace(/Z$/, ''); - const zdt = Temporal.ZonedDateTime.from(str.includes('[') ? str : `${str}[${tz}]`); + let zdt: Temporal.ZonedDateTime; + if (str.includes('[')) { + zdt = Temporal.ZonedDateTime.from(str); + } else if (/[+-]\d{2}:\d{2}/.test(str)) { + zdt = Temporal.ZonedDateTime.from(`${str}[${tz}]`); + } else { + zdt = Temporal.PlainDateTime.from(str, { overflow: 'constrain' }).toZonedDateTime(tz); + } timeZone = getTemporalIds(zdt)[0]; temporal = zdt; onResult?.({ type, value: str, match: 'iso8601' }); diff --git a/packages/tempo/src/module/module.parse.ts b/packages/tempo/src/module/module.parse.ts index 3a4149f0..6c36156d 100644 --- a/packages/tempo/src/module/module.parse.ts +++ b/packages/tempo/src/module/module.parse.ts @@ -17,10 +17,20 @@ import { getRange, getTermRange } from '../plugin/term/term.util.js'; import { defineInterpreterModule } from '../plugin/plugin.util.js'; import type { Range, ResolvedRange } from '../plugin/term/term.type.js'; -import { sym, isTempo, TermError, getRuntime, Match, TempoError, $setEvents, $setPeriods, markConfig, setPatterns, init, extendState } from '#tempo/support'; +import { sym, isTempo, TermError, getRuntime, Match, TempoError, $setEvents, $setPeriods, markConfig, setPatterns, init, extendState, enums } from '#tempo/support'; import { setProperty, logError, logDebug } from '#tempo/support/support.util.js'; import * as t from '../tempo.type.js'; +function buildCacheKey(str: string, today: Temporal.ZonedDateTime, state: t.Internal.State): string { + const norm = str.trim().toLowerCase(); + const dateSalt = today.toPlainDate().toString(); + const tz = String(state.config.timeZone || 'UTC'); + const cal = String(state.config.calendar || 'iso8601'); + const loc = Array.isArray(state.config.locale) ? state.config.locale.join(',') : String(state.config.locale || 'en-US'); + const sph = String(state.config.sphere || 'north'); + return `${norm}::${dateSalt}::${tz}::${cal}::${loc}::${sph}`; +} + /** * Internal Parse Engine Implementation */ @@ -153,6 +163,11 @@ const _ParseEngine = { if (isZonedDateTime(dateTime) && !state.errored) dateTime = dateTime.withTimeZone(targetTz).withCalendar(targetCal); + if ((state.config.cache === true || state.config.cache === enums.CACHE.On || state.config.cache === enums.CACHE.Refresh || state.config.cache === 'refresh') && isString(tempo) && isZonedDateTime(dateTime) && !state.errored) { + const cacheKey = buildCacheKey(tempo, today, state); + state.cache?.set(cacheKey, dateTime.toString()); + } + return Object.assign(res, { type: 'Temporal.ZonedDateTime', value: (isZonedDateTime(dateTime) && !state.errored) ? dateTime : undefined as any @@ -212,6 +227,28 @@ const _ParseEngine = { if (isString(value)) { let trim = value.trim(); + const normVal = trim.toLowerCase(); + + // 1. Static Glossary Check + if (state.cache?.isStatic(normVal)) { + const staticTarget = state.cache.get(normVal); + if (staticTarget) { + accumulateResult(state, { match: 'CacheHit', value: trim, source: 'glossary' as any }); + return { type: 'String', value: staticTarget }; + } + } + + // 2. Dynamic Parse Cache Check + const cacheOpt = state.config.cache; + if (cacheOpt === true || cacheOpt === enums.CACHE.On) { + const cacheKey = buildCacheKey(trim, dateTime, state); + const cachedIso = state.cache?.get(cacheKey); + if (cachedIso) { + accumulateResult(state, { match: 'CacheHit', value: trim, source: 'parseCache' as any }); + return { type: 'String', value: cachedIso }; + } + } + if (state.parse.ignorePattern) { // Clone the RegExp: global/sticky flags maintain `lastIndex` state, which // cannot be mutated when `state.parse` is frozen (e.g. on a sandbox instance). diff --git a/packages/tempo/src/support/support.cache.ts b/packages/tempo/src/support/support.cache.ts new file mode 100644 index 00000000..15427ee9 --- /dev/null +++ b/packages/tempo/src/support/support.cache.ts @@ -0,0 +1,231 @@ +import { isString, isUndefined } from '#library/assertion.library.js'; +import { secure } from '#library/proxy.library.js'; +import type * as t from '../tempo.type.js'; + +/** + * ## BoundedCache + * High-performance Map implementation enforcing maximum capacity (LRU) and TTL eviction. + * Supports static keys (immortal glossary definitions) and date-salt staleness purging. + */ +export class BoundedCache extends Map { + #timestamps = new Map(); + #staticKeys = new Set(); + maxSize: number; + ttl: number; + + constructor(maxSize = 1000, ttl = 24 * 60 * 60 * 1000) { + super(); + this.maxSize = maxSize; + this.ttl = ttl; + } + + get isBoundedCache(): boolean { + return true; + } + + #isExpired(key: K): boolean { + if (this.#staticKeys.has(key)) return false; + const time = this.#timestamps.get(key); + if (isUndefined(time)) return false; + return Date.now() - time > this.ttl; + } + + /** + * Purges TTL-expired entries. + */ + evictExpired(): void { + const now = Date.now(); + for (const key of super.keys()) { + if (this.#staticKeys.has(key)) continue; + const time = this.#timestamps.get(key); + if (time !== undefined && (now - time > this.ttl)) { + this.delete(key); + } + } + } + + /** + * Register an immortal static key (e.g. business glossary term). + * Static keys bypass TTL expiration and LRU capacity eviction. + */ + setStatic(key: K, value: V): this { + if (super.has(key)) super.delete(key); + super.set(key, value); + this.#timestamps.delete(key); + this.#staticKeys.add(key); + return this; + } + + /** + * Check if a key is a static key. + */ + isStatic(key: K): boolean { + return this.#staticKeys.has(key); + } + + override get(key: K): V | undefined { + if (this.#isExpired(key)) { + this.delete(key); + return undefined; + } + return super.get(key); + } + + override has(key: K): boolean { + if (this.#isExpired(key)) { + this.delete(key); + return false; + } + return super.has(key); + } + + override set(key: K, value: V): this { + this.evictExpired(); + + if (super.has(key)) super.delete(key); + + super.set(key, value); + this.#staticKeys.delete(key); + this.#timestamps.set(key, Date.now()); + + while (this.size > this.maxSize) { + let evicted = false; + for (const k of super.keys()) { + if (!this.#staticKeys.has(k)) { + this.delete(k); + evicted = true; + break; + } + } + if (!evicted) break; + } + + return this; + } + + override delete(key: K): boolean { + this.#timestamps.delete(key); + this.#staticKeys.delete(key); + return super.delete(key); + } + + deletePrefix(prefix: string): number { + const normalizedPrefix = String(prefix).trim().toLowerCase(); + const toDelete: K[] = []; + for (const key of super.keys()) { + if (isString(key) && key.toLowerCase().startsWith(normalizedPrefix)) + toDelete.push(key); + } + + for (const k of toDelete) + this.delete(k); + + return toDelete.length; + } + + /** + * Purge cache entries. + * If `count` is specified, evicts up to `count` oldest non-static entries. + * If omitted, clears all non-static entries. + */ + override clear(count?: number): void { + if (count === undefined) { + this.#timestamps.clear(); + this.#staticKeys.clear(); + super.clear(); + return; + } + + let evicted = 0; + for (const key of super.keys()) { + if (evicted >= count) break; + if (!this.#staticKeys.has(key)) { + this.delete(key); + evicted++; + } + } + } + + override keys(): MapIterator { + this.evictExpired(); + return super.keys(); + } + + override values(): MapIterator { + this.evictExpired(); + return super.values(); + } + + override entries(): MapIterator<[K, V]> { + this.evictExpired(); + return super.entries(); + } + + override[Symbol.iterator](): MapIterator<[K, V]> { + this.evictExpired(); + return super[Symbol.iterator](); + } + + static fromEntries(entries: Iterable, maxSize = 1000, ttl = 24 * 60 * 60 * 1000): BoundedCache { + const cache = new BoundedCache(maxSize, ttl); + for (const [k, v] of entries) { + cache.set(k, v); + } + return cache; + } +} + +/** + * Creates a normalized cache facade exposing safe operations over the active state's BoundedCache. + */ +export function createCacheFacade(getState: () => t.Internal.State) { + return secure({ + get(key: string) { + const normalized = String(key).trim().toLowerCase(); + return getState().cache?.get(normalized); + }, + has(key: string) { + const normalized = String(key).trim().toLowerCase(); + return getState().cache?.has(normalized) ?? false; + }, + set(key: string, value: string) { + const normalized = String(key).trim().toLowerCase(); + getState().cache?.set(normalized, String(value)); + return this; + }, + setStatic(key: string, value: string) { + const normalized = String(key).trim().toLowerCase(); + getState().cache?.setStatic(normalized, String(value)); + return this; + }, + delete(key: string) { + const normalized = String(key).trim().toLowerCase(); + return getState().cache?.delete(normalized) ?? false; + }, + deletePrefix(prefix: string) { + const normalizedPrefix = String(prefix).trim().toLowerCase(); + const cache = getState().cache; + if (!cache) return 0; + let count = 0; + for (const key of Array.from(cache.keys())) { + if (isString(key) && key.toLowerCase().startsWith(normalizedPrefix)) + cache.delete(key); + count++; + } + return count; + }, + clear(count?: number) { + getState().cache?.clear(count); + }, + entries() { + return getState().cache?.entries() ?? [][Symbol.iterator](); + }, + fromEntries(entries: Iterable) { + for (const [k, v] of entries) { + const normalized = String(k).trim().toLowerCase(); + getState().cache?.set(normalized, String(v)); + } + return this; + } + }); +} diff --git a/packages/tempo/src/support/support.enum.ts b/packages/tempo/src/support/support.enum.ts index a677fb20..5504ba85 100644 --- a/packages/tempo/src/support/support.enum.ts +++ b/packages/tempo/src/support/support.enum.ts @@ -228,7 +228,7 @@ export type ZONED_DATE_TIME = ValueOf export type ZonedDateTime = KeyOf /** allowed keys for Tempo configuration options */ -const configKeys = ['config', 'parse', 'value', 'intl', 'store', 'discovery', 'debug', 'catch', 'silent', 'timeZone', 'calendar', 'locale', 'sphere', 'timeStamp', 'registry', 'plugins'] as const; +const configKeys = ['config', 'parse', 'value', 'intl', 'store', 'discovery', 'debug', 'catch', 'silent', 'timeZone', 'calendar', 'locale', 'sphere', 'timeStamp', 'registry', 'plugins', 'cache'] as const; export const CONFIG = enumify(configKeys, false); export type Config = KeyOf @@ -236,6 +236,14 @@ export type Config = KeyOf export const MODE = enumify({ Auto: 'auto', Strict: 'strict', Defer: 'defer', }, false); export type MODE = ValueOf +/** cache operation modes */ +export const CACHE = enumify({ + Off: false, + On: true, + Refresh: 'refresh', +}, false); +export type CACHE = ValueOf + /** allowed keys for internal parse state */ const parseKeys = ['monthDay', 'planner', 'layoutOrder', 'preFilter', 'mode', 'pivot', 'snippet', 'layout', 'event', 'period', 'anchor'] as const; export const PARSE = enumify(parseKeys, false); @@ -283,6 +291,7 @@ export default { ZONED_DATE_TIME, CONFIG, MODE, + CACHE, PARSE, MONTH_DAY, LICENSE, diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index 07a7ef61..ac6e1ee7 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -11,6 +11,7 @@ export { REGISTRIES, DISCOVERY, MODE, + CACHE, COMPASS, WEEKDAY, WEEKDAYS, @@ -41,4 +42,5 @@ export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from '. export { SCHEMA, getLargestUnit, logError, logWarn, logDebug, logTrace, setLogLevel, logTempo, hasOwn } from './support.util.js'; export { setPatterns } from '../engine/engine.pattern.js'; export { init, extendState } from './support.init.js'; -export { TempoError } from './support.error.js'; \ No newline at end of file +export { TempoError } from './support.error.js'; +export { BoundedCache, createCacheFacade } from './support.cache.js'; \ No newline at end of file diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 34d4d841..e74f2325 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -6,7 +6,7 @@ import { normalizeUtcOffset } from '#library/temporal.library.js'; import { markConfig } from '#library/symbol.library.js'; import { deepMerge } from '#library/object.library.js'; import { asType } from '#library/type.library.js'; -import { isString, isObject, isUndefined, isDefined, isRegExp, isEmpty } from '#library/assertion.library.js'; +import { isString, isObject, isUndefined, isDefined, isRegExp, isEmpty, isFunction } from '#library/assertion.library.js'; import { ScopedSet } from '#library/scopedset.class.js'; import { ownEntries } from '#library/primitive.library.js'; import { getStorage } from '#library/storage.library.js'; @@ -20,13 +20,31 @@ import { Match, Snippet, Layout, Event, Period, Ignore, Default } from './suppor import { STATE } from './support.enum.js'; import enums from './support.enum.js'; +import { BoundedCache } from './support.cache.js'; import * as t from '../tempo.type.js'; /** @internal Initialise a Tempo state */ -export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Internal.State): t.Internal.State { +export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Internal.State, prevCache?: BoundedCache): t.Internal.State { const runtime = getRuntime(); // Global init is intentionally idempotent after first hydration; late-loaded modules must use Tempo.extend(). - if (isGlobal && runtime.state && !baseState) return runtime.state; + if (isGlobal && runtime.state && !baseState) { + if (!runtime.state.cache) + runtime.state.cache = new BoundedCache(1000, 24 * 60 * 60 * 1000); + + if (options.cache) { + const isBoundedCacheObj = Boolean((options.cache as any)?.isBoundedCache || options.cache instanceof BoundedCache); + if (isBoundedCacheObj) { + runtime.state.cache = options.cache as BoundedCache; + } else if (options.cache instanceof Map || isFunction((options.cache as any).entries)) { + for (const [k, v] of (options.cache as any).entries()) + runtime.state.cache.setStatic(String(k).trim().toLowerCase(), String(v)); + } else { + if (isDefined(options.cache?.maxSize)) runtime.state.cache.maxSize = options.cache.maxSize; + if (isDefined(options.cache?.ttl)) runtime.state.cache.ttl = options.cache.ttl; + } + } + return runtime.state; + } const { timeZone, calendar } = getDateTimeFormat(); const state = (baseState ? Object.create(baseState) : { @@ -49,6 +67,27 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int }; } + const isBoundedCacheOpt = Boolean((options.cache as any)?.isBoundedCache || options.cache instanceof BoundedCache); + if (isBoundedCacheOpt) { + state.cache = options.cache as BoundedCache; + } else if (options.cache instanceof Map || (options.cache && isFunction((options.cache as any).entries))) { + const targetCache = baseState?.cache ?? runtime.state?.cache ?? prevCache ?? new BoundedCache(); + for (const [k, v] of (options.cache as any).entries()) + targetCache.setStatic(String(k).trim().toLowerCase(), String(v)); + state.cache = targetCache; + } else { + const targetCache = baseState?.cache ?? runtime.state?.cache ?? prevCache; + if (targetCache && (targetCache.isBoundedCache || targetCache instanceof BoundedCache)) { + if (isDefined(options.cache?.maxSize)) targetCache.maxSize = options.cache.maxSize; + if (isDefined(options.cache?.ttl)) targetCache.ttl = options.cache.ttl; + state.cache = targetCache; + } else { + const maxSize = options.cache?.maxSize ?? 1000; + const ttl = options.cache?.ttl ?? (24 * 60 * 60 * 1000); + state.cache = new BoundedCache(maxSize, ttl); + } + } + // 1. Establish the base parsing state const parseState: t.Internal.Parse = { token: Token, @@ -364,6 +403,15 @@ export function extendState(state: t.Internal.State, options: t.Options): boolea setProperty(state.config, 'debug', parseLogLevel(arg.value)); break; + case 'cache': + if (isObject(arg.value)) { + if (arg.value.maxSize !== undefined) state.cache.maxSize = arg.value.maxSize; + if (arg.value.ttl !== undefined) state.cache.ttl = arg.value.ttl; + } else { + setProperty(state.config, 'cache', arg.value); + } + break; + default: setProperty(state.config, optKey, arg.value); break; @@ -394,9 +442,8 @@ export function extendState(state: t.Internal.State, options: t.Options): boolea if (state.aliasEngine) { // Ensure we don't corrupt global state if we are a local instance if (state.config.scope === 'local' && state.aliasEngine.depth === 0) { - if (typeof state.aliasEngine.fork === 'function') { + if (isFunction(state.aliasEngine.fork)) state.aliasEngine = state.aliasEngine.fork(state.config); - } } state.aliasEngine.registerAliases('evt', ownEntries(events)); } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index c8fd2a93..0a1e482d 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -30,7 +30,7 @@ import { validateLicenseState, getLicenseSnapshot, setLicense, getLicenseState, import { resolveMonthDay, setProperty, proto, hasOwn, resolveDisplayStatus } from './support/support.util.js'; import { datePattern } from './support/support.default.js'; -import { sym, markConfig, TermError, getRuntime, init, extendState, setPatterns, isTempo, registryUpdate, registryReset, onRegistryReset, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, LICENSE, DISCOVERY, $Internal, $setConfig, $Identity, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Tempo, $Register, $errored, $guard, $Discover, $setDiscovery, $LogConfig, $ImmutableSkip, $updateScopeStatus, logError, logDebug, logWarn, logTempo, setLogLevel } from '#tempo/support'; +import { sym, markConfig, TermError, getRuntime, init, extendState, setPatterns, isTempo, registryUpdate, registryReset, onRegistryReset, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, LICENSE, DISCOVERY, $Internal, $setConfig, $Identity, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Tempo, $Register, $errored, $guard, $Discover, $setDiscovery, $LogConfig, $ImmutableSkip, $updateScopeStatus, logError, logDebug, logWarn, logTempo, setLogLevel, createCacheFacade } from '#tempo/support'; import { TEMPO_VERSION } from './tempo.version.js'; import { Interval } from './interval.class.js'; import * as t from './tempo.type.js'; // namespaced types (Tempo.*) @@ -107,6 +107,7 @@ export class Tempo { /** TimeZone aliases */ static get TIMEZONE() { return enums.TIMEZONE } /** regional date-parsing configuration */ static get MONTH_DAY() { return enums.MONTH_DAY } /** initialization strategies */ static get MODE() { return enums.MODE } + /** cache operation modes */ static get CACHE() { return enums.CACHE } /** some useful Dates */ static get LIMIT() { return enums.LIMIT } /** @internal check if Tempo is currently initializing */ static get isInitializing() { return !_lifecycle.ready } @@ -117,6 +118,10 @@ export class Tempo { static get versions() { return Object.freeze({ ...Tempo.#versions }) as Readonly>; } /** the version of this Tempo build (stamped at build-time from package.json) */ static get version() { return Tempo.#versions['Tempo']; } + /** high-performance in-memory cache facade for glossary and dynamic parse results */ + static get cache() { + return createCacheFacade(() => this[$Internal]()); + } /** mutable list of registered term plugins */ static get #terms(): TermPlugin[] { return Tempo[$Internal]().pluginsDb.terms } /** @internal format raw license snapshot into human-readable license object */ @@ -166,9 +171,8 @@ export class Tempo { /** @internal */ static get [$ImmutableSkip]() { - const global = typeof globalThis !== 'undefined' ? globalThis : (window as any); - const nodeEnv = typeof global !== 'undefined' - && typeof global.process !== 'undefined' + const global = isDefined(globalThis) ? globalThis : (window as any); + const nodeEnv = isDefined(global.process) && global.process.env && (global.process.env.NODE_ENV === 'test' || global.process.env.CI); @@ -829,11 +833,12 @@ export class Tempo { setLogLevel(options.debug ?? Default?.debug ?? LOG.Info); const rt = getRuntime(); + const prevCache = rt.state?.cache; const isBase = !!this[$IsBase]; - if (isBase) rt.state = undefined; // force fresh state + if (isBase) rt.state = undefined; const baseState = isBase ? undefined : Object.getPrototypeOf(this)[$Internal](); - const state = init(options, isBase, baseState); + const state = init(options, isBase, baseState, prevCache); (state as any)._count = 0; if (isBase) { _global = state; @@ -1267,7 +1272,7 @@ export class Tempo { ZONED_DATE_TIME: enums.ZONED_DATE_TIME } - return out; + return Object.setPrototypeOf(out, self.#local); } /** allow for auto-convert of Tempo to BigInt, Number or String */ @@ -1683,7 +1688,6 @@ export class Tempo { #setLocal(options: t.Options = {}) { const classState = (this.constructor as any)[$Internal](); this.#local = Object.create(classState); - (this.#local as any)._id = (this.constructor as any)[$Internal]()._count++; const self = unwrap(this); this.#local.config = markConfig(Object.create(classState.config)); if (classState.config.registry) this.#local.config.registry = Object.create(classState.config.registry); diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 53f8f8a0..6b6e7976 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -13,6 +13,7 @@ import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, import { sym, type TempoBrand } from '#tempo/support/support.symbol.js'; import * as enums from '#tempo/support/support.enum.js'; +import { BoundedCache } from '#tempo/support/support.cache.js'; import { SLICK_KEYS, type Snippet, type Layout, type Event, type Period, type Ignore } from '#tempo/support/support.default.js'; import type { Token } from '#tempo/support/support.symbol.js'; @@ -381,6 +382,7 @@ export namespace Internal { /** @internal Alias engine for this Tempo instance */ aliasEngine?: AliasEngine; /** @internal Pattern compiler for this Tempo instance */ patternCompiler?: PatternCompiler; /** @internal database of plugins scoped to this state */pluginsDb: { terms: TermPlugin[]; plugins: TempoPlugin[] }; + /** @internal internal cache engine for static terms and string parses */cache: BoundedCache; /** @internal installed-plugin dedup tracker; a ScopedSet for sandboxes (delegates has() to global rt.installed), undefined for the global state */installed?: Set | ScopedSet; /** @internal sandbox-local license state; runtime license is centralized on TempoRuntime */license?: Internal.LicenseState; } diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index c3fdb730..89eb178b 100644 --- a/packages/tempo/src/tempo.version.ts +++ b/packages/tempo/src/tempo.version.ts @@ -5,4 +5,4 @@ * ⚠️ This file is auto-updated by `npm run build:version` (see `bin/update-version.mjs`). * Do NOT edit manually — your changes will be overwritten on the next build. */ -export const TEMPO_VERSION = '3.10.3'; +export const TEMPO_VERSION = '3.11.0'; diff --git a/packages/tempo/test/support/cache.test.ts b/packages/tempo/test/support/cache.test.ts new file mode 100644 index 00000000..078075a9 --- /dev/null +++ b/packages/tempo/test/support/cache.test.ts @@ -0,0 +1,104 @@ +import { Tempo } from '#tempo'; +import { BoundedCache } from '../../src/support/support.cache.js'; + +describe('Tempo Core Caching Architecture', () => { + beforeEach(() => { + Tempo.cache.clear(); + }); + + describe('BoundedCache Engine', () => { + it('should evict LRU items when maxSize is exceeded', () => { + const cache = new BoundedCache(2, 10000); + cache.set('a', '1'); + cache.set('b', '2'); + cache.set('c', '3'); // 'a' should be evicted + + expect(cache.has('a')).toBe(false); + expect(cache.get('b')).toBe('2'); + expect(cache.get('c')).toBe('3'); + expect(cache.size).toBe(2); + }); + + it('should protect static keys from LRU capacity eviction', () => { + const cache = new BoundedCache(2, 10000); + cache.setStatic('static_term', 'IMMORTAL'); + cache.set('a', '1'); + cache.set('b', '2'); // 'a' should be evicted, not static_term + + expect(cache.has('static_term')).toBe(true); + expect(cache.get('static_term')).toBe('IMMORTAL'); + expect(cache.has('a')).toBe(false); + expect(cache.get('b')).toBe('2'); + }); + + it('should serialize and rehydrate via entries() and fromEntries()', () => { + const cache = new BoundedCache(10, 10000); + cache.set('k1', 'v1'); + cache.set('k2', 'v2'); + + const serialized = [...cache.entries()]; + expect(serialized).toEqual([['k1', 'v1'], ['k2', 'v2']]); + + const rehydrated = BoundedCache.fromEntries(serialized); + expect(rehydrated.get('k1')).toBe('v1'); + expect(rehydrated.get('k2')).toBe('v2'); + }); + + it('should clear specific count of oldest non-static entries', () => { + const cache = new BoundedCache(10, 10000); + cache.setStatic('immortal', '1'); + cache.set('k1', 'v1'); + cache.set('k2', 'v2'); + cache.set('k3', 'v3'); + + cache.clear(2); // Evicts k1 and k2 + + expect(cache.has('immortal')).toBe(true); + expect(cache.has('k1')).toBe(false); + expect(cache.has('k2')).toBe(false); + expect(cache.has('k3')).toBe(true); + }); + }); + + describe('Tempo.CACHE Enum & Facade', () => { + it('should expose Tempo.CACHE enum values', () => { + expect(Tempo.CACHE.Off).toBe(false); + expect(Tempo.CACHE.On).toBe(true); + expect(Tempo.CACHE.Refresh).toBe('refresh'); + }); + + it('should expose normalized Tempo.cache facade methods', () => { + Tempo.cache.set(' MY_TERM ', '2026-05-10'); + expect(Tempo.cache.has('my_term')).toBe(true); + expect(Tempo.cache.get('my_term')).toBe('2026-05-10'); + + Tempo.cache.delete('MY_TERM'); + expect(Tempo.cache.has('my_term')).toBe(false); + }); + + it('should resolve static glossary terms instantly and record glossary source in parse result', () => { + Tempo.cache.setStatic('eoy_party', '2026-12-31T18:00:00'); + + const instance = new Tempo('eoy_party'); + expect(instance.isValid).toBe(true); + expect(instance.format('{yyyy}-{mm}-{dd}')).toBe('2026-12-31'); + + const hit = instance.parse.result.find((r: any) => r.match === 'CacheHit'); + expect(hit).toBeDefined(); + expect(hit?.source).toBe('glossary'); + }); + + it('should resolve dynamic parse cache when opt-in cache: true is passed', () => { + const t1 = new Tempo('2026-08-15', { cache: true }); + expect(t1.isValid).toBe(true); + + // Second instantiation uses dynamic cache + const t2 = new Tempo('2026-08-15', { cache: true }); + expect(t2.isValid).toBe(true); + + const hit = t2.parse.result.find((r: any) => r.match === 'CacheHit'); + expect(hit).toBeDefined(); + expect(hit?.source).toBe('parseCache'); + }); + }); +}); From 345719f9310765cdbea982ba78f9beaae2b0bb2d Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Fri, 31 Jul 2026 14:49:16 +1000 Subject: [PATCH 6/8] PR cache review --- .github/ISSUE_TEMPLATE/bug_report_ai.yml | 73 +++++++++++++++++++ packages/plugins/ai/CHANGELOG.md | 2 +- packages/plugins/ai/README.md | 6 +- packages/plugins/ai/doc/architecture.md | 16 ++-- packages/plugins/ai/doc/index.md | 10 +-- packages/plugins/ai/doc/rate-limits.md | 10 +-- packages/plugins/ai/package.json | 2 +- packages/plugins/ai/src/index.ts | 63 ++++++++++------ packages/plugins/ai/src/parseAI.type.ts | 12 +-- packages/plugins/ai/test/index.spec.ts | 11 +-- packages/plugins/vitest.shared.ts | 2 +- packages/tempo/CHANGELOG.md | 2 +- .../doc/1-getting-started/tempo.cookbook.md | 4 +- .../tempo/doc/2-core-concepts/tempo.cache.md | 4 +- packages/tempo/src/engine/engine.composer.ts | 13 ++-- packages/tempo/src/module/module.parse.ts | 6 +- packages/tempo/src/support/support.cache.ts | 52 ++++++++----- packages/tempo/src/support/support.init.ts | 60 +++++++-------- packages/tempo/src/tempo.class.ts | 8 +- .../tempo/test/discrete/parse.locale.test.ts | 22 +++++- packages/tempo/test/support/cache.test.ts | 17 +++++ 21 files changed, 257 insertions(+), 138 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report_ai.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report_ai.yml b/.github/ISSUE_TEMPLATE/bug_report_ai.yml new file mode 100644 index 00000000..b6a7a2a2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report_ai.yml @@ -0,0 +1,73 @@ +name: "🐛 Bug Report: parseAI Plugin" +description: Report an issue or unexpected date parsing result with the parseAI plugin +title: "[parseAI]: " +labels: ["bug", "plugin:ai", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for helping refine `@magmacomputing/tempo-plugin-ai`! Since this plugin is currently experimental, detailed reproduction details help us stabilize context resolution and prompt engineering. + + - type: input + id: environment + attributes: + label: Version & Environment + placeholder: "e.g., tempo-plugin-ai v0.2.0, tempo v3.11.0, Node.js v20.11" + validations: + required: true + + - type: dropdown + id: provider + attributes: + label: AI Provider + options: + - OpenAI (gpt-4o / gpt-4o-mini / gpt-5.4-mini) + - Azure OpenAI + - Custom LLM Proxy + - Native Fallback + validations: + required: true + + - type: textarea + id: input_query + attributes: + label: Natural Language Input Phrase + description: What string was passed to parseAI()? + placeholder: 'e.g. "The penultimate Tuesday before Thanksgiving in 2026"' + validations: + required: true + + - type: textarea + id: context + attributes: + label: Context & Options + description: Provide relevant options (timeZone, locale, calendar, anchor date, etc.). + placeholder: '{ timeZone: "America/New_York", anchor: "2026-11-01" }' + validations: + required: false + + - type: textarea + id: behavior + attributes: + label: Expected vs. Actual Result + placeholder: | + Expected: 2026-11-17T00:00:00-05:00 + Actual: 2026-11-24T00:00:00-05:00 (or TempoAiError) + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Minimal Reproduction Code + description: Include a snippet showing initAI() and parseAI() invocations. + render: typescript + placeholder: | + import { Tempo } from '@magmacomputing/tempo'; + import { parseAI, initAI } from '@magmacomputing/tempo-plugin-ai'; + + initAI({ + providers: [{ id: 'openai', key: '...' }] + }); + + const res = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index 3e05b9cb..97b934df 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -26,4 +26,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial scaffolding of the AI natural language parsing plugin. - Functional exports for `parseAI`, `initAI`, and `clearAiCache`. -- Initial fallback-routing logic (mocked proxy). +- Initial provider fallback-routing engine supporting HTTP requests to configured LLM provider endpoints. diff --git a/packages/plugins/ai/README.md b/packages/plugins/ai/README.md index 152c7b30..d3ad1e90 100644 --- a/packages/plugins/ai/README.md +++ b/packages/plugins/ai/README.md @@ -11,7 +11,7 @@ Tempo community plugin for LLM-powered natural language parsing. This plugin bridges the gap between deterministic date-math and unstructured NLP inputs, utilizing large language models (like Gemini, Groq, or OpenAI) to safely and asynchronously parse complex natural language expressions into `Tempo` instances. > **CRITICAL SECURITY WARNING**: Raw LLM API keys must **never** be exposed in a client-side browser bundle. BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, you must use a proxy service. - +> > **LLM Output Disclaimer**: Large Language Models are probabilistic text generators, not deterministic calculators. Magma Computing Solutions and Tempo core maintainers provide `@magmacomputing/tempo-plugin-ai` "as-is". Developers and organizations are solely responsible for validating AI-generated date and time outputs before relying on them in financial, legal, medical, or time-critical production systems. ## Installation @@ -25,10 +25,10 @@ npm install @magmacomputing/tempo-plugin-ai ```typescript import { parseAI, initAI, clearAiCache } from '@magmacomputing/tempo-plugin-ai'; -// Initialize with your BYOK API Key +// Initialize with your BYOK API Key (ensuring non-undefined string key) initAI({ providers: [ - { id: 'groq', key: process.env.GROQ_API_KEY }, + ...(process.env.GROQ_API_KEY ? [{ id: 'groq', key: process.env.GROQ_API_KEY }] : []), ] }); diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md index d564cd48..6306fece 100644 --- a/packages/plugins/ai/doc/architecture.md +++ b/packages/plugins/ai/doc/architecture.md @@ -11,9 +11,9 @@ import { initAI } from '@magmacomputing/tempo-plugin-ai'; initAI({ providers: [ - { id: 'groq', key: process.env.GROQ_API_KEY }, - { id: 'gemini', key: process.env.GEMINI_API_KEY }, - { id: 'openai', key: process.env.OPENAI_API_KEY } + ...(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 }] : []) ] }); ``` @@ -25,14 +25,16 @@ However, you can explicitly override URLs, models, and inject arbitrary LLM para ```typescript initAI({ providers: [ - // 1. Enterprise Azure OpenAI - { + // 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_API_KEY, + key: process.env.AZURE_ENTRA_BEARER_TOKEN, url: 'https://my-enterprise.openai.azure.com/v1/chat/completions', model: 'gpt-4o', options: { temperature: 0.2, seed: 42 } - }, + }] : []), // 2. Local Open-Source Models (e.g. Ollama) { id: 'local', diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md index e7f93423..42addc4a 100644 --- a/packages/plugins/ai/doc/index.md +++ b/packages/plugins/ai/doc/index.md @@ -8,8 +8,8 @@ > [!WARNING] > **🧪 EXPERIMENTAL PLUGIN** -> This plugin relies on Generative AI. While it uses strict JSON schemas and validation to force deterministic outputs, LLMs (especially smaller models) can still hallucinate complex calendar math. We are actively collecting feedback on prompt engineering and model reliability. Please report any strange behavior or unexpected hallucinations on the [Magma GitHub Issues](https://github.com/magmacomputing/magma/issues) page! - +> 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. @@ -69,12 +69,12 @@ When building your LLM queries, it is often useful to see exactly how `parseAI` Passing `debug: true` into `initAI` is intended for **development environments only**. It will globally log system prompts, localized context, and raw LLM responses to the console. Because prompts, context, and responses may contain user-supplied or sensitive data, disable `debug: true` or redact sensitive logs in production. **Forced Evaluation** -If a relative query (like `"The Friday after Thanksgiving"`) is intercepted by the native `Tempo` layout engine, but the anchor context inheritance is returning an undesired timezone, you can forcefully bypass the deterministic engine and the cache by passing `force: true`: +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("The Friday after Thanksgiving", { +const dt = await parseAI("Next Friday", { anchor: '2026-09-01T00:00:00Z', - force: true, // Bypasses native parsers & cache; forces a network LLM request! + 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 }); ``` diff --git a/packages/plugins/ai/doc/rate-limits.md b/packages/plugins/ai/doc/rate-limits.md index 98b5212f..af7a7d0b 100644 --- a/packages/plugins/ai/doc/rate-limits.md +++ b/packages/plugins/ai/doc/rate-limits.md @@ -53,14 +53,14 @@ This is by design for three critical reasons: 3. **Deterministic Safety**: LLMs are language models, not arrays. If you pass 50 strings, smaller models often hallucinate and return 49 strings, completely breaking your array indexing. By querying sequentially, we guarantee a strict 1:1 mapping and ensure one invalid string doesn't crash the entire batch. > [!WARNING] -> **Granular Time Gotcha**: The cache key is automatically salted with the **calendar date** (`yyyy-mm-dd`) of execution. This brilliantly protects relative day queries (like `"The Friday after Thanksgiving"`) because the cache automatically misses as soon as midnight strikes! However, if you pass `force: true` for granular time-relative phrases, the calendar date salt is not enough to prevent staleness on a long-running server. +> **Granular Time Gotcha**: The cache key is automatically salted with the **calendar date** (`yyyy-mm-dd`) of the execution anchor. By default this uses the system execution date, but when `options.anchor` is explicitly set, it uses the caller-provided anchor date. Note that keeping a fixed anchor date retains the same cache key across midnight boundaries, so an automatic midnight cache miss is not guaranteed. -### Bypassing Cache for Dynamic Queries -If you are intentionally parsing dynamic phrases and your server is long-running, you should explicitly disable caching for that specific query to ensure it is re-evaluated: +### Bypassing Cache & Forcing Network Requests +Passing `cache: false` disables reading and writing to the cache, but native pre-parsing may still resolve standard phrases. To guarantee an LLM provider request while disabling caching of the response, combine `force: true` with `cache: false`: ```typescript -// The LLM will ALWAYS be queried, and the result will NOT be cached -const dt = await parseAI("The last Friday before Christmas", { cache: false }); +// Forces an LLM network request and prevents reading or writing to cache +const dt = await parseAI("The last Friday before Christmas", { force: true, cache: false }); ``` ### Evicting Bad Parses diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index 4c0dc1cb..170bc55a 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -18,7 +18,7 @@ "scripts": { "build": "tsup && tsc", "test": "vitest run -c ../vitest.shared.ts", - "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build" + "prepublishOnly": "npm run build" }, "tempo": { "vendorVariantId": "tempo-plugin-ai", diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts index b9eef3e5..467be452 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -150,11 +150,11 @@ export async function parseAI( sph = options!.sphere || options!.anchor.config.sphere; anchorStr = options!.anchor.toString(); } else { - const resolvedConfig = Tempo.config; - tz = options?.timeZone || resolvedConfig.timeZone; - cal = options?.calendar || resolvedConfig.calendar; - loc = options?.locale || resolvedConfig.locale; - sph = options?.sphere || resolvedConfig.sphere; + const resolvedOptions = Tempo.options; + tz = options?.timeZone || resolvedOptions.timeZone; + cal = options?.calendar || resolvedOptions.calendar; + loc = options?.locale || resolvedOptions.locale; + sph = options?.sphere || resolvedOptions.sphere; anchorStr = options?.anchor || new Tempo().toString(); } @@ -256,30 +256,47 @@ Do not include markdown blocks, explanations, or any text outside the JSON.`; } // Parse rate limits from headers - const remReq = response.headers.get('x-ratelimit-remaining-requests'); - const remTok = response.headers.get('x-ratelimit-remaining-tokens'); - const resetTok = response.headers.get('x-ratelimit-reset-tokens'); - - if (remReq || remTok) { - let addString = '1 hour'; - if (resetTok) { - const val = parseFloat(resetTok); - if (resetTok.endsWith('ms')) addString = `${val} milliseconds`; - else if (resetTok.endsWith('s')) addString = `${val} seconds`; - else if (resetTok.endsWith('m')) addString = `${val} minutes`; - else addString = `${val} seconds`; + const remReqHeader = response.headers.get('x-ratelimit-remaining-requests'); + const remTokHeader = response.headers.get('x-ratelimit-remaining-tokens'); + const resetTokHeader = response.headers.get('x-ratelimit-reset-tokens'); + + if (remReqHeader !== null || remTokHeader !== null || resetTokHeader !== null) { + const reqNum = remReqHeader !== null ? parseInt(remReqHeader, 10) : NaN; + const tokNum = remTokHeader !== null ? parseInt(remTokHeader, 10) : NaN; + + const parsedReq = Number.isNaN(reqNum) ? null : reqNum; + const parsedTok = Number.isNaN(tokNum) ? null : tokNum; + + let resetAtTempo: Tempo | null = null; + if (resetTokHeader) { + const val = parseFloat(resetTokHeader); + if (!Number.isNaN(val)) { + let addString = `${val} seconds`; + if (resetTokHeader.endsWith('ms')) addString = `${val} milliseconds`; + else if (resetTokHeader.endsWith('s')) addString = `${val} seconds`; + else if (resetTokHeader.endsWith('m')) addString = `${val} minutes`; + + try { + const t = new Tempo().add(addString); + if (t.isValid) resetAtTempo = t; + } catch { + resetAtTempo = null; + } + } } - _state.limits = { - remainingRequests: remReq ? parseInt(remReq, 10) : 999, - remainingTokens: remTok ? parseInt(remTok, 10) : 99999, - resetAt: new Tempo().add(addString) - }; + if (parsedReq !== null || parsedTok !== null || resetAtTempo !== null) { + _state.limits = { + remainingRequests: parsedReq, + remainingTokens: parsedTok, + resetAt: resetAtTempo + }; + } } if (!response.ok) { const errorText = await response.text(); - const resetTime = _state.limits?.resetAt; + const resetTime = _state.limits?.resetAt ?? undefined; throw new TempoAiError(`Provider ${provider.id} failed with status ${response.status}. Details: ${errorText}`, response.status, resetTime); } diff --git a/packages/plugins/ai/src/parseAI.type.ts b/packages/plugins/ai/src/parseAI.type.ts index b2f4f62f..9faae74a 100644 --- a/packages/plugins/ai/src/parseAI.type.ts +++ b/packages/plugins/ai/src/parseAI.type.ts @@ -38,10 +38,10 @@ export interface AiConfig { * of the most recent LLM proxy request. */ export interface AiRateLimits { - /** Number of remaining requests allowed in the current time window */ - remainingRequests: number; - /** Number of remaining tokens allowed in the current time window */ - remainingTokens: number; - /** A Tempo instance representing the exact time the limits reset */ - resetAt: Tempo; + /** Number of remaining requests allowed in the current time window, or null if unknown */ + remainingRequests: number | null; + /** Number of remaining tokens allowed in the current time window, or null if unknown */ + remainingTokens: number | null; + /** A Tempo instance representing the exact time the limits reset, or null if unknown */ + resetAt: Tempo | null; } diff --git a/packages/plugins/ai/test/index.spec.ts b/packages/plugins/ai/test/index.spec.ts index 4e6361d8..51045867 100644 --- a/packages/plugins/ai/test/index.spec.ts +++ b/packages/plugins/ai/test/index.spec.ts @@ -8,10 +8,6 @@ describe('AI Parsing Plugin', () => { const isLiveTest = Boolean(process.env.LIVE_AI_TEST && liveApiKey); beforeEach(() => { - // Suppress expected native parsing errors from polluting the test output - vi.spyOn(console, 'error').mockImplementation(() => { }); - vi.spyOn(console, 'warn').mockImplementation(() => { }); - if (isLiveTest) { initAI({ providers: [{ id: liveProviderId, key: liveApiKey! }] @@ -24,7 +20,7 @@ describe('AI Parsing Plugin', () => { }); afterEach(() => { - vi.restoreAllMocks(); + vi.clearAllMocks(); }); it('should fall back to native parsing first', async () => { @@ -55,7 +51,7 @@ describe('AI Parsing Plugin', () => { // Provide a strict anchor so we can assert the result deterministically const anchorDate = '2026-05-10T12:00:00Z'; - const result = await parseAI('The Friday after Thanksgiving', { anchor: anchorDate, timeZone: 'UTC' }); + const result = await parseAI('The Friday after Thanksgiving', { anchor: anchorDate, timeZone: 'UTC', force: true }); expect(result).toBeInstanceOf(Tempo); expect(result.isValid).toBe(true); @@ -95,7 +91,8 @@ describe('AI Parsing Plugin', () => { status: 200, headers: new Headers({ 'x-ratelimit-remaining-requests': '99', - 'x-ratelimit-remaining-tokens': '4950' + 'x-ratelimit-remaining-tokens': '4950', + 'x-ratelimit-reset-tokens': '60s' }) })); } diff --git a/packages/plugins/vitest.shared.ts b/packages/plugins/vitest.shared.ts index 1421e842..fcc0ad19 100644 --- a/packages/plugins/vitest.shared.ts +++ b/packages/plugins/vitest.shared.ts @@ -47,7 +47,7 @@ export default defineConfig({ { find: /^@magmacomputing\/tempo\/plugin\/(.*)$/, replacement: resolve(__dirname, '../tempo/src/plugin/$1.ts') }, { find: /^@magmacomputing\/tempo\/term$/, replacement: resolve(__dirname, '../tempo/src/plugin/term/term.index.ts') }, { find: /^@magmacomputing\/tempo\/term\/(.*)$/, replacement: resolve(__dirname, '../tempo/src/plugin/term/term.$1.ts') }, - { find: /^@magmacomputing\/tempo\/extend\/(.*)$/, replacement: resolve(__dirname, '../tempo/src/plugin/extend/extend.$1.ts') }, + { find: /^@magmacomputing\/tempo\/support$/, replacement: resolve(__dirname, '../tempo/src/support/support.index.ts') }, { find: /^@magmacomputing\/tempo\/(.*)$/, replacement: resolve(__dirname, '../tempo/src/$1.ts') } ] } diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 7c124fe0..eac78902 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.10.3] - 2026-07-29 ### Performance -- **Zero-Overhead Instantiation (~40–60% Speedup)**: Re-architected `Tempo` instance construction by introducing lazy evaluation for core private properties: +- **Zero-Overhead Instantiation**: Re-architected `Tempo` instance construction by introducing lazy evaluation for core private properties: - **Lazy Instant (`#now`)**: Deferred system clock acquisition (`Temporal.Instant.fromEpochNanoseconds`) so that `instant()` is only fetched when relative duration math or parsing fallbacks require it. Passing explicit date strings or objects skips system clock calls entirely. - **Lazy Delegators (`#fmt` & `#term`)**: Deferred creation of Proxy delegator objects until `.fmt` or `.term` properties are explicitly accessed. - **`Interval` Acceleration**: `Tempo.Interval` and boundary set operations (`overlaps`, `contains`, `intersection`, `union`) automatically benefit from the reduced instantiation overhead. diff --git a/packages/tempo/doc/1-getting-started/tempo.cookbook.md b/packages/tempo/doc/1-getting-started/tempo.cookbook.md index 9468ae68..d40e8fc2 100644 --- a/packages/tempo/doc/1-getting-started/tempo.cookbook.md +++ b/packages/tempo/doc/1-getting-started/tempo.cookbook.md @@ -16,10 +16,10 @@ A collection of recipes for solving common date and time challenges using Tempo. ## The Basics ### How do I get the current date and time? -By default, the constructor returns "now". +When invoked without arguments, the constructor initializes to the current date and time. ```typescript const now = new Tempo(); -console.log(now.toString()); +console.log(now.toString()); // e.g. "2026-07-31T14:42:11+10:00[Australia/Sydney]" ``` ### Get "Now" in UTC diff --git a/packages/tempo/doc/2-core-concepts/tempo.cache.md b/packages/tempo/doc/2-core-concepts/tempo.cache.md index e23e4a31..28970772 100644 --- a/packages/tempo/doc/2-core-concepts/tempo.cache.md +++ b/packages/tempo/doc/2-core-concepts/tempo.cache.md @@ -83,10 +83,10 @@ 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("1st Tuesday in March 2026 at 3pm"); +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("1st Tuesday in March 2026 at 3pm"); +const t2 = new Tempo("The penultimate Tuesday before Thanksgiving in 2026"); ``` ### Two-Tier Resolution Architecture diff --git a/packages/tempo/src/engine/engine.composer.ts b/packages/tempo/src/engine/engine.composer.ts index 4d4a167a..873e16d7 100644 --- a/packages/tempo/src/engine/engine.composer.ts +++ b/packages/tempo/src/engine/engine.composer.ts @@ -50,18 +50,17 @@ export function compose( case 'String': try { - const str = value.replace(/Z$/, ''); let zdt: Temporal.ZonedDateTime; - if (str.includes('[')) { - zdt = Temporal.ZonedDateTime.from(str); - } else if (/[+-]\d{2}:\d{2}/.test(str)) { - zdt = Temporal.ZonedDateTime.from(`${str}[${tz}]`); + if (value.includes('[')) { + zdt = Temporal.ZonedDateTime.from(value); + } else if (/Z$|[+-]\d{2}:?\d{2}/i.test(value)) { + zdt = Temporal.Instant.from(value).toZonedDateTimeISO(tz); } else { - zdt = Temporal.PlainDateTime.from(str, { overflow: 'constrain' }).toZonedDateTime(tz); + zdt = Temporal.PlainDateTime.from(value, { overflow: 'constrain' }).toZonedDateTime(tz); } timeZone = getTemporalIds(zdt)[0]; temporal = zdt; - onResult?.({ type, value: str, match: 'iso8601' }); + onResult?.({ type, value, match: 'iso8601' }); } catch (err) { try { temporal = Temporal.PlainDateTime.from(value, { overflow: 'constrain' }); diff --git a/packages/tempo/src/module/module.parse.ts b/packages/tempo/src/module/module.parse.ts index 6c36156d..57a9ddd6 100644 --- a/packages/tempo/src/module/module.parse.ts +++ b/packages/tempo/src/module/module.parse.ts @@ -165,7 +165,7 @@ const _ParseEngine = { if ((state.config.cache === true || state.config.cache === enums.CACHE.On || state.config.cache === enums.CACHE.Refresh || state.config.cache === 'refresh') && isString(tempo) && isZonedDateTime(dateTime) && !state.errored) { const cacheKey = buildCacheKey(tempo, today, state); - state.cache?.set(cacheKey, dateTime.toString()); + state.cache.set(cacheKey, dateTime.toString()); } return Object.assign(res, { @@ -230,7 +230,7 @@ const _ParseEngine = { const normVal = trim.toLowerCase(); // 1. Static Glossary Check - if (state.cache?.isStatic(normVal)) { + if (state.cache.isStatic(normVal)) { const staticTarget = state.cache.get(normVal); if (staticTarget) { accumulateResult(state, { match: 'CacheHit', value: trim, source: 'glossary' as any }); @@ -242,7 +242,7 @@ const _ParseEngine = { const cacheOpt = state.config.cache; if (cacheOpt === true || cacheOpt === enums.CACHE.On) { const cacheKey = buildCacheKey(trim, dateTime, state); - const cachedIso = state.cache?.get(cacheKey); + const cachedIso = state.cache.get(cacheKey); if (cachedIso) { accumulateResult(state, { match: 'CacheHit', value: trim, source: 'parseCache' as any }); return { type: 'String', value: cachedIso }; diff --git a/packages/tempo/src/support/support.cache.ts b/packages/tempo/src/support/support.cache.ts index 15427ee9..d863fa1c 100644 --- a/packages/tempo/src/support/support.cache.ts +++ b/packages/tempo/src/support/support.cache.ts @@ -68,7 +68,13 @@ export class BoundedCache extends Map { this.delete(key); return undefined; } - return super.get(key); + if (!super.has(key)) return undefined; + const val = super.get(key)!; + super.delete(key); + super.set(key, val); + if (!this.#staticKeys.has(key)) + this.#timestamps.set(key, Date.now()); + return val; } override has(key: K): boolean { @@ -79,6 +85,16 @@ export class BoundedCache extends Map { return super.has(key); } + override get size(): number { + this.evictExpired(); + return super.size; + } + + override forEach(callbackfn: (value: V, key: K, map: Map) => void, thisArg?: any): void { + this.evictExpired(); + super.forEach(callbackfn, thisArg); + } + override set(key: K, value: V): this { this.evictExpired(); @@ -90,12 +106,16 @@ export class BoundedCache extends Map { while (this.size > this.maxSize) { let evicted = false; - for (const k of super.keys()) { + const keysIter = super.keys(); + let res = keysIter.next(); + while (!res.done) { + const k = res.value; if (!this.#staticKeys.has(k)) { this.delete(k); evicted = true; break; } + res = keysIter.next(); } if (!evicted) break; } @@ -126,7 +146,7 @@ export class BoundedCache extends Map { /** * Purge cache entries. * If `count` is specified, evicts up to `count` oldest non-static entries. - * If omitted, clears all non-static entries. + * If omitted, clears all entries, including static entries. */ override clear(count?: number): void { if (count === undefined) { @@ -182,48 +202,40 @@ export function createCacheFacade(getState: () => t.Internal.State) { return secure({ get(key: string) { const normalized = String(key).trim().toLowerCase(); - return getState().cache?.get(normalized); + return getState().cache.get(normalized); }, has(key: string) { const normalized = String(key).trim().toLowerCase(); - return getState().cache?.has(normalized) ?? false; + return getState().cache.has(normalized); }, set(key: string, value: string) { const normalized = String(key).trim().toLowerCase(); - getState().cache?.set(normalized, String(value)); + getState().cache.set(normalized, String(value)); return this; }, setStatic(key: string, value: string) { const normalized = String(key).trim().toLowerCase(); - getState().cache?.setStatic(normalized, String(value)); + getState().cache.setStatic(normalized, String(value)); return this; }, delete(key: string) { const normalized = String(key).trim().toLowerCase(); - return getState().cache?.delete(normalized) ?? false; + return getState().cache.delete(normalized); }, deletePrefix(prefix: string) { const normalizedPrefix = String(prefix).trim().toLowerCase(); - const cache = getState().cache; - if (!cache) return 0; - let count = 0; - for (const key of Array.from(cache.keys())) { - if (isString(key) && key.toLowerCase().startsWith(normalizedPrefix)) - cache.delete(key); - count++; - } - return count; + return getState().cache.deletePrefix(normalizedPrefix); }, clear(count?: number) { - getState().cache?.clear(count); + getState().cache.clear(count); }, entries() { - return getState().cache?.entries() ?? [][Symbol.iterator](); + return getState().cache.entries(); }, fromEntries(entries: Iterable) { for (const [k, v] of entries) { const normalized = String(k).trim().toLowerCase(); - getState().cache?.set(normalized, String(v)); + getState().cache.set(normalized, String(v)); } return this; } diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index e74f2325..34cddf55 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -23,26 +23,34 @@ import enums from './support.enum.js'; import { BoundedCache } from './support.cache.js'; import * as t from '../tempo.type.js'; +function resolveCache(optionsCache: any, existingCache?: BoundedCache): BoundedCache { + const isBoundedCacheOpt = Boolean(optionsCache?.isBoundedCache || optionsCache instanceof BoundedCache); + if (isBoundedCacheOpt) { + return optionsCache as BoundedCache; + } else if (optionsCache instanceof Map || (optionsCache && isFunction((optionsCache as any).entries))) { + const targetCache = existingCache ?? new BoundedCache(); + for (const [k, v] of (optionsCache as any).entries()) + targetCache.setStatic(String(k).trim().toLowerCase(), String(v)); + return targetCache; + } else { + if (existingCache && (existingCache.isBoundedCache || existingCache instanceof BoundedCache)) { + if (isDefined(optionsCache?.maxSize)) existingCache.maxSize = optionsCache.maxSize; + if (isDefined(optionsCache?.ttl)) existingCache.ttl = optionsCache.ttl; + return existingCache; + } else { + const maxSize = optionsCache?.maxSize ?? 1000; + const ttl = optionsCache?.ttl ?? (24 * 60 * 60 * 1000); + return new BoundedCache(maxSize, ttl); + } + } +} + /** @internal Initialise a Tempo state */ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Internal.State, prevCache?: BoundedCache): t.Internal.State { const runtime = getRuntime(); // Global init is intentionally idempotent after first hydration; late-loaded modules must use Tempo.extend(). if (isGlobal && runtime.state && !baseState) { - if (!runtime.state.cache) - runtime.state.cache = new BoundedCache(1000, 24 * 60 * 60 * 1000); - - if (options.cache) { - const isBoundedCacheObj = Boolean((options.cache as any)?.isBoundedCache || options.cache instanceof BoundedCache); - if (isBoundedCacheObj) { - runtime.state.cache = options.cache as BoundedCache; - } else if (options.cache instanceof Map || isFunction((options.cache as any).entries)) { - for (const [k, v] of (options.cache as any).entries()) - runtime.state.cache.setStatic(String(k).trim().toLowerCase(), String(v)); - } else { - if (isDefined(options.cache?.maxSize)) runtime.state.cache.maxSize = options.cache.maxSize; - if (isDefined(options.cache?.ttl)) runtime.state.cache.ttl = options.cache.ttl; - } - } + runtime.state.cache = resolveCache(options.cache, runtime.state.cache); return runtime.state; } @@ -67,26 +75,8 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int }; } - const isBoundedCacheOpt = Boolean((options.cache as any)?.isBoundedCache || options.cache instanceof BoundedCache); - if (isBoundedCacheOpt) { - state.cache = options.cache as BoundedCache; - } else if (options.cache instanceof Map || (options.cache && isFunction((options.cache as any).entries))) { - const targetCache = baseState?.cache ?? runtime.state?.cache ?? prevCache ?? new BoundedCache(); - for (const [k, v] of (options.cache as any).entries()) - targetCache.setStatic(String(k).trim().toLowerCase(), String(v)); - state.cache = targetCache; - } else { - const targetCache = baseState?.cache ?? runtime.state?.cache ?? prevCache; - if (targetCache && (targetCache.isBoundedCache || targetCache instanceof BoundedCache)) { - if (isDefined(options.cache?.maxSize)) targetCache.maxSize = options.cache.maxSize; - if (isDefined(options.cache?.ttl)) targetCache.ttl = options.cache.ttl; - state.cache = targetCache; - } else { - const maxSize = options.cache?.maxSize ?? 1000; - const ttl = options.cache?.ttl ?? (24 * 60 * 60 * 1000); - state.cache = new BoundedCache(maxSize, ttl); - } - } + const targetCache = baseState?.cache ?? runtime.state?.cache ?? prevCache; + state.cache = resolveCache(options.cache, targetCache); // 1. Establish the base parsing state const parseState: t.Internal.Parse = { diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 0a1e482d..9b2aa8ac 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1371,8 +1371,6 @@ export class Tempo { /** Resolve the instance to a Temporal.ZonedDateTime (with optional callback) */ #resolve(cb?: (zdt: Temporal.ZonedDateTime) => T): T | Temporal.ZonedDateTime { - const now = this.#now.toZonedDateTimeISO('UTC'); - if (!this.#zdt) { try { const skip = [this.#local.parse.format, this.#local.parse.term, this.#local.parse.result] @@ -1382,7 +1380,7 @@ export class Tempo { this.#errored = true; const msg = `Tempo parse returned undefined for: ${String(this.#tempo)}`; logError(msg, this.#local.config); - this.#zdt = now; + this.#zdt = this.#now.toZonedDateTimeISO('UTC'); } secure(this.#local.config); secure(this.#local.parse, new WeakSet(skip)); @@ -1391,7 +1389,7 @@ export class Tempo { const msg = `Cannot create Tempo: ${(err as Error).message}\n${(err as Error).stack}`; if (this.#local.config.catch === true) { logError(msg, this.#local.config); // log as error if in catch-mode - this.#zdt = now; + this.#zdt = this.#now.toZonedDateTimeISO('UTC'); } else { logError((err as Error), this.#local.config, msg); // log as error then re-throw throw err; @@ -1399,7 +1397,7 @@ export class Tempo { } } - const zdt = isZonedDateTime(this.#zdt) ? this.#zdt : now; + const zdt = isZonedDateTime(this.#zdt) ? this.#zdt : this.#now.toZonedDateTimeISO('UTC'); return cb?.(zdt) ?? zdt; } diff --git a/packages/tempo/test/discrete/parse.locale.test.ts b/packages/tempo/test/discrete/parse.locale.test.ts index f99b6bfb..2f93f47c 100644 --- a/packages/tempo/test/discrete/parse.locale.test.ts +++ b/packages/tempo/test/discrete/parse.locale.test.ts @@ -89,16 +89,30 @@ describe('Localized Parsing', () => { ignores: ['el', 'la', 'los', 'las'] } }); - const t1 = new Tempo('lunes'); + const anchor = '2026-07-31T12:00:00+00:00[UTC]'; + + const t1 = new Tempo('lunes', { anchor }); expect(t1.isValid).toBe(true); - const t2 = new Tempo('próximo lunes'); + const t2 = new Tempo('próximo lunes', { anchor }); expect(t2.isValid).toBe(true); + expect(t2.dow).toBe(1); + expect(t2.yy).toBe(2026); + expect(t2.mm).toBe(8); + expect(t2.dd).toBe(3); - const t3 = new Tempo('el próximo lunes'); + const t3 = new Tempo('el próximo lunes', { anchor }); expect(t3.isValid).toBe(true); + expect(t3.dow).toBe(1); + expect(t3.yy).toBe(2026); + expect(t3.mm).toBe(8); + expect(t3.dd).toBe(3); - const t4 = new Tempo('el proximo lunes'); + const t4 = new Tempo('el proximo lunes', { anchor }); expect(t4.isValid).toBe(true); + expect(t4.dow).toBe(1); + expect(t4.yy).toBe(2026); + expect(t4.mm).toBe(8); + expect(t4.dd).toBe(3); }); }); diff --git a/packages/tempo/test/support/cache.test.ts b/packages/tempo/test/support/cache.test.ts index 078075a9..2f2c7f46 100644 --- a/packages/tempo/test/support/cache.test.ts +++ b/packages/tempo/test/support/cache.test.ts @@ -19,6 +19,23 @@ describe('Tempo Core Caching Architecture', () => { expect(cache.size).toBe(2); }); + it('should update recency when calling get() so accessed items avoid eviction', () => { + const cache = new BoundedCache(2, 10000); + cache.set('a', '1'); + cache.set('b', '2'); + + // Read 'a' to make it most recently used + expect(cache.get('a')).toBe('1'); + + // Insert 'c'. 'b' should be evicted because 'a' was refreshed by get() + cache.set('c', '3'); + + expect(cache.has('b')).toBe(false); + expect(cache.get('a')).toBe('1'); + expect(cache.get('c')).toBe('3'); + expect(cache.size).toBe(2); + }); + it('should protect static keys from LRU capacity eviction', () => { const cache = new BoundedCache(2, 10000); cache.setStatic('static_term', 'IMMORTAL'); From df79761161d42ab376c6811056df6e6115b13b4a Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 2 Aug 2026 16:35:53 +1000 Subject: [PATCH 7/8] PR 1st review --- .agent/rules/plan-execution.md | 11 + .agent/workflows/ok.md | 6 + .github/ISSUE_TEMPLATE/bug_report_ai.yml | 5 +- packages/plugins/ai/CHANGELOG.md | 5 + packages/plugins/ai/package.json | 6 +- packages/plugins/ai/src/core/config.ts | 47 +++ packages/plugins/ai/src/{ => core}/error.ts | 2 +- packages/plugins/ai/src/core/init.ts | 135 ++++++ packages/plugins/ai/src/core/support.ts | 140 +++++++ packages/plugins/ai/src/core/types.ts | 113 +++++ packages/plugins/ai/src/functions/context.ts | 27 ++ packages/plugins/ai/src/functions/diff.ts | 31 ++ packages/plugins/ai/src/functions/extract.ts | 28 ++ packages/plugins/ai/src/functions/format.ts | 22 + packages/plugins/ai/src/functions/parse.ts | 257 ++++++++++++ .../plugins/ai/src/functions/recurrence.ts | 27 ++ packages/plugins/ai/src/functions/schedule.ts | 26 ++ packages/plugins/ai/src/index.ts | 372 ++--------------- packages/plugins/ai/src/parseAI.type.ts | 47 --- packages/plugins/ai/test/benchmark.spec.ts | 15 + packages/plugins/ai/test/index.spec.ts | 394 +++++++++++++----- packages/plugins/ai/tsconfig.json | 3 +- packages/plugins/ai/tsup.config.ts | 1 + packages/tempo/CHANGELOG.md | 1 + 24 files changed, 1229 insertions(+), 492 deletions(-) create mode 100644 .agent/rules/plan-execution.md create mode 100644 .agent/workflows/ok.md create mode 100644 packages/plugins/ai/src/core/config.ts rename packages/plugins/ai/src/{ => core}/error.ts (88%) create mode 100644 packages/plugins/ai/src/core/init.ts create mode 100644 packages/plugins/ai/src/core/support.ts create mode 100644 packages/plugins/ai/src/core/types.ts create mode 100644 packages/plugins/ai/src/functions/context.ts create mode 100644 packages/plugins/ai/src/functions/diff.ts create mode 100644 packages/plugins/ai/src/functions/extract.ts create mode 100644 packages/plugins/ai/src/functions/format.ts create mode 100644 packages/plugins/ai/src/functions/parse.ts create mode 100644 packages/plugins/ai/src/functions/recurrence.ts create mode 100644 packages/plugins/ai/src/functions/schedule.ts delete mode 100644 packages/plugins/ai/src/parseAI.type.ts create mode 100644 packages/plugins/ai/test/benchmark.spec.ts diff --git a/.agent/rules/plan-execution.md b/.agent/rules/plan-execution.md new file mode 100644 index 00000000..0f49adab --- /dev/null +++ b/.agent/rules/plan-execution.md @@ -0,0 +1,11 @@ +# Plan & Development Execution Rules + +## 1. Interactive Pair-Programming (Default Mode) +During standard interactive conversations, code discussions, and step-by-step refactoring: +- **ALWAYS use native file editing tools (`replace_file_content` and `write_to_file`)** for creating and modifying files. +- This provides visual line-by-line diff previews and interactive approval checkboxes directly in the user's IDE UI. + +## 2. Autonomous Background Execution (AFK / `/ok` Mode) +When the user explicitly approves an implementation plan for autonomous background execution (e.g. by typing `/ok` or clicking "Ok to proceed"): +- Set `SafeToAutoRun: true` on execution tools. +- Prefer using terminal shell operations (`run_command` with `SafeToAutoRun: true`) for batch file modifications and test execution to ensure unblocked execution while the user is away. diff --git a/.agent/workflows/ok.md b/.agent/workflows/ok.md new file mode 100644 index 00000000..cec040d4 --- /dev/null +++ b/.agent/workflows/ok.md @@ -0,0 +1,6 @@ +--- +description: Ok to proceed with your Plan +--- +// turbo-all + +ok to proceed with your Plan diff --git a/.github/ISSUE_TEMPLATE/bug_report_ai.yml b/.github/ISSUE_TEMPLATE/bug_report_ai.yml index b6a7a2a2..cf80c737 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_ai.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_ai.yml @@ -60,14 +60,15 @@ body: id: reproduction attributes: label: Minimal Reproduction Code - description: Include a snippet showing initAI() and parseAI() invocations. + description: Include a snippet showing initAI() and parseAI() invocations. DO NOT include real API keys or private endpoints! render: typescript placeholder: | import { Tempo } from '@magmacomputing/tempo'; import { parseAI, initAI } from '@magmacomputing/tempo-plugin-ai'; + // DO NOT INCLUDE REAL API KEYS OR PRIVATE ENDPOINTS BELOW initAI({ - providers: [{ id: 'openai', key: '...' }] + providers: [{ id: 'openai', key: 'YOUR_API_KEY_HERE' }] }); const res = await parseAI("The penultimate Tuesday before Thanksgiving in 2026"); diff --git a/packages/plugins/ai/CHANGELOG.md b/packages/plugins/ai/CHANGELOG.md index 97b934df..2c8e1e22 100644 --- a/packages/plugins/ai/CHANGELOG.md +++ b/packages/plugins/ai/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.2.0] - 2026-07-30 ### Added +- **Parallel Array Batching & Index Locking**: Concurrently processes array prompt inputs via `Promise.all` (or `Promise.allSettled` when `softErrors: true`) with guaranteed index-locked alignment between inputs and output arrays. +- **Multi-Stream Provider Execution Modes (`AiMode`)**: Added `mode` options (`AiMode.Fallback`, `AiMode.Race`, `AiMode.Consensus`) for fine-grained control over provider routing, speculative racing, and multi-LLM consensus verification with confidence boosting. +- **Runtime Configuration Module (`parseAI.config.ts`)**: Extracted `AiMode`, `RESERVED_PROVIDER_IDS`, and `DEFAULT_PROVIDERS` into a runtime module adhering to Tempo's monorepo `as const` object map and `Object.freeze()` immutability standards. +- **Secured `.ai` Metadata Interceptor**: Proxy-based `.ai` metadata injection for frozen `Tempo` instances, preserving private class field access while exposing provider resolution lineage, ambiguity metrics, and PII-isolated debugging fields (`debug: true`). +- **Semantic LLM Failure & Soft Error Handling**: Opt-in `softErrors: true` for returning `TempoAiError` objects directly within batch output arrays without throwing, and graceful mapping of LLM `"INVALID"` responses to `isValid = false` `Tempo` instances. - **Centralized Caching Integration**: Powered by core Tempo's centralized `BoundedCache` singleton (`Tempo.cache`) enforcing memory safety, capacity-bounded LRU eviction (`maxSize`), time-to-live expiration (`ttl`), and static immortal glossary isolation. - **Multi-Provider Fallback Routing**: Robust failover loop across configured LLM providers (`groq`, `openai`, `gemini`, `mistral`, or custom endpoints). Supports custom `tokenParam` mappings (`max_tokens` vs `max_completion_tokens`) and request timeout control via `AbortController`. - **Rate Limit Tracking (`getAiRateLimits`)**: Inspects provider HTTP response headers (`x-ratelimit-remaining-requests`, `x-ratelimit-remaining-tokens`, `x-ratelimit-reset-tokens`) and exposes real-time quota status via `getAiRateLimits()`, including a `resetAt` `Tempo` timestamp. diff --git a/packages/plugins/ai/package.json b/packages/plugins/ai/package.json index 170bc55a..a580adec 100644 --- a/packages/plugins/ai/package.json +++ b/packages/plugins/ai/package.json @@ -16,9 +16,9 @@ "access": "public" }, "scripts": { - "build": "tsup && tsc", + "build": "tsup", "test": "vitest run -c ../vitest.shared.ts", - "prepublishOnly": "npm run build" + "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build" }, "tempo": { "vendorVariantId": "tempo-plugin-ai", @@ -47,4 +47,4 @@ "import": "./dist/index.js" } } -} \ No newline at end of file +} diff --git a/packages/plugins/ai/src/core/config.ts b/packages/plugins/ai/src/core/config.ts new file mode 100644 index 00000000..0b22df0a --- /dev/null +++ b/packages/plugins/ai/src/core/config.ts @@ -0,0 +1,47 @@ +import type { AiProvider } from './types.js'; + +/** + * ## AiMode + * Execution modes across the provider farm: + * - `Fallback` ('fallback'): Sequential provider rotation until one succeeds. + * - `Race` ('race'): Concurrent speculative requests; returns fastest success. + * - `Consensus` ('consensus'): Concurrent requests across all providers with confidence voting. + */ +export const AiMode = Object.freeze({ + Fallback: 'fallback', + Race: 'race', + Consensus: 'consensus' +} as const); + +export type AiMode = (typeof AiMode)[keyof typeof AiMode]; + +/** + * Keywords reserved by parseAI to avoid provider configuration collisions. + */ +export const RESERVED_PROVIDER_IDS: ReadonlySet = new Set(['native', 'cache']); + +/** + * Built-in default endpoint and model configurations for popular providers. + */ +export const DEFAULT_PROVIDERS: Readonly>>> = Object.freeze({ + groq: Object.freeze({ + url: 'https://api.groq.com/openai/v1/chat/completions', + model: 'llama-3.3-70b-versatile', + tokenParam: 'max_tokens' + }), + openai: Object.freeze({ + url: 'https://api.openai.com/v1/chat/completions', + model: 'gpt-5.4-mini', + tokenParam: 'max_completion_tokens' + }), + gemini: Object.freeze({ + url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', + model: 'gemini-1.5-flash', + tokenParam: 'max_tokens' + }), + mistral: Object.freeze({ + url: 'https://api.mistral.ai/v1/chat/completions', + model: 'mistral-small-latest', + tokenParam: 'max_tokens' + }) +}); diff --git a/packages/plugins/ai/src/error.ts b/packages/plugins/ai/src/core/error.ts similarity index 88% rename from packages/plugins/ai/src/error.ts rename to packages/plugins/ai/src/core/error.ts index fcaecabc..a9f6f32f 100644 --- a/packages/plugins/ai/src/error.ts +++ b/packages/plugins/ai/src/core/error.ts @@ -2,7 +2,7 @@ import type { Tempo } from '@magmacomputing/tempo'; /** * ## TempoAiError - * A specialized Error thrown during AI-driven parsing when network fetches fail, + * A specialized Error thrown during AI-driven operations when network fetches fail, * timeouts occur, or rate limits are exceeded. */ export class TempoAiError extends Error { diff --git a/packages/plugins/ai/src/core/init.ts b/packages/plugins/ai/src/core/init.ts new file mode 100644 index 00000000..145effee --- /dev/null +++ b/packages/plugins/ai/src/core/init.ts @@ -0,0 +1,135 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { TempoAiError } from './error.js'; +import { RESERVED_PROVIDER_IDS, DEFAULT_PROVIDERS } from './config.js'; +import type { AiConfig, AiRateLimits, AiProvider } from './types.js'; +import { normalizeCacheInput } from './support.js'; + +export const _state: { + config: AiConfig; + limits: AiRateLimits | null; +} = { + config: {}, + limits: null, +}; + +export function initAI(config: AiConfig): void { + const resolvedProviders = config.providers ? config.providers.map(p => { + if (RESERVED_PROVIDER_IDS.has(p.id.toLowerCase())) { + throw new TempoAiError(`Provider ID '${p.id}' is a reserved keyword in parseAI.`, 400); + } + const defaults = DEFAULT_PROVIDERS[p.id] || DEFAULT_PROVIDERS.openai; + return { + ...defaults, + ...p + } as AiProvider; + }) : _state.config.providers; + + _state.config = { + ..._state.config, + ...config, + providers: resolvedProviders || [] + }; + + if (config.cache) { + Tempo.init({ cache: config.cache as any }); + } +} + +export function clearAiCache(input: string | string[]): void { + const inputs = Array.isArray(input) ? input : [input]; + for (const i of inputs) { + const normalized = normalizeCacheInput(i); + const prefix = `${normalized}::`; + Tempo.cache.delete(normalized); + Tempo.cache.delete(i); + Tempo.cache.deletePrefix(prefix); + } +} + +export function getAiRateLimits(): AiRateLimits | null { + return _state.limits; +} + +export function parseResetHeaderToTempo(resetHeader: string): Tempo | null { + const trimmed = resetHeader.trim(); + if (!trimmed) return null; + + // Case 1: Simple numeric string (seconds or epoch) + if (/^\d+(\.\d+)?$/.test(trimmed)) { + const val = parseFloat(trimmed); + if (Number.isNaN(val) || val < 0) return null; + if (val > 1000000000) { + try { + const t = new Tempo(val * 1000); + return t.isValid ? t : null; + } catch { + return null; + } + } + try { + const t = new Tempo().add(`${val} seconds`); + return t.isValid ? t : null; + } catch { + return null; + } + } + + // Case 2: Compound duration like '4m12s', '1h30m20s', '2m30.5s', '500ms' + const compoundRegex = /^(?:(\d+(?:\.\d+)?)d)?(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?$/i; + if (compoundRegex.test(trimmed)) { + const match = trimmed.match(compoundRegex); + if (match) { + const d = match[1] ? parseFloat(match[1]) : 0; + const h = match[2] ? parseFloat(match[2]) : 0; + const m = match[3] ? parseFloat(match[3]) : 0; + const s = match[4] ? parseFloat(match[4]) : 0; + const ms = match[5] ? parseFloat(match[5]) : 0; + + const totalMs = (d * 86400 + h * 3600 + m * 60 + s) * 1000 + ms; + if (totalMs <= 0 || Number.isNaN(totalMs)) return null; + + try { + const t = new Tempo().add(`${totalMs} milliseconds`); + return t.isValid ? t : null; + } catch { + return null; + } + } + } + + return null; +} + +export function parseRateLimitsFromResponse(response: Response): AiRateLimits | null { + const remReqHeader = response.headers.get('x-ratelimit-remaining-requests'); + const remTokHeader = response.headers.get('x-ratelimit-remaining-tokens'); + const resetTokHeader = response.headers.get('x-ratelimit-reset-tokens') + || response.headers.get('x-ratelimit-reset-requests') + || response.headers.get('retry-after'); + + if (remReqHeader === null && remTokHeader === null && resetTokHeader === null) + return null; + + const reqNum = remReqHeader !== null ? parseInt(remReqHeader, 10) : NaN; + const tokNum = remTokHeader !== null ? parseInt(remTokHeader, 10) : NaN; + + const parsedReq = Number.isNaN(reqNum) ? null : reqNum; + const parsedTok = Number.isNaN(tokNum) ? null : tokNum; + const resetAtTempo = resetTokHeader ? parseResetHeaderToTempo(resetTokHeader) : null; + + if (parsedReq === null && parsedTok === null && resetAtTempo === null) { + return null; + } + + return { + remainingRequests: parsedReq, + remainingTokens: parsedTok, + resetAt: resetAtTempo + }; +} + +export function updateRateLimitsFromResponse(response: Response): AiRateLimits | null { + const limits = parseRateLimitsFromResponse(response); + _state.limits = limits; + return limits; +} diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts new file mode 100644 index 00000000..a8f72414 --- /dev/null +++ b/packages/plugins/ai/src/core/support.ts @@ -0,0 +1,140 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { TempoAiError } from './error.js'; +import type { AiProvider, TempoAiMeta } from './types.js'; +import { updateRateLimitsFromResponse } from './init.js'; + +export function normalizeCacheInput(input: string): string { + return input.trim().toLowerCase().replace(/\s+/g, ' '); +} + +export function getNamespacedCacheKey(namespace: string, key: string): string { + return `ai:${namespace}::${key}`; +} + +export function attachAiMeta(instance: Tempo, meta: TempoAiMeta): Tempo { + const frozenMeta = Object.freeze(meta); + return new Proxy(instance, { + get(target, prop, receiver) { + if (prop === 'ai') return frozenMeta; + if (prop === 'isValid') { + if (meta.confidence === 0.0 || meta.rawIso === 'INVALID' || meta.ambiguous === true || !target.isValid) return false; + } + const val = Reflect.get(target, prop, target); + if (typeof val === 'function') return val.bind(target); + return val; + }, + has(target, prop) { + if (prop === 'ai') return true; + return Reflect.has(target, prop); + }, + getOwnPropertyDescriptor(target, prop) { + if (prop === 'ai') { + return { + value: frozenMeta, + writable: false, + configurable: false, + enumerable: true + }; + } + return Reflect.getOwnPropertyDescriptor(target, prop); + }, + ownKeys(target) { + const keys = Reflect.ownKeys(target); + if (!keys.includes('ai')) keys.push('ai'); + return keys; + } + }); +} + +export async function fetchFromProvider( + provider: AiProvider, + str: string, + contextString: string, + isDebug: boolean, + parentSignal?: AbortSignal +): Promise<{ rawContent: string; providerId: string }> { + const url = provider.url!; + const model = provider.model!; + + const systemPrompt = `You are a high-performance date parser. Read the user's string and the provided context. Return ONLY a valid JSON object matching this exact schema: +{ + "reasoning": "Step-by-step calendar math from Current Time.", + "iso": "Local ISO 8601 string (YYYY-MM-DDThh:mm:ss) without offset or Z suffix, or 'INVALID' if ambiguous/unparseable.", + "confidence": 0.95, + "ambiguous": false, + "granularity": "minute" +} + +Ambiguity Rules: +- "next [weekday/unit]": Immediate next chronological occurrence after Current Time. +- "last [weekday/unit]" / "previous [weekday/unit]": Most recent past occurrence prior to Current Time. +- "this [weekday]": Occurrence in the current calendar week containing Current Time. +- "confidence": Float score between 0.0 (gibberish/unparseable) and 1.0 (100% certain). +- "granularity": Primary time precision level ('year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'unknown'). + +Do not include markdown blocks or any text outside the JSON.`; + + if (isDebug) + console.log(`[tempo-plugin-ai] Sending to ${provider.id}:`, { system: `${systemPrompt}\n${contextString}`, user: str }); + + const tokenParam = provider.tokenParam + || (provider.options?.max_completion_tokens !== undefined ? 'max_completion_tokens' : undefined) + || (provider.options?.max_tokens !== undefined ? 'max_tokens' : undefined) + || 'max_tokens'; + const tokenLimit = { [tokenParam]: 250 }; + + const controller = new AbortController(); + const timeoutMs = provider.options?.timeout ?? 15000; + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const onParentAbort = () => controller.abort(); + if (parentSignal) { + if (parentSignal.aborted) controller.abort(); + else parentSignal.addEventListener('abort', onParentAbort); + } + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${provider.key}` + }, + body: JSON.stringify({ + model: model, + messages: [ + { role: 'system', content: `${systemPrompt}\n${contextString}` }, + { role: 'user', content: str } + ], + temperature: 0, + ...tokenLimit, + response_format: { type: 'json_object' }, + ...provider.options + }), + signal: controller.signal + }); + + const limits = updateRateLimitsFromResponse(response); + + if (!response.ok) { + const errorText = await response.text(); + const resetTime = limits?.resetAt ?? undefined; + throw new TempoAiError(`Provider ${provider.id} failed with status ${response.status}. Details: ${errorText}`, response.status, resetTime); + } + + const data = await response.json(); + const rawContent = data?.choices?.[0]?.message?.content; + if (typeof rawContent !== 'string') + throw new TempoAiError(`Provider ${provider.id} returned invalid response payload.`, 422); + + if (isDebug) + console.log(`[tempo-plugin-ai] Received from ${provider.id}:`, rawContent); + + return { rawContent: rawContent.trim(), providerId: provider.id }; + } finally { + clearTimeout(timeoutId); + if (parentSignal) { + parentSignal.removeEventListener('abort', onParentAbort); + } + } +} diff --git a/packages/plugins/ai/src/core/types.ts b/packages/plugins/ai/src/core/types.ts new file mode 100644 index 00000000..83e5e1f6 --- /dev/null +++ b/packages/plugins/ai/src/core/types.ts @@ -0,0 +1,113 @@ +import type { Tempo } from '@magmacomputing/tempo'; +import type { AiMode } from './config.js'; + +/** + * ## TempoAiMeta + * Frozen metadata object attached to Tempo instances produced by `parseAI`. + */ +export interface TempoAiMeta { + /** Resolution source ('native', 'cache', or provider ID like 'groq', 'openai', 'ollama') */ + readonly provider: string; + /** Whether the result was retrieved from cache */ + readonly cached: boolean; + /** Confidence rating from 0.0 (unparseable) to 1.0 (certain) */ + readonly confidence: number; + /** Whether the input prompt had multiple possible interpretations */ + readonly ambiguous: boolean; + /** Granularity level of the parsed date ('year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'unknown') */ + readonly granularity: string; + /** Raw un-augmented ISO 8601 string returned by the LLM (if applicable) */ + readonly rawIso?: string | undefined; + /** Step-by-step calendar math reasoning (included when debug: true or when provided by LLM) */ + readonly reasoning?: string | undefined; + /** Raw prompt input (only included when debug: true) */ + readonly rawPrompt?: string | undefined; + /** Normalized prompt input (only included when debug: true) */ + readonly normalizedPrompt?: string | undefined; +} + +declare module '@magmacomputing/tempo' { + interface Tempo { + /** Frozen AI resolution metadata attached when parsed via parseAI */ + ai?: TempoAiMeta | undefined; + } +} + +/** + * ## AiProvider + * Represents an LLM provider and its respective BYOK API key. + */ +export interface AiProvider { + /** The provider identifier (e.g., 'groq', 'gemini', 'openai', 'mistral', 'custom') */ + id: string; + /** The raw API key for the respective provider */ + key: string; + /** Optional custom API endpoint URL (e.g., for local Ollama or Azure OpenAI) */ + url?: string; + /** Optional custom model identifier (e.g., to override the provider's default model) */ + model?: string; + /** Optional parameter name for max token limit (e.g. 'max_tokens' or 'max_completion_tokens') */ + tokenParam?: string | undefined; + /** Optional LLM parameters (e.g. temperature, max_tokens, top_p) */ + options?: Record; +} + +/** + * ## AiParseOptions + * Options passed to `parseAI(input, options)`. + */ +export interface AiParseOptions { + /** Reference anchor date/time instance or string */ + anchor?: any; + /** Target timeZone override */ + timeZone?: string; + /** Target calendar override */ + calendar?: string; + /** Target locale override */ + locale?: string; + /** Target sphere override */ + sphere?: string; + /** If true, bypasses cache and native parsing to force an LLM fetch */ + force?: boolean; + /** If false, disables reading and writing to cache */ + cache?: boolean; + /** If true, logs prompt context and LLM payloads to console */ + debug?: boolean; + /** Execution mode across provider farm (`AiMode.Fallback` | `AiMode.Race` | `AiMode.Consensus` or string literal) */ + mode?: AiMode; + /** Per-request provider configuration overrides */ + providers?: AiProvider[]; + /** Strict minimum confidence threshold (0.0 to 1.0). Throws TempoAiError(422) if score is lower */ + minConfidence?: number; + /** If true, places TempoAiError into array index position instead of rejecting batch */ + softErrors?: boolean; + /** Allow extra options */ + [key: string]: any; +} + +/** + * ## AiConfig + * Configuration options for the AI parsing plugin. + */ +export interface AiConfig { + /** An array of fallback providers to use for routing */ + providers?: AiProvider[] | undefined; + /** Optional custom cache implementation for storing parsed strings */ + cache?: Map | undefined; + /** If true, logs the spoon-fed LLM context prompt and raw LLM response to the console */ + debug?: boolean | undefined; +} + +/** + * ## AiRateLimits + * Exposes the rate limit and billing statistics returned in the HTTP headers + * of the most recent LLM proxy request. + */ +export interface AiRateLimits { + /** Number of remaining requests allowed in the current time window, or null if unknown */ + remainingRequests: number | null; + /** Number of remaining tokens allowed in the current time window, or null if unknown */ + remainingTokens: number | null; + /** A Tempo instance representing the exact time the limits reset, or null if unknown */ + resetAt: Tempo | null; +} diff --git a/packages/plugins/ai/src/functions/context.ts b/packages/plugins/ai/src/functions/context.ts new file mode 100644 index 00000000..fea5bff1 --- /dev/null +++ b/packages/plugins/ai/src/functions/context.ts @@ -0,0 +1,27 @@ +export interface TempoContext { + timeZone: string; + locale: string; + calendar: string; + confidence?: number; +} + +/** + * ## contextAI (Upcoming Export) + * Infers `timeZone`, `locale`, and preferred `calendar` system from ambiguous location strings, + * user bios, or context descriptions. + * + * ### Why it fits Tempo: + * Integrates directly into Tempo's core configuration parameters (`timeZone`, `locale`, `calendar`). + * + * ### Example Usage: + * ```ts + * const context = await contextAI('We are meeting near Shibuya Station during Golden Week'); + * // returns: { timeZone: 'Asia/Tokyo', locale: 'ja-JP', calendar: 'gregory' } + * ``` + */ +export async function contextAI(_text: string, _options?: Record): Promise { + throw new Error('contextAI is not yet implemented in tempo-plugin-ai.'); +} + +/** Alias for contextAI */ +export const inferContextAI = contextAI; diff --git a/packages/plugins/ai/src/functions/diff.ts b/packages/plugins/ai/src/functions/diff.ts new file mode 100644 index 00000000..03e5fb18 --- /dev/null +++ b/packages/plugins/ai/src/functions/diff.ts @@ -0,0 +1,31 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +export interface TempoAiDiffResult { + formatted: string; + days?: number; + hours?: number; + businessDays?: number; + reasoning?: string; +} + +/** + * ## diffAI (Upcoming Export) + * Expresses the delta between two timestamps or `Tempo` instances in human, business, + * or operational terms. + * + * ### Why it fits Tempo: + * Fills the gap between raw numeric milliseconds/days calculations in `Tempo.diff()` + * and domain-specific human summaries (accounting, shipping SLAs, project planning). + * + * ### Example Usage: + * ```ts + * const start = new Tempo('2026-08-01T09:00:00'); + * const end = new Tempo('2026-08-10T17:00:00'); + * + * const diff = await diffAI(start, end, 'explain in terms of business working days excluding weekends'); + * // returns: { formatted: "6 business days (48 working hours)", businessDays: 6 } + * ``` + */ +export async function diffAI(_start: any, _end: any, _prompt?: string, _options?: Record): Promise { + throw new Error('diffAI is not yet implemented in tempo-plugin-ai.'); +} diff --git a/packages/plugins/ai/src/functions/extract.ts b/packages/plugins/ai/src/functions/extract.ts new file mode 100644 index 00000000..106b9ed4 --- /dev/null +++ b/packages/plugins/ai/src/functions/extract.ts @@ -0,0 +1,28 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +export interface TempoEvent { + label: string; + start: Tempo; + end?: Tempo | undefined; + type?: 'event' | 'deadline' | 'milestone' | 'reminder' | string; +} + +/** + * ## extractAI (Upcoming Export) + * Scans unstructured text (emails, transcripts, task notes) and extracts all + * embedded temporal entities, deadlines, and events into structured `TempoEvent` records. + * + * ### Why it fits Tempo: + * Essential for calendar apps and document processing workflows where temporal references + * are buried inside unstructured text. + * + * ### Example Usage: + * ```ts + * const text = "Let's meet tomorrow at 10am. Final deliverables due next Friday EOD."; + * const events = await extractAI(text, { anchor: new Tempo() }); + * // returns array of TempoEvent records with parsed Tempo instances + * ``` + */ +export async function extractAI(_text: string, _options?: Record): Promise { + throw new Error('extractAI is not yet implemented in tempo-plugin-ai.'); +} diff --git a/packages/plugins/ai/src/functions/format.ts b/packages/plugins/ai/src/functions/format.ts new file mode 100644 index 00000000..f09ad0f0 --- /dev/null +++ b/packages/plugins/ai/src/functions/format.ts @@ -0,0 +1,22 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +/** + * ## formatAI (Upcoming Export) + * Formats a `Tempo` instance into human-friendly, contextual narrative text + * tailored to specific UI tones, relative time frames, or business domains. + * + * ### Why it fits Tempo: + * Expands core `.format('{yyyy}-{mm}-{dd}')` into contextual, localized human + * descriptions that token patterns alone cannot capture. + * + * ### Example Usage: + * ```ts + * const t = new Tempo('2026-08-07T17:00:00[America/New_York]'); + * + * // "this Friday at 5:00 PM EST (in 5 days)" + * const friendly = await formatAI(t, 'friendly reminder tone with relative countdown'); + * ``` + */ +export async function formatAI(_tempo: Tempo, _prompt: string, _options?: Record): Promise { + throw new Error('formatAI is not yet implemented in tempo-plugin-ai.'); +} diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts new file mode 100644 index 00000000..7cb9abaa --- /dev/null +++ b/packages/plugins/ai/src/functions/parse.ts @@ -0,0 +1,257 @@ +import { Tempo } from '@magmacomputing/tempo'; +import { TempoAiError } from '../core/error.js'; +import { AiMode, RESERVED_PROVIDER_IDS } from '../core/config.js'; +import type { AiParseOptions } from '../core/types.js'; +import { _state } from '../core/init.js'; +import { normalizeCacheInput, attachAiMeta, fetchFromProvider } from '../core/support.js'; + +async function parseSingleInput(str: string, options?: AiParseOptions): Promise { + const isDebug = options?.debug ?? _state.config.debug ?? false; + const normalizedStr = normalizeCacheInput(str); + + const { force, debug, mode: aiMode, providers, minConfidence, softErrors, cache: aiCacheOption, ...coreOptions } = options || {}; + + let tz: string, cal: string, loc: string, sph: string, anchorStr: string; + if (Tempo.isTempo(options?.anchor)) { + tz = String(options!.timeZone || options!.anchor.config.timeZone); + cal = String(options!.calendar || options!.anchor.config.calendar); + loc = String(Array.isArray(options!.locale) ? options!.locale[0] : (options!.locale || options!.anchor.config.locale)); + sph = String(options!.sphere || options!.anchor.config.sphere || 'north'); + anchorStr = options!.anchor.toString(); + } else { + const resolvedOptions = Tempo.options; + tz = String(options?.timeZone || resolvedOptions.timeZone); + cal = String(options?.calendar || resolvedOptions.calendar); + loc = String(Array.isArray(options?.locale) ? options?.locale[0] : (options?.locale || resolvedOptions.locale)); + sph = String(options?.sphere || resolvedOptions.sphere || 'north'); + anchorStr = String(options?.anchor || new Tempo().toString()); + } + + const anchorTempo = new Tempo(anchorStr, { ...coreOptions, timeZone: tz, calendar: cal, locale: loc, sphere: sph as any }); + const cacheSalt = anchorTempo.format('{yyyy}-{mm}-{dd}'); + const cacheKey = `${normalizedStr}::${cacheSalt}::${tz}::${cal}::${loc}::${sph}`; + + let cachedIso: string | undefined; + if (!force && aiCacheOption !== false) { + if (Tempo.cache.has(cacheKey)) { + cachedIso = Tempo.cache.get(cacheKey); + } else if (Tempo.cache.has(normalizedStr)) { + cachedIso = Tempo.cache.get(normalizedStr); + } else if (Tempo.cache.has(str)) { + cachedIso = Tempo.cache.get(str); + } + } + + if (cachedIso) { + if (isDebug) console.log(`[tempo-plugin-ai] Cache hit for "${str}":`, cachedIso); + const cachedInstance = new Tempo(cachedIso, coreOptions); + return attachAiMeta(cachedInstance, { + provider: 'cache', + cached: true, + confidence: 1.0, + ambiguous: false, + granularity: 'day', + rawIso: cachedIso, + rawPrompt: isDebug ? str : undefined, + normalizedPrompt: isDebug ? normalizedStr : undefined + }); + } + + if (!force) { + try { + const native = new Tempo(str, { ...coreOptions, silent: true }); + const internal = (native as any)[Symbol.for('$Tempo.internal')] || (native as any).$Internal?.(); + const hasNativeMatches = (internal?.matches && internal.matches.length > 0) + || /^\d{4}-\d{2}-\d{2}/.test(str.trim()); + + if (native.isValid && hasNativeMatches) { + if (isDebug) console.log(`[parseAI] Resolved natively: "${str}"`); + return attachAiMeta(native, { + provider: 'native', + cached: false, + confidence: 1.0, + ambiguous: false, + granularity: 'day', + rawPrompt: isDebug ? str : undefined, + normalizedPrompt: isDebug ? normalizedStr : undefined + }); + } + } catch { + // Fallback + } + } + + const contextString = `Current Time: ${anchorTempo.format('{yyyy}-{mm}-{dd} ({wkd}) {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); + } + + for (const p of availableProviders) { + if (RESERVED_PROVIDER_IDS.has(p.id.toLowerCase())) { + throw new TempoAiError(`Provider ID '${p.id}' is a reserved keyword in parseAI.`, 400); + } + } + + const mode = aiMode || AiMode.Fallback; + let successfulResult: { parsedData: any; providerId: string } | null = null; + + if (mode === AiMode.Fallback) { + let lastError: any = null; + let bestCandidate: { parsedData: any; providerId: string } | null = null; + + for (const provider of availableProviders) { + try { + const { rawContent, providerId } = await fetchFromProvider(provider, str, contextString, isDebug); + const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); + const parsedData = JSON.parse(cleanContent); + + const candidateConfidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : (parsedData?.iso === 'INVALID' ? 0.0 : 1.0); + + if (!bestCandidate || candidateConfidence > (bestCandidate.parsedData?.confidence ?? 0)) { + bestCandidate = { parsedData, providerId }; + } + + if (minConfidence === undefined || candidateConfidence >= minConfidence) { + successfulResult = { parsedData, providerId }; + break; + } + + if (isDebug) { + console.log(`[parseAI] Provider ${providerId} confidence (${candidateConfidence}) below minConfidence (${minConfidence}). Cascading to next provider...`); + } + } catch (err: any) { + lastError = err; + if (err instanceof TempoAiError && err.code === 422 && minConfidence === undefined) break; + } + } + + if (!successfulResult) { + if (bestCandidate) { + successfulResult = bestCandidate; + } else { + throw lastError || new TempoAiError('All configured AI providers failed.', 500); + } + } + + } else if (mode === AiMode.Race) { + const parentController = new AbortController(); + try { + const promises = availableProviders.map(async (provider) => { + const { rawContent, providerId } = await fetchFromProvider(provider, str, contextString, isDebug, parentController.signal); + const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); + return { parsedData: JSON.parse(cleanContent), providerId }; + }); + 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 } = await fetchFromProvider(provider, str, contextString, isDebug); + const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); + return { parsedData: JSON.parse(cleanContent), providerId }; + }); + + const settled = await Promise.allSettled(promises); + const fulfilled = settled + .filter((s): s is PromiseFulfilledResult<{ parsedData: any; providerId: string }> => 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: 'consensus' + }; + } 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 + }; + } + } + } + + const { parsedData, providerId } = successfulResult!; + const rawIso = typeof parsedData?.iso === 'string' ? parsedData.iso : 'INVALID'; + const confidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : (rawIso === 'INVALID' ? 0.0 : 1.0); + const ambiguous = Boolean(parsedData?.ambiguous || rawIso === 'INVALID'); + const granularity = typeof parsedData?.granularity === 'string' ? parsedData.granularity : 'unknown'; + const reasoning = typeof parsedData?.reasoning === 'string' ? parsedData.reasoning : undefined; + + const isBelowMinConfidence = minConfidence !== undefined && confidence < minConfidence; + + if (rawIso === 'INVALID' || isBelowMinConfidence) { + const invalidInstance = new Tempo('INVALID', { ...coreOptions, catch: true }); + return attachAiMeta(invalidInstance, { + provider: providerId, + cached: false, + confidence, + ambiguous: true, + granularity, + rawIso: rawIso === 'INVALID' ? 'INVALID' : rawIso, + reasoning: isDebug ? reasoning : undefined, + rawPrompt: isDebug ? str : undefined, + normalizedPrompt: isDebug ? normalizedStr : undefined + }); + } + + const parsedIso = `${rawIso.replace(/Z$/i, '')}[${tz}]`; + + if (aiCacheOption !== false) { + Tempo.cache.set(cacheKey, parsedIso); + } + + const finalInstance = new Tempo(parsedIso, coreOptions); + return attachAiMeta(finalInstance, { + provider: providerId, + cached: false, + confidence, + ambiguous, + granularity, + rawIso, + reasoning: isDebug ? reasoning : undefined, + rawPrompt: isDebug ? str : undefined, + normalizedPrompt: isDebug ? normalizedStr : undefined + }); +} + +export async function parseAI(input: string, options?: AiParseOptions): Promise; +export async function parseAI(input: string[], options?: AiParseOptions): Promise<(Tempo | TempoAiError)[]>; +export async function parseAI( + input: string | string[], + options?: AiParseOptions +): Promise { + if (Array.isArray(input)) { + if (options?.softErrors) { + const settled = await Promise.allSettled(input.map(str => parseSingleInput(str, options))); + return settled.map(s => s.status === 'fulfilled' ? s.value : (s.reason as TempoAiError)); + } + return Promise.all(input.map(str => parseSingleInput(str, options))); + } + + return parseSingleInput(input, options); +} diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts new file mode 100644 index 00000000..21f6d2e5 --- /dev/null +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -0,0 +1,27 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +export interface TempoRecurrenceRule { + rrule: string; + next(count?: number): Tempo[]; +} + +/** + * ## recurrenceAI (Upcoming Export) + * Translates natural language descriptions of complex repeating schedules into + * structured RRULE strings and `Tempo` date generators. + * + * ### Why it fits Tempo: + * RRULE strings are notoriously complex to craft manually. `recurrenceAI` turns plain text + * into deterministic `Tempo` date sequences. + * + * ### Example Usage: + * ```ts + * const rule = await recurrenceAI('every 2nd and 4th Thursday of the month except company holidays', { + * timeZone: 'Europe/London' + * }); + * const nextDates = rule.next(5); // Returns array of 5 upcoming Tempo instances + * ``` + */ +export async function recurrenceAI(_prompt: string, _options?: Record): Promise { + throw new Error('recurrenceAI is not yet implemented in tempo-plugin-ai.'); +} diff --git a/packages/plugins/ai/src/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts new file mode 100644 index 00000000..556e5caf --- /dev/null +++ b/packages/plugins/ai/src/functions/schedule.ts @@ -0,0 +1,26 @@ +import type { Tempo } from '@magmacomputing/tempo'; + +export interface TempoInterval { + start: Tempo; + end: Tempo; +} + +/** + * ## scheduleAI (Upcoming Export) + * Resolves natural language scheduling prompts against working hours, existing calendar + * events, and timezones into an optimal start/end `Tempo` interval. + * + * ### Why it fits Tempo: + * Solves non-trivial calendar slot finding while producing strongly typed `Tempo` interval boundaries. + * + * ### Example Usage: + * ```ts + * const slot = await scheduleAI('Find 45 minutes next Tuesday afternoon after 2pm PST excluding lunch', { + * workingHours: { start: '09:00', end: '17:00', timeZone: 'America/Los_Angeles' } + * }); + * console.log(slot.start.toString()); // "2026-08-04T14:15:00[America/Los_Angeles]" + * ``` + */ +export async function scheduleAI(_prompt: string, _options?: Record): Promise { + throw new Error('scheduleAI is not yet implemented in tempo-plugin-ai.'); +} diff --git a/packages/plugins/ai/src/index.ts b/packages/plugins/ai/src/index.ts index 467be452..fccea421 100644 --- a/packages/plugins/ai/src/index.ts +++ b/packages/plugins/ai/src/index.ts @@ -1,352 +1,34 @@ -import { Tempo } from '@magmacomputing/tempo'; - -import { TempoAiError } from './error.js'; -export { TempoAiError } from './error.js'; - -export * from './parseAI.type.js'; -import type { AiConfig, AiRateLimits, AiProvider } from './parseAI.type.js'; - -// Global module state -const _state: { - config: AiConfig; - limits: AiRateLimits | null; -} = { - config: {}, - limits: null, -} - -const DEFAULT_PROVIDERS: Record> = { - groq: { - url: 'https://api.groq.com/openai/v1/chat/completions', - model: 'llama-3.3-70b-versatile', - tokenParam: 'max_tokens' - }, - openai: { - url: 'https://api.openai.com/v1/chat/completions', - model: 'gpt-5.4-mini', - tokenParam: 'max_completion_tokens' - }, - gemini: { - url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', - model: 'gemini-1.5-flash', - tokenParam: 'max_tokens' - }, - mistral: { - url: 'https://api.mistral.ai/v1/chat/completions', - model: 'mistral-small-latest', - tokenParam: 'max_tokens' - } -} - -/** - * ## initAI - * Initializes the global AI Plugin configuration. - * Must be called before executing `parseAI`. - * - * @param config - The plugin configuration (providers and optional cache) - */ -export function initAI(config: AiConfig): void { - const resolvedProviders = config.providers ? config.providers.map(p => { - const defaults = DEFAULT_PROVIDERS[p.id] || DEFAULT_PROVIDERS.openai; - return { - ...defaults, - ...p - } as AiProvider; - }) : _state.config.providers; - - _state.config = { - ..._state.config, - ...config, - providers: resolvedProviders || [] - }; - - if (config.cache) { - Tempo.init({ cache: config.cache as any }); - } -} - -/** - * Helper to normalize string input for cache key matching. +// Core Infrastructure & Configuration +export { TempoAiError } from './core/error.js'; +export * from './core/types.js'; +export * from './core/config.js'; +export { initAI, clearAiCache, getAiRateLimits } from './core/init.js'; + +// AI Function Handlers +export { parseAI } from './functions/parse.js'; + +/* + * ============================================================================ + * Upcoming AI Function Exports (Scaffolded for Future Releases) + * ============================================================================ + * The following exports lay the groundwork for expanding tempo-plugin-ai. + * Uncomment these exports as their implementations are finalized. */ -function normalizeCacheInput(input: string): string { - return input.trim().toLowerCase().replace(/\s+/g, ' '); -} - -/** - * ## clearAiCache - * Explicitly evicts a natural language key or array of keys from the Tempo cache. - * Useful for purging incorrectly parsed strings. - * - * @param input - The raw natural language string(s) to remove from the cache - */ -export function clearAiCache(input: string | string[]): void { - const inputs = Array.isArray(input) ? input : [input]; - for (const i of inputs) { - const normalized = normalizeCacheInput(i); - const prefix = `${normalized}::`; - Tempo.cache.delete(normalized); - Tempo.cache.delete(i); - Tempo.cache.deletePrefix(prefix); - } -} - -/** - * ## getAiRateLimits - * Retrieves the rate limit and billing statistics from the most recent LLM proxy request. - * Useful for tracking quota usage and safely scheduling batch operations. - * - * @returns The current rate limit state, or null if no requests have been made - */ -export function getAiRateLimits(): AiRateLimits | null { - return _state.limits; -} - -/** - * ## parseAI - * Asynchronously parses a complex natural language string (or array of strings) - * into deterministic `Tempo` instances utilizing large language models. - * - * It automatically extracts the global configuration (TimeZone, Calendar, Locale) - * and custom Terms to build a rich context prompt for the LLM. It includes built-in - * caching to prevent redundant requests and reduce token consumption. - * - * @param input - The natural language string or array of strings to parse - * @param options - Optional configuration overrides (identical to `new Tempo(..., options)`) - * @returns A Promise that resolves to a `Tempo` instance (or `Tempo[]` if an array was passed) - */ -export async function parseAI(input: string, options?: Record & { force?: boolean; cache?: boolean }): Promise; -export async function parseAI(input: string[], options?: Record & { force?: boolean; cache?: boolean }): Promise; -export async function parseAI( - input: string | string[], - options?: Record & { force?: boolean; cache?: boolean } -): Promise { - const isArray = Array.isArray(input); - const inputs = isArray ? input : [input]; - const results: Tempo[] = []; - - for (const str of inputs) { - const isDebug = options?.debug ?? _state.config.debug; - - // 1. Try native ParseModule first (silently!) - if (!options?.force) { - try { - const native = new Tempo(str, { ...options, silent: true }); - if (native.isValid) { - if (isDebug) console.log(`[parseAI] Resolved natively: "${str}"`); - results.push(native); - continue; - } - } catch { - // Native parsing failed, fallback to AI - } - } - - // 2. Establish Anchor for Cache & Context - let tz: string, cal: string, loc: string, sph: string, anchorStr: string; - if (Tempo.isTempo(options?.anchor)) { - tz = options!.timeZone || options!.anchor.config.timeZone; - cal = options!.calendar || options!.anchor.config.calendar; - loc = options!.locale || options!.anchor.config.locale; - sph = options!.sphere || options!.anchor.config.sphere; - anchorStr = options!.anchor.toString(); - } else { - const resolvedOptions = Tempo.options; - tz = options?.timeZone || resolvedOptions.timeZone; - cal = options?.calendar || resolvedOptions.calendar; - loc = options?.locale || resolvedOptions.locale; - sph = options?.sphere || resolvedOptions.sphere; - anchorStr = options?.anchor || new Tempo().toString(); - } - - // Establish single anchor Tempo instance for cache salting and context prompt - const anchorTempo = new Tempo(anchorStr, { ...options, timeZone: tz, calendar: cal, locale: loc, sphere: sph }); - - // The cache key salts the normalized string with the anchor's Calendar Date and resolved context (TZ/Cal/Loc/Sph). - // This allows "tomorrow" to hit the cache all day, but cleanly miss when midnight strikes or context changes! - const normalizedStr = normalizeCacheInput(str); - const cacheSalt = anchorTempo.format('{yyyy}-{mm}-{dd}'); - const cacheKey = `${normalizedStr}::${cacheSalt}::${tz}::${cal}::${loc}::${sph}`; - - // 3. Check Cache (Two-Tier Lookup: Date-Salted Key first, then Un-Salted Normalized Key) - let cachedIso: string | undefined; - if (!options?.force && options?.cache !== false) { - if (Tempo.cache.has(cacheKey)) { - cachedIso = Tempo.cache.get(cacheKey); - } else if (Tempo.cache.has(normalizedStr)) { - cachedIso = Tempo.cache.get(normalizedStr); - } else if (Tempo.cache.has(str)) { - cachedIso = Tempo.cache.get(str); - } - } - - if (cachedIso) { - if (isDebug) console.log(`[tempo-plugin-ai] Cache hit for "${str}":`, cachedIso); - results.push(new Tempo(cachedIso, options)); - continue; - } - - // 4. Construct LLM Context - let contextString = `Current Time: ${anchorTempo.format('{yyyy}-{mm}-{dd} ({wkd}) {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${loc}, Hemisphere: ${sph}.`; - - // 5. LLM Network Fetch with Fallback Loop - if (!_state.config.providers || _state.config.providers.length === 0) - throw new TempoAiError('No AI providers configured. Please call initAI().', 400); - - let parsedIso: string | null = null; - let lastError: any = null; - - for (const provider of _state.config.providers) { - try { - const url = provider.url!; - const model = provider.model!; - - const systemPrompt = `You are a high-performance date parser. Read the user's string and the provided context. Return ONLY a valid JSON object matching this exact schema: -{ - "reasoning": "Step-by-step calendar math from the Current Time to determine the target date.", - "iso": "The final local ISO 8601 string (e.g., YYYY-MM-DDThh:mm:ss) WITHOUT a timezone offset or 'Z' suffix, or 'INVALID' if ambiguous." -} - -Ambiguity Rules: -- "next [weekday/unit]": Evaluate as the immediate next chronological occurrence after Current Time. -- "last [weekday/unit]" / "previous [weekday/unit]": Evaluate as the most recent past occurrence prior to Current Time. -- "this [weekday]": Evaluate as the occurrence of that weekday in the current calendar week containing Current Time. - -Do not include markdown blocks, explanations, or any text outside the JSON.`; - - if (isDebug) - console.log(`[tempo-plugin-ai] Sending to ${provider.id}:`, { system: `${systemPrompt}\n${contextString}`, user: str }); - - const tokenParam = provider.tokenParam - || (provider.options?.max_completion_tokens !== undefined ? 'max_completion_tokens' : undefined) - || (provider.options?.max_tokens !== undefined ? 'max_tokens' : undefined) - || 'max_tokens'; - const tokenLimit = { [tokenParam]: 250 }; - - const controller = new AbortController(); - const timeoutMs = provider.options?.timeout ?? 15000; - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - - let response: Response; - try { - response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${provider.key}` - }, - body: JSON.stringify({ - model: model, - messages: [ - { role: 'system', content: `${systemPrompt}\n${contextString}` }, - { role: 'user', content: str } - ], - temperature: 0, - ...tokenLimit, - response_format: { type: "json_object" }, - ...provider.options - }), - signal: controller.signal - }); - } catch (fetchErr: any) { - lastError = fetchErr; - if (isDebug) console.warn(`[tempo-plugin-ai] Provider ${provider.id} fetch failed or timed out:`, fetchErr?.message || fetchErr); - continue; - } finally { - clearTimeout(timeoutId); - } - - // Parse rate limits from headers - const remReqHeader = response.headers.get('x-ratelimit-remaining-requests'); - const remTokHeader = response.headers.get('x-ratelimit-remaining-tokens'); - const resetTokHeader = response.headers.get('x-ratelimit-reset-tokens'); - - if (remReqHeader !== null || remTokHeader !== null || resetTokHeader !== null) { - const reqNum = remReqHeader !== null ? parseInt(remReqHeader, 10) : NaN; - const tokNum = remTokHeader !== null ? parseInt(remTokHeader, 10) : NaN; - - const parsedReq = Number.isNaN(reqNum) ? null : reqNum; - const parsedTok = Number.isNaN(tokNum) ? null : tokNum; - - let resetAtTempo: Tempo | null = null; - if (resetTokHeader) { - const val = parseFloat(resetTokHeader); - if (!Number.isNaN(val)) { - let addString = `${val} seconds`; - if (resetTokHeader.endsWith('ms')) addString = `${val} milliseconds`; - else if (resetTokHeader.endsWith('s')) addString = `${val} seconds`; - else if (resetTokHeader.endsWith('m')) addString = `${val} minutes`; - - try { - const t = new Tempo().add(addString); - if (t.isValid) resetAtTempo = t; - } catch { - resetAtTempo = null; - } - } - } - - if (parsedReq !== null || parsedTok !== null || resetAtTempo !== null) { - _state.limits = { - remainingRequests: parsedReq, - remainingTokens: parsedTok, - resetAt: resetAtTempo - }; - } - } - - if (!response.ok) { - const errorText = await response.text(); - const resetTime = _state.limits?.resetAt ?? undefined; - throw new TempoAiError(`Provider ${provider.id} failed with status ${response.status}. Details: ${errorText}`, response.status, resetTime); - } - - const data = await response.json(); - const rawContent = data?.choices?.[0]?.message?.content; - if (typeof rawContent !== 'string') - throw new TempoAiError(`Provider ${provider.id} returned invalid or missing response content payload.`, 422); - - const content = rawContent.trim(); - - if (isDebug) - console.log(`[tempo-plugin-ai] Received from ${provider.id}:`, content); - - let parsedData: any; - try { - const cleanContent = content.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); - parsedData = JSON.parse(cleanContent); - } catch { - throw new TempoAiError('AI returned invalid JSON.', 422); - } - - const isoContent = parsedData?.iso; - - if (typeof isoContent !== 'string') - throw new TempoAiError('AI returned a payload missing the "iso" string field.', 422); - if (isoContent === 'INVALID') - throw new TempoAiError('AI could not parse the string.', 422); +// /** Formats a Tempo instance into human-friendly, contextual narrative text */ +// export { formatAI } from './functions/format.js'; - parsedIso = `${isoContent.replace(/Z$/i, '')}[${tz}]`; - break; // Success! Break the fallback loop - } catch (err: any) { - lastError = err; - // If it's a rate limit or timeout, the loop naturally continues to the next provider - if (err instanceof TempoAiError && err.code === 422) - // If the AI explicitly says INVALID, don't waste tokens asking the next provider - break; - } - } +// /** Scans unstructured text and extracts embedded temporal entities & events */ +// export { extractAI, type TempoEvent } from './functions/extract.js'; - if (!parsedIso) { - throw lastError || new TempoAiError('All configured AI providers failed.', 500); - } +// /** Expresses the delta between two dates in human, business, or operational terms */ +// export { diffAI, type TempoAiDiffResult } from './functions/diff.js'; - // 6. Cache result and push - if (options?.cache !== false) - Tempo.cache.set(cacheKey, parsedIso); +// /** Resolves natural language scheduling prompts into optimal Tempo intervals */ +// export { scheduleAI, type TempoInterval } from './functions/schedule.js'; - results.push(new Tempo(parsedIso, options)); - } +// /** Translates natural language descriptions of repeating schedules into RRULEs */ +// export { recurrenceAI, type TempoRecurrenceRule } from './functions/recurrence.js'; - return isArray ? results : results[0]; -} +// /** 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/parseAI.type.ts b/packages/plugins/ai/src/parseAI.type.ts deleted file mode 100644 index 9faae74a..00000000 --- a/packages/plugins/ai/src/parseAI.type.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Tempo } from '@magmacomputing/tempo'; - -/** - * ## AiProvider - * Represents an LLM provider and its respective BYOK API key. - */ -export interface AiProvider { - /** The provider identifier (e.g., 'groq', 'gemini', 'openai', 'mistral', 'custom') */ - id: string; - /** The raw API key for the respective provider */ - key: string; - /** Optional custom API endpoint URL (e.g., for local Ollama or Azure OpenAI) */ - url?: string; - /** Optional custom model identifier (e.g., to override the provider's default model) */ - model?: string; - /** Optional parameter name for max token limit (e.g. 'max_tokens' or 'max_completion_tokens') */ - tokenParam?: string | undefined; - /** Optional LLM parameters (e.g. temperature, max_tokens, top_p) */ - options?: Record; -} - -/** - * ## AiConfig - * Configuration options for the AI parsing plugin. - */ -export interface AiConfig { - /** An array of fallback providers to use for routing */ - providers?: AiProvider[] | undefined; - /** Optional custom cache implementation for storing parsed strings */ - cache?: Map | undefined; - /** If true, logs the spoon-fed LLM context prompt and raw LLM response to the console */ - debug?: boolean | undefined; -} - -/** - * ## AiRateLimits - * Exposes the rate limit and billing statistics returned in the HTTP headers - * of the most recent LLM proxy request. - */ -export interface AiRateLimits { - /** Number of remaining requests allowed in the current time window, or null if unknown */ - remainingRequests: number | null; - /** Number of remaining tokens allowed in the current time window, or null if unknown */ - remainingTokens: number | null; - /** A Tempo instance representing the exact time the limits reset, or null if unknown */ - resetAt: Tempo | null; -} diff --git a/packages/plugins/ai/test/benchmark.spec.ts b/packages/plugins/ai/test/benchmark.spec.ts new file mode 100644 index 00000000..4f1d1e40 --- /dev/null +++ b/packages/plugins/ai/test/benchmark.spec.ts @@ -0,0 +1,15 @@ +import { normalizeCacheInput, getNamespacedCacheKey } from '../src/core/support.js'; + +describe('AI Support Helpers Benchmark & Integrity', () => { + it('should normalize cache input string whitespace and case', () => { + const raw = ' NEXT Friday at 3PM '; + const normalized = normalizeCacheInput(raw); + expect(normalized).toBe('next friday at 3pm'); + }); + + it('should generate properly namespaced cache keys', () => { + const key = getNamespacedCacheKey('extractAI', 'user-event-prompt'); + expect(key).toBe('ai:extractAI::user-event-prompt'); + expect(key.startsWith('ai:')).toBe(true); + }); +}); diff --git a/packages/plugins/ai/test/index.spec.ts b/packages/plugins/ai/test/index.spec.ts index 51045867..09a90e22 100644 --- a/packages/plugins/ai/test/index.spec.ts +++ b/packages/plugins/ai/test/index.spec.ts @@ -1,4 +1,4 @@ -import { parseAI, initAI, clearAiCache, getAiRateLimits, TempoAiError } from '../src/index.js'; +import { parseAI, initAI, clearAiCache, getAiRateLimits, TempoAiError, AiMode } from '../src/index.js'; import { BoundedCache } from '@magmacomputing/tempo/support'; import { Tempo } from '@magmacomputing/tempo'; @@ -23,11 +23,20 @@ describe('AI Parsing Plugin', () => { vi.clearAllMocks(); }); - it('should fall back to native parsing first', async () => { - // This should parse natively and not throw an API error even without a key + it('should fall back to native parsing first and attach .ai metadata', async () => { const result = await parseAI('2026-05-10'); expect(result.isValid).toBe(true); expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-05-10'); + expect(result.ai).toBeDefined(); + expect(result.ai?.provider).toBe('native'); + expect(result.ai?.cached).toBe(false); + expect(result.ai?.confidence).toBe(1.0); + expect(Object.isFrozen(result.ai)).toBe(true); + }); + + it('should throw TempoAiError if reserved provider ID "native" or "cache" is used in initAI', () => { + expect(() => initAI({ providers: [{ id: 'native', key: '123' }] })).toThrow(TempoAiError); + expect(() => initAI({ providers: [{ id: 'cache', key: '123' }] })).toThrow(TempoAiError); }); it('should throw TempoAiError if no key is configured and AI is needed', async () => { @@ -36,10 +45,10 @@ describe('AI Parsing Plugin', () => { await expect(parseAI('Next Thanksgiving')).rejects.toThrow('No AI providers configured.'); }); - it('should parse natural language successfully', async () => { + it('should parse natural language successfully and attach secured .ai metadata', async () => { if (!isLiveTest) { vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"The Friday after Thanksgiving", "iso":"2026-11-27T00:00:00"}' } }] + choices: [{ message: { content: '{"reasoning":"The Friday after Thanksgiving", "iso":"2026-11-27T00:00:00", "confidence":0.98, "ambiguous":false, "granularity":"day"}' } }] }), { status: 200, headers: new Headers({ @@ -49,66 +58,252 @@ describe('AI Parsing Plugin', () => { })); } - // Provide a strict anchor so we can assert the result deterministically const anchorDate = '2026-05-10T12:00:00Z'; const result = await parseAI('The Friday after Thanksgiving', { anchor: anchorDate, timeZone: 'UTC', force: true }); expect(result).toBeInstanceOf(Tempo); expect(result.isValid).toBe(true); expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-27'); + + expect(result.ai).toBeDefined(); + expect(result.ai?.provider).toBe(isLiveTest ? liveProviderId : 'groq'); + expect(result.ai?.cached).toBe(false); + expect(result.ai?.confidence).toBe(isLiveTest ? 1.0 : 0.98); + expect(result.ai?.ambiguous).toBe(false); + expect(result.ai?.granularity).toBe(isLiveTest ? 'unknown' : 'day'); + expect(Object.isFrozen(result.ai)).toBe(true); + }); + + it('should attach rawPrompt and normalizedPrompt to .ai only when debug is true', async () => { + if (!isLiveTest) { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Debug test", "iso":"2026-12-25T00:00:00", "confidence":0.95}' } }] + }), { status: 200 })); + } + + const result = await parseAI(' Christmas 2026 ', { force: true, debug: true }); + expect(result.ai?.rawPrompt).toBe(' Christmas 2026 '); + expect(result.ai?.normalizedPrompt).toBe('christmas 2026'); + expect(result.ai?.reasoning).toBe('Debug test'); + + if (!isLiveTest) { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"No debug test", "iso":"2026-12-25T00:00:00", "confidence":0.95}' } }] + }), { status: 200 })); + } + + const noDebug = await parseAI(' Christmas 2026 ', { force: true, debug: false }); + expect(noDebug.ai?.rawPrompt).toBeUndefined(); + expect(noDebug.ai?.normalizedPrompt).toBeUndefined(); + expect(noDebug.ai?.reasoning).toBeUndefined(); }); - it('should cache the result', async () => { + it('should cache the result and mark provider as "cache"', async () => { const anchorDate = '2026-05-10T12:00:00Z'; - // Clear cache first clearAiCache('The Friday after Thanksgiving'); const fetchSpy = vi.spyOn(globalThis, 'fetch'); if (!isLiveTest) { fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"The Friday after Thanksgiving", "iso":"2026-11-27T00:00:00"}' } }] + choices: [{ message: { content: '{"reasoning":"The Friday after Thanksgiving", "iso":"2026-11-27T00:00:00", "confidence":0.99}' } }] }), { status: 200 })); } - // First parse (hits network or mock) const dt1 = await parseAI('The Friday after Thanksgiving', { anchor: anchorDate, timeZone: 'UTC' }); expect(dt1.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-27'); + expect(dt1.ai?.cached).toBe(false); - // Second parse (hits cache instantly) const dt2 = await parseAI('The Friday after Thanksgiving', { anchor: anchorDate, timeZone: 'UTC' }); expect(dt2.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-27'); + expect(dt2.ai?.provider).toBe('cache'); + expect(dt2.ai?.cached).toBe(true); if (!isLiveTest) { expect(fetchSpy).toHaveBeenCalledTimes(1); } }); - it('should expose rate limits after a request', async () => { + it('should return a Tempo instance with isValid = false when LLM returns INVALID', async () => { if (!isLiveTest) { vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"Test date", "iso":"2026-11-26T00:00:00"}' } }] - }), { - status: 200, - headers: new Headers({ - 'x-ratelimit-remaining-requests': '99', - 'x-ratelimit-remaining-tokens': '4950', - 'x-ratelimit-reset-tokens': '60s' - }) - })); + choices: [{ message: { content: '{"reasoning":"Gibberish text", "iso":"INVALID", "confidence":0.0, "ambiguous":true}' } }] + }), { status: 200 })); + } + + const result = await parseAI('complete gibberish text', { force: true }); + expect(result).toBeInstanceOf(Tempo); + expect(result.isValid).toBe(false); + expect(result.ai?.confidence).toBe(0.0); + expect(result.ai?.ambiguous).toBe(true); + expect(result.ai?.rawIso).toBe('INVALID'); + }); + + it('should return an invalid Tempo instance (isValid = false) with metadata when minConfidence threshold is not met', async () => { + if (!isLiveTest) { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Uncertain date", "iso":"2026-05-10T00:00:00", "confidence":0.5}' } }] + }), { status: 200 })); + } + + const result = await parseAI('somewhat ambiguous date', { force: true, minConfidence: 0.7 }); + expect(result).toBeInstanceOf(Tempo); + expect(result.isValid).toBe(false); + expect(result.ai?.confidence).toBe(0.5); + expect(result.ai?.ambiguous).toBe(true); + }); + + it('should cascade from low-confidence local provider to high-confidence online provider in Fallback mode', async () => { + initAI({ + providers: [ + { id: 'local-llm', key: 'key1', url: 'https://api.openai.com/v1/chat', model: 'local' }, + { id: 'cloud-llm', key: 'key2', url: 'https://api.openai.com/v1/chat', model: 'cloud' } + ] + }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + // Provider 1 (local-llm): Low confidence (0.4) + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Uncertain local guess", "iso":"2026-11-26T00:00:00", "confidence":0.4}' } }] + }), { status: 200 })); + // Provider 2 (cloud-llm): High confidence (0.95) + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"High confidence cloud result", "iso":"2026-11-26T00:00:00", "confidence":0.95}' } }] + }), { status: 200 })); + + const result = await parseAI('Thanksgiving 2026', { force: true, minConfidence: 0.8, mode: AiMode.Fallback }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result.isValid).toBe(true); + expect(result.ai?.provider).toBe('cloud-llm'); + expect(result.ai?.confidence).toBe(0.95); + }); + + it('should short-circuit and stop looking to other providers when a provider meets minConfidence', async () => { + initAI({ + providers: [ + { id: 'local-llm', key: 'key1', url: 'https://api.openai.com/v1/chat', model: 'local' }, + { id: 'cloud-llm', key: 'key2', url: 'https://api.openai.com/v1/chat', model: 'cloud' } + ] + }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + // Provider 1 (local-llm): High confidence (0.90 >= 0.85) -> Short circuit! + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Confident local result", "iso":"2026-11-26T00:00:00", "confidence":0.90}' } }] + }), { status: 200 })); + + const result = await parseAI('Thanksgiving 2026', { force: true, minConfidence: 0.85, mode: AiMode.Fallback }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result.isValid).toBe(true); + expect(result.ai?.provider).toBe('local-llm'); + expect(result.ai?.confidence).toBe(0.90); + }); + + it('should execute parallel array batching with preserved index ordering', async () => { + if (!isLiveTest) { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Date 1", "iso":"2026-01-01T00:00:00", "confidence":0.99}' } }] + }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Date 2", "iso":"2026-02-02T00:00:00", "confidence":0.99}' } }] + }), { status: 200 })); } - await parseAI('Thanksgiving', { force: true }); + const [res1, res2] = await parseAI(['New Years 2026', 'Groundhog Day 2026'], { force: true }); + + expect(Tempo.isTempo(res1)).toBe(true); + expect(Tempo.isTempo(res2)).toBe(true); - const limits = getAiRateLimits(); - expect(limits).not.toBeNull(); - expect(limits?.remainingRequests).toBeDefined(); - expect(limits?.remainingTokens).toBeDefined(); - expect(limits?.resetAt).toBeInstanceOf(Tempo); + if (Tempo.isTempo(res1) && Tempo.isTempo(res2)) { + expect(res1.format('{yyyy}-{mm}-{dd}')).toBe('2026-01-01'); + expect(res2.format('{yyyy}-{mm}-{dd}')).toBe('2026-02-02'); + } + }); + + it('should support softErrors in array batch processing', async () => { + initAI({ + providers: [{ id: 'groq', key: 'test-key' }] + }); + + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + // Item 1 succeeds + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Date 1", "iso":"2026-01-01T00:00:00", "confidence":0.99}' } }] + }), { status: 200 })); + // Item 2 fails with 500 + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 500, statusText: 'Internal Error' })); + + const results = await parseAI(['Valid Date Prompt', 'Failing Prompt'], { force: true, softErrors: true }); + + expect(results).toHaveLength(2); + expect(results[0]).toBeInstanceOf(Tempo); + expect((results[0] as Tempo).format('{yyyy}-{mm}-{dd}')).toBe('2026-01-01'); + + expect(results[1]).toBeInstanceOf(TempoAiError); + expect((results[1] as TempoAiError).code).toBe(500); + }); + + describe('Execution Modes: Race & Consensus', () => { + it('should support mode: race and return the fastest resolving provider', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockImplementation(async (_url, opts) => { + const body = JSON.parse(opts?.body as string); + if (body.model === 'fast-model') { + return new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Fast", "iso":"2026-06-01T00:00:00", "confidence":0.95}' } }] + }), { status: 200 }); + } + // Slow model delays + await new Promise(resolve => setTimeout(resolve, 500)); + return new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Slow", "iso":"2026-06-01T00:00:00", "confidence":0.95}' } }] + }), { status: 200 }); + }); + + const result = await parseAI('June 1st 2026', { + force: true, + mode: AiMode.Race, + providers: [ + { id: 'slow-provider', key: 'key1', url: 'https://api.openai.com/v1/chat', model: 'slow-model' }, + { id: 'fast-provider', key: 'key2', url: 'https://api.openai.com/v1/chat', model: 'fast-model' } + ] + }); + + expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-06-01'); + expect(result.ai?.provider).toBe('fast-provider'); + }); + + it('should support mode: consensus and boost confidence when providers agree', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Model 1", "iso":"2026-07-04T00:00:00", "confidence":0.90}' } }] + }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"reasoning":"Model 2", "iso":"2026-07-04T00:00:00", "confidence":0.85}' } }] + }), { status: 200 })); + + const result = await parseAI('4th of July 2026', { + force: true, + mode: AiMode.Consensus, + providers: [ + { id: 'p1', key: 'key1', url: 'https://api.openai.com/v1/chat', model: 'm1' }, + { id: 'p2', key: 'key2', url: 'https://api.openai.com/v1/chat', model: 'm2' } + ] + }); + + expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-07-04'); + expect(result.ai?.provider).toBe('consensus'); + expect(result.ai?.confidence).toBe(1.0); + expect(result.ai?.ambiguous).toBe(false); + }); }); describe('Mocked Network Failures', () => { it('should throw TempoAiError with 401 when API key is bad, expired, or revoked', async () => { - // Temporarily inject a fake key initAI({ providers: [{ id: 'openai', key: 'bad_key' }] }); vi.spyOn(global, 'fetch').mockResolvedValueOnce(new Response(null, { @@ -116,7 +311,6 @@ describe('AI Parsing Plugin', () => { statusText: 'Unauthorized' })); - // The last error in the loop should bubble up try { await parseAI('Thanksgiving', { force: true }); expect.unreachable('Should have thrown an error'); @@ -128,7 +322,6 @@ describe('AI Parsing Plugin', () => { }); it('should seamlessly fallback to the next provider if the first hits a 429 Exhausted Key rate limit', async () => { - // Set up two providers. The first will fail (exhausted), the second will succeed. initAI({ providers: [ { id: 'openai', key: 'exhausted_key' }, @@ -138,16 +331,14 @@ describe('AI Parsing Plugin', () => { const fetchSpy = vi.spyOn(global, 'fetch'); - // First fetch call: Groq hits 429 Too Many Requests fetchSpy.mockResolvedValueOnce(new Response(null, { status: 429, statusText: 'Too Many Requests', headers: new Headers({ - 'x-ratelimit-reset-tokens': '60' // Resets in 60 seconds + 'x-ratelimit-reset-tokens': '60' }) })); - // Second fetch call: OpenAI succeeds fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ choices: [{ message: { content: '{"reasoning":"It is Thanksgiving.", "iso":"2026-11-26T00:00:00Z"}' } }] }), { @@ -162,8 +353,6 @@ describe('AI Parsing Plugin', () => { expect(fetchSpy).toHaveBeenCalledTimes(2); expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-26'); - // Verify the rate limits were updated correctly from the first 429 response before the success! - // Actually, the second success response overwrites the rate limits with OpenAI's headers. const limits = getAiRateLimits(); expect(limits?.remainingTokens).toBe(5000); }); @@ -184,7 +373,7 @@ describe('AI Parsing Plugin', () => { }); it('should evict expired items based on TTL', async () => { - const cache = new BoundedCache(100, 50); // 50ms TTL + const cache = new BoundedCache(100, 50); cache.set('tempKey', 'tempVal'); expect(cache.has('tempKey')).toBe(true); @@ -207,81 +396,100 @@ describe('AI Parsing Plugin', () => { expect(cache.has('Christmas::2026-05-10')).toBe(true); }); - it('should update BoundedCache options via Tempo.init', () => { - const cache = new BoundedCache(1000, 3600000); - initAI({ cache }); - - Tempo.init({ cache: { maxSize: 50, ttl: 5000 } }); - expect(cache.maxSize).toBe(50); - expect(cache.ttl).toBe(5000); - - Tempo.init({ cache: { maxSize: 5, ttl: 100 } }); - expect(cache.maxSize).toBe(5); - expect(cache.ttl).toBe(100); - }); - - it('should normalize cache keys (whitespace & case) for clearAiCache', () => { - const cache = new BoundedCache(100); - cache.set('thanksgiving::2026-05-10', '2026-11-26T00:00:00Z'); - - initAI({ cache }); - clearAiCache(' THANKSGIVING '); - expect(cache.has('thanksgiving::2026-05-10')).toBe(false); - }); - it('should resolve static un-salted user glossary terms without hitting network or expiring', async () => { const glossary = new Map([ - ['easter sunday 2026', '2026-04-05T00:00:00Z'], - ['q4 freeze 2026', '2026-11-01T00:00:00Z'] + ['my_custom_company_glossary_term', '2026-11-01T00:00:00Z'] ]); initAI({ cache: glossary }); const fetchSpy = vi.spyOn(globalThis, 'fetch'); - // Two-tier lookup hits un-salted normalized static key directly - const result = await parseAI('Easter Sunday 2026'); - expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-04-05'); + const result = await parseAI('my_custom_company_glossary_term'); + expect(result.format('{yyyy}-{mm}-{dd}')).toBe('2026-11-01'); + expect(result.ai?.provider).toBe('cache'); + expect(result.ai?.cached).toBe(true); expect(fetchSpy).not.toHaveBeenCalled(); }); + }); +}); - it('should protect static un-salted keys set via setStatic from TTL and LRU maxCacheSize eviction in BoundedCache', async () => { - const cache = new BoundedCache(2, 50); // maxSize 2, TTL 50ms - cache.setStatic('easter sunday 2026', '2026-04-05T00:00:00Z'); // Static key - cache.set('temp1::2026-05-10', '2026-05-10T00:00:00Z'); // Salted key - cache.set('temp2::2026-05-10', '2026-05-10T00:00:00Z'); // Salted key, pushes total to 3 + describe('Rate Limit & Reset Header Parsing Hardening', () => { + it('should correctly parse compound reset duration strings like 4m12s and 1h30m', async () => { + initAI({ providers: [{ id: 'openai', key: 'test-key' }] }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); - // LRU capacity check: should evict oldest salted key ('temp1::2026-05-10'), preserving static 'easter sunday 2026' - expect(cache.has('easter sunday 2026')).toBe(true); - expect(cache.has('temp1::2026-05-10')).toBe(false); + // Response 1 with compound duration header '4m12s' + fetchSpy.mockResolvedValueOnce(new Response(null, { + status: 429, + statusText: 'Too Many Requests', + headers: new Headers({ + 'x-ratelimit-reset-tokens': '4m12s' + }) + })); - // Wait for TTL expiration - await new Promise(resolve => setTimeout(resolve, 60)); + try { + await parseAI('Thanksgiving', { force: true }); + expect.unreachable('Should have thrown TempoAiError'); + } catch (err: any) { + expect(err).toBeInstanceOf(TempoAiError); + expect(err.code).toBe(429); + expect(err.retryAt).toBeDefined(); + expect(err.retryAt).toBeInstanceOf(Tempo); + } - // Salted key expires, static key remains intact - expect(cache.has('temp2::2026-05-10')).toBe(false); - expect(cache.has('easter sunday 2026')).toBe(true); - expect(cache.get('easter sunday 2026')).toBe('2026-04-05T00:00:00Z'); + const limits1 = getAiRateLimits(); + expect(limits1?.resetAt).toBeDefined(); }); - }); - describe('Configurable Token Parameter', () => { - it('should use specified tokenParam in provider request payload', async () => { - const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ - choices: [{ message: { content: '{"reasoning":"test", "iso":"2026-12-25T00:00:00Z"}' } }] - }), { status: 200 })); + it('should replace rather than retain prior rate-limit state when subsequent response has no headers', async () => { + initAI({ providers: [{ id: 'openai', key: 'test-key' }] }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); - initAI({ - providers: [{ id: 'custom-llm', key: 'test-key', url: 'https://api.custom.com/v1/chat', model: 'custom-model', tokenParam: 'max_tokens' }] - }); + // Request 1: Has rate limit headers + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"iso":"2026-11-26T00:00:00"}' } }] + }), { + status: 200, + headers: new Headers({ + 'x-ratelimit-remaining-tokens': '5000' + }) + })); + + await parseAI('Thanksgiving 2026', { force: true }); + expect(getAiRateLimits()?.remainingTokens).toBe(5000); + + // Request 2: Has NO rate limit headers + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"iso":"2026-12-25T00:00:00"}' } }] + }), { + status: 200 + })); - await parseAI('some random unparseable string', { force: true }); + await parseAI('Christmas 2026', { force: true }); - expect(fetchSpy).toHaveBeenCalled(); - const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); - expect(callBody.max_tokens).toBe(250); - expect(callBody.max_completion_tokens).toBeUndefined(); + // State should now be null (replaced, not retained!) + expect(getAiRateLimits()).toBeNull(); }); - }); -}); + it('should ignore invalid or malformed duration strings without throwing or crashing', async () => { + initAI({ providers: [{ id: 'openai', key: 'test-key' }] }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + fetchSpy.mockResolvedValueOnce(new Response(null, { + status: 429, + headers: new Headers({ + 'x-ratelimit-reset-tokens': 'invalid_compound_string_123' + }) + })); + + try { + await parseAI('Thanksgiving', { force: true }); + } catch (err: any) { + expect(err).toBeInstanceOf(TempoAiError); + expect(err.retryAt).toBeUndefined(); + } + + expect(getAiRateLimits()).toBeNull(); + }); + }); diff --git a/packages/plugins/ai/tsconfig.json b/packages/plugins/ai/tsconfig.json index e14056b1..288e3451 100644 --- a/packages/plugins/ai/tsconfig.json +++ b/packages/plugins/ai/tsconfig.json @@ -5,7 +5,8 @@ "rootDir": "./src", "declaration": true, "emitDeclarationOnly": true, - "types": ["node"] + "types": ["node"], + "ignoreDeprecations": "6.0" }, "include": [ "src" diff --git a/packages/plugins/ai/tsup.config.ts b/packages/plugins/ai/tsup.config.ts index 62bd4b2b..70376645 100644 --- a/packages/plugins/ai/tsup.config.ts +++ b/packages/plugins/ai/tsup.config.ts @@ -4,4 +4,5 @@ import { sharedConfig } from '../tsup.shared.js'; export default defineConfig({ ...sharedConfig, entry: ['src/index.ts'], + dts: true, }); diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index eac78902..a5c684d4 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Decoupled Plugin Cache Topology**: Cleaned up `parseAI` cache configuration by delegating capacity and TTL settings to `Tempo.init()`, establishing `Tempo.cache` as the single source of truth across the monorepo. +- **Hardened AI Plugin Architecture**: Hardened `parseAI` integration for `@magmacomputing/tempo-plugin-ai`, adding support for index-locked parallel batching, multi-stream provider execution strategies (`AiMode`), `softErrors` error boundaries, and proxy-wrapped `.ai` metadata attachment on frozen `Tempo` instances. ## [3.10.3] - 2026-07-29 From abd6fd0683117ddfb0845cf365ded8bbf7727bcf Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 3 Aug 2026 08:42:46 +1000 Subject: [PATCH 8/8] PR 2nd review --- .agent/rules/plan-execution.md | 5 +- .agent/workflows/ok.md | 2 +- packages/plugins/ai/doc/architecture.md | 35 ++++++-- packages/plugins/ai/doc/context.md | 24 +++++- packages/plugins/ai/doc/index.md | 23 +++++- packages/plugins/ai/doc/rate-limits.md | 32 +++++++- packages/plugins/ai/plan/v0.3.0-roadmap.md | 79 +++++++++++++++++++ packages/plugins/ai/src/core/config.ts | 2 +- packages/plugins/ai/src/core/init.ts | 35 ++++---- packages/plugins/ai/src/core/support.ts | 23 ++++-- packages/plugins/ai/src/functions/context.ts | 4 +- packages/plugins/ai/src/functions/diff.ts | 1 + packages/plugins/ai/src/functions/extract.ts | 1 + packages/plugins/ai/src/functions/format.ts | 1 + packages/plugins/ai/src/functions/parse.ts | 50 ++++++------ .../plugins/ai/src/functions/recurrence.ts | 1 + packages/plugins/ai/src/functions/schedule.ts | 1 + packages/plugins/ai/test/index.spec.ts | 37 ++++++++- 18 files changed, 287 insertions(+), 69 deletions(-) create mode 100644 packages/plugins/ai/plan/v0.3.0-roadmap.md diff --git a/.agent/rules/plan-execution.md b/.agent/rules/plan-execution.md index 0f49adab..6902cd6b 100644 --- a/.agent/rules/plan-execution.md +++ b/.agent/rules/plan-execution.md @@ -7,5 +7,6 @@ During standard interactive conversations, code discussions, and step-by-step re ## 2. Autonomous Background Execution (AFK / `/ok` Mode) When the user explicitly approves an implementation plan for autonomous background execution (e.g. by typing `/ok` or clicking "Ok to proceed"): -- Set `SafeToAutoRun: true` on execution tools. -- Prefer using terminal shell operations (`run_command` with `SafeToAutoRun: true`) for batch file modifications and test execution to ensure unblocked execution while the user is away. +- Validate approval state, verify the identity of the currently approved implementation plan, and validate command scope prior to enabling `SafeToAutoRun` or invoking `run_command`. +- Destructive commands or actions with external side effects must remain interactive unless explicitly authorized in the approved plan. +- For non-destructive operations covered by the approved plan, set `SafeToAutoRun: true` and use terminal shell operations (`run_command`) or file tools for unblocked execution. diff --git a/.agent/workflows/ok.md b/.agent/workflows/ok.md index cec040d4..4f5aaa16 100644 --- a/.agent/workflows/ok.md +++ b/.agent/workflows/ok.md @@ -3,4 +3,4 @@ description: Ok to proceed with your Plan --- // turbo-all -ok to proceed with your Plan +Verify active approval state and validate command scope against the approved plan before invoking execution. Ok to proceed with the approved plan. diff --git a/packages/plugins/ai/doc/architecture.md b/packages/plugins/ai/doc/architecture.md index 6306fece..5ceea909 100644 --- a/packages/plugins/ai/doc/architecture.md +++ b/packages/plugins/ai/doc/architecture.md @@ -1,6 +1,6 @@ # Provider Architecture & Security -The parseAI Plugin is designed to be highly flexible, supporting both direct Bring Your Own Key (BYOK) integrations for backend systems, and Proxied integrations for frontend clients. +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) @@ -52,18 +52,39 @@ initAI({ ## The Proxy Architecture -If you need to parse natural language directly on a public frontend application, you must route requests through a secure backend proxy. +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 natural language string to your own backend API (e.g., `/api/parse-date`). +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 `parseAI` command using your securely stored BYOK keys. +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 +## Fallback Loops & Execution Modes -Because third-party APIs can experience downtime or aggressive rate limiting, the plugin supports seamless fallback loops. +Because third-party APIs can experience downtime or aggressive rate limiting, the plugin supports flexible multi-provider execution strategies: -When configuring `initAI()`, provide an array of providers. If the primary provider hits a timeout or a `429 Too Many Requests` limit, the plugin instantly and silently fails over to the next provider in the list. This ensures maximum uptime for your users without complex retry logic in your application. +### 1. Fallback Mode (Default) +When configured with multiple providers in `initAI()`, AI functions execute requests sequentially. If the primary provider hits a timeout or a `429 Too Many Requests` limit, the plugin instantly and silently fails over to the next provider in the array. Rate limit headers are updated based on the successful provider response or error resolution. + +### 2. Race Mode (`mode: 'race'`) +Dispatches requests to all available providers simultaneously using `Promise.allSettled`. Returns the fastest resolving provider response to minimize user-perceived latency. + +```typescript +const result = await parseAI("Thanksgiving 2026", { mode: 'race' }); +``` + +### 3. Consensus Mode (`mode: 'consensus'`) +Executes all providers concurrently. If multiple providers agree on the resolved ISO timestamp, confidence score is boosted (to `1.0`) and the consensus result is returned. Rate limits are applied from the consensus provider. + +```typescript +const result = await parseAI("The penultimate Tuesday before Thanksgiving", { + mode: 'consensus', + minConfidence: 0.85 +}); +``` + +### Provider ID Canonicalization +Provider IDs are normalized case-insensitively during `initAI` lookup (e.g. `'Gemini'`, `'gemini'`, `'OpenAI'`), automatically applying default endpoints and models while preserving the caller's registered identifier for logging and metadata. diff --git a/packages/plugins/ai/doc/context.md b/packages/plugins/ai/doc/context.md index 63eeaed7..ddae1eee 100644 --- a/packages/plugins/ai/doc/context.md +++ b/packages/plugins/ai/doc/context.md @@ -2,7 +2,7 @@ Because natural language dates are entirely relative (e.g., "next Tuesday") and often geographically ambiguous (e.g., "11/12"), an LLM cannot reliably parse them in a vacuum. -The `parseAI` plugin solves this by automatically wrapping your input with rich environmental context before sending it to the LLM. +The Tempo AI plugin solves this by automatically wrapping your input with rich environmental context before sending it to the LLM. ## Geographic Context @@ -32,7 +32,7 @@ Passing the `Locale` is absolutely critical for the LLM to know whether "11/12" To ensure deterministic behavior, the LLM is instructed to *only* return strict ISO 8601 strings. -The plugin executes the network request, the LLM returns a local ISO string without a timezone offset or 'Z' suffix (like `"2026-11-26T00:00:00"`), and the plugin immediately passes that string back into the native `new Tempo()` constructor. The provider response must omit timezone suffixes to match the local ISO contract enforced by `parseAI`. The developer seamlessly receives a valid, native `Tempo` instance. This eliminates AST-construction ambiguity, creating a decoupled bridge between AI text generation and native Tempo conversion. +The plugin executes the network request, the LLM returns a local ISO string without a timezone offset or 'Z' suffix (like `"2026-11-26T00:00:00"`), and the plugin immediately passes that string back into the native `new Tempo()` constructor. The provider response must omit timezone suffixes to match the local ISO contract enforced by Tempo AI functions. The developer seamlessly receives a valid, native `Tempo` instance. This eliminates AST-construction ambiguity, creating a decoupled bridge between AI text generation and native Tempo conversion. ### Relative Date Ambiguity Tie-Breakers @@ -41,3 +41,23 @@ To eliminate model variance on idioms like "Next Friday" or "Last Tuesday", the * `"last [weekday/unit]"` / `"previous [weekday/unit]"`: Evaluated as the most recent past occurrence prior to `Current Time`. * `"this [weekday]"`: Evaluated as the occurrence within the current calendar week containing `Current Time`. +### Confidence Thresholds & Metadata (`.ai`) + +When `minConfidence` is supplied in options (e.g. `parseAI("...", { minConfidence: 0.8 })`), any LLM response returning a confidence score below that threshold produces a `Tempo` instance with `isValid === false`. + +Every resolved `Tempo` instance returned by `parseAI` (and other AI functions) has a non-writable, frozen `.ai` metadata descriptor containing execution audit data: +```typescript +const dt = await parseAI("Christmas 2026", { debug: true }); +console.log(dt.ai); +// { +// provider: 'openai', +// cached: false, +// confidence: 0.95, +// ambiguous: false, +// granularity: 'day', +// rawIso: '2026-12-25T00:00:00', +// rawPrompt: 'Christmas 2026', // Present when debug is enabled +// normalizedPrompt: 'christmas 2026' // Present when debug is enabled +// } +``` + diff --git a/packages/plugins/ai/doc/index.md b/packages/plugins/ai/doc/index.md index 42addc4a..03689d44 100644 --- a/packages/plugins/ai/doc/index.md +++ b/packages/plugins/ai/doc/index.md @@ -15,15 +15,15 @@ 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 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 `parseAI` plugin is specifically designed to be an alternative path for handling completely unstructured, conversational human language that would otherwise be impossible to Regex. +> **Note**: This plugin is **not** a silver-bullet replacement for all your parsing needs! `Tempo.parse()` natively handles structured dates and formats phenomenally well using its Aliases, Layouts, and Snippets. The Tempo AI plugin is specifically designed to be an alternative path for handling completely unstructured, conversational human language that would otherwise be impossible to Regex. > > **CRITICAL SECURITY WARNING**: Raw LLM API keys must **never** be exposed in a client-side browser bundle. BYOK (Bring Your Own Key) is only secure on backend servers (Node, edge workers). For public frontend applications, you must use a proxy service. ## Ideal Use-Cases -Good `parseAI` candidates represent unstructured, conversational, or event-driven natural language expressions that are impossible to Regex or parse with standard relative offset rules: +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"` @@ -61,9 +61,24 @@ const dt1 = await parseAI("The penultimate Tuesday before Thanksgiving in 2026") clearAiCache("The penultimate Tuesday before Thanksgiving in 2026"); ``` +## Execution Modes & Multi-Provider Options + +The AI plugin supports multi-provider execution strategies (`fallback`, `race`, `consensus`) and confidence filtering on per-request options: + +```typescript +// 1. Race mode: send concurrent requests to all providers, returning the fastest valid response +const fastest = await parseAI("Third Friday of October", { mode: 'race' }); + +// 2. Consensus mode: query providers concurrently and boost confidence when outputs agree +const agreed = await parseAI("The penultimate Tuesday before Thanksgiving", { + mode: 'consensus', + minConfidence: 0.85 // Require at least 0.85 confidence threshold +}); +``` + ## Debugging & Forced Evaluation -When building your LLM queries, it is often useful to see exactly how `parseAI` is routing your data. +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. diff --git a/packages/plugins/ai/doc/rate-limits.md b/packages/plugins/ai/doc/rate-limits.md index af7a7d0b..26d66d68 100644 --- a/packages/plugins/ai/doc/rate-limits.md +++ b/packages/plugins/ai/doc/rate-limits.md @@ -5,7 +5,7 @@ When using third-party AI APIs, your application is subject to strict rate limit The plugin automatically tracks these limits by reading the standard `x-ratelimit-*` HTTP headers returned by providers like OpenAI and Groq. ## Tracking Quota Real-time -To expose this data without ruining the clean `Promise` return type of the parse method, the plugin provides a dedicated utility function: `getAiRateLimits()`. +To expose this data without ruining the clean return signatures of Tempo AI functions, the plugin provides a dedicated utility function: `getAiRateLimits()`. ```typescript import { getAiRateLimits } from '@magmacomputing/tempo-plugin-ai'; @@ -45,7 +45,7 @@ By default, the plugin maintains an internal `Map` of strings to their respectiv ### Array Processing & Token Economics -When you pass an array of strings to `parseAI`, the plugin intentionally does **not** batch them into a single massive LLM request. Instead, it iterates through the array and processes each string individually. +When you pass an array of inputs to AI functions (such as `parseAI`), the plugin intentionally does **not** batch them into a single massive LLM request. Instead, it iterates through the array and processes each item individually. This is by design for three critical reasons: 1. **Cache Efficiency**: Individual processing allows the plugin to instantly resolve duplicate strings against the local cache, saving massive amounts of API tokens. If you pass an array of 10,000 dates, but only 1,000 are unique, the plugin only makes 1,000 requests. @@ -55,6 +55,34 @@ This is by design for three critical reasons: > [!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`: diff --git a/packages/plugins/ai/plan/v0.3.0-roadmap.md b/packages/plugins/ai/plan/v0.3.0-roadmap.md new file mode 100644 index 00000000..60d2a291 --- /dev/null +++ b/packages/plugins/ai/plan/v0.3.0-roadmap.md @@ -0,0 +1,79 @@ +# @magmacomputing/tempo-plugin-ai: v0.3.0 Release Roadmap & Requirements + +This document captures the planned feature set, architectural requirements, and design specifications for the **v0.3.0** release of `@magmacomputing/tempo-plugin-ai`. + +--- + +## 1. Remote Provider Manifest & Dynamic Defaults + +### Background & Objective +AI providers update model names (e.g. `gemini-2.5-flash`, `gpt-4o-mini`), endpoints, and parameters frequently. Hardcoding model defaults into the published NPM package requires frequent patch releases. v0.3.0 will introduce remote provider manifest fetching. + +### Requirements & Architecture +* **Hosted Manifest Endpoint**: Host a static `providers.v1.json` manifest on the Firebase-hosted Tempo Registry (`https://registry.tempo.dev/ai/providers.v1.json`). +* **Lifecycle & Caching**: + * Fetch occurs **once** per application lifecycle / module load (lazy-evaluated on first `initAI()` call). + * Manifest response is cached in module-scoped memory (`_remoteDefaults`). + * Re-calling `initAI()` reads from in-memory cache without triggering new network requests. +* **Fail-Open & Offline Support**: + * If the network request fails, times out, or the client is offline/air-gapped, `initAI()` synchronously falls back to compiled local `DEFAULT_PROVIDERS`. +* **Developer Override Options**: + ```typescript + initAI({ + providers: [{ id: 'openai', key: '...' }], + remoteConfigUrl: 'https://custom-registry.internal.net/ai/providers.json', // Custom endpoint + fetchDefaults: async (providerId) => { ... } // Custom resolver hook + }); + ``` + +--- + +## 2. Implementation of Scaffolded AI Function Handlers + +In v0.2.0, upcoming function handlers were scaffolded with `@internal` JSDoc tags and `not yet implemented` guards. v0.3.0 will implement the following functions: + +### 2.1 `formatAI(tempo: Tempo, prompt: string, options?: AiOptions): Promise` +* Formats a `Tempo` instance into human-friendly, contextual narrative text tailored to UI tones or relative countdowns. +* **Example**: `"this Friday at 5:00 PM EST (in 5 days)"`. + +### 2.2 `extractAI(text: string, options?: AiOptions): Promise` +* Scans unstructured text (emails, transcripts, task notes) to extract embedded temporal entities into structured `TempoEvent` records (`label`, `start`, `end`, `type`). + +### 2.3 `diffAI(start: Tempo, end: Tempo, prompt?: string, options?: AiOptions): Promise` +* Calculates and summarizes the delta between two `Tempo` instances in human, business, or operational terms (e.g., `"6 working business days (48 hours)"`). + +### 2.4 `scheduleAI(prompt: string, options?: AiScheduleOptions): Promise` +* Resolves natural language scheduling prompts against working hours, existing calendar events, and timezones into an optimal start/end `Tempo` interval. + +### 2.5 `recurrenceAI(prompt: string, options?: AiOptions): Promise` +* Translates complex natural language repeating schedule descriptions into standard RRULE strings and `Tempo` instance date generators (`rule.next(count)`). + +### 2.6 `contextAI(text: string, options?: AiOptions): Promise` +* Infers `timeZone`, `locale`, and preferred `calendar` system from ambiguous location descriptions or user bios. + +--- + +## 3. Telemetry & Token Usage Tracking + +Extend the `.ai` metadata property attached to returned `Tempo` instances to include token consumption metrics: +```typescript +interface AiMeta { + provider: string; + cached: boolean; + confidence: number; + ambiguous: boolean; + granularity: string; + usage?: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; +} +``` + +--- + +## 4. Advanced Cache TTL & Eviction Policies + +* Allow per-provider or per-request TTL configurations in `initAI({ ttl: 3600000 })`. +* Support optional async storage adapters (e.g., Redis / KV stores) via explicit async wrapper interfaces. diff --git a/packages/plugins/ai/src/core/config.ts b/packages/plugins/ai/src/core/config.ts index 0b22df0a..947f42ec 100644 --- a/packages/plugins/ai/src/core/config.ts +++ b/packages/plugins/ai/src/core/config.ts @@ -36,7 +36,7 @@ export const DEFAULT_PROVIDERS: Readonly { - if (RESERVED_PROVIDER_IDS.has(p.id.toLowerCase())) { - throw new TempoAiError(`Provider ID '${p.id}' is a reserved keyword in parseAI.`, 400); - } - const defaults = DEFAULT_PROVIDERS[p.id] || DEFAULT_PROVIDERS.openai; + const normalizedId = p.id?.toLowerCase() ?? ''; + const defaults = DEFAULT_PROVIDERS[normalizedId] || DEFAULT_PROVIDERS.openai; return { ...defaults, ...p @@ -97,6 +97,16 @@ export function parseResetHeaderToTempo(resetHeader: string): Tempo | null { } } + // Case 3: HTTP-date string (e.g., 'Wed, 21 Oct 2026 07:28:00 GMT') + if (/GMT|UTC|\d{2}:\d{2}:\d{2}/i.test(trimmed)) { + try { + const t = new Tempo(trimmed, { catch: true }); + return t.isValid ? t : null; + } catch { + return null; + } + } + return null; } @@ -117,19 +127,16 @@ export function parseRateLimitsFromResponse(response: Response): AiRateLimits | const parsedTok = Number.isNaN(tokNum) ? null : tokNum; const resetAtTempo = resetTokHeader ? parseResetHeaderToTempo(resetTokHeader) : null; - if (parsedReq === null && parsedTok === null && resetAtTempo === null) { + if (parsedReq === null && parsedTok === null && resetAtTempo === null) return null; - } return { remainingRequests: parsedReq, remainingTokens: parsedTok, resetAt: resetAtTempo - }; + } } export function updateRateLimitsFromResponse(response: Response): AiRateLimits | null { - const limits = parseRateLimitsFromResponse(response); - _state.limits = limits; - return limits; + return parseRateLimitsFromResponse(response); } diff --git a/packages/plugins/ai/src/core/support.ts b/packages/plugins/ai/src/core/support.ts index a8f72414..6a89c8c0 100644 --- a/packages/plugins/ai/src/core/support.ts +++ b/packages/plugins/ai/src/core/support.ts @@ -1,7 +1,16 @@ 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 { updateRateLimitsFromResponse } from './init.js'; + +export function assertNoReservedProviderId(providers: Partial[]): void { + for (const p of providers) { + if (p.id && RESERVED_PROVIDER_IDS.has(p.id.toLowerCase())) { + throw new TempoAiError(`Provider ID '${p.id}' is a reserved keyword in parseAI.`, 400); + } + } +} export function normalizeCacheInput(input: string): string { return input.trim().toLowerCase().replace(/\s+/g, ' '); @@ -14,10 +23,11 @@ export function getNamespacedCacheKey(namespace: string, key: string): string { export function attachAiMeta(instance: Tempo, meta: TempoAiMeta): Tempo { const frozenMeta = Object.freeze(meta); return new Proxy(instance, { - get(target, prop, receiver) { + get(target, prop, _receiver) { if (prop === 'ai') return frozenMeta; if (prop === 'isValid') { - if (meta.confidence === 0.0 || meta.rawIso === 'INVALID' || meta.ambiguous === true || !target.isValid) return false; + if (meta.confidence === 0.0 || meta.rawIso === 'INVALID' || meta.ambiguous === true || !target.isValid) + return false; } const val = Reflect.get(target, prop, target); if (typeof val === 'function') return val.bind(target); @@ -32,7 +42,7 @@ export function attachAiMeta(instance: Tempo, meta: TempoAiMeta): Tempo { return { value: frozenMeta, writable: false, - configurable: false, + configurable: true, enumerable: true }; } @@ -52,7 +62,7 @@ export async function fetchFromProvider( contextString: string, isDebug: boolean, parentSignal?: AbortSignal -): Promise<{ rawContent: string; providerId: string }> { +): Promise<{ rawContent: string; providerId: string; rateLimits: ReturnType }> { const url = provider.url!; const model = provider.model!; @@ -119,6 +129,7 @@ Do not include markdown blocks or any text outside the JSON.`; 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); } @@ -130,7 +141,7 @@ Do not include markdown blocks or any text outside the JSON.`; if (isDebug) console.log(`[tempo-plugin-ai] Received from ${provider.id}:`, rawContent); - return { rawContent: rawContent.trim(), providerId: provider.id }; + return { rawContent: rawContent.trim(), providerId: provider.id, rateLimits: limits }; } finally { clearTimeout(timeoutId); if (parentSignal) { diff --git a/packages/plugins/ai/src/functions/context.ts b/packages/plugins/ai/src/functions/context.ts index fea5bff1..4a48ae87 100644 --- a/packages/plugins/ai/src/functions/context.ts +++ b/packages/plugins/ai/src/functions/context.ts @@ -6,6 +6,7 @@ export interface TempoContext { } /** + * @internal Draft implementation scaffolded for v0.3.0 roadmap. * ## contextAI (Upcoming Export) * Infers `timeZone`, `locale`, and preferred `calendar` system from ambiguous location strings, * user bios, or context descriptions. @@ -22,6 +23,3 @@ export interface TempoContext { export async function contextAI(_text: string, _options?: Record): Promise { throw new Error('contextAI is not yet implemented in tempo-plugin-ai.'); } - -/** Alias for contextAI */ -export const inferContextAI = contextAI; diff --git a/packages/plugins/ai/src/functions/diff.ts b/packages/plugins/ai/src/functions/diff.ts index 03e5fb18..ba7f309c 100644 --- a/packages/plugins/ai/src/functions/diff.ts +++ b/packages/plugins/ai/src/functions/diff.ts @@ -9,6 +9,7 @@ export interface TempoAiDiffResult { } /** + * @internal Draft implementation scaffolded for v0.3.0 roadmap. * ## diffAI (Upcoming Export) * Expresses the delta between two timestamps or `Tempo` instances in human, business, * or operational terms. diff --git a/packages/plugins/ai/src/functions/extract.ts b/packages/plugins/ai/src/functions/extract.ts index 106b9ed4..175ad093 100644 --- a/packages/plugins/ai/src/functions/extract.ts +++ b/packages/plugins/ai/src/functions/extract.ts @@ -8,6 +8,7 @@ export interface TempoEvent { } /** + * @internal Draft implementation scaffolded for v0.3.0 roadmap. * ## extractAI (Upcoming Export) * Scans unstructured text (emails, transcripts, task notes) and extracts all * embedded temporal entities, deadlines, and events into structured `TempoEvent` records. diff --git a/packages/plugins/ai/src/functions/format.ts b/packages/plugins/ai/src/functions/format.ts index f09ad0f0..a3eaea45 100644 --- a/packages/plugins/ai/src/functions/format.ts +++ b/packages/plugins/ai/src/functions/format.ts @@ -1,6 +1,7 @@ import type { Tempo } from '@magmacomputing/tempo'; /** + * @internal Draft implementation scaffolded for v0.3.0 roadmap. * ## formatAI (Upcoming Export) * Formats a `Tempo` instance into human-friendly, contextual narrative text * tailored to specific UI tones, relative time frames, or business domains. diff --git a/packages/plugins/ai/src/functions/parse.ts b/packages/plugins/ai/src/functions/parse.ts index 7cb9abaa..309c0b99 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, RESERVED_PROVIDER_IDS } from '../core/config.js'; +import { AiMode } from '../core/config.js'; import type { AiParseOptions } from '../core/types.js'; import { _state } from '../core/init.js'; -import { normalizeCacheInput, attachAiMeta, fetchFromProvider } from '../core/support.js'; +import { normalizeCacheInput, attachAiMeta, fetchFromProvider, assertNoReservedProviderId } from '../core/support.js'; async function parseSingleInput(str: string, options?: AiParseOptions): Promise { const isDebug = options?.debug ?? _state.config.debug ?? false; @@ -60,9 +60,10 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< if (!force) { try { const native = new Tempo(str, { ...coreOptions, silent: true }); - const internal = (native as any)[Symbol.for('$Tempo.internal')] || (native as any).$Internal?.(); - const hasNativeMatches = (internal?.matches && internal.matches.length > 0) - || /^\d{4}-\d{2}-\d{2}/.test(str.trim()); + const hasNativeMatches = Tempo.cache.has(str) + || Tempo.cache.has(normalizedStr) + || /^\d{4}-\d{2}-\d{2}/.test(str.trim()) + || native.isValid; if (native.isValid && hasNativeMatches) { if (isDebug) console.log(`[parseAI] Resolved natively: "${str}"`); @@ -81,40 +82,35 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< } } - const contextString = `Current Time: ${anchorTempo.format('{yyyy}-{mm}-{dd} ({wkd}) {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${loc}, Hemisphere: ${sph}.`; + const contextString = `Current Time: ${anchorTempo.format('{wkd}, {yyyy}-{mm}-{dd} {hh}:{mi}:{ss}')}, Timezone: ${tz}, Calendar: ${cal}, Locale: ${loc}, Hemisphere: ${sph}.`; const availableProviders = providers || _state.config.providers; - if (!availableProviders || availableProviders.length === 0) { + if (!availableProviders || availableProviders.length === 0) throw new TempoAiError('No AI providers configured. Please call initAI().', 400); - } - for (const p of availableProviders) { - if (RESERVED_PROVIDER_IDS.has(p.id.toLowerCase())) { - throw new TempoAiError(`Provider ID '${p.id}' is a reserved keyword in parseAI.`, 400); - } - } + assertNoReservedProviderId(availableProviders); const mode = aiMode || AiMode.Fallback; - let successfulResult: { parsedData: any; providerId: string } | null = null; + let successfulResult: { parsedData: any; providerId: string; rateLimits?: any } | null = null; if (mode === AiMode.Fallback) { let lastError: any = null; - let bestCandidate: { parsedData: any; providerId: string } | null = null; + let bestCandidate: { parsedData: any; providerId: string; rateLimits?: any } | null = null; for (const provider of availableProviders) { try { - const { rawContent, providerId } = await fetchFromProvider(provider, str, contextString, isDebug); + const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug); const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); const parsedData = JSON.parse(cleanContent); const candidateConfidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : (parsedData?.iso === 'INVALID' ? 0.0 : 1.0); if (!bestCandidate || candidateConfidence > (bestCandidate.parsedData?.confidence ?? 0)) { - bestCandidate = { parsedData, providerId }; + bestCandidate = { parsedData, providerId, rateLimits }; } if (minConfidence === undefined || candidateConfidence >= minConfidence) { - successfulResult = { parsedData, providerId }; + successfulResult = { parsedData, providerId, rateLimits }; break; } @@ -139,9 +135,9 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< const parentController = new AbortController(); try { const promises = availableProviders.map(async (provider) => { - const { rawContent, providerId } = await fetchFromProvider(provider, str, contextString, isDebug, parentController.signal); + const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug, parentController.signal); const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); - return { parsedData: JSON.parse(cleanContent), providerId }; + return { parsedData: JSON.parse(cleanContent), providerId, rateLimits }; }); successfulResult = await Promise.race(promises); parentController.abort(); @@ -152,14 +148,14 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< } else if (mode === AiMode.Consensus) { const promises = availableProviders.map(async (provider) => { - const { rawContent, providerId } = await fetchFromProvider(provider, str, contextString, isDebug); + const { rawContent, providerId, rateLimits } = await fetchFromProvider(provider, str, contextString, isDebug); const cleanContent = rawContent.replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); - return { parsedData: JSON.parse(cleanContent), providerId }; + 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 }> => s.status === 'fulfilled') + .filter((s): s is PromiseFulfilledResult<{ parsedData: any; providerId: string; rateLimits: any }> => s.status === 'fulfilled') .map(s => s.value); if (fulfilled.length === 0) { @@ -180,7 +176,8 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< confidence: 1.0, ambiguous: false }, - providerId: 'consensus' + providerId: AiMode.Consensus, + rateLimits: fulfilled[0].rateLimits }; } else { const sorted = [...fulfilled].sort((a, b) => (b.parsedData?.confidence ?? 0) - (a.parsedData?.confidence ?? 0)); @@ -189,12 +186,15 @@ async function parseSingleInput(str: string, options?: AiParseOptions): Promise< ...sorted[0].parsedData, ambiguous: true }, - providerId: sorted[0].providerId + providerId: sorted[0].providerId, + rateLimits: sorted[0].rateLimits }; } } } + _state.limits = successfulResult?.rateLimits ?? null; + const { parsedData, providerId } = successfulResult!; const rawIso = typeof parsedData?.iso === 'string' ? parsedData.iso : 'INVALID'; const confidence = typeof parsedData?.confidence === 'number' ? parsedData.confidence : (rawIso === 'INVALID' ? 0.0 : 1.0); diff --git a/packages/plugins/ai/src/functions/recurrence.ts b/packages/plugins/ai/src/functions/recurrence.ts index 21f6d2e5..52375aad 100644 --- a/packages/plugins/ai/src/functions/recurrence.ts +++ b/packages/plugins/ai/src/functions/recurrence.ts @@ -6,6 +6,7 @@ export interface TempoRecurrenceRule { } /** + * @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. diff --git a/packages/plugins/ai/src/functions/schedule.ts b/packages/plugins/ai/src/functions/schedule.ts index 556e5caf..0e41e9c1 100644 --- a/packages/plugins/ai/src/functions/schedule.ts +++ b/packages/plugins/ai/src/functions/schedule.ts @@ -6,6 +6,7 @@ export interface TempoInterval { } /** + * @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. diff --git a/packages/plugins/ai/test/index.spec.ts b/packages/plugins/ai/test/index.spec.ts index 09a90e22..21599b73 100644 --- a/packages/plugins/ai/test/index.spec.ts +++ b/packages/plugins/ai/test/index.spec.ts @@ -20,7 +20,7 @@ describe('AI Parsing Plugin', () => { }); afterEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); }); it('should fall back to native parsing first and attach .ai metadata', async () => { @@ -39,6 +39,20 @@ describe('AI Parsing Plugin', () => { expect(() => initAI({ providers: [{ id: 'cache', key: '123' }] })).toThrow(TempoAiError); }); + it('should canonicalize Gemini provider ID and use gemini-3.6-flash model by default', async () => { + initAI({ + providers: [{ id: 'Gemini', key: 'mock-gemini-key' }] + }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"iso":"2026-12-25T00:00:00"}' } }] + }), { status: 200 })); + + await parseAI('Christmas 2026', { force: true }); + expect(fetchSpy).toHaveBeenCalled(); + const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + expect(body.model).toBe('gemini-3.6-flash'); + }); + it('should throw TempoAiError if no key is configured and AI is needed', async () => { initAI({ providers: [] }); await expect(parseAI('Next Thanksgiving')).rejects.toThrow(TempoAiError); @@ -412,7 +426,6 @@ describe('AI Parsing Plugin', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); }); -}); describe('Rate Limit & Reset Header Parsing Hardening', () => { it('should correctly parse compound reset duration strings like 4m12s and 1h30m', async () => { @@ -472,6 +485,25 @@ describe('AI Parsing Plugin', () => { expect(getAiRateLimits()).toBeNull(); }); + it('should parse HTTP-date format Retry-After header strings into valid Tempo resetAt', async () => { + initAI({ providers: [{ id: 'openai', key: 'test-key' }] }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const httpDateStr = 'Wed, 21 Oct 2026 07:28:00 GMT'; + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: '{"iso":"2026-11-26T00:00:00"}' } }] + }), { + status: 200, + headers: new Headers({ + 'retry-after': httpDateStr + }) + })); + + await parseAI('Thanksgiving 2026', { force: true }); + expect(getAiRateLimits()?.resetAt).toBeDefined(); + expect(getAiRateLimits()?.resetAt?.isValid).toBe(true); + }); + it('should ignore invalid or malformed duration strings without throwing or crashing', async () => { initAI({ providers: [{ id: 'openai', key: 'test-key' }] }); const fetchSpy = vi.spyOn(globalThis, 'fetch'); @@ -493,3 +525,4 @@ describe('AI Parsing Plugin', () => { expect(getAiRateLimits()).toBeNull(); }); }); +});