diff --git a/packages/ai-controllers/CHANGELOG.md b/packages/ai-controllers/CHANGELOG.md index 9cedbc4dfe9..b4d6ef7c5de 100644 --- a/packages/ai-controllers/CHANGELOG.md +++ b/packages/ai-controllers/CHANGELOG.md @@ -10,5 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release ([#7693](https://github.com/MetaMask/core/pull/7693)) +- Add `AiDigestController` for fetching and caching AI-generated asset digests ([#7746](https://github.com/MetaMask/core/pull/7746)) [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/ai-controllers/jest.config.js b/packages/ai-controllers/jest.config.js index ca084133399..55df7971f9f 100644 --- a/packages/ai-controllers/jest.config.js +++ b/packages/ai-controllers/jest.config.js @@ -14,7 +14,6 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, - // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { branches: 100, diff --git a/packages/ai-controllers/package.json b/packages/ai-controllers/package.json index 702e28b0cef..44d70a40d48 100644 --- a/packages/ai-controllers/package.json +++ b/packages/ai-controllers/package.json @@ -47,6 +47,10 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@metamask/base-controller": "^9.0.0", + "@metamask/messenger": "^0.3.0" + }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", diff --git a/packages/ai-controllers/src/AiDigestController.test.ts b/packages/ai-controllers/src/AiDigestController.test.ts new file mode 100644 index 00000000000..a0003183f13 --- /dev/null +++ b/packages/ai-controllers/src/AiDigestController.test.ts @@ -0,0 +1,177 @@ +import { Messenger } from '@metamask/messenger'; + +import { + AiDigestController, + getDefaultAiDigestControllerState, + CACHE_DURATION_MS, + MAX_CACHE_ENTRIES, +} from '.'; +import type { AiDigestControllerMessenger } from '.'; + +const mockData = { + id: '123e4567-e89b-12d3-a456-426614174000', + assetId: 'eth-ethereum', + assetSymbol: 'ETH', + digest: 'ETH is trading at $3,245.67 (+2.3% 24h).', + generatedAt: '2026-01-21T10:30:00.000Z', + processingTime: 1523, + success: true, + createdAt: '2026-01-21T10:30:00.000Z', + updatedAt: '2026-01-21T10:30:00.000Z', +}; + +const createMessenger = (): AiDigestControllerMessenger => { + return new Messenger({ + namespace: 'AiDigestController', + }) as AiDigestControllerMessenger; +}; + +describe('AiDigestController', () => { + it('returns default state', () => { + expect(getDefaultAiDigestControllerState()).toStrictEqual({ digests: {} }); + }); + + it('fetches and caches a digest', async () => { + const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + const controller = new AiDigestController({ + messenger: createMessenger(), + digestService: mockService, + }); + + const result = await controller.fetchDigest('ethereum'); + + expect(result).toStrictEqual(mockData); + expect(controller.state.digests.ethereum).toBeDefined(); + expect(controller.state.digests.ethereum.data).toStrictEqual(mockData); + }); + + it('returns cached digest on subsequent calls', async () => { + const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + const controller = new AiDigestController({ + messenger: createMessenger(), + digestService: mockService, + }); + + await controller.fetchDigest('ethereum'); + await controller.fetchDigest('ethereum'); + + expect(mockService.fetchDigest).toHaveBeenCalledTimes(1); + }); + + it('refetches after cache expires', async () => { + jest.useFakeTimers(); + const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + const controller = new AiDigestController({ + messenger: createMessenger(), + digestService: mockService, + }); + + await controller.fetchDigest('ethereum'); + jest.advanceTimersByTime(CACHE_DURATION_MS + 1); + await controller.fetchDigest('ethereum'); + + expect(mockService.fetchDigest).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it('throws on fetch errors', async () => { + const mockService = { + fetchDigest: jest.fn().mockRejectedValue(new Error('Network error')), + }; + const controller = new AiDigestController({ + messenger: createMessenger(), + digestService: mockService, + }); + + await expect(controller.fetchDigest('ethereum')).rejects.toThrow( + 'Network error', + ); + expect(controller.state.digests.ethereum).toBeUndefined(); + }); + + it('clears a specific digest', async () => { + const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + const controller = new AiDigestController({ + messenger: createMessenger(), + digestService: mockService, + }); + + await controller.fetchDigest('ethereum'); + controller.clearDigest('ethereum'); + + expect(controller.state.digests.ethereum).toBeUndefined(); + }); + + it('clears all digests', async () => { + const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + const controller = new AiDigestController({ + messenger: createMessenger(), + digestService: mockService, + }); + + await controller.fetchDigest('ethereum'); + await controller.fetchDigest('bitcoin'); + controller.clearAllDigests(); + + expect(controller.state.digests).toStrictEqual({}); + }); + + it('evicts stale entries on fetch', async () => { + jest.useFakeTimers(); + const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + const controller = new AiDigestController({ + messenger: createMessenger(), + digestService: mockService, + }); + + await controller.fetchDigest('ethereum'); + jest.advanceTimersByTime(CACHE_DURATION_MS + 1); + await controller.fetchDigest('bitcoin'); + + expect(controller.state.digests.ethereum).toBeUndefined(); + expect(controller.state.digests.bitcoin).toBeDefined(); + jest.useRealTimers(); + }); + + it('evicts oldest entries when exceeding max cache size', async () => { + const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + const controller = new AiDigestController({ + messenger: createMessenger(), + digestService: mockService, + }); + + for (let i = 0; i < MAX_CACHE_ENTRIES + 1; i++) { + await controller.fetchDigest(`asset${i}`); + } + + expect(Object.keys(controller.state.digests)).toHaveLength( + MAX_CACHE_ENTRIES, + ); + expect(controller.state.digests.asset0).toBeUndefined(); + }); + + it('registers action handlers', async () => { + const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + const messenger = createMessenger(); + const controller = new AiDigestController({ + messenger, + digestService: mockService, + }); + + const result = await messenger.call( + 'AiDigestController:fetchDigest', + 'ethereum', + ); + expect(result).toStrictEqual(mockData); + + messenger.call('AiDigestController:clearDigest', 'ethereum'); + messenger.call('AiDigestController:clearAllDigests'); + + expect(controller.state.digests).toStrictEqual({}); + }); + + it('uses expected cache constants', () => { + expect(CACHE_DURATION_MS).toBe(10 * 60 * 1000); + expect(MAX_CACHE_ENTRIES).toBe(50); + }); +}); diff --git a/packages/ai-controllers/src/AiDigestController.ts b/packages/ai-controllers/src/AiDigestController.ts new file mode 100644 index 00000000000..f215e4412d4 --- /dev/null +++ b/packages/ai-controllers/src/AiDigestController.ts @@ -0,0 +1,187 @@ +import type { + StateMetadata, + ControllerStateChangeEvent, + ControllerGetStateAction, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +import { + controllerName, + CACHE_DURATION_MS, + MAX_CACHE_ENTRIES, +} from './ai-digest-constants'; +import type { + AiDigestControllerState, + DigestEntry, + DigestService, + DigestData, +} from './ai-digest-types'; + +export type AiDigestControllerFetchDigestAction = { + type: `${typeof controllerName}:fetchDigest`; + handler: AiDigestController['fetchDigest']; +}; + +export type AiDigestControllerClearDigestAction = { + type: `${typeof controllerName}:clearDigest`; + handler: AiDigestController['clearDigest']; +}; + +export type AiDigestControllerClearAllDigestsAction = { + type: `${typeof controllerName}:clearAllDigests`; + handler: AiDigestController['clearAllDigests']; +}; + +export type AiDigestControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + AiDigestControllerState +>; + +export type AiDigestControllerActions = + | AiDigestControllerFetchDigestAction + | AiDigestControllerClearDigestAction + | AiDigestControllerClearAllDigestsAction + | AiDigestControllerGetStateAction; + +export type AiDigestControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + AiDigestControllerState +>; + +export type AiDigestControllerEvents = AiDigestControllerStateChangeEvent; + +export type AiDigestControllerMessenger = Messenger< + typeof controllerName, + AiDigestControllerActions, + AiDigestControllerEvents +>; + +export type AiDigestControllerOptions = { + messenger: AiDigestControllerMessenger; + state?: Partial; + digestService: DigestService; +}; + +export function getDefaultAiDigestControllerState(): AiDigestControllerState { + return { + digests: {}, + }; +} + +const aiDigestControllerMetadata: StateMetadata = { + digests: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, +}; + +export class AiDigestController extends BaseController< + typeof controllerName, + AiDigestControllerState, + AiDigestControllerMessenger +> { + readonly #digestService: DigestService; + + constructor({ messenger, state, digestService }: AiDigestControllerOptions) { + super({ + name: controllerName, + metadata: aiDigestControllerMetadata, + state: { + ...getDefaultAiDigestControllerState(), + ...state, + }, + messenger, + }); + + this.#digestService = digestService; + this.#registerMessageHandlers(); + } + + #registerMessageHandlers(): void { + this.messenger.registerActionHandler( + `${controllerName}:fetchDigest`, + this.fetchDigest.bind(this), + ); + this.messenger.registerActionHandler( + `${controllerName}:clearDigest`, + this.clearDigest.bind(this), + ); + this.messenger.registerActionHandler( + `${controllerName}:clearAllDigests`, + this.clearAllDigests.bind(this), + ); + } + + async fetchDigest(assetId: string): Promise { + const existingDigest = this.state.digests[assetId]; + if (existingDigest) { + const age = Date.now() - existingDigest.fetchedAt; + if (age < CACHE_DURATION_MS) { + return existingDigest.data; + } + } + + const data = await this.#digestService.fetchDigest(assetId); + + const entry: DigestEntry = { + asset: assetId, + fetchedAt: Date.now(), + data, + }; + + this.update((state) => { + state.digests[assetId] = entry; + this.#evictStaleEntries(state); + }); + + return data; + } + + clearDigest(assetId: string): void { + this.update((state) => { + delete state.digests[assetId]; + }); + } + + clearAllDigests(): void { + this.update((state) => { + state.digests = {}; + }); + } + + /** + * Evicts stale (TTL expired) and oldest entries (FIFO) if cache exceeds max size. + * + * @param state - The current controller state to evict entries from. + */ + #evictStaleEntries(state: AiDigestControllerState): void { + const now = Date.now(); + const entries = Object.entries(state.digests); + const keysToDelete: string[] = []; + const freshEntries: [string, DigestEntry][] = []; + + for (const [key, entry] of entries) { + if (now - entry.fetchedAt >= CACHE_DURATION_MS) { + keysToDelete.push(key); + } else { + freshEntries.push([key, entry]); + } + } + + // Evict oldest entries if over max cache size + if (freshEntries.length > MAX_CACHE_ENTRIES) { + freshEntries.sort((a, b) => a[1].fetchedAt - b[1].fetchedAt); + const entriesToRemove = freshEntries.length - MAX_CACHE_ENTRIES; + for (let i = 0; i < entriesToRemove; i++) { + keysToDelete.push(freshEntries[i][0]); + } + } + + for (const key of keysToDelete) { + delete state.digests[key]; + } + } +} diff --git a/packages/ai-controllers/src/AiDigestService.test.ts b/packages/ai-controllers/src/AiDigestService.test.ts new file mode 100644 index 00000000000..93a60c24f25 --- /dev/null +++ b/packages/ai-controllers/src/AiDigestService.test.ts @@ -0,0 +1,88 @@ +import { AiDigestService } from '.'; + +const mockDigestResponse = { + id: '123e4567-e89b-12d3-a456-426614174000', + assetId: 'eth-ethereum', + assetSymbol: 'ETH', + digest: 'ETH is trading at $3,245.67 (+2.3% 24h).', + generatedAt: '2026-01-21T10:30:00.000Z', + processingTime: 1523, + success: true, + createdAt: '2026-01-21T10:30:00.000Z', + updatedAt: '2026-01-21T10:30:00.000Z', +}; + +describe('AiDigestService', () => { + const mockFetch = jest.fn(); + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = mockFetch; + mockFetch.mockReset(); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it('fetches latest digest from API', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockDigestResponse), + }); + + const service = new AiDigestService({ baseUrl: 'http://test.com' }); + const result = await service.fetchDigest('eth-ethereum'); + + expect(result).toStrictEqual(mockDigestResponse); + expect(mockFetch).toHaveBeenCalledWith( + `http://test.com/digests/assets/${encodeURIComponent('eth-ethereum')}/latest`, + ); + }); + + it('throws on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + + const service = new AiDigestService({ baseUrl: 'http://test.com' }); + + await expect(service.fetchDigest('eth-ethereum')).rejects.toThrow( + 'API request failed: 500', + ); + }); + + it('throws on unsuccessful response', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockDigestResponse, + success: false, + error: 'Asset not found', + }), + }); + + const service = new AiDigestService({ baseUrl: 'http://test.com' }); + + await expect(service.fetchDigest('invalid-asset')).rejects.toThrow( + 'Asset not found', + ); + }); + + it('throws default error when no error message provided', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockDigestResponse, + success: false, + error: undefined, + }), + }); + + const service = new AiDigestService({ baseUrl: 'http://test.com' }); + + await expect(service.fetchDigest('invalid-asset')).rejects.toThrow( + 'API returned error', + ); + }); +}); diff --git a/packages/ai-controllers/src/AiDigestService.ts b/packages/ai-controllers/src/AiDigestService.ts new file mode 100644 index 00000000000..da90e19d78f --- /dev/null +++ b/packages/ai-controllers/src/AiDigestService.ts @@ -0,0 +1,36 @@ +import { AiDigestControllerErrorMessage } from './ai-digest-constants'; +import type { DigestService, DigestData } from './ai-digest-types'; + +export type AiDigestServiceConfig = { + baseUrl: string; +}; + +export class AiDigestService implements DigestService { + readonly #baseUrl: string; + + constructor(config: AiDigestServiceConfig) { + this.#baseUrl = config.baseUrl; + } + + async fetchDigest(assetId: string): Promise { + const response = await fetch( + `${this.#baseUrl}/digests/assets/${encodeURIComponent(assetId)}/latest`, + ); + + if (!response.ok) { + throw new Error( + `${AiDigestControllerErrorMessage.API_REQUEST_FAILED}: ${response.status}`, + ); + } + + const data: DigestData = await response.json(); + + if (!data.success) { + throw new Error( + data.error ?? AiDigestControllerErrorMessage.API_RETURNED_ERROR, + ); + } + + return data; + } +} diff --git a/packages/ai-controllers/src/ai-digest-constants.ts b/packages/ai-controllers/src/ai-digest-constants.ts new file mode 100644 index 00000000000..a13539f54f1 --- /dev/null +++ b/packages/ai-controllers/src/ai-digest-constants.ts @@ -0,0 +1,10 @@ +export const controllerName = 'AiDigestController'; + +export const CACHE_DURATION_MS = 10 * 60 * 1000; // 10 minutes + +export const MAX_CACHE_ENTRIES = 50; + +export const AiDigestControllerErrorMessage = { + API_REQUEST_FAILED: 'API request failed', + API_RETURNED_ERROR: 'API returned error', +} as const; diff --git a/packages/ai-controllers/src/ai-digest-types.ts b/packages/ai-controllers/src/ai-digest-types.ts new file mode 100644 index 00000000000..248a9c383f2 --- /dev/null +++ b/packages/ai-controllers/src/ai-digest-types.ts @@ -0,0 +1,32 @@ +/** + * Response from the digest API. + */ +export type DigestData = { + id: string; + assetId: string; + assetSymbol?: string; + digest: string; + generatedAt: string; + processingTime: number; + success: boolean; + error?: string; + createdAt: string; + updatedAt: string; +}; + +/** + * A cached digest entry. Only successful fetches are stored. + */ +export type DigestEntry = { + asset: string; + fetchedAt: number; + data: DigestData; +}; + +export type AiDigestControllerState = { + digests: Record; +}; + +export type DigestService = { + fetchDigest(assetId: string): Promise; +}; diff --git a/packages/ai-controllers/src/index.test.ts b/packages/ai-controllers/src/index.test.ts deleted file mode 100644 index bc062d3694a..00000000000 --- a/packages/ai-controllers/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/ai-controllers/src/index.ts b/packages/ai-controllers/src/index.ts index 6972c117292..865becf87bc 100644 --- a/packages/ai-controllers/src/index.ts +++ b/packages/ai-controllers/src/index.ts @@ -1,9 +1,32 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export type { + AiDigestControllerActions, + AiDigestControllerClearAllDigestsAction, + AiDigestControllerClearDigestAction, + AiDigestControllerEvents, + AiDigestControllerFetchDigestAction, + AiDigestControllerGetStateAction, + AiDigestControllerMessenger, + AiDigestControllerOptions, + AiDigestControllerStateChangeEvent, +} from './AiDigestController'; +export { + AiDigestController, + getDefaultAiDigestControllerState, +} from './AiDigestController'; + +export type { AiDigestServiceConfig } from './AiDigestService'; +export { AiDigestService } from './AiDigestService'; + +export type { + AiDigestControllerState, + DigestData, + DigestEntry, + DigestService, +} from './ai-digest-types'; + +export { + controllerName as aiDigestControllerName, + CACHE_DURATION_MS, + MAX_CACHE_ENTRIES, + AiDigestControllerErrorMessage, +} from './ai-digest-constants'; diff --git a/packages/ai-controllers/tsconfig.build.json b/packages/ai-controllers/tsconfig.build.json index 02a0eea03fe..931c4d6594b 100644 --- a/packages/ai-controllers/tsconfig.build.json +++ b/packages/ai-controllers/tsconfig.build.json @@ -5,6 +5,9 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], "include": ["../../types", "./src"] } diff --git a/packages/ai-controllers/tsconfig.json b/packages/ai-controllers/tsconfig.json index 025ba2ef7f4..68c3ddfc2cd 100644 --- a/packages/ai-controllers/tsconfig.json +++ b/packages/ai-controllers/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], + "references": [{ "path": "../base-controller" }, { "path": "../messenger" }], "include": ["../../types", "./src"] } diff --git a/yarn.lock b/yarn.lock index c2ac8be9d70..fe3bea543fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2543,6 +2543,8 @@ __metadata: resolution: "@metamask/ai-controllers@workspace:packages/ai-controllers" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2"