diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..1a8f7c5c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "pro"] + path = pro + url = https://github.com/dishit-wednesday/private-offgrid.git diff --git a/App.tsx b/App.tsx index ef79264c..4064597f 100644 --- a/App.tsx +++ b/App.tsx @@ -12,8 +12,11 @@ import { NavigationContainer } from '@react-navigation/native'; import { AppNavigator } from './src/navigation'; import { useTheme } from './src/theme'; import { hardwareService, modelManager, authService, ragService, remoteServerManager } from './src/services'; -import logger from './src/utils/logger'; +import logger, { setLogListener } from './src/utils/logger'; import { useAppStore, useAuthStore, useRemoteServerStore } from './src/stores'; +import { useDebugLogsStore } from './src/stores/debugLogsStore'; +import { loadProFeatures } from './src/bootstrap/loadProFeatures'; +import { configureRevenueCat, checkProStatus } from './src/services/proLicenseService'; import { hydrateDownloadStore } from './src/services/downloadHydration'; import { useDownloadListeners } from './src/hooks/useDownloads'; import { LockScreen } from './src/screens'; @@ -22,6 +25,9 @@ import { useDownloadStore } from './src/stores/downloadStore'; LogBox.ignoreAllLogs(); // Suppress all logs +// Wire logger → in-app debug viewer (runs before any component mounts) +setLogListener((entry) => useDebugLogsStore.getState().addLog(entry)); + const ensureRemoteServerStoreHydrated = async () => { const persistApi = useRemoteServerStore.persist; if (!persistApi?.hasHydrated || !persistApi.rehydrate) return; @@ -166,6 +172,24 @@ function App() { // Initialize RAG database tables ragService.ensureReady().catch((err) => logger.error('Failed to initialize RAG service on startup', err)); + // Configure RevenueCat and read the cached entitlement before Pro features load. + // configureRevenueCat is sync; checkProStatus reads the keychain cache immediately + // and fires a background RC network sync so the next launch stays fresh. + // + // Pro is optional: a failure here (missing native module, keychain locked, + // bad RC config) must never abort app init or hang the splash screen, so the + // whole block is isolated and only logs on error. + try { + configureRevenueCat(); + const isPro = await checkProStatus(); + + // Load pro features — only activates if the keychain entitlement is set. + // Reuse the entitlement read above to avoid a second keychain round-trip. + await loadProFeatures(isPro); + } catch (proError) { + logger.error('[App] Pro initialization failed, continuing without Pro:', proError); + } + // Show the UI immediately setIsInitializing(false); diff --git a/__tests__/integration/generation/toolExtensionLoop.test.ts b/__tests__/integration/generation/toolExtensionLoop.test.ts new file mode 100644 index 00000000..a921cf6f --- /dev/null +++ b/__tests__/integration/generation/toolExtensionLoop.test.ts @@ -0,0 +1,178 @@ +/** + * Integration test: registered ToolExtension flows through runToolLoop. + * + * Verifies: + * 1. Extension system-prompt hint is appended to the system message. + * 2. Extension tool calls parsed from LLM text are collected. + * 3. Extension executor is called instead of the built-in executeToolCall. + * 4. Extension tool calls result in a tool-result message in the chat store. + * 5. Free path (no extensions): behaviour is identical to today. + */ + +import { runToolLoop, ToolLoopContext } from '../../../src/services/generationToolLoop'; +import { + registerToolExtension, + _clearExtensionsForTesting, + ToolExtension, +} from '../../../src/services/tools/extensions'; +import type { ToolCall } from '../../../src/services/tools/types'; +import { useChatStore } from '../../../src/stores'; +import { resetStores } from '../../utils/testHelpers'; + +// Mock the LLM so we control what it "says" +jest.mock('../../../src/services/llm'); +jest.mock('../../../src/services/litert'); +jest.mock('../../../src/services/activeModelService'); + +const { llmService } = require('../../../src/services/llm'); +const { liteRTService } = require('../../../src/services/litert'); + +// Mock executeToolCall so built-in tools don't actually run +jest.mock('../../../src/services/tools', () => ({ + getToolsAsOpenAISchema: jest.fn(() => []), + executeToolCall: jest.fn().mockResolvedValue({ name: 'builtin', content: 'builtin-result', durationMs: 1 }), +})); + +// Mock the stores index: pull in the REAL chat + app stores (the loop reads/writes +// them and the test asserts on stored messages), stub the remote server store so we +// stay on the local path. Requiring the submodules directly avoids loading the full +// stores index (auth/whisper/project), which fails to initialise in this test env. +jest.mock('../../../src/stores', () => { + const { useChatStore: realChatStore } = jest.requireActual('../../../src/stores/chatStore'); + const { useAppStore: realAppStore } = jest.requireActual('../../../src/stores/appStore'); + return { + useChatStore: realChatStore, + useAppStore: realAppStore, + useRemoteServerStore: { + getState: () => ({ activeServerId: null, activeRemoteTextModelId: null }), + }, + }; +}); + +// Fake extension that parses tool_name tags +const MCP_TOOL_NAME = 'mcp_fake_tool'; +const MCP_HINT = '\n\nMCP tools available:\n- mcp_fake_tool: A fake MCP tool'; +const MCP_RESULT = 'mcp-result-content'; + +function makeFakeExtension(executorMock: jest.Mock): ToolExtension { + return { + id: 'mcp', + getSystemPromptHint: () => MCP_HINT, + parseToolCalls: (text: string): ToolCall[] => { + const match = /([\s\S]*?)<\/mcp_call>/.exec(text); + if (!match) return []; + return [{ id: 'mcp-tc-1', name: match[1].trim(), arguments: {} }]; + }, + stripFromVisibleText: (text: string) => text.replace(/[\s\S]*?<\/mcp_call>/g, '').trim(), + canHandle: (name: string) => name === MCP_TOOL_NAME, + execute: executorMock, + enabledToolCount: () => 1, + }; +} + +function makeCtx(overrides: Partial = {}): ToolLoopContext { + // createConversation takes a modelId and returns the generated conversation UUID + const conversationId = useChatStore.getState().createConversation('test-model'); + return { + conversationId, + messages: [ + { id: 'sys', role: 'system', content: 'You are helpful.', timestamp: 0 }, + { id: 'u1', role: 'user', content: 'Run the MCP tool.', timestamp: 1 }, + ], + enabledToolIds: [], + isAborted: () => false, + onThinkingDone: jest.fn(), + onFinalResponse: jest.fn(), + ...overrides, + }; +} + +describe('tool extension loop integration', () => { + beforeEach(() => { + resetStores(); + _clearExtensionsForTesting(); + jest.clearAllMocks(); + liteRTService.isModelLoaded.mockReturnValue(false); + llmService.isModelLoaded.mockReturnValue(true); + llmService.stopGeneration.mockResolvedValue(undefined); + }); + + describe('free path — no extensions registered', () => { + it('calls onFinalResponse with the LLM text', async () => { + llmService.generateResponseWithTools.mockResolvedValue({ + fullResponse: 'Hello world', + toolCalls: [], + }); + const ctx = makeCtx(); + await runToolLoop(ctx); + expect(ctx.onFinalResponse).toHaveBeenCalledWith('Hello world'); + }); + }); + + function setupProExtension( + firstResponse = `${MCP_TOOL_NAME}`, + secondResponse = 'Done.', + ): jest.Mock { + const executorMock = jest.fn().mockResolvedValue({ + name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, + }); + registerToolExtension(makeFakeExtension(executorMock)); + llmService.generateResponseWithTools + .mockResolvedValueOnce({ fullResponse: firstResponse, toolCalls: [] }) + .mockResolvedValueOnce({ fullResponse: secondResponse, toolCalls: [] }); + return executorMock; + } + + describe('pro path — extension registered', () => { + it('appends extension hint to the system prompt sent to LLM', async () => { + setupProExtension(); + + const ctx = makeCtx(); + await runToolLoop(ctx); + + // System prompt in the first LLM call should contain the extension hint + const firstCallMessages = llmService.generateResponseWithTools.mock.calls[0][0] as any[]; + const sysMsg = firstCallMessages.find((m: any) => m.role === 'system'); + expect(sysMsg.content).toContain(MCP_HINT); + }); + + it('routes execution to the extension executor, not built-in executeToolCall', async () => { + const executorMock = setupProExtension(); + + const ctx = makeCtx(); + await runToolLoop(ctx); + + expect(executorMock).toHaveBeenCalledTimes(1); + expect(executorMock).toHaveBeenCalledWith( + expect.objectContaining({ name: MCP_TOOL_NAME }), + ); + + const { executeToolCall } = require('../../../src/services/tools'); + expect(executeToolCall).not.toHaveBeenCalled(); + }); + + it('stores tool result in chat store', async () => { + setupProExtension(); + + const ctx = makeCtx(); + await runToolLoop(ctx); + + const messages = useChatStore.getState().conversations.find(c => c.id === ctx.conversationId)?.messages ?? []; + const toolResultMsg = messages.find(m => m.role === 'tool' && m.toolName === MCP_TOOL_NAME); + expect(toolResultMsg).toBeDefined(); + expect(toolResultMsg?.content).toBe(MCP_RESULT); + }); + + it('strips extension syntax from visible text', async () => { + setupProExtension(`Thinking...${MCP_TOOL_NAME}`, 'Final answer.'); + + const ctx = makeCtx(); + await runToolLoop(ctx); + + // The assistant message stored for the tool-call turn must not contain the raw tag + const messages = useChatStore.getState().conversations.find(c => c.id === ctx.conversationId)?.messages ?? []; + const assistantMsg = messages.find(m => m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0); + expect(assistantMsg?.content).not.toContain(''); + }); + }); +}); diff --git a/__tests__/integration/onboarding/proBootFlow.test.ts b/__tests__/integration/onboarding/proBootFlow.test.ts new file mode 100644 index 00000000..58e3c2c9 --- /dev/null +++ b/__tests__/integration/onboarding/proBootFlow.test.ts @@ -0,0 +1,112 @@ +/** + * Integration: Pro boot flow + * + * Verifies that configureRevenueCat + checkProStatus run before loadProFeatures, + * and that Pro features only activate when the keychain entitlement is set. + */ + +jest.mock('react-native-purchases', () => ({ + __esModule: true, + default: { + setLogLevel: jest.fn(), + configure: jest.fn(), + getCustomerInfo: jest.fn().mockResolvedValue({ entitlements: { active: {} }, originalAppUserId: 'anon', allPurchaseDates: {} }), + invalidateCustomerInfoCache: jest.fn().mockResolvedValue(undefined), + getOfferings: jest.fn(), + purchasePackage: jest.fn(), + restorePurchases: jest.fn(), + logOut: jest.fn(), + }, + LOG_LEVEL: { DEBUG: 'debug', ERROR: 'error' }, +})); + +jest.mock('react-native-keychain', () => ({ + getGenericPassword: jest.fn(), + setGenericPassword: jest.fn(), + resetGenericPassword: jest.fn(), + ACCESSIBLE: { AFTER_FIRST_UNLOCK: 'AfterFirstUnlock' }, +})); + +jest.mock('../../../src/stores/appStore', () => { + const setHasRegisteredPro = jest.fn(); + return { useAppStore: { getState: () => ({ setHasRegisteredPro }) } }; +}); + +jest.mock('../../../src/services/tools/extensions', () => ({ registerToolExtension: jest.fn() })); +jest.mock('../../../src/navigation/screenRegistry', () => ({ registerScreen: jest.fn() })); +jest.mock('../../../src/components/settings/sectionRegistry', () => ({ registerSettingsSection: jest.fn() })); +jest.mock('@offgrid/pro', () => ({ activate: jest.fn() }), { virtual: true }); + +import { configureRevenueCat, checkProStatus } from '../../../src/services/proLicenseService'; +import { loadProFeatures } from '../../../src/bootstrap/loadProFeatures'; + +const Purchases = require('react-native-purchases').default; +const Keychain = require('react-native-keychain'); +const mockConfigure = Purchases.configure; +const mockGetCustomerInfo = Purchases.getCustomerInfo; +const mockGetGenericPassword = Keychain.getGenericPassword; +const mockSetGenericPassword = Keychain.setGenericPassword; +const mockActivate = require('@offgrid/pro').activate; +const mockSetHasRegisteredPro = require('../../../src/stores/appStore').useAppStore.getState().setHasRegisteredPro; + +describe('Pro boot flow integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('configures RevenueCat, reads entitlement, and skips Pro activation when not subscribed', async () => { + mockGetGenericPassword.mockResolvedValue(false); + mockGetCustomerInfo.mockResolvedValue({ entitlements: { active: {} } }); + + configureRevenueCat(); + await checkProStatus(); + await loadProFeatures(); + + expect(mockConfigure).toHaveBeenCalledTimes(1); + expect(mockActivate).not.toHaveBeenCalled(); + }); + + it('configures RevenueCat, reads entitlement, and activates Pro when subscribed', async () => { + const license = JSON.stringify({ isPro: true, verifiedAt: 0 }); + mockGetGenericPassword.mockResolvedValue({ password: license }); + mockGetCustomerInfo.mockResolvedValue({ + entitlements: { active: { pro: { productIdentifier: 'offgrid_pro' } } }, + }); + + configureRevenueCat(); + await checkProStatus(); + await loadProFeatures(); + + expect(mockConfigure).toHaveBeenCalledTimes(1); + expect(mockActivate).toHaveBeenCalledWith( + expect.objectContaining({ + registerToolExtension: expect.any(Function), + registerScreen: expect.any(Function), + registerSettingsSection: expect.any(Function), + }), + ); + }); + + it('background RC sync writes updated entitlement to store after boot', async () => { + // Keychain is empty but RC says the user is subscribed (e.g. new device install) + mockGetGenericPassword + .mockResolvedValueOnce(false) // first read in checkProStatus (returns cached false) + .mockResolvedValue({ password: JSON.stringify({ isPro: true, verifiedAt: 1 }) }); + mockGetCustomerInfo.mockResolvedValue({ + entitlements: { active: { pro: { productIdentifier: 'offgrid_pro' } } }, + }); + mockSetGenericPassword.mockResolvedValue(true); + + configureRevenueCat(); + const isPro = await checkProStatus(); + + // Cached value from empty keychain is false; background sync fires async + expect(isPro).toBe(false); + + // Allow the background syncWithRevenueCat to complete + await new Promise(resolve => setImmediate(resolve)); + + expect(mockSetGenericPassword).toHaveBeenCalledTimes(1); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true); + }); +}); diff --git a/__tests__/rntl/screens/ProDetailScreen.test.tsx b/__tests__/rntl/screens/ProDetailScreen.test.tsx new file mode 100644 index 00000000..39a6bc93 --- /dev/null +++ b/__tests__/rntl/screens/ProDetailScreen.test.tsx @@ -0,0 +1,118 @@ +/** + * ProDetailScreen Tests + * + * Renders the real Pro screen and exercises the purchase / restore handlers + * and the entitlement-driven UI states (Get Pro vs Pro Active). + */ + +import React from 'react'; +import { Alert } from 'react-native'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import { useAppStore } from '../../../src/stores/appStore'; + +const mockPresentProPaywall = jest.fn(); +const mockRestorePro = jest.fn(); +const mockResetProIdentityForTesting = jest.fn(); +jest.mock('../../../src/services/proLicenseService', () => ({ + presentProPaywall: (...args: unknown[]) => mockPresentProPaywall(...args), + restorePro: (...args: unknown[]) => mockRestorePro(...args), + resetProIdentityForTesting: (...args: unknown[]) => mockResetProIdentityForTesting(...args), +})); + +import { ProDetailScreen } from '../../../src/screens/ProDetailScreen'; + +describe('ProDetailScreen', () => { + let alertSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + useAppStore.setState({ hasRegisteredPro: false }); + alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + }); + + afterEach(() => { + alertSpy.mockRestore(); + }); + + it('renders the Get Pro call-to-action when the user is not Pro', () => { + const { queryAllByText } = render(); + expect(queryAllByText('Get Pro').length).toBeGreaterThan(0); + }); + + it('shows the restart prompt after a successful purchase', async () => { + mockPresentProPaywall.mockResolvedValueOnce(true); + const { getAllByText } = render(); + + fireEvent.press(getAllByText('Get Pro')[0]); + + await waitFor(() => expect(mockPresentProPaywall).toHaveBeenCalledTimes(1)); + expect(alertSpy).toHaveBeenCalledWith('Pro activated', expect.any(String), expect.anything()); + }); + + it('does not show the restart prompt when the purchase is not completed', async () => { + mockPresentProPaywall.mockResolvedValueOnce(false); + const { getAllByText } = render(); + + fireEvent.press(getAllByText('Get Pro')[0]); + + await waitFor(() => expect(mockPresentProPaywall).toHaveBeenCalledTimes(1)); + expect(alertSpy).not.toHaveBeenCalled(); + }); + + it('shows a failure alert when the purchase throws', async () => { + mockPresentProPaywall.mockRejectedValueOnce(new Error('boom')); + const { getAllByText } = render(); + + fireEvent.press(getAllByText('Get Pro')[0]); + + await waitFor(() => expect(alertSpy).toHaveBeenCalledWith('Purchase failed', expect.any(String))); + }); + + it('shows the restart prompt when a restore finds an active subscription', async () => { + mockRestorePro.mockResolvedValueOnce(true); + const { getByText } = render(); + + fireEvent.press(getByText('Restore purchases')); + + await waitFor(() => expect(mockRestorePro).toHaveBeenCalledTimes(1)); + expect(alertSpy).toHaveBeenCalledWith('Pro activated', expect.any(String), expect.anything()); + }); + + it('tells the user when a restore finds no purchases', async () => { + mockRestorePro.mockResolvedValueOnce(false); + const { getByText } = render(); + + fireEvent.press(getByText('Restore purchases')); + + await waitFor(() => expect(alertSpy).toHaveBeenCalledWith('No purchases found', expect.any(String))); + }); + + it('shows a failure alert when the restore throws', async () => { + mockRestorePro.mockRejectedValueOnce(new Error('boom')); + const { getByText } = render(); + + fireEvent.press(getByText('Restore purchases')); + + await waitFor(() => expect(alertSpy).toHaveBeenCalledWith('Restore failed', expect.any(String))); + }); + + it('renders the Pro Active state when the user already owns Pro', () => { + useAppStore.setState({ hasRegisteredPro: true }); + const { getByText, queryByText } = render(); + + expect(getByText('Pro Active')).toBeTruthy(); + expect(getByText('Pro is active on this account.')).toBeTruthy(); + expect(queryByText('Restore purchases')).toBeNull(); + }); + + it('runs the dev reset and confirms when the Pro user taps [DEV] reset', async () => { + useAppStore.setState({ hasRegisteredPro: true }); + mockResetProIdentityForTesting.mockResolvedValueOnce(undefined); + const { getByText } = render(); + + fireEvent.press(getByText('[DEV] Reset Pro identity')); + + await waitFor(() => expect(mockResetProIdentityForTesting).toHaveBeenCalledTimes(1)); + expect(alertSpy).toHaveBeenCalledWith('Dev reset', expect.any(String)); + }); +}); diff --git a/__tests__/unit/components/settings/sectionRegistry.test.ts b/__tests__/unit/components/settings/sectionRegistry.test.ts new file mode 100644 index 00000000..5ef5a517 --- /dev/null +++ b/__tests__/unit/components/settings/sectionRegistry.test.ts @@ -0,0 +1,33 @@ +import { + registerSettingsSection, + getSettingsSections, + _clearSectionsForTesting, +} from '../../../../src/components/settings/sectionRegistry'; + +const FakeSection = () => null; +const AnotherSection = () => null; + +describe('settings section registry', () => { + beforeEach(() => { + _clearSectionsForTesting(); + }); + + it('returns empty array when nothing registered', () => { + expect(getSettingsSections()).toEqual([]); + }); + + it('registers a section component', () => { + registerSettingsSection(FakeSection); + expect(getSettingsSections()).toHaveLength(1); + expect(getSettingsSections()[0]).toBe(FakeSection); + }); + + it('registers multiple sections in order', () => { + registerSettingsSection(FakeSection); + registerSettingsSection(AnotherSection); + const sections = getSettingsSections(); + expect(sections).toHaveLength(2); + expect(sections[0]).toBe(FakeSection); + expect(sections[1]).toBe(AnotherSection); + }); +}); diff --git a/__tests__/unit/navigation/screenRegistry.test.ts b/__tests__/unit/navigation/screenRegistry.test.ts new file mode 100644 index 00000000..976d55cb --- /dev/null +++ b/__tests__/unit/navigation/screenRegistry.test.ts @@ -0,0 +1,38 @@ +import { + registerScreen, + getRegisteredScreens, + _clearScreensForTesting, +} from '../../../src/navigation/screenRegistry'; + +const FakeScreen = () => null; +const AnotherScreen = () => null; + +describe('screen registry', () => { + beforeEach(() => { + _clearScreensForTesting(); + }); + + it('returns empty array when nothing registered', () => { + expect(getRegisteredScreens()).toEqual([]); + }); + + it('registers a screen', () => { + registerScreen({ name: 'McpServers', component: FakeScreen }); + expect(getRegisteredScreens()).toHaveLength(1); + expect(getRegisteredScreens()[0].name).toBe('McpServers'); + expect(getRegisteredScreens()[0].component).toBe(FakeScreen); + }); + + it('registers multiple screens', () => { + registerScreen({ name: 'McpServers', component: FakeScreen }); + registerScreen({ name: 'DebugLogs', component: AnotherScreen }); + expect(getRegisteredScreens()).toHaveLength(2); + expect(getRegisteredScreens().map(s => s.name)).toEqual(['McpServers', 'DebugLogs']); + }); + + it('allows duplicate names (no dedup — navigation handles it)', () => { + registerScreen({ name: 'McpServers', component: FakeScreen }); + registerScreen({ name: 'McpServers', component: AnotherScreen }); + expect(getRegisteredScreens()).toHaveLength(2); + }); +}); diff --git a/__tests__/unit/services/loadProFeatures.test.ts b/__tests__/unit/services/loadProFeatures.test.ts new file mode 100644 index 00000000..03f5e9ab --- /dev/null +++ b/__tests__/unit/services/loadProFeatures.test.ts @@ -0,0 +1,71 @@ +import { loadProFeatures } from '../../../src/bootstrap/loadProFeatures'; + +jest.mock('../../../src/services/tools/extensions', () => ({ + registerToolExtension: jest.fn(), +})); +jest.mock('../../../src/navigation/screenRegistry', () => ({ + registerScreen: jest.fn(), +})); +jest.mock('../../../src/components/settings/sectionRegistry', () => ({ + registerSettingsSection: jest.fn(), +})); + +const mockReadProFromKeychain = jest.fn(); +jest.mock('../../../src/services/proLicenseService', () => ({ + readProFromKeychain: (...args: any[]) => mockReadProFromKeychain(...args), +})); + +describe('loadProFeatures()', () => { + beforeEach(() => { + jest.resetModules(); + mockReadProFromKeychain.mockResolvedValue(false); + }); + + it('returns without error when @offgrid/pro package is not installed', async () => { + jest.mock('@offgrid/pro', () => { throw new Error('Cannot find module'); }, { virtual: true }); + await expect(loadProFeatures()).resolves.toBeUndefined(); + }); + + it('returns without error when @offgrid/pro resolves to null (stub build)', async () => { + jest.mock('@offgrid/pro', () => null, { virtual: true }); + await expect(loadProFeatures()).resolves.toBeUndefined(); + }); + + it('does not call pro.activate when there is no entitlement', async () => { + const mockActivate = jest.fn(); + jest.mock('@offgrid/pro', () => ({ activate: mockActivate }), { virtual: true }); + mockReadProFromKeychain.mockResolvedValueOnce(false); + await loadProFeatures(); + expect(mockActivate).not.toHaveBeenCalled(); + }); + + it('calls pro.activate with the three registries when entitlement is active', async () => { + const mockActivate = jest.fn(); + jest.mock('@offgrid/pro', () => ({ activate: mockActivate }), { virtual: true }); + mockReadProFromKeychain.mockResolvedValueOnce(true); + await loadProFeatures(); + expect(mockActivate).toHaveBeenCalledWith( + expect.objectContaining({ + registerToolExtension: expect.any(Function), + registerScreen: expect.any(Function), + registerSettingsSection: expect.any(Function), + }), + ); + }); + + it('reuses a passed isPro=true without re-reading the keychain', async () => { + const mockActivate = jest.fn(); + jest.mock('@offgrid/pro', () => ({ activate: mockActivate }), { virtual: true }); + await loadProFeatures(true); + expect(mockActivate).toHaveBeenCalledTimes(1); + expect(mockReadProFromKeychain).not.toHaveBeenCalled(); + }); + + it('reuses a passed isPro=false without re-reading the keychain', async () => { + const mockActivate = jest.fn(); + jest.mock('@offgrid/pro', () => ({ activate: mockActivate }), { virtual: true }); + await loadProFeatures(false); + expect(mockActivate).not.toHaveBeenCalled(); + expect(mockReadProFromKeychain).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/unit/services/proLicenseService.test.ts b/__tests__/unit/services/proLicenseService.test.ts new file mode 100644 index 00000000..6fcf1e15 --- /dev/null +++ b/__tests__/unit/services/proLicenseService.test.ts @@ -0,0 +1,301 @@ +import { + readProFromKeychain, + checkProStatus, + presentProPaywall, + restorePro, + clearProForTesting, + configureRevenueCat, + resetProIdentityForTesting, +} from '../../../src/services/proLicenseService'; + +jest.mock('react-native-purchases', () => ({ + __esModule: true, + default: { + setLogLevel: jest.fn(), + configure: jest.fn(), + getCustomerInfo: jest.fn().mockResolvedValue({ entitlements: { active: {} }, originalAppUserId: 'anon', allPurchaseDates: {} }), + restorePurchases: jest.fn(), + getOfferings: jest.fn(), + purchasePackage: jest.fn(), + invalidateCustomerInfoCache: jest.fn().mockResolvedValue(undefined), + logOut: jest.fn(), + }, + LOG_LEVEL: { DEBUG: 'debug', ERROR: 'error' }, +})); + +jest.mock('react-native-keychain', () => ({ + getGenericPassword: jest.fn(), + setGenericPassword: jest.fn(), + resetGenericPassword: jest.fn(), + ACCESSIBLE: { AFTER_FIRST_UNLOCK: 'AfterFirstUnlock' }, +})); + +const mockSetHasRegisteredPro = jest.fn(); +jest.mock('../../../src/stores/appStore', () => ({ + useAppStore: { getState: () => ({ setHasRegisteredPro: mockSetHasRegisteredPro }) }, +})); + +const { getGenericPassword: mockGetGenericPassword, setGenericPassword: mockSetGenericPassword, resetGenericPassword: mockResetGenericPassword } = + require('react-native-keychain'); +const Purchases = require('react-native-purchases').default; + +const makeOffering = () => ({ + all: {}, + current: { + identifier: 'default', + availablePackages: [ + { identifier: '$rc_lifetime', product: { identifier: 'off_grid_pro_lifetime', priceString: '$9.99' } }, + ], + }, +}); + +const ENTITLEMENT_ACTIVE = { pro: { productIdentifier: 'pro_monthly' } }; +const ENTITLEMENT_EMPTY = {}; + +describe('proLicenseService', () => { + beforeAll(() => { + // configureRevenueCat sets the module-level isConfigured flag that the + // purchase/restore/reset entry points require. Pin Platform.OS first since + // its default varies in the RN test environment. + const Platform = require('react-native').Platform; + Platform.OS = 'ios'; + configureRevenueCat(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('readProFromKeychain()', () => { + it('returns false when no keychain entry exists', async () => { + mockGetGenericPassword.mockResolvedValueOnce(false); + expect(await readProFromKeychain()).toBe(false); + }); + + it('returns false when keychain entry has isPro=false', async () => { + mockGetGenericPassword.mockResolvedValueOnce({ password: JSON.stringify({ isPro: false, verifiedAt: 0 }) }); + expect(await readProFromKeychain()).toBe(false); + }); + + it('returns true when keychain entry has isPro=true', async () => { + mockGetGenericPassword.mockResolvedValueOnce({ password: JSON.stringify({ isPro: true, verifiedAt: 0 }) }); + expect(await readProFromKeychain()).toBe(true); + }); + + it('returns false when keychain entry is malformed', async () => { + mockGetGenericPassword.mockResolvedValueOnce({ password: 'not-json' }); + expect(await readProFromKeychain()).toBe(false); + }); + }); + + describe('checkProStatus()', () => { + it('returns the cached keychain value immediately', async () => { + mockGetGenericPassword.mockResolvedValueOnce({ password: JSON.stringify({ isPro: true, verifiedAt: 0 }) }); + Purchases.getCustomerInfo.mockResolvedValueOnce({ entitlements: { active: ENTITLEMENT_ACTIVE } }); + expect(await checkProStatus()).toBe(true); + }); + + it('returns false when keychain is empty', async () => { + mockGetGenericPassword.mockResolvedValueOnce(false); + Purchases.getCustomerInfo.mockResolvedValueOnce({ entitlements: { active: ENTITLEMENT_EMPTY } }); + expect(await checkProStatus()).toBe(false); + }); + }); + + describe('presentProPaywall()', () => { + it('returns true and writes license when the purchase grants the entitlement', async () => { + Purchases.getOfferings.mockResolvedValueOnce(makeOffering()); + Purchases.purchasePackage.mockResolvedValueOnce({ + customerInfo: { entitlements: { active: ENTITLEMENT_ACTIVE }, originalAppUserId: 'anon' }, + }); + mockSetGenericPassword.mockResolvedValueOnce(true); + expect(await presentProPaywall()).toBe(true); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true); + }); + + it('returns false when the purchase does not grant the entitlement', async () => { + Purchases.getOfferings.mockResolvedValueOnce(makeOffering()); + Purchases.purchasePackage.mockResolvedValueOnce({ + customerInfo: { entitlements: { active: ENTITLEMENT_EMPTY }, originalAppUserId: 'anon' }, + }); + expect(await presentProPaywall()).toBe(false); + expect(mockSetHasRegisteredPro).not.toHaveBeenCalled(); + }); + + it('returns false when the user cancels', async () => { + Purchases.getOfferings.mockResolvedValueOnce(makeOffering()); + Purchases.purchasePackage.mockRejectedValueOnce({ userCancelled: true }); + expect(await presentProPaywall()).toBe(false); + expect(mockSetHasRegisteredPro).not.toHaveBeenCalled(); + }); + + it('still reports success when the keychain write fails after a granted purchase', async () => { + Purchases.getOfferings.mockResolvedValueOnce(makeOffering()); + Purchases.purchasePackage.mockResolvedValueOnce({ + customerInfo: { entitlements: { active: ENTITLEMENT_ACTIVE }, originalAppUserId: 'anon' }, + }); + // A keychain failure must not turn a charged purchase into a "Purchase failed". + mockSetGenericPassword.mockRejectedValueOnce(new Error('keychain locked')); + expect(await presentProPaywall()).toBe(true); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true); + }); + + it('falls back to the first package when no $rc_lifetime package exists', async () => { + const offering = makeOffering(); + offering.current.availablePackages = [ + { identifier: '$rc_monthly', product: { identifier: 'off_grid_pro_monthly', priceString: '$1.99' } }, + ]; + Purchases.getOfferings.mockResolvedValueOnce(offering); + Purchases.purchasePackage.mockResolvedValueOnce({ + customerInfo: { entitlements: { active: ENTITLEMENT_ACTIVE }, originalAppUserId: 'anon' }, + }); + mockSetGenericPassword.mockResolvedValueOnce(true); + expect(await presentProPaywall()).toBe(true); + expect(Purchases.purchasePackage).toHaveBeenCalledWith(offering.current.availablePackages[0]); + }); + }); + + describe('restorePro()', () => { + it('returns true and updates store when entitlement is active', async () => { + Purchases.restorePurchases.mockResolvedValueOnce({ entitlements: { active: ENTITLEMENT_ACTIVE } }); + mockSetGenericPassword.mockResolvedValueOnce(true); + expect(await restorePro()).toBe(true); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true); + }); + + it('returns false and updates store when entitlement is not active', async () => { + Purchases.restorePurchases.mockResolvedValueOnce({ entitlements: { active: ENTITLEMENT_EMPTY } }); + mockSetGenericPassword.mockResolvedValueOnce(true); + expect(await restorePro()).toBe(false); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(false); + }); + }); + + describe('clearProForTesting()', () => { + it('resets keychain and clears store', async () => { + mockResetGenericPassword.mockResolvedValueOnce(true); + await clearProForTesting(); + expect(mockResetGenericPassword).toHaveBeenCalledTimes(1); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(false); + }); + }); + + describe('presentProPaywall() error paths', () => { + it('throws when there is no current offering', async () => { + Purchases.getOfferings.mockResolvedValueOnce({ all: {}, current: null }); + await expect(presentProPaywall()).rejects.toThrow('No offering available'); + }); + + it('throws when the current offering has no packages', async () => { + Purchases.getOfferings.mockResolvedValueOnce({ + all: {}, + current: { identifier: 'default', availablePackages: [] }, + }); + await expect(presentProPaywall()).rejects.toThrow('No package available'); + }); + }); + + describe('configureRevenueCat()', () => { + it('configures RC SDK on iOS', () => { + const Platform = require('react-native').Platform; + Platform.OS = 'ios'; + configureRevenueCat(); + expect(Purchases.configure).toHaveBeenCalledTimes(1); + }); + + it('configures RC SDK on Android', () => { + const Platform = require('react-native').Platform; + Platform.OS = 'android'; + configureRevenueCat(); + expect(Purchases.configure).toHaveBeenCalledTimes(1); + }); + + it('rethrows when the RC SDK fails to configure', () => { + const Platform = require('react-native').Platform; + Platform.OS = 'ios'; + Purchases.configure.mockImplementationOnce(() => { + throw new Error('native module missing'); + }); + expect(() => configureRevenueCat()).toThrow('native module missing'); + }); + + it('skips configuration on unsupported platforms (e.g. web)', () => { + const Platform = require('react-native').Platform; + Platform.OS = 'web'; + configureRevenueCat(); + expect(Purchases.configure).not.toHaveBeenCalled(); + Platform.OS = 'ios'; + }); + }); + + describe('guards when the SDK is not configured', () => { + // A fresh module instance where configureRevenueCat() never ran, so the + // module-level isConfigured flag is false. + let svc: typeof import('../../../src/services/proLicenseService'); + let isolatedKeychain: { getGenericPassword: jest.Mock; resetGenericPassword: jest.Mock }; + let isolatedPurchases: { getCustomerInfo: jest.Mock }; + + beforeEach(() => { + jest.isolateModules(() => { + svc = require('../../../src/services/proLicenseService'); + isolatedKeychain = require('react-native-keychain'); + isolatedPurchases = require('react-native-purchases').default; + }); + }); + + it('presentProPaywall throws', async () => { + await expect(svc.presentProPaywall()).rejects.toThrow('RevenueCat is not configured'); + }); + + it('restorePro throws', async () => { + await expect(svc.restorePro()).rejects.toThrow('RevenueCat is not configured'); + }); + + it('resetProIdentityForTesting no-ops without touching the keychain', async () => { + await svc.resetProIdentityForTesting(); + expect(isolatedKeychain.resetGenericPassword).not.toHaveBeenCalled(); + }); + + it('checkProStatus does not fire a background sync', async () => { + isolatedKeychain.getGenericPassword.mockResolvedValue(false); + expect(await svc.checkProStatus()).toBe(false); + await new Promise(resolve => setImmediate(resolve)); + expect(isolatedPurchases.getCustomerInfo).not.toHaveBeenCalled(); + }); + }); + + describe('resetProIdentityForTesting()', () => { + it('skips logOut for an anonymous user and clears the keychain', async () => { + Purchases.getCustomerInfo.mockResolvedValueOnce({ + originalAppUserId: '$RCAnonymousID:abc', + entitlements: { active: {} }, + allPurchaseDates: {}, + }); + await resetProIdentityForTesting(); + expect(Purchases.logOut).not.toHaveBeenCalled(); + expect(mockResetGenericPassword).toHaveBeenCalledTimes(1); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(false); + }); + + it('logs out an identified user before clearing the keychain', async () => { + Purchases.getCustomerInfo + .mockResolvedValueOnce({ + originalAppUserId: 'real-user-123', + entitlements: { active: {} }, + allPurchaseDates: {}, + }) + .mockResolvedValueOnce({ originalAppUserId: '$RCAnonymousID:new', entitlements: { active: {} } }); + Purchases.logOut.mockResolvedValueOnce(undefined); + await resetProIdentityForTesting(); + expect(Purchases.logOut).toHaveBeenCalledTimes(1); + expect(mockResetGenericPassword).toHaveBeenCalledTimes(1); + }); + + it('continues clearing the keychain when the RC lookup throws', async () => { + Purchases.getCustomerInfo.mockRejectedValueOnce(new Error('network down')); + await resetProIdentityForTesting(); + expect(mockResetGenericPassword).toHaveBeenCalledTimes(1); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/__tests__/unit/services/toolHandlers.test.ts b/__tests__/unit/services/toolHandlers.test.ts index 2bdadef0..f7ba7df1 100644 --- a/__tests__/unit/services/toolHandlers.test.ts +++ b/__tests__/unit/services/toolHandlers.test.ts @@ -5,17 +5,28 @@ */ import { executeToolCall } from '../../../src/services/tools/handlers'; +import { Linking } from 'react-native'; // Mock fetch globally const mockFetch = jest.fn(); (globalThis as any).fetch = mockFetch; +const mockOpenURL = jest.spyOn(Linking, 'openURL'); + // Mock RAG service for search_knowledge_base tests const mockSearchProject = jest.fn(); jest.mock('../../../src/services/rag', () => ({ ragService: { searchProject: (...args: any[]) => mockSearchProject(...args) }, })); +// Mock react-native-calendar-events +jest.mock('react-native-calendar-events', () => ({ + requestPermissions: jest.fn(), + saveEvent: jest.fn(), + fetchAllEvents: jest.fn(), +})); +const RNCalendarEvents = require('react-native-calendar-events'); + describe('read_url handler', () => { beforeEach(() => { jest.clearAllMocks(); @@ -275,3 +286,219 @@ describe('search_knowledge_base handler', () => { expect(typeof result.durationMs).toBe('number'); }); }); + +describe('calendar handlers', () => { + beforeEach(() => { + jest.clearAllMocks(); + RNCalendarEvents.requestPermissions.mockResolvedValue('authorized'); + RNCalendarEvents.saveEvent.mockResolvedValue('event-id-123'); + RNCalendarEvents.fetchAllEvents.mockResolvedValue([]); + }); + + describe('create_calendar_event', () => { + it('throws when calendar package is unavailable', async () => { + const origFn = RNCalendarEvents.requestPermissions; + delete RNCalendarEvents.requestPermissions; + const result = await executeToolCall({ + id: 'cal_1', + name: 'create_calendar_event', + arguments: { title: 'Meeting', start_date: '2025-01-01T10:00:00Z', end_date: '2025-01-01T11:00:00Z' }, + }); + RNCalendarEvents.requestPermissions = origFn; + expect(result.error).toContain('Calendar package not available'); + }); + + it('throws when calendar permission is denied', async () => { + RNCalendarEvents.requestPermissions.mockResolvedValue('denied'); + const result = await executeToolCall({ + id: 'cal_2', + name: 'create_calendar_event', + arguments: { title: 'Meeting', start_date: '2025-01-01T10:00:00Z', end_date: '2025-01-01T11:00:00Z' }, + }); + expect(result.error).toContain('Calendar permission denied'); + }); + + it('throws on invalid date format', async () => { + const result = await executeToolCall({ + id: 'cal_3', + name: 'create_calendar_event', + arguments: { title: 'Meeting', start_date: 'not-a-date', end_date: '2025-01-01T11:00:00Z' }, + }); + expect(result.error).toContain('Invalid start_date'); + }); + + it('creates event and returns success message', async () => { + const result = await executeToolCall({ + id: 'cal_4', + name: 'create_calendar_event', + arguments: { title: 'Meeting', start_date: '2025-01-01T10:00:00Z', end_date: '2025-01-01T11:00:00Z' }, + }); + expect(result.error).toBeUndefined(); + expect(result.content).toContain('Meeting'); + }); + + it('includes location suffix when location is provided', async () => { + const result = await executeToolCall({ + id: 'cal_5', + name: 'create_calendar_event', + arguments: { title: 'Standup', start_date: '2025-01-01T09:00:00Z', end_date: '2025-01-01T09:30:00Z', location: 'Room 4' }, + }); + expect(result.content).toContain('at Room 4'); + expect(RNCalendarEvents.saveEvent).toHaveBeenCalledWith('Standup', expect.objectContaining({ location: 'Room 4' })); + }); + + it('passes notes to saveEvent when provided', async () => { + const result = await executeToolCall({ + id: 'cal_6', + name: 'create_calendar_event', + arguments: { title: 'Review', start_date: '2025-01-02T14:00:00Z', end_date: '2025-01-02T15:00:00Z', notes: 'Bring slides' }, + }); + expect(result.error).toBeUndefined(); + expect(RNCalendarEvents.saveEvent).toHaveBeenCalledWith('Review', expect.objectContaining({ notes: 'Bring slides' })); + }); + }); + + describe('read_calendar_events', () => { + it('returns no-events message when calendar is empty', async () => { + RNCalendarEvents.fetchAllEvents.mockResolvedValue([]); + const result = await executeToolCall({ + id: 'cal_7', + name: 'read_calendar_events', + arguments: { start_date: '2025-01-01T00:00:00Z', end_date: '2025-01-07T00:00:00Z' }, + }); + expect(result.content).toContain('No calendar events found'); + }); + + it('uses current date when no start_date provided', async () => { + RNCalendarEvents.fetchAllEvents.mockResolvedValue([]); + const result = await executeToolCall({ + id: 'cal_8', + name: 'read_calendar_events', + arguments: {}, + }); + expect(result.error).toBeUndefined(); + expect(RNCalendarEvents.fetchAllEvents).toHaveBeenCalledTimes(1); + }); + + it('throws on invalid start date', async () => { + const result = await executeToolCall({ + id: 'cal_9', + name: 'read_calendar_events', + arguments: { start_date: 'bad-date' }, + }); + expect(result.error).toContain('Invalid start date'); + }); + + it('throws on invalid end date', async () => { + const result = await executeToolCall({ + id: 'cal_10', + name: 'read_calendar_events', + arguments: { start_date: '2025-01-01T00:00:00Z', end_date: 'bad-date' }, + }); + expect(result.error).toContain('Invalid end date'); + }); + + it('formats events with location and notes', async () => { + RNCalendarEvents.fetchAllEvents.mockResolvedValue([ + { title: 'Sprint Review', startDate: '2025-01-03T10:00:00Z', endDate: '2025-01-03T11:00:00Z', location: 'Conf Room', notes: 'Bring laptop' }, + ]); + const result = await executeToolCall({ + id: 'cal_11', + name: 'read_calendar_events', + arguments: { start_date: '2025-01-01T00:00:00Z', end_date: '2025-01-07T00:00:00Z' }, + }); + expect(result.content).toContain('Sprint Review'); + expect(result.content).toContain('Location: Conf Room'); + expect(result.content).toContain('Notes: Bring laptop'); + }); + + it('formats events without location and notes', async () => { + RNCalendarEvents.fetchAllEvents.mockResolvedValue([ + { title: 'Daily Standup', startDate: '2025-01-03T09:00:00Z', endDate: null }, + ]); + const result = await executeToolCall({ + id: 'cal_12', + name: 'read_calendar_events', + arguments: { start_date: '2025-01-01T00:00:00Z', end_date: '2025-01-07T00:00:00Z' }, + }); + expect(result.content).toContain('Daily Standup'); + expect(result.content).toContain('unknown'); + expect(result.content).not.toContain('Location:'); + }); + }); +}); + +describe('web_search handler', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns error for missing query parameter', async () => { + const result = await executeToolCall({ id: 'ws_1', name: 'web_search', arguments: {} }); + expect(result.error).toContain('Missing required parameter: query'); + }); + + it('returns no-results message when search returns empty HTML', async () => { + mockFetch.mockResolvedValue({ text: async () => 'Nothing here' }); + const result = await executeToolCall({ id: 'ws_2', name: 'web_search', arguments: { query: 'xyzzy' } }); + expect(result.error).toBeUndefined(); + expect(result.content).toContain('No results found'); + }); + + it('returns formatted results when search finds matches', async () => { + const html = [ + '
', + 'Click me', + 'Example Title', + '

This is a snippet about the result

', + '
', + ].join(''); + mockFetch.mockResolvedValue({ text: async () => html }); + const result = await executeToolCall({ id: 'ws_3', name: 'web_search', arguments: { query: 'example' } }); + expect(result.error).toBeUndefined(); + expect(result.content).toBeDefined(); + }); +}); + +describe('send_email handler', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOpenURL.mockResolvedValue(undefined); + }); + + it('opens mail app with to, subject, and body', async () => { + const result = await executeToolCall({ + id: 'se_1', + name: 'send_email', + arguments: { to: 'test@example.com', subject: 'Hello', body: 'World' }, + }); + expect(result.error).toBeUndefined(); + expect(result.content).toContain('test@example.com'); + expect(result.content).toContain('Hello'); + expect(mockOpenURL).toHaveBeenCalledWith(expect.stringContaining('mailto:')); + }); + + it('opens mail app with only the to address when no subject or body', async () => { + const result = await executeToolCall({ + id: 'se_2', + name: 'send_email', + arguments: { to: 'user@example.com' }, + }); + expect(result.error).toBeUndefined(); + expect(result.content).toContain('user@example.com'); + expect(mockOpenURL).toHaveBeenCalledWith(expect.stringContaining('mailto:')); + }); + + it('returns error when mail app cannot be opened', async () => { + mockOpenURL.mockRejectedValue(new Error('No mail app')); + const result = await executeToolCall({ + id: 'se_3', + name: 'send_email', + arguments: { to: 'fail@example.com' }, + }); + expect(result.error).toContain('mail app'); + }); + + it('returns error for missing to parameter', async () => { + const result = await executeToolCall({ id: 'se_4', name: 'send_email', arguments: {} }); + expect(result.error).toContain('Missing required parameter: to'); + }); +}); diff --git a/__tests__/unit/services/tools/extensions.test.ts b/__tests__/unit/services/tools/extensions.test.ts new file mode 100644 index 00000000..fea829d4 --- /dev/null +++ b/__tests__/unit/services/tools/extensions.test.ts @@ -0,0 +1,58 @@ +import { + registerToolExtension, + getToolExtensions, + _clearExtensionsForTesting, + ToolExtension, +} from '../../../../src/services/tools/extensions'; + +const makeExt = (id: string, toolCount = 0): ToolExtension => ({ + id, + getSystemPromptHint: () => `[hint:${id}]`, + parseToolCalls: () => [], + stripFromVisibleText: (text: string) => text, + canHandle: () => false, + execute: () => Promise.resolve({ name: id, content: '', durationMs: 0 }), + enabledToolCount: () => toolCount, +}); + +describe('tool extension registry', () => { + beforeEach(() => { + _clearExtensionsForTesting(); + }); + + it('returns empty array when no extensions registered', () => { + expect(getToolExtensions()).toEqual([]); + }); + + it('registers a single extension', () => { + const ext = makeExt('mcp'); + registerToolExtension(ext); + expect(getToolExtensions()).toHaveLength(1); + expect(getToolExtensions()[0].id).toBe('mcp'); + }); + + it('ignores duplicate registrations by id', () => { + const ext1 = makeExt('mcp'); + const ext2 = makeExt('mcp'); + registerToolExtension(ext1); + registerToolExtension(ext2); + expect(getToolExtensions()).toHaveLength(1); + expect(getToolExtensions()[0]).toBe(ext1); + }); + + it('allows multiple extensions with different ids', () => { + registerToolExtension(makeExt('mcp')); + registerToolExtension(makeExt('calendar')); + expect(getToolExtensions()).toHaveLength(2); + }); + + it('returns extension with correct interface', () => { + registerToolExtension(makeExt('mcp', 3)); + const [ext] = getToolExtensions(); + expect(ext.getSystemPromptHint()).toBe('[hint:mcp]'); + expect(ext.enabledToolCount()).toBe(3); + expect(ext.parseToolCalls('anything')).toEqual([]); + expect(ext.stripFromVisibleText('hello')).toBe('hello'); + expect(ext.canHandle('any_tool')).toBe(false); + }); +}); diff --git a/__tests__/unit/services/tools/handlers.test.ts b/__tests__/unit/services/tools/handlers.test.ts index 74e14143..0385ea2b 100644 --- a/__tests__/unit/services/tools/handlers.test.ts +++ b/__tests__/unit/services/tools/handlers.test.ts @@ -27,6 +27,18 @@ jest.mock('../../../../src/services/rag', () => ({ }, })); +const mockSaveEvent = jest.fn(); +const mockRequestPermissions = jest.fn(); +const mockFetchAllEvents = jest.fn(); +jest.mock('react-native-calendar-events', () => ({ + __esModule: true, + default: { + saveEvent: (...args: any[]) => mockSaveEvent(...args), + requestPermissions: (...args: any[]) => mockRequestPermissions(...args), + fetchAllEvents: (...args: any[]) => mockFetchAllEvents(...args), + }, +})); + // ============================================================================ // Helpers // ============================================================================ @@ -561,4 +573,143 @@ describe('Tool Handlers', () => { expect(result.error).toBeUndefined(); }); }); + + // ========================================================================== + // Calendar handlers + // ========================================================================== + describe('calendar handlers', () => { + beforeEach(() => { + mockSaveEvent.mockReset(); + mockRequestPermissions.mockReset(); + mockFetchAllEvents.mockReset(); + }); + + describe('create_calendar_event', () => { + const validArgs = { + title: 'Dentist', + start_date: '2026-07-01T09:00:00.000Z', + end_date: '2026-07-01T10:00:00.000Z', + location: 'Clinic', + notes: 'bring insurance card', + }; + + it('requests write permission and saves the event', async () => { + mockRequestPermissions.mockResolvedValue('authorized'); + mockSaveEvent.mockResolvedValue('event-id-1'); + + const result = await runTool('create_calendar_event', validArgs); + + expect(result.error).toBeUndefined(); + expect(mockRequestPermissions).toHaveBeenCalledWith(false); + expect(mockSaveEvent).toHaveBeenCalledWith( + 'Dentist', + expect.objectContaining({ + startDate: '2026-07-01T09:00:00.000Z', + endDate: '2026-07-01T10:00:00.000Z', + location: 'Clinic', + // notes (iOS) and description (Android) both set from the same input + notes: 'bring insurance card', + description: 'bring insurance card', + }), + ); + expect(result.content).toContain('Dentist'); + }); + + it('returns an error when permission is denied', async () => { + mockRequestPermissions.mockResolvedValue('denied'); + + const result = await runTool('create_calendar_event', validArgs); + + expect(result.error).toBe('Calendar permission denied'); + expect(mockSaveEvent).not.toHaveBeenCalled(); + }); + + it('returns an error for an invalid start date', async () => { + mockRequestPermissions.mockResolvedValue('authorized'); + + const result = await runTool('create_calendar_event', { + title: 'Bad', + start_date: 'not-a-date', + end_date: 'also-bad', + }); + + expect(result.error).toContain('Invalid start_date'); + expect(mockSaveEvent).not.toHaveBeenCalled(); + }); + + it('defaults end_date to one hour after start when omitted', async () => { + mockRequestPermissions.mockResolvedValue('authorized'); + mockSaveEvent.mockResolvedValue('event-id-3'); + + const result = await runTool('create_calendar_event', { + title: 'Lunch', + start_date: '2026-07-01T13:00:00.000Z', + }); + + expect(result.error).toBeUndefined(); + expect(mockSaveEvent).toHaveBeenCalledWith( + 'Lunch', + expect.objectContaining({ + startDate: '2026-07-01T13:00:00.000Z', + endDate: '2026-07-01T14:00:00.000Z', + }), + ); + }); + + it('omits optional fields when not provided', async () => { + mockRequestPermissions.mockResolvedValue('authorized'); + mockSaveEvent.mockResolvedValue('event-id-2'); + + await runTool('create_calendar_event', { + title: 'Standup', + start_date: '2026-07-01T09:00:00.000Z', + end_date: '2026-07-01T09:15:00.000Z', + }); + + const details = mockSaveEvent.mock.calls[0][1]; + expect(details).not.toHaveProperty('location'); + expect(details).not.toHaveProperty('notes'); + expect(details).not.toHaveProperty('description'); + }); + }); + + describe('read_calendar_events', () => { + it('returns a formatted list of events', async () => { + mockRequestPermissions.mockResolvedValue('authorized'); + mockFetchAllEvents.mockResolvedValue([ + { + title: 'Lunch', + startDate: '2026-07-01T12:00:00.000Z', + endDate: '2026-07-01T13:00:00.000Z', + location: 'Cafe', + }, + ]); + + const result = await runTool('read_calendar_events', {}); + + expect(result.error).toBeUndefined(); + expect(result.content).toContain('Lunch'); + expect(result.content).toContain('Cafe'); + }); + + it('reports when no events are found', async () => { + mockRequestPermissions.mockResolvedValue('authorized'); + mockFetchAllEvents.mockResolvedValue([]); + + const result = await runTool('read_calendar_events', {}); + + expect(result.error).toBeUndefined(); + expect(result.content).toContain('No calendar events found'); + }); + + it('returns an error when permission is denied', async () => { + mockRequestPermissions.mockResolvedValue('denied'); + + const result = await runTool('read_calendar_events', {}); + + expect(result.error).toBe('Calendar permission denied'); + expect(mockFetchAllEvents).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/__tests__/unit/services/tools/registry.test.ts b/__tests__/unit/services/tools/registry.test.ts index 94991672..0ce727ee 100644 --- a/__tests__/unit/services/tools/registry.test.ts +++ b/__tests__/unit/services/tools/registry.test.ts @@ -16,8 +16,8 @@ describe('Tool Registry', () => { // AVAILABLE_TOOLS // ======================================================================== describe('AVAILABLE_TOOLS', () => { - it('has exactly 6 tools with correct IDs', () => { - expect(AVAILABLE_TOOLS).toHaveLength(6); + it('has exactly 9 tools with correct IDs', () => { + expect(AVAILABLE_TOOLS).toHaveLength(9); const ids = AVAILABLE_TOOLS.map(t => t.id); expect(ids).toEqual([ @@ -27,6 +27,9 @@ describe('Tool Registry', () => { 'get_device_info', 'search_knowledge_base', 'read_url', + 'send_email', + 'create_calendar_event', + 'read_calendar_events', ]); }); diff --git a/__tests__/unit/stores/debugLogsStore.test.ts b/__tests__/unit/stores/debugLogsStore.test.ts deleted file mode 100644 index b0660faf..00000000 --- a/__tests__/unit/stores/debugLogsStore.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -jest.mock('@react-native-async-storage/async-storage', () => ({ - getItem: jest.fn(() => Promise.resolve(null)), - setItem: jest.fn(() => Promise.resolve()), - removeItem: jest.fn(() => Promise.resolve()), -})); - -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useDebugLogsStore } from '../../../src/stores/debugLogsStore'; - -const mockedGetItem = AsyncStorage.getItem as jest.Mock; -const mockedRemoveItem = AsyncStorage.removeItem as jest.Mock; - -beforeEach(() => { - jest.clearAllMocks(); - useDebugLogsStore.setState({ logs: [], loaded: false } as any); -}); - -describe('debugLogsStore', () => { - describe('loadFromStorage', () => { - it('loads logs from AsyncStorage when raw data exists', async () => { - const stored = [{ timestamp: 1000, level: 'log', message: 'hello' }]; - mockedGetItem.mockResolvedValueOnce(JSON.stringify(stored)); - - await useDebugLogsStore.getState().loadFromStorage(); - - expect(useDebugLogsStore.getState().logs).toHaveLength(1); - expect(useDebugLogsStore.getState().logs[0].message).toBe('hello'); - expect(useDebugLogsStore.getState().loaded).toBe(true); - }); - - it('sets loaded=true and keeps empty logs when AsyncStorage has no data', async () => { - mockedGetItem.mockResolvedValueOnce(null); - - await useDebugLogsStore.getState().loadFromStorage(); - - expect(useDebugLogsStore.getState().logs).toHaveLength(0); - expect(useDebugLogsStore.getState().loaded).toBe(true); - }); - - it('skips the read when already loaded', async () => { - useDebugLogsStore.setState({ loaded: true } as any); - - await useDebugLogsStore.getState().loadFromStorage(); - - expect(mockedGetItem).not.toHaveBeenCalled(); - }); - - it('sets loaded=true and keeps empty logs when AsyncStorage throws', async () => { - mockedGetItem.mockRejectedValueOnce(new Error('storage error')); - - await useDebugLogsStore.getState().loadFromStorage(); - - expect(useDebugLogsStore.getState().loaded).toBe(true); - expect(useDebugLogsStore.getState().logs).toHaveLength(0); - }); - }); - - describe('clearLogs', () => { - it('empties the logs array and calls AsyncStorage.removeItem', () => { - useDebugLogsStore.setState({ logs: [{ timestamp: 1, level: 'log', message: 'x' }] } as any); - - useDebugLogsStore.getState().clearLogs(); - - expect(useDebugLogsStore.getState().logs).toHaveLength(0); - expect(mockedRemoveItem).toHaveBeenCalled(); - }); - }); -}); diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 69f1e4be..ea8f0cf8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,6 +23,10 @@ + + + + This app needs permission to save generated images to your photo library. NSPhotoLibraryUsageDescription This app needs access to your photo library to attach images to conversations. + NSCalendarsUsageDescription + Used to read and create calendar events on your request. + NSCalendarsFullAccessUsageDescription + Used to read and create calendar events on your request. NSSpeechRecognitionUsageDescription This app uses on-device speech recognition to transcribe voice input. RCTNewArchEnabled diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cac5f375..67bd0783 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -98,6 +98,11 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - PurchasesHybridCommon (18.14.1): + - RevenueCat (= 5.78.0) + - PurchasesHybridCommonUI (18.14.1): + - PurchasesHybridCommon (= 18.14.1) + - RevenueCatUI (= 5.78.0) - RCT-Folly (2024.11.18.00): - boost - DoubleConversion @@ -2797,6 +2802,11 @@ PODS: - React-perflogger (= 0.83.1) - React-utils (= 0.83.1) - SocketRocket + - RevenueCat (5.78.0) + - RevenueCatUI (5.78.0): + - RevenueCat (= 5.78.0) + - RNCalendarEvents (2.2.0): + - React - RNCAsyncStorage (2.2.0): - boost - DoubleConversion @@ -2885,6 +2895,12 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - RNPaywalls (10.3.0): + - PurchasesHybridCommonUI (= 18.14.1) + - React-Core + - RNPurchases (10.3.0): + - PurchasesHybridCommon (= 18.14.1) + - React-Core - RNReactNativeHapticFeedback (2.3.3): - boost - DoubleConversion @@ -3368,11 +3384,14 @@ DEPENDENCIES: - ReactAppDependencyProvider (from `build/generated/ios/ReactAppDependencyProvider`) - ReactCodegen (from `build/generated/ios/ReactCodegen`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - RNCalendarEvents (from `../node_modules/react-native-calendar-events`) - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - RNDeviceInfo (from `../node_modules/react-native-device-info`) - RNFS (from `../node_modules/react-native-fs`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNKeychain (from `../node_modules/react-native-keychain`) + - RNPaywalls (from `../node_modules/react-native-purchases-ui`) + - RNPurchases (from `../node_modules/react-native-purchases`) - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) @@ -3387,6 +3406,10 @@ DEPENDENCIES: SPEC REPOS: trunk: - lottie-ios + - PurchasesHybridCommon + - PurchasesHybridCommonUI + - RevenueCat + - RevenueCatUI - SocketRocket - SSZipArchive @@ -3566,6 +3589,8 @@ EXTERNAL SOURCES: :path: build/generated/ios/ReactCodegen ReactCommon: :path: "../node_modules/react-native/ReactCommon" + RNCalendarEvents: + :path: "../node_modules/react-native-calendar-events" RNCAsyncStorage: :path: "../node_modules/@react-native-async-storage/async-storage" RNDeviceInfo: @@ -3576,6 +3601,10 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-gesture-handler" RNKeychain: :path: "../node_modules/react-native-keychain" + RNPaywalls: + :path: "../node_modules/react-native-purchases-ui" + RNPurchases: + :path: "../node_modules/react-native-purchases" RNReactNativeHapticFeedback: :path: "../node_modules/react-native-haptic-feedback" RNReanimated: @@ -3603,11 +3632,13 @@ SPEC CHECKSUMS: FBLazyVector: 309703e71d3f2f1ed7dc7889d58309c9d77a95a4 fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - hermes-engine: 4d40ce008f57c9348a66614a3167581aa861e379 + hermes-engine: 8c6be38f94b3bf8b864981980e64e55f08e467ec llama-rn: 796fa53f37f89e2c77cd6c462ad1172ee96d4c80 lottie-ios: a881093fab623c467d3bce374367755c272bdd59 lottie-react-native: 691b8363e8c591fb78a78254ff2517258891456b op-sqlite: bafff369cecaee4fe65c89eec47deaba26f2db95 + PurchasesHybridCommon: 825e4e748b62c919bc4cb4032b0d1e452409bd74 + PurchasesHybridCommonUI: 536abdfc64b82adcbb9ba352630722a4a1270571 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: a41bbdd9af30bf2e5715796b313e44ec43eefff1 RCTRequired: 7be34aabb0b77c3cefe644528df0fa0afad4e4d0 @@ -3684,11 +3715,16 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 0eb286cc274abb059ee601b862ebddac2e681d01 ReactCodegen: 3d48510bcef445f6403c0004047d4d9cbb915435 ReactCommon: ac934cb340aee91282ecd6f273a26d24d4c55cae + RevenueCat: f3641992451c1426da8d8d1466cec3bbdb765962 + RevenueCatUI: 32998f100fe73f1d0172df7eef4f0dd1bc781a05 + RNCalendarEvents: f90f73666b6bcbb3cc8a491ffbb5e48c0db3de37 RNCAsyncStorage: 29f0230e1a25f36c20b05f65e2eb8958d6526e82 RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388 RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 RNGestureHandler: cd4be101cfa17ea6bbd438710caa02e286a84381 RNKeychain: a2c134ab796272c3d605e035ab727591000b30f3 + RNPaywalls: f125e0b623a8ccadbe45e2815ac024bbf9053bb9 + RNPurchases: ebcfb4effc608048bf2481a413b0bcaa2b6677f7 RNReactNativeHapticFeedback: be4f1b4bf0398c30b59b76ed92ecb0a2ff3a69c6 RNReanimated: 292cd58688552a22b3fc1cefcfbc49b336dfed68 RNScreens: 714e10b6b554f7dc7ad9f78dcf36dc8e3fc73415 @@ -3701,6 +3737,6 @@ SPEC CHECKSUMS: whisper-rn: 7566faf9b7d78e39ab9fc634cb90fdee81177793 Yoga: 5456bb010373068fc92221140921b09d126b116e -PODFILE CHECKSUM: 31818a1f7d1207c486dba2e42df373cf65ace073 +PODFILE CHECKSUM: 88f0d247acf3f66fc1ac0eb9612471f79e7f0cce COCOAPODS: 1.16.2 diff --git a/jest.setup.ts b/jest.setup.ts index 15d0f8cb..b007647d 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -216,6 +216,24 @@ jest.mock('react-native-keychain', () => ({ resetGenericPassword: jest.fn(() => Promise.resolve(true)), })); +// react-native-purchases mock — the real package loads a native module that +// is unavailable in the node test env, so any (even transitive / coverage-only) +// require would throw. Suites that need behaviour override this with a local mock. +jest.mock('react-native-purchases', () => ({ + __esModule: true, + default: { + setLogLevel: jest.fn(), + configure: jest.fn(), + getCustomerInfo: jest.fn(() => Promise.resolve({ entitlements: { active: {} }, originalAppUserId: 'anon', allPurchaseDates: {} })), + restorePurchases: jest.fn(() => Promise.resolve({ entitlements: { active: {} } })), + getOfferings: jest.fn(() => Promise.resolve({ all: {}, current: null })), + purchasePackage: jest.fn(() => Promise.resolve({ customerInfo: { entitlements: { active: {} } } })), + invalidateCustomerInfoCache: jest.fn(() => Promise.resolve()), + logOut: jest.fn(() => Promise.resolve()), + }, + LOG_LEVEL: { DEBUG: 'debug', ERROR: 'error' }, +})); + // @react-native-voice/voice mock jest.mock('@react-native-voice/voice', () => ({ start: jest.fn(() => Promise.resolve()), diff --git a/metro.config.js b/metro.config.js index 2a0a21ce..8b4780e9 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,11 +1,30 @@ +const path = require('node:path'); +const fs = require('node:fs'); const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); -/** - * Metro configuration - * https://reactnative.dev/docs/metro - * - * @type {import('@react-native/metro-config').MetroConfig} - */ -const config = {}; +const proPackagePath = path.resolve(__dirname, 'pro'); +const proStubPath = path.resolve(__dirname, 'src/bootstrap/proStub.js'); +// pro/ is a git submodule: the directory exists even when not checked out, so test +// for a real file inside it (package.json) to detect a populated submodule. +const proExists = fs.existsSync(path.resolve(proPackagePath, 'package.json')); + +const config = { + // pro/ is a submodule inside the project root, so Metro already watches it by + // default; nothing extra needed here. (When absent it's just an empty dir.) + watchFolders: [], + resolver: { + // When resolving modules from outside the project root (i.e. @offgrid/pro), + // Metro falls back here so @babel/runtime and all other peer deps are found. + nodeModulesPaths: [path.resolve(__dirname, 'node_modules')], + extraNodeModules: { + // Exposes src/ as @offgrid/core so @offgrid/pro can import the design system, + // stores, and registries without a circular package dependency. + '@offgrid/core': path.resolve(__dirname, 'src'), + // Points to the real pro package when present on disk (store builds), + // falls back to a null stub so free builds bundle cleanly. + '@offgrid/pro': proExists ? proPackagePath : proStubPath, + }, + }, +}; module.exports = mergeConfig(getDefaultConfig(__dirname), config); diff --git a/package-lock.json b/package-lock.json index 9f919a7a..8ea212dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "patch-package": "^8.0.1", "react": "19.2.0", "react-native": "0.83.1", + "react-native-calendar-events": "^2.2.0", "react-native-device-info": "^15.0.1", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "^2.30.0", @@ -39,6 +40,8 @@ "react-native-image-picker": "^8.2.1", "react-native-keychain": "^10.0.0", "react-native-linear-gradient": "^2.8.3", + "react-native-purchases": "^10.3.0", + "react-native-purchases-ui": "^10.3.0", "react-native-reanimated": "^4.2.1", "react-native-safe-area-context": "^5.6.2", "react-native-screens": "^4.20.0", @@ -4054,6 +4057,27 @@ "nanoid": "^3.3.11" } }, + "node_modules/@revenuecat/purchases-js": { + "version": "1.42.3", + "resolved": "https://registry.npmjs.org/@revenuecat/purchases-js/-/purchases-js-1.42.3.tgz", + "integrity": "sha512-CkR+Jj0QtsO+SO1AN7stXwJsWGNO33NJEPXkvk//v1ppB0Qff1B3y+022rCo27tJiQHFllR0K78czcUKBrizvg==", + "license": "MIT" + }, + "node_modules/@revenuecat/purchases-js-hybrid-mappings": { + "version": "18.14.1", + "resolved": "https://registry.npmjs.org/@revenuecat/purchases-js-hybrid-mappings/-/purchases-js-hybrid-mappings-18.14.1.tgz", + "integrity": "sha512-O5U3h7qsvGFCUG1C4Kmb/CgGwK/zSF5NDXVu2v1AMTlKxXdDU3hpDyC/lesfmSpW8x4qIPeQU+p9wLMY8V68Bg==", + "license": "MIT", + "dependencies": { + "@revenuecat/purchases-js": "1.42.3" + } + }, + "node_modules/@revenuecat/purchases-typescript-internal": { + "version": "18.14.1", + "resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal/-/purchases-typescript-internal-18.14.1.tgz", + "integrity": "sha512-A4dn2jxmSVWO7LbjGxHT/tNFH3INKCoM0utu9fPzTtqdi8hd61mPrjBAzBLMCHal4KzCzp8MKRguNCedzLP7qA==", + "license": "MIT" + }, "node_modules/@ronradtke/react-native-markdown-display": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@ronradtke/react-native-markdown-display/-/react-native-markdown-display-8.1.0.tgz", @@ -12335,6 +12359,15 @@ } } }, + "node_modules/react-native-calendar-events": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/react-native-calendar-events/-/react-native-calendar-events-2.2.0.tgz", + "integrity": "sha512-tNUbhT6Ief0JM4OQzQAaz1ri0+MCcAoHptBcEXCz2g7q3A05pg62PR2Dio4F9t2fCAD7Y2+QggdY1ycAsF3Tsg==", + "license": "MIT", + "peerDependencies": { + "react-native": ">=0.60.0" + } + }, "node_modules/react-native-device-info": { "version": "15.0.1", "resolved": "https://registry.npmjs.org/react-native-device-info/-/react-native-device-info-15.0.1.tgz", @@ -12442,6 +12475,52 @@ "react-native": "*" } }, + "node_modules/react-native-purchases": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/react-native-purchases/-/react-native-purchases-10.3.0.tgz", + "integrity": "sha512-PTxp+MbGIIeZomcTcUGiZLw6IzoSd4Vmfpw0Htdxk5axPBS4O+sRXtgScI6rsEe1EwKSqBHSdi4LPQhZgZeAgQ==", + "license": "MIT", + "workspaces": [ + "examples/purchaseTesterTypescript", + "react-native-purchases-store-galaxy", + "react-native-purchases-ui", + "e2e-tests/MaestroTestApp" + ], + "dependencies": { + "@revenuecat/purchases-js-hybrid-mappings": "18.14.1", + "@revenuecat/purchases-typescript-internal": "18.14.1" + }, + "peerDependencies": { + "react": ">= 16.6.3", + "react-native": ">= 0.73.0", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/react-native-purchases-ui": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/react-native-purchases-ui/-/react-native-purchases-ui-10.3.0.tgz", + "integrity": "sha512-tMumwPy/tJsdoYdPIDgMiGiGxUSzEevJfW+DGtFX/207PeB5tLF/CQG8/tw+34A+lCZ51JLrMoourEDCDgJvMA==", + "license": "MIT", + "dependencies": { + "@revenuecat/purchases-typescript-internal": "18.14.1" + }, + "peerDependencies": { + "react": "*", + "react-native": ">= 0.73.0", + "react-native-purchases": "10.3.0", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/react-native-reanimated": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz", diff --git a/package.json b/package.json index c0557f25..9b02d5b9 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "lint": "eslint . && npm run lint:android && npm run lint:ios", "lint:android": "cd android && ./gradlew :app:lintDebug", "lint:ios": "swiftlint lint --quiet || echo 'SwiftLint not installed, skipping iOS lint'", - "prepare": "husky", + "prepare": "husky && (cd pro && git config core.hooksPath .githooks 2>/dev/null || true)", "sonar": "./scripts/run-sonar.sh", "start": "react-native start", "test": "jest --coverage --forceExit --detectOpenHandles && npm run test:android && npm run test:ios", @@ -43,6 +43,7 @@ "patch-package": "^8.0.1", "react": "19.2.0", "react-native": "0.83.1", + "react-native-calendar-events": "^2.2.0", "react-native-device-info": "^15.0.1", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "^2.30.0", @@ -50,6 +51,8 @@ "react-native-image-picker": "^8.2.1", "react-native-keychain": "^10.0.0", "react-native-linear-gradient": "^2.8.3", + "react-native-purchases": "^10.3.0", + "react-native-purchases-ui": "^10.3.0", "react-native-reanimated": "^4.2.1", "react-native-safe-area-context": "^5.6.2", "react-native-screens": "^4.20.0", diff --git a/pro b/pro new file mode 160000 index 00000000..20767551 --- /dev/null +++ b/pro @@ -0,0 +1 @@ +Subproject commit 20767551eb73b8cdf70fdbef41ea5c7b799eb5f2 diff --git a/src/bootstrap/loadProFeatures.ts b/src/bootstrap/loadProFeatures.ts new file mode 100644 index 00000000..4ff06b14 --- /dev/null +++ b/src/bootstrap/loadProFeatures.ts @@ -0,0 +1,25 @@ +import { registerToolExtension } from '../services/tools/extensions'; +import { registerScreen } from '../navigation/screenRegistry'; +import { registerSettingsSection } from '../components/settings/sectionRegistry'; +import { readProFromKeychain } from '../services/proLicenseService'; + +export async function loadProFeatures(isPro?: boolean): Promise { + let pro: any; + try { + pro = require('@offgrid/pro'); + } catch { + return; // free / contributor build: package not installed + } + if (!pro) { + return; // proStub.js returns null — free build via metro extraNodeModules + } + + // The boot path already read the entitlement in checkProStatus(); reuse it to + // avoid a second keychain round-trip. Fall back to a read for standalone callers. + const active = isPro ?? (await readProFromKeychain()); + if (!active) { + return; // paid features stay dormant until the user purchases + } + + pro.activate({ registerToolExtension, registerScreen, registerSettingsSection }); +} diff --git a/src/bootstrap/proStub.js b/src/bootstrap/proStub.js new file mode 100644 index 00000000..6ff63fd5 --- /dev/null +++ b/src/bootstrap/proStub.js @@ -0,0 +1,3 @@ +// Free-build stub. Metro resolves @offgrid/pro here when ../offgrid-pro is absent. +// loadProFeatures.ts checks for null and skips activation. +module.exports = null; diff --git a/src/components/ChatInput/Popovers.tsx b/src/components/ChatInput/Popovers.tsx index 2aa4e7ca..278dcab4 100644 --- a/src/components/ChatInput/Popovers.tsx +++ b/src/components/ChatInput/Popovers.tsx @@ -70,6 +70,8 @@ interface QuickSettingsPopoverProps { supportsToolCalling: boolean; enabledToolCount: number; onToolsPress?: () => void; + mcpToolCount?: number; + onMcpPress?: () => void; } function getImageModeBadge(mode: ImageModeState, colors: any) { @@ -99,6 +101,7 @@ export const QuickSettingsPopover: React.FC = ({ visible, onClose, anchorY, anchorX, imageMode, onImageModeToggle, imageModelLoaded, supportsThinking, supportsToolCalling, enabledToolCount, onToolsPress, + mcpToolCount = 0, onMcpPress, }) => { const { colors } = useTheme(); const { settings, updateSettings, toolCountHintDismissed } = useAppStore(); @@ -107,9 +110,15 @@ export const QuickSettingsPopover: React.FC = ({ const imgBadge = getImageModeBadge(imageMode, colors); const tools = getToolsStyle(supportsToolCalling, enabledToolCount, colors); - const showToolWarning = supportsToolCalling && enabledToolCount > 3 && !toolCountHintDismissed; - const toolIconColor = showToolWarning ? TOOL_WARNING_COLOR : tools.iconColor; - const toolBadgeBg = showToolWarning ? TOOL_WARNING_COLOR : tools.badgeBg; + + // Tools and MCP warnings are independent — each turns amber at 3+ + const showToolsWarning = supportsToolCalling && enabledToolCount >= 3 && !toolCountHintDismissed; + const showMcpWarning = mcpToolCount >= 3; + + const toolIconColor = showToolsWarning ? TOOL_WARNING_COLOR : tools.iconColor; + const toolBadgeBg = showToolsWarning ? TOOL_WARNING_COLOR : tools.badgeBg; + const mcpDefaultBg = mcpToolCount > 0 ? colors.primary : colors.textMuted; + const mcpBadgeBg = showMcpWarning ? TOOL_WARNING_COLOR : mcpDefaultBg; return ( @@ -170,6 +179,22 @@ export const QuickSettingsPopover: React.FC = ({ {tools.badgeLabel} + + { + triggerHaptic('impactLight'); + onClose(); + onMcpPress?.(); + }} + > + + MCP + + {mcpToolCount} + + diff --git a/src/components/ChatInput/index.tsx b/src/components/ChatInput/index.tsx index 6c90940a..716a81d1 100644 --- a/src/components/ChatInput/index.tsx +++ b/src/components/ChatInput/index.tsx @@ -31,6 +31,8 @@ interface ChatInputProps { onToolsPress?: () => void; enabledToolCount?: number; supportsToolCalling?: boolean; + mcpToolCount?: number; + onMcpPress?: () => void; supportsThinking?: boolean; onRepairVision?: () => void; /** When set, mounts a single AttachStep for that index. Only one at a time to avoid waypoint dots. */ @@ -60,6 +62,8 @@ export const ChatInput: React.FC = ({ enabledToolCount = 0, supportsToolCalling = false, supportsThinking = false, + mcpToolCount = 0, + onMcpPress, onRepairVision, activeSpotlight = null, showSettingsDot = false, @@ -292,6 +296,8 @@ export const ChatInput: React.FC = ({ supportsToolCalling={supportsToolCalling} enabledToolCount={enabledToolCount} onToolsPress={onToolsPress} + mcpToolCount={mcpToolCount} + onMcpPress={onMcpPress} /> void; loading?: boolean; closeLabel?: string; + prominentMessage?: boolean; } export const CustomAlert: React.FC = ({ @@ -34,6 +35,7 @@ export const CustomAlert: React.FC = ({ onClose, loading = false, closeLabel = 'Done', + prominentMessage = false, }) => { const { colors } = useTheme(); const styles = useThemedStyles(createStyles); @@ -55,7 +57,7 @@ export const CustomAlert: React.FC = ({ {loading ? ( ) : null} - {message ? {message} : null} + {message ? {message} : null} {buttons.map((button, index) => ( ({ lineHeight: 20, marginBottom: SPACING.lg, }, + messageProminent: { + ...TYPOGRAPHY.body, + color: colors.text, + lineHeight: 22, + }, buttonContainer: { flexDirection: 'row' as const, marginTop: SPACING.sm, diff --git a/src/components/DebugLogsScreen/index.tsx b/src/components/DebugLogsScreen/index.tsx index 94c4d13c..a9966b9e 100644 --- a/src/components/DebugLogsScreen/index.tsx +++ b/src/components/DebugLogsScreen/index.tsx @@ -3,7 +3,7 @@ * Simple modal showing captured debug logs with copy and clear options */ -import React, { useEffect } from 'react'; +import React from 'react'; import { View, Text, @@ -27,11 +27,7 @@ interface DebugLogsScreenProps { export const DebugLogsScreen: React.FC = ({ visible, onClose }) => { const theme = useTheme(); const styles = useThemedStyles(createStyles); - const { logs, clearLogs, loadFromStorage } = useDebugLogsStore() as any; // NOSONAR - zustand store - - useEffect(() => { - loadFromStorage(); - }, []); + const { logs, clearLogs } = useDebugLogsStore(); const formatTime = (timestamp: number) => { const date = new Date(timestamp); diff --git a/src/components/ToolPickerSheet.tsx b/src/components/ToolPickerSheet.tsx index c32a762f..269d8d7f 100644 --- a/src/components/ToolPickerSheet.tsx +++ b/src/components/ToolPickerSheet.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, Switch, TouchableOpacity } from 'react-native'; +import { View, Text, Switch, TouchableOpacity, ScrollView } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; import { AppSheet } from './AppSheet'; import { useTheme, useThemedStyles } from '../theme'; @@ -36,7 +36,7 @@ export const ToolPickerSheet: React.FC = ({ enableDynamicSizing title="Tools" > - + {showHint && ( @@ -78,15 +78,18 @@ export const ToolPickerSheet: React.FC = ({ Enabling more tools can confuse the model and increases latency on first response. - + ); }; const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ container: { - paddingHorizontal: 16, - paddingBottom: 24, + maxHeight: 420, + }, + contentContainer: { + paddingHorizontal: SPACING.lg, + paddingBottom: SPACING.xl, }, toolRow: { flexDirection: 'row' as const, diff --git a/src/components/settings/sectionRegistry.ts b/src/components/settings/sectionRegistry.ts new file mode 100644 index 00000000..0b0b2cbf --- /dev/null +++ b/src/components/settings/sectionRegistry.ts @@ -0,0 +1,17 @@ +import type { ComponentType } from 'react'; + +const sections: ComponentType[] = []; + +export function registerSettingsSection(component: ComponentType): void { + if (!sections.includes(component)) { + sections.push(component); + } +} + +export function getSettingsSections(): ComponentType[] { + return sections; +} + +export function _clearSectionsForTesting(): void { + sections.length = 0; +} diff --git a/src/config/revenueCatKeys.ts b/src/config/revenueCatKeys.ts new file mode 100644 index 00000000..a78d6b0e --- /dev/null +++ b/src/config/revenueCatKeys.ts @@ -0,0 +1,25 @@ +/** + * RevenueCat public SDK keys. + * + * The production iOS/Android keys are intentionally NOT committed. Put the real keys in + * your local working copy, then keep them out of git with: + * + * git update-index --skip-worktree src/config/revenueCatKeys.ts + * + * (RevenueCat public SDK keys are not secret — they're extractable from any app binary — + * but we keep the production keys out of the open-source repo as a hygiene measure.) + * + * Get the keys from the RevenueCat dashboard: + * - iOS key starts with 'appl_' + * - Android key starts with 'goog_' + */ +export const RC_API_KEY_IOS = 'appl_REPLACE_WITH_IOS_KEY'; +export const RC_API_KEY_ANDROID = 'goog_REPLACE_WITH_ANDROID_KEY'; + +/** + * RevenueCat Test Store key — public, safe to commit. Routes purchases through RC's + * simulated store (no App Store / Play Store needed) for local flow testing. Only used + * when USE_RC_TEST_STORE is true AND the build is __DEV__, so it can never reach production. + */ +export const RC_API_KEY_TEST_STORE = 'test_UDUmOVwoEWFUtYONRUfQOOjVisB'; +export const USE_RC_TEST_STORE = false; diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index fbbc4322..3f28f3dd 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -44,6 +44,7 @@ import { RootStackParamList, MainTabParamList, } from './types'; +import { getRegisteredScreens } from './screenRegistry'; const RootStack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); @@ -254,6 +255,9 @@ export const AppNavigator: React.FC = () => { component={GalleryScreen} options={{ presentation: 'modal', animation: 'slide_from_bottom' }} /> + {getRegisteredScreens().map(s => ( + + ))} ); diff --git a/src/navigation/screenRegistry.ts b/src/navigation/screenRegistry.ts new file mode 100644 index 00000000..c9f15986 --- /dev/null +++ b/src/navigation/screenRegistry.ts @@ -0,0 +1,20 @@ +import type { ComponentType } from 'react'; + +export interface RegisteredScreen { + name: string; + component: ComponentType; +} + +const screens: RegisteredScreen[] = []; + +export function registerScreen(screen: RegisteredScreen): void { + screens.push(screen); +} + +export function getRegisteredScreens(): RegisteredScreen[] { + return screens; +} + +export function _clearScreensForTesting(): void { + screens.length = 0; +} diff --git a/src/screens/ChatScreen/ChatMessageArea.tsx b/src/screens/ChatScreen/ChatMessageArea.tsx index bd69d3b4..7cafe867 100644 --- a/src/screens/ChatScreen/ChatMessageArea.tsx +++ b/src/screens/ChatScreen/ChatMessageArea.tsx @@ -13,6 +13,8 @@ import { getPlaceholderText, useChatScreen } from './useChatScreen'; import { createStyles } from './styles'; import { useTheme } from '../../theme'; import { useAppStore } from '../../stores'; +import { getToolExtensions } from '../../services/tools/extensions'; +import { getRegisteredScreens } from '../../navigation/screenRegistry'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { RootStackParamList } from '../../navigation/types'; @@ -33,7 +35,17 @@ export const ChatMessageArea: React.FC = ({ }) => { const tabNav = useNavigation>(); const { toolCountHintDismissed } = useAppStore(); - const showSettingsDot = chat.enabledTools.length > 3 && !toolCountHintDismissed; + const extToolCount = getToolExtensions().reduce((n, e) => n + e.enabledToolCount(), 0); + const totalToolCount = chat.enabledTools.length + extToolCount; + const handleMcpPress = () => { + const hasMcpScreen = getRegisteredScreens().some(s => s.name === 'McpServers'); + if (hasMcpScreen) { + tabNav.navigate('McpServers' as any); + } else { + tabNav.navigate('ProDetail'); + } + }; + const showSettingsDot = totalToolCount > 3 && !toolCountHintDismissed; const [inputHeight, setInputHeight] = useState(84); const flatListHeightRef = useRef(0); const isStreaming = chat.isStreaming || chat.isThinking; @@ -159,6 +171,8 @@ export const ChatMessageArea: React.FC = ({ onToolsPress={() => chat.setShowToolPicker(true)} enabledToolCount={chat.enabledTools.length} showSettingsDot={showSettingsDot} + mcpToolCount={extToolCount} + onMcpPress={handleMcpPress} supportsToolCalling={chat.supportsToolCalling} supportsThinking={chat.supportsThinking} onRepairVision={handleRepairVision} diff --git a/src/screens/ChatScreen/index.tsx b/src/screens/ChatScreen/index.tsx index af48635c..4983632e 100644 --- a/src/screens/ChatScreen/index.tsx +++ b/src/screens/ChatScreen/index.tsx @@ -72,7 +72,7 @@ export const ChatScreen: React.FC = () => { const task = InteractionManager.runAfterInteractions(() => goTo(pending)); return () => task.cancel(); } - }, []); + }, [goTo]); const chainingRef = useRef(false); // When the spotlight tour stops after step 3, fire the chained step 12 useEffect(() => { @@ -132,6 +132,7 @@ export const ChatScreen: React.FC = () => { title={chat.alertState.title} message={chat.alertState.message} buttons={chat.alertState.buttons} + prominentMessage={chat.alertState.prominentMessage} onClose={() => chat.setAlertState(hideAlert())} /> ); diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index 9c15769b..77fdfa32 100644 --- a/src/screens/ChatScreen/useChatGenerationActions.ts +++ b/src/screens/ChatScreen/useChatGenerationActions.ts @@ -1,4 +1,4 @@ -import { Dispatch, MutableRefObject, SetStateAction } from 'react'; + import { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { AlertState, showAlert, @@ -17,6 +17,7 @@ import { ragService, retrievalService, } from '../../services'; +import { getToolExtensions } from '../../services/tools/extensions'; import { liteRTService } from '../../services/litert'; import { embeddingService } from '../../services/rag/embedding'; import { useChatStore, useProjectStore, useRemoteServerStore } from '../../stores'; @@ -121,8 +122,7 @@ export async function shouldRouteToImageGenerationFn( deps.setAppIsGeneratingImage(false); } return intent === 'image'; - } catch (error) { - logger.warn('[ChatScreen] Intent classification failed:', error); + } catch { deps.setIsClassifying(false); deps.setAppImageGenerationStatus(null); deps.setAppIsGeneratingImage(false); @@ -168,11 +168,10 @@ async function prepareContext(setDebugInfo: SetState, systemPrompt: string, try { const contextDebug = await llmService.getContextDebugInfo(messages); setDebugInfo({ systemPrompt, ...contextDebug }); - logger.log(`[ChatGen] Context prepared: ${contextDebug.contextUsagePercent}% used, ${contextDebug.truncatedCount} truncated`); if (contextDebug.truncatedCount > 0 || contextDebug.contextUsagePercent > 70) { await llmService.clearKVCache(false).catch(() => { }); } - } catch (e) { logger.log('Debug info error:', e); } + } catch { /* ignore */ } } /** Run generation; if context is full, compact old messages and retry once. */ async function generateWithCompactionRetry( @@ -180,17 +179,16 @@ async function generateWithCompactionRetry( enabledTools: string[], projectId?: string, ): Promise { - const gen = (msgs: Message[]) => enabledTools.length > 0 + const extCount = getToolExtensions().reduce((n, e) => n + e.enabledToolCount(), 0); + const gen = (msgs: Message[]) => (enabledTools.length > 0 || extCount > 0) ? generationService.generateWithTools(opts.id, msgs, { enabledToolIds: enabledTools, projectId }) : generationService.generateResponse(opts.id, msgs); try { await gen(opts.messages); } catch (error: any) { if (!contextCompactionService.isContextFullError(error)) throw error; - logger.log('[ChatGen] Context full — compacting'); await llmService.stopGeneration().catch(() => { }); const conversation = useChatStore.getState().conversations.find(c => c.id === opts.id); const previousSummary = conversation?.compactionSummary; const compacted = await contextCompactionService.compact({ conversationId: opts.id, systemPrompt: opts.prompt, allMessages: opts.messages, previousSummary }).catch(async () => { - logger.log(`[ChatGen] Compaction failed — falling back to last ${FALLBACK_RECENT_MESSAGE_COUNT} messages`); await llmService.clearKVCache(true).catch(() => { }); const recent = opts.messages.filter(m => m.role !== 'system').slice(-FALLBACK_RECENT_MESSAGE_COUNT); return [{ id: 'system', role: 'system', content: opts.prompt, timestamp: 0 } as Message, ...recent]; @@ -244,7 +242,6 @@ function resolveToolsAndPrompt(deps: GenerationDeps, conversation: any, _message } const rawPrompt = project?.systemPrompt || deps.settings.systemPrompt || APP_CONFIG.defaultSystemPrompt; - logger.log(`[ChatGen][resolveTools] isLiteRT=${isLiteRT}, canUseTools=${canUseTools}, enabledTools=[${enabledTools.join(', ')}]`); return { enabledTools, rawPrompt, isLiteRT }; } export async function startGenerationFn(deps: GenerationDeps, call: StartGenerationCall): Promise { @@ -274,13 +271,20 @@ export async function startGenerationFn(deps: GenerationDeps, call: StartGenerat // LiteRT passes tools natively via ConversationConfig — text hint would double-inject. // llama.cpp uses text hint only when it lacks native Jinja tool calling support. const useTextHint = !isRemote && !isLiteRT && activeTools.length > 0 && !llmService.supportsToolCalling(); + + // Collect extension hints (MCP tools etc.) and append when using text-hint mode + const extensions = getToolExtensions(); + const extHints = extensions.map(e => e.getSystemPromptHint()).filter(Boolean); + + const extHintBlock = extHints.join(''); + const systemPrompt = applyGemma4ThinkToken( - useTextHint ? `${basePrompt}${buildToolSystemPromptHint(activeTools)}` : basePrompt, + useTextHint + ? `${basePrompt}${buildToolSystemPromptHint(activeTools)}${extHintBlock}` + : `${basePrompt}${extHintBlock}`, isRemote, { isLiteRT, thinkingEnabled: deps.settings.thinkingEnabled }, ); - logger.log(`[ChatGen][DEBUG] isRemote=${isRemote}, isLiteRT=${isLiteRT}, useTextHint=${useTextHint}, tools=[${activeTools.join(', ')}], path=${activeTools.length > 0 ? 'withTools' : 'generate'}`); - logger.log(`[ChatGen][PROMPT] systemPrompt (${systemPrompt.length}ch): "${systemPrompt.substring(0, 800)}"`); const messagesForContext = buildMessagesForContext(targetConversationId, messageText, systemPrompt); await prepareContext(setDebugInfo, systemPrompt, messagesForContext); try { @@ -288,7 +292,35 @@ export async function startGenerationFn(deps: GenerationDeps, call: StartGenerat } catch (error: any) { const msg = error?.message || error?.toString?.() || 'Failed to generate response'; logger.error('[ChatGen] Generation failed:', msg, error); - deps.setAlertState(showAlert('Generation Error', msg)); + const isContextOverflow = msg.includes('too long') || msg.includes('Exceeding the maximum number of tokens') || msg.includes('Input token ids'); + if (isContextOverflow) { + deps.setAlertState({ + ...showAlert( + 'Context window full', + 'The conversation is too long for this model\'s context window.\n\nIncrease the context limit in Settings, reduce the number of enabled tools, or start a new chat.', + [ + { + text: 'Settings', + onPress: () => { deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); deps.setShowSettingsPanel?.(true); }, + }, + { + text: 'New chat', + onPress: () => { + deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); + const modelId = deps.activeModelInfo?.modelId; + if (modelId) { + const newId = deps.createConversation(modelId); + deps.setActiveConversation(newId); + } + }, + }, + ], + ), + prominentMessage: true, + }); + } else { + deps.setAlertState(showAlert('Generation Error', msg)); + } deps.generatingForConversationRef.current = null; return; } @@ -369,8 +401,11 @@ export async function regenerateResponseFn(deps: GenerationDeps, call: Regenerat const activeTools = enabledTools; const basePrompt = await injectRagContext(conversation?.projectId, messageText, rawPrompt); const useTextHint = !isRemote && !isLiteRTRegen && activeTools.length > 0 && !llmService.supportsToolCalling(); + const regenExtHints = getToolExtensions().map(e => e.getSystemPromptHint()).filter(Boolean).join(''); const systemPrompt = applyGemma4ThinkToken( - useTextHint ? `${basePrompt}${buildToolSystemPromptHint(activeTools)}` : basePrompt, + useTextHint + ? `${basePrompt}${buildToolSystemPromptHint(activeTools)}${regenExtHints}` + : `${basePrompt}${regenExtHints}`, isRemote, { isLiteRT: isLiteRTRegen, thinkingEnabled: deps.settings.thinkingEnabled }, ); @@ -378,7 +413,36 @@ export async function regenerateResponseFn(deps: GenerationDeps, call: Regenerat try { await generateWithCompactionRetry({ id: targetConversationId, prompt: systemPrompt, messages: [...prefix, ...filtered] }, activeTools, conversation?.projectId); } catch (error: any) { - deps.setAlertState(showAlert('Generation Error', error.message || 'Failed to generate response')); + const msg = error?.message || 'Failed to generate response'; + const isContextOverflow = msg.includes('too long') || msg.includes('Exceeding the maximum number of tokens') || msg.includes('Input token ids'); + if (isContextOverflow) { + deps.setAlertState({ + ...showAlert( + 'Context window full', + 'The conversation is too long for this model\'s context window.\n\nIncrease the context limit in Settings, reduce the number of enabled tools, or start a new chat.', + [ + { + text: 'Settings', + onPress: () => { deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); deps.setShowSettingsPanel?.(true); }, + }, + { + text: 'New chat', + onPress: () => { + deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); + const modelId = deps.activeModelInfo?.modelId; + if (modelId) { + const newId = deps.createConversation(modelId); + deps.setActiveConversation(newId); + } + }, + }, + ], + ), + prominentMessage: true, + }); + } else { + deps.setAlertState(showAlert('Generation Error', msg)); + } } deps.generatingForConversationRef.current = null; } diff --git a/src/screens/ProDetailScreen/index.tsx b/src/screens/ProDetailScreen/index.tsx index b3e06444..49404d61 100644 --- a/src/screens/ProDetailScreen/index.tsx +++ b/src/screens/ProDetailScreen/index.tsx @@ -1,13 +1,13 @@ -import React from 'react'; -import { View, Text, TouchableOpacity, ScrollView, Linking, StyleSheet } from 'react-native'; +import React, { useState } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, Alert, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import Icon from 'react-native-vector-icons/Feather'; import LinearGradient from 'react-native-linear-gradient'; import { useTheme, useThemedStyles } from '../../theme'; import type { ThemeColors, ThemeShadows } from '../../theme'; import { SPACING, TYPOGRAPHY } from '../../constants'; -import { PRO_URL } from '../../utils/proPrompt'; import { useAppStore } from '../../stores'; +import { presentProPaywall, restorePro, resetProIdentityForTesting } from '../../services/proLicenseService'; const INTEGRATIONS = [ { icon: 'mic', title: 'Voice', desc: 'Local speech-to-text\nprocessing.' }, @@ -16,15 +16,48 @@ const INTEGRATIONS = [ { icon: 'message-square', title: 'Messaging', desc: 'Slack,\nTelegram & more.' }, ]; +function showRestartPrompt(): void { + Alert.alert( + 'Pro activated', + 'Force-close and reopen the app to unlock Pro features.', + [{ text: 'OK' }], + ); +} export const ProDetailScreen: React.FC = () => { const { colors, isDark } = useTheme(); const styles = useThemedStyles(createStyles); - const setHasRegisteredPro = useAppStore((s) => s.setHasRegisteredPro); + const hasRegisteredPro = useAppStore((s) => s.hasRegisteredPro); + const [loading, setLoading] = useState(false); + + const handleGetPro = async () => { + setLoading(true); + try { + const purchased = await presentProPaywall(); + if (purchased) { + showRestartPrompt(); + } + } catch { + Alert.alert('Purchase failed', 'Something went wrong. Please try again.'); + } finally { + setLoading(false); + } + }; - const handleCTA = () => { - setHasRegisteredPro(true); - Linking.openURL(PRO_URL); + const handleRestore = async () => { + setLoading(true); + try { + const restored = await restorePro(); + if (restored) { + showRestartPrompt(); + } else { + Alert.alert('No purchases found', 'No active Pro subscription was found for this account.'); + } + } catch { + Alert.alert('Restore failed', 'Something went wrong. Please try again.'); + } finally { + setLoading(false); + } }; return ( @@ -49,9 +82,20 @@ export const ProDetailScreen: React.FC = () => { Off Grid Pro - - Get Pro - + {hasRegisteredPro ? ( + + + Pro Active + + ) : ( + + {loading ? 'Loading...' : 'Get Pro'} + + )} {/* Hero */} @@ -134,10 +178,44 @@ export const ProDetailScreen: React.FC = () => { - {/* CTA */} - - I am in 🔥 - + {/* CTA / Pro active */} + {hasRegisteredPro ? ( + <> + + + Pro is active on this account. + + {__DEV__ && ( + { + await resetProIdentityForTesting(); + Alert.alert('Dev reset', 'RC identity reset. Restart the app to test purchase from scratch.'); + }} + > + [DEV] Reset Pro identity + + )} + + ) : ( + <> + + {loading ? 'Loading...' : 'Get Pro'} + + + + Restore purchases + + + )} @@ -183,6 +261,16 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ borderRadius: 20, }, getProButtonText: { ...TYPOGRAPHY.bodySmall, color: '#FFFFFF' }, + proActiveBadge: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: SPACING.xs, + backgroundColor: colors.primary, + paddingHorizontal: SPACING.md, + paddingVertical: SPACING.sm, + borderRadius: 20, + }, + proActiveBadgeText: { ...TYPOGRAPHY.bodySmall, color: '#FFFFFF' }, // Hero hero: { @@ -348,19 +436,48 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ // CTA ctaButton: { marginHorizontal: SPACING.xl, - marginBottom: SPACING.xl, + marginBottom: SPACING.md, backgroundColor: colors.primary, borderRadius: 12, paddingVertical: SPACING.lg, flexDirection: 'row' as const, alignItems: 'center' as const, justifyContent: 'center' as const, - gap: SPACING.sm, }, ctaText: { ...TYPOGRAPHY.body, color: '#FFFFFF', letterSpacing: 0.5, }, + restoreButton: { + marginHorizontal: SPACING.xl, + marginBottom: SPACING.xl, + paddingVertical: SPACING.md, + alignItems: 'center' as const, + }, + restoreText: { + ...TYPOGRAPHY.bodySmall, + color: colors.textSecondary, + }, + buttonDisabled: { + opacity: 0.5, + }, + // Pro active state + proActiveCard: { + marginHorizontal: SPACING.xl, + marginBottom: SPACING.xl, + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + gap: SPACING.sm, + paddingVertical: SPACING.lg, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.primary, + }, + proActiveText: { + ...TYPOGRAPHY.body, + color: colors.primary, + }, }); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 8eb46099..7b4756d5 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { View, Text, @@ -19,6 +19,8 @@ import { Card } from '../components'; import { AnimatedEntry } from '../components/AnimatedEntry'; import { AnimatedListItem } from '../components/AnimatedListItem'; import { MadeWithLove } from '../components/MadeWithLove'; +import { DebugLogsScreen } from '../components/DebugLogsScreen'; +import { getSettingsSections } from '../components/settings/sectionRegistry'; import { useFocusTrigger } from '../hooks/useFocusTrigger'; import { useTheme, useThemedStyles } from '../theme'; import type { ThemeColors, ThemeShadows } from '../theme'; @@ -48,6 +50,7 @@ export const SettingsScreen: React.FC = () => { const setThemeMode = useAppStore((s) => s.setThemeMode); const completeChecklistStep = useAppStore((s) => s.completeChecklistStep); const resetChecklist = useAppStore((s) => s.resetChecklist); + const [showDebugLogs, setShowDebugLogs] = useState(false); const deviceInfo = useAppStore((s) => s.deviceInfo); const showProBanner = useAppStore((s) => !s.proBannerDismissed); const setProBannerDismissed = useAppStore((s) => s.setProBannerDismissed); @@ -316,6 +319,9 @@ export const SettingsScreen: React.FC = () => { + {/* Pro feature sections registered at runtime by @offgrid/pro */} + {getSettingsSections().map((Section, i) =>
)} + {/* Reset Onboarding */} @@ -327,9 +333,14 @@ export const SettingsScreen: React.FC = () => { Reset Onboarding Checklist + setShowDebugLogs(true)}> + + Debug Logs + + setShowDebugLogs(false)} /> ); diff --git a/src/services/generationToolLoop.ts b/src/services/generationToolLoop.ts index fb974f23..d1cf087d 100644 --- a/src/services/generationToolLoop.ts +++ b/src/services/generationToolLoop.ts @@ -7,6 +7,7 @@ import { useChatStore, useRemoteServerStore, useAppStore } from '../stores'; import { Message } from '../types'; import { getToolsAsOpenAISchema, executeToolCall } from './tools'; import type { ToolCall, ToolResult } from './tools/types'; +import { getToolExtensions } from './tools/extensions'; import { providerRegistry } from './providers'; import type { GenerationOptions, CompletionResult } from './providers/types'; import logger from '../utils/logger'; @@ -82,7 +83,6 @@ function parseGemmaColonArgs(name: string, colonArgs: string): Recordcall:NAME{...} and */ @@ -146,7 +143,6 @@ export function parseToolCallsFromText(text: string): { cleanText: string; toolC matchedRanges.push([match.index, match.index + match[0].length]); const call = parseToolCallBody(match[1].trim(), toolCalls.length); if (call) { toolCalls.push(call); } - else { logger.log(`[ToolLoop] Failed to parse tool_call tag: ${match[1].trim().substring(0, 100)}`); } } // Also match unclosed at end of text (model hit EOS without closing tag) const unclosedMatch = /([\s\S]+)$/.exec(text); @@ -193,21 +189,20 @@ function getLastUserQuery(messages: Message[]): string { } async function executeToolCalls(ctx: ToolLoopContext, toolCalls: import('./tools/types').ToolCall[], loopMessages: Message[]): Promise { const chatStore = useChatStore.getState(); + const exts = getToolExtensions(); for (const tc of toolCalls) { if (ctx.isAborted()) break; // Small models often call web_search with empty args — use user's message as fallback if (tc.name === 'web_search' && (!tc.arguments.query || typeof tc.arguments.query !== 'string' || !tc.arguments.query.trim())) { const fallbackQuery = getLastUserQuery(loopMessages); if (fallbackQuery) { - logger.log(`[ToolLoop] web_search called with empty query, using user message: "${fallbackQuery.substring(0, 80)}"`); tc.arguments = { ...tc.arguments, query: fallbackQuery }; } } - logger.log(`[ToolLoop][DEBUG] Executing tool: ${tc.name}, args: ${JSON.stringify(tc.arguments).substring(0, 200)}`); if (ctx.projectId) tc.context = { projectId: ctx.projectId }; ctx.callbacks?.onToolCallStart?.(tc.name, tc.arguments); - const result = await executeToolCall(tc); - logger.log(`[ToolLoop][DEBUG] Tool ${tc.name} result: error=${result.error || 'none'}, content length=${result.content?.length || 0}, duration=${result.durationMs}ms`); + const ext = exts.find(e => e.canHandle(tc.name)); + const result = ext ? await ext.execute(tc) : await executeToolCall(tc); ctx.callbacks?.onToolCallComplete?.(tc.name, result); const toolResultMsg: Message = { id: `tool-result-${Date.now()}-${tc.id || tc.name}`, role: 'tool', @@ -236,7 +231,6 @@ async function callRemoteLLMWithTools( const settings = useAppStore.getState().settings; const thinkingEnabled = !opts?.disableThinking && settings.thinkingEnabled && provider.capabilities.supportsThinking; const options: GenerationOptions = { temperature: settings.temperature, maxTokens: settings.maxTokens, topP: settings.topP, tools, enableThinking: thinkingEnabled }; - logger.log(`[ToolLoop] callRemoteLLM — server=${activeServerId}, tools=${tools.length}, thinking=${thinkingEnabled}`); let _fullContent = '', toolCalls: ToolCall[] = []; const onStream = opts?.onStream; return new Promise((resolve, reject) => { @@ -249,7 +243,6 @@ async function callRemoteLLMWithTools( onStream?.({ reasoningContent: content }); }, onComplete: (result: CompletionResult) => { - logger.log(`[ToolLoop] onComplete — content=${result.content?.length || 0}, toolCalls=${result.toolCalls?.length || 0}`); if (result.toolCalls && result.toolCalls.length > 0) { toolCalls = result.toolCalls.map(tc => ({ id: tc.id || `call-${Date.now()}`, @@ -282,7 +275,6 @@ async function callLocalWithRetry( lastError = e; const msg = e?.message || String(e) || ''; if (isNonRetryableError(msg) || attempt >= MAX_LLM_RETRIES - 1) break; - logger.log(`[ToolLoop] Error: "${msg.substring(0, 120) || '(no message)'}", stopping context and retrying (attempt ${attempt + 1}/${MAX_LLM_RETRIES})`); await llmService.stopGeneration().catch(() => { }); await new Promise(resolve => setTimeout(resolve, (attempt + 1) * RETRY_BACKOFF_MS)); } @@ -333,14 +325,14 @@ function buildLiteRTToolCallHandler(ctx: ToolLoopContext, conversationId: string if (ctx.isAborted()) return 'Aborted'; toolCallCount++; if (toolCallCount > MAX_LITERT_TOOL_CALLS) { - logger.log(`[ToolLoop][LiteRT] tool call cap reached (${MAX_LITERT_TOOL_CALLS}) — refusing "${name}", instructing model to answer`); return `Tool call limit reached (${MAX_LITERT_TOOL_CALLS} per response). Do not call any more tools. Answer now using the information you already have.`; } - logger.log(`[ToolLoop][LiteRT] native tool call ${toolCallCount}/${MAX_LITERT_TOOL_CALLS} — name=${name}, args=${JSON.stringify(args).substring(0, 200)}`); ctx.callbacks?.onToolCallStart?.(name, args as Record); const toolCall: ToolCall = { id: `native-tc-${Date.now()}`, name, arguments: args as Record }; if (ctx.projectId) (toolCall as any).context = { projectId: ctx.projectId }; - const result = await executeToolCall(toolCall); + const exts = getToolExtensions(); + const ext = exts.find(e => e.canHandle(name)); + const result = ext ? await ext.execute(toolCall) : await executeToolCall(toolCall); ctx.callbacks?.onToolCallComplete?.(name, result); const resultContent = result.error ? `Error: ${result.error}` : result.content; const toolCallMsg: Message = { id: `tc-${Date.now()}-${name}`, role: 'assistant', content: '', @@ -349,7 +341,6 @@ function buildLiteRTToolCallHandler(ctx: ToolLoopContext, conversationId: string toolCallId: toolCall.id, toolName: name, timestamp: Date.now() }; useChatStore.getState().addMessage(conversationId, toolCallMsg); useChatStore.getState().addMessage(conversationId, toolResultMsg); - logger.log(`[ToolLoop][LiteRT] tool ${name} completed — resultLen=${resultContent.length}, first200="${resultContent.substring(0, 200)}"`); return resultContent; }; } @@ -374,12 +365,7 @@ async function callLiteRTForLoop( topK: 40, topP: liteRTSettings.liteRTTopP, }; - logger.log(`[ToolLoop][LiteRT] callLiteRTForLoop — convId=${conversationId}, text=${text.length}ch, sysPrompt=${systemPrompt.length}ch, tools=${tools.length}, history=${history.length}, imageCount=${imageUris?.length ?? 0}`); - logger.log(`[ToolLoop][LiteRT] samplerConfig — temperature=${samplerConfig.temperature} topK=${samplerConfig.topK} topP=${samplerConfig.topP}`); - logger.log(`[ToolLoop][LiteRT] sysPrompt first500: "${systemPrompt.substring(0, 500)}"`); - logger.log(`[ToolLoop][LiteRT] sending text: "${text.substring(0, 300)}"`); if (!text) { - logger.warn('[ToolLoop][LiteRT] no message text — aborting'); return { fullResponse: '', toolCalls: [] }; } await liteRTService.prepareConversation(conversationId, systemPrompt, { samplerConfig, tools, history }); @@ -393,19 +379,58 @@ async function callLiteRTForLoop( onReasoning: token => onStream?.({ reasoningContent: token }), }, ); - logger.log(`[ToolLoop][LiteRT] raw response (${fullResponse.length}ch): "${fullResponse.substring(0, 400)}"`); // Native SDK handles all tool→model cycles internally; toolCalls always empty here return { fullResponse, toolCalls: [] }; } const TOOL_BEHAVIOR_GUIDANCE = '\n\nMake good use of the tools available to you. If you are uncertain or lack current information, use the appropriate tool rather than guessing. Never refuse or say you cannot help when a tool is available. For multiple distinct items, make a separate tool call for each. Call tools silently — do not announce them first.'; -function augmentSystemPromptForTools(messages: Message[]): Message[] { +/** Tools that need precise time-of-day to resolve relative phrases like "in half an hour". */ +const TIME_SENSITIVE_TOOL_IDS = ['create_calendar_event', 'read_calendar_events']; + +/** + * Build a current-date(/time) context line for the system prompt. On-device models + * have no built-in clock, so without this they cannot resolve relative dates + * ("tomorrow", "next Friday") into the ISO timestamps the calendar tools need. + * + * `precise` controls the prompt-cache tradeoff: + * - true -> full minute/second timestamp, so "in half an hour" resolves correctly. + * The timestamp changes every turn, which breaks llama.rn prefix-cache reuse from + * this point on. Only used when a time-sensitive tool (calendar) is enabled. + * - false -> date only. Stable for the whole day, so the prompt cache is preserved; + * day-relative phrasing still works, but sub-day phrasing does not. + * + * Computed at send-time (not module load) so it stays current across a session. + */ +function buildDateTimeContext(precise: boolean): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + let dayOfWeek = ''; + let tz = ''; + try { + dayOfWeek = now.toLocaleDateString(undefined, { weekday: 'long' }); + tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + } catch { + // toLocaleDateString/Intl can be unavailable on some JS engines; date alone still helps. + } + const dayPart = dayOfWeek ? ` Today is ${dayOfWeek}.` : ''; + const tzPart = tz ? ` Timezone: ${tz}.` : ''; + if (precise) { + const local = `${dateStr}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; + return `\n\nThe current date and time is ${local} (device local time, format YYYY-MM-DDTHH:MM:SS).${dayPart}${tzPart} When the user refers to relative dates or times such as "today", "tomorrow", "next Friday", or "in half an hour", resolve them against this current date and time.`; + } + return `\n\nThe current date is ${dateStr} (device local date, format YYYY-MM-DD).${dayPart}${tzPart} When the user refers to relative dates such as "today", "tomorrow", or "next Friday", resolve them against this current date.`; +} + +function augmentSystemPromptForTools(messages: Message[], enabledToolIds: string[] = []): Message[] { const sysIdx = messages.findIndex(m => m.role === 'system'); if (sysIdx === -1) return messages; const sys = messages[sysIdx]; const existing = typeof sys.content === 'string' ? sys.content : ''; - const updated = { ...sys, content: existing + TOOL_BEHAVIOR_GUIDANCE }; + const extHints = getToolExtensions().map(e => e.getSystemPromptHint()).filter(Boolean).join(''); + const precise = enabledToolIds.some(id => TIME_SENSITIVE_TOOL_IDS.includes(id)); + const updated = { ...sys, content: existing + TOOL_BEHAVIOR_GUIDANCE + buildDateTimeContext(precise) + extHints }; return [...messages.slice(0, sysIdx), updated, ...messages.slice(sysIdx + 1)]; } @@ -419,15 +444,17 @@ async function callLLMWithRetry( ): Promise<{ fullResponse: string; toolCalls: ToolCall[] }> { // Append tool-use behavioral guidance to the system prompt when tools are present. // Only covers the "when and how" — schemas are injected separately by each engine. + // Also append extension system-prompt hints so the model knows about MCP/pro tools. // We shallow-copy messages to avoid mutating the caller's array. - const augmentedMessages = tools.length > 0 ? augmentSystemPromptForTools(messages) : messages; + const exts = getToolExtensions(); + const extCount = exts.reduce((n, e) => n + e.enabledToolCount(), 0); + const augmentedMessages = (tools.length > 0 || extCount > 0) ? augmentSystemPromptForTools(messages, ctx?.enabledToolIds) : messages; if (isLiteRTActive() && conversationId) { return callLiteRTForLoop(conversationId, augmentedMessages, { tools, onStream, ctx }); } const activeServerId = useRemoteServerStore.getState().activeServerId; const useRemote = forceRemote || (!!activeServerId && providerRegistry.hasProvider(activeServerId) && !llmService.isModelLoaded()); - logger.log(`[ToolLoop] callLLM — remote=${useRemote}, tools=${tools.length}`); if (useRemote) { try { return await callRemoteLLMWithTools(augmentedMessages, tools, { onStream, disableThinking }); } catch (e: any) { throw new Error(e?.message || String(e) || 'Remote LLM error'); } @@ -435,24 +462,38 @@ async function callLLMWithRetry( return callLocalWithRetry(augmentedMessages, tools, onStream); } -/** If no structured tool calls, try parsing tags or Gemma's native format from text. */ +/** If no structured tool calls, try parsing tags or Gemma's native format from text. + * Also collects tool calls from any registered extensions and strips their syntax from display text. */ function resolveToolCalls(fullResponse: string, toolCalls: ToolCall[]) { - if (toolCalls.length > 0) return { effectiveToolCalls: toolCalls, displayResponse: fullResponse }; - if (fullResponse.includes('')) { - const parsed = parseToolCallsFromText(fullResponse); - if (parsed.toolCalls.length > 0) { - logger.log(`[ToolLoop] Parsed ${parsed.toolCalls.length} tool call(s) from tags`); - return { effectiveToolCalls: parsed.toolCalls, displayResponse: parsed.cleanText }; + let effectiveToolCalls: ToolCall[] = toolCalls.length > 0 ? [...toolCalls] : []; + let displayResponse = fullResponse; + + if (effectiveToolCalls.length === 0) { + if (fullResponse.includes('')) { + const parsed = parseToolCallsFromText(fullResponse); + if (parsed.toolCalls.length > 0) { + effectiveToolCalls = parsed.toolCalls; + displayResponse = parsed.cleanText; + } + } else if (fullResponse.includes('<|tool_call>') || fullResponse.includes(' 0) { + effectiveToolCalls = parsed.toolCalls; + displayResponse = parsed.cleanText; + } } } - if (fullResponse.includes('<|tool_call>') || fullResponse.includes(' 0) { - logger.log(`[ToolLoop] Parsed ${parsed.toolCalls.length} tool call(s) from Gemma native format`); - return { effectiveToolCalls: parsed.toolCalls, displayResponse: parsed.cleanText }; + + // Parse extension tool calls and strip their syntax from the visible text + for (const ext of getToolExtensions()) { + const extCalls = ext.parseToolCalls(displayResponse); + if (extCalls.length > 0) { + effectiveToolCalls.push(...extCalls); } + displayResponse = ext.stripFromVisibleText(displayResponse); } - return { effectiveToolCalls: toolCalls, displayResponse: fullResponse }; + + return { effectiveToolCalls, displayResponse }; } interface ToolLoopState { @@ -480,9 +521,7 @@ function buildStreamHandler(ctx: ToolLoopContext, state: ToolLoopState): ((data: } function emitFinalResponse(ctx: ToolLoopContext, state: ToolLoopState, displayResponse: string): void { - if (state.streamedContent) { - logger.log(`[ToolLoop][DEBUG] emitFinalResponse — already streamed (${state.streamedContent.length} chars), skipping`); - } else { + if (!state.streamedContent) { if (!state.thinkingDoneFired) { ctx.onThinkingDone(); ctx.callbacks?.onFirstToken?.(); @@ -493,13 +532,11 @@ function emitFinalResponse(ctx: ToolLoopContext, state: ToolLoopState, displayRe /** Force a final text-only generation (no tools) when iteration/call caps are hit. */ async function forceFinalTextResponse(ctx: ToolLoopContext, state: ToolLoopState, loopMessages: Message[]): Promise { - logger.log(`[ToolLoop] Hit cap — forcing final text response`); state.streamedContent = ''; state.reasoningContent = ''; state.firstTokenFired = false; const forcedOnStream = buildStreamHandler(ctx, state); const { fullResponse: forcedResponse } = await callLLMWithRetry(loopMessages, [], { onStream: forcedOnStream, forceRemote: ctx.forceRemote, disableThinking: true, conversationId: ctx.conversationId, ctx }); - logger.log(`[ToolLoop][DEBUG] Forced response — length=${forcedResponse.length}, streamedContent=${state.streamedContent.length}, reasoning=${state.reasoningContent.length}`); emitFinalResponse(ctx, state, forcedResponse); } @@ -509,14 +546,15 @@ async function forceFinalTextResponse(ctx: ToolLoopContext, state: ToolLoopState */ export async function runToolLoop(ctx: ToolLoopContext): Promise { const chatStore = useChatStore.getState(); - const toolSchemas = getToolsAsOpenAISchema(ctx.enabledToolIds); + const toolSchemas = [ + ...getToolsAsOpenAISchema(ctx.enabledToolIds), + ...getToolExtensions().flatMap(e => e.getOpenAISchemas?.() ?? []), + ]; const loopMessages = [...ctx.messages]; let totalToolCalls = 0; const state: ToolLoopState = { firstTokenFired: false, thinkingDoneFired: false, streamedContent: '', reasoningContent: '' }; - logger.log(`[ToolLoop][DEBUG] === runToolLoop START === enabledToolIds=[${ctx.enabledToolIds.join(', ')}], toolSchemas=${toolSchemas.length}, messages=${ctx.messages.length}, forceRemote=${ctx.forceRemote}`); for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) { if (ctx.isAborted()) { - logger.log(`[ToolLoop][DEBUG] Aborted at iteration ${iteration}`); break; } @@ -528,24 +566,18 @@ export async function runToolLoop(ctx: ToolLoopContext): Promise { state.streamedContent = ''; state.reasoningContent = ''; - logger.log(`[ToolLoop][DEBUG] === Iteration ${iteration} === messages=${loopMessages.length}, tools=${toolSchemas.length}, totalCalls=${totalToolCalls}`); const onStream = buildStreamHandler(ctx, state); const { fullResponse, toolCalls } = await callLLMWithRetry(loopMessages, toolSchemas, { onStream, forceRemote: ctx.forceRemote, conversationId: ctx.conversationId, ctx }); - logger.log(`[ToolLoop][DEBUG] LLM returned — response=${fullResponse.length}, toolCalls=${toolCalls.length}, streamed=${state.streamedContent.length}, reasoning=${state.reasoningContent.length}`); - if (fullResponse.length === 0 && state.streamedContent.length === 0) { - logger.log(`[ToolLoop][DEBUG] *** EMPTY RESPONSE *** reasoning=${state.reasoningContent.length}: "${state.reasoningContent.substring(0, 200)}"`); - } const { effectiveToolCalls, displayResponse } = resolveToolCalls(fullResponse, toolCalls); const cappedToolCalls = effectiveToolCalls.slice(0, MAX_TOTAL_TOOL_CALLS - totalToolCalls); totalToolCalls += cappedToolCalls.length; - logger.log(`[ToolLoop][DEBUG] After resolve — toolCalls=${cappedToolCalls.length}, displayResponse=${displayResponse.length}`); + // No tool calls → model gave a final text response if (cappedToolCalls.length === 0) { // Empty response with tools — retry once without tools (some models choke on tool schemas) if (!state.streamedContent && !displayResponse) { - logger.log(`[ToolLoop][DEBUG] *** EMPTY RESPONSE WITH TOOLS — retrying WITHOUT tools ***`); state.streamedContent = ''; state.reasoningContent = ''; state.firstTokenFired = false; @@ -561,7 +593,6 @@ export async function runToolLoop(ctx: ToolLoopContext): Promise { } // Execute the tool calls - logger.log(`[ToolLoop][DEBUG] Executing ${cappedToolCalls.length} tool calls: ${cappedToolCalls.map(tc => tc.name).join(', ')}`); if (state.streamedContent) { ctx.onStreamReset?.(); chatStore.setStreamingMessage(''); } const assistantMsg: Message = { @@ -577,5 +608,4 @@ export async function runToolLoop(ctx: ToolLoopContext): Promise { chatStore.setIsThinking(true); await new Promise(resolve => setTimeout(resolve, CONTEXT_RELEASE_PAUSE_MS)); } - logger.log(`[ToolLoop][DEBUG] === runToolLoop END ===`); } diff --git a/src/services/proLicenseService.ts b/src/services/proLicenseService.ts new file mode 100644 index 00000000..fdbf0bae --- /dev/null +++ b/src/services/proLicenseService.ts @@ -0,0 +1,229 @@ +import { Platform } from 'react-native'; +import Purchases, { LOG_LEVEL } from 'react-native-purchases'; +import * as Keychain from 'react-native-keychain'; +import logger from '../utils/logger'; +import { + RC_API_KEY_IOS, + RC_API_KEY_ANDROID, + RC_API_KEY_TEST_STORE, + USE_RC_TEST_STORE, +} from '../config/revenueCatKeys'; + +const KEYCHAIN_SERVICE = 'off-grid-pro-license'; +const ENTITLEMENT_ID = 'pro'; + +// react-native-purchases only ships native modules for iOS and Android. On any +// other platform (e.g. React Native Web) configure is skipped and this stays +// false, so the RC-backed entry points below no-op or fail loudly instead of +// throwing native "module not found" errors. +let isConfigured = false; + +type ProLicense = { isPro: boolean; verifiedAt: number }; + +function setProInStore(isPro: boolean): void { + const { useAppStore } = require('../stores/appStore'); + useAppStore.getState().setHasRegisteredPro(isPro); +} + +export function configureRevenueCat(): void { + if (Platform.OS !== 'ios' && Platform.OS !== 'android') { + logger.log(`[RC] configure skipped: unsupported platform ${Platform.OS}`); + return; + } + try { + Purchases.setLogLevel(__DEV__ ? LOG_LEVEL.DEBUG : LOG_LEVEL.ERROR); + const useTestStore = __DEV__ && USE_RC_TEST_STORE; + const apiKey = useTestStore + ? RC_API_KEY_TEST_STORE + : Platform.OS === 'ios' ? RC_API_KEY_IOS : RC_API_KEY_ANDROID; + logger.log(`[RC] configure platform=${Platform.OS} store=${useTestStore ? 'TEST' : Platform.OS} key=${apiKey.slice(0, 12)}...`); + Purchases.configure({ apiKey }); + isConfigured = true; + logger.log('[RC] configure: SDK configured OK'); + } catch (e: any) { + logger.error(`[RC] configure FAILED: ${e?.message ?? e}`); + throw e; + } +} + +async function writeLicense(isPro: boolean): Promise { + const license: ProLicense = { isPro, verifiedAt: Date.now() }; + logger.log(`[RC] writeLicense isPro=${isPro}`); + try { + await Keychain.setGenericPassword('license', JSON.stringify(license), { + service: KEYCHAIN_SERVICE, + accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK, + }); + } catch (e) { + // A keychain write failure (locked keychain, unsupported platform) must not + // surface as a "Purchase failed"/"Restore failed" after the user was charged. + // The entitlement is still live on RevenueCat and the next background sync + // re-writes the cache, so log and continue rather than throwing. + const message = e instanceof Error ? e.message : String(e); + logger.error(`[RC] writeLicense failed to persist to keychain: ${message}`); + } +} + +export async function readProFromKeychain(): Promise { + try { + const result = await Keychain.getGenericPassword({ service: KEYCHAIN_SERVICE }); + if (!result) { + logger.log('[RC] readProFromKeychain: no entry found → false'); + return false; + } + const license: ProLicense = JSON.parse(result.password); + const age = Math.round((Date.now() - license.verifiedAt) / 1000); + logger.log(`[RC] readProFromKeychain: isPro=${license.isPro} verifiedAt=${license.verifiedAt} age=${age}s`); + return license.isPro ?? false; + } catch (e: any) { + logger.error(`[RC] readProFromKeychain error: ${e?.message ?? e}`); + return false; + } +} + +export async function checkProStatus(): Promise { + logger.log('[RC] checkProStatus: reading keychain...'); + const cached = await readProFromKeychain(); + logger.log(`[RC] checkProStatus: cached=${cached}, firing background sync`); + syncWithRevenueCat().catch(() => {}); + return cached; +} + +async function syncWithRevenueCat(): Promise { + if (!isConfigured) { + logger.log('[RC] syncWithRevenueCat skipped: SDK not configured'); + return; + } + try { + logger.log('[RC] syncWithRevenueCat: invalidating cache + fetching...'); + await Purchases.invalidateCustomerInfoCache(); + const info = await Purchases.getCustomerInfo(); + const activeKeys = Object.keys(info.entitlements.active); + logger.log(`[RC] syncWithRevenueCat: customerID=${info.originalAppUserId}`); + logger.log(`[RC] syncWithRevenueCat: activeEntitlements=[${activeKeys.join(', ') || 'none'}]`); + logger.log(`[RC] syncWithRevenueCat: allPurchaseDates=${JSON.stringify(info.allPurchaseDates)}`); + const isPro = info.entitlements.active[ENTITLEMENT_ID] !== undefined; + if (isPro) { + const ent = info.entitlements.active[ENTITLEMENT_ID]; + logger.log(`[RC] syncWithRevenueCat: entitlement productID=${ent?.productIdentifier} isSandbox=${ent?.isSandbox} unsubscribeDetected=${ent?.unsubscribeDetectedAt ?? 'null'}`); + } + logger.log(`[RC] syncWithRevenueCat: isPro=${isPro} → writing to keychain`); + await writeLicense(isPro); + setProInStore(isPro); + logger.log('[RC] syncWithRevenueCat: done'); + } catch (e: any) { + logger.error(`[RC] syncWithRevenueCat error: ${e?.message ?? e}`); + } +} + +export async function presentProPaywall(): Promise { + if (!isConfigured) { + logger.error('[RC] presentProPaywall ABORT: SDK not configured'); + throw new Error('RevenueCat is not configured'); + } + try { + logger.log('[RC] presentProPaywall: fetching offerings...'); + const offerings = await Purchases.getOfferings(); + logger.log(`[RC] presentProPaywall: availableOfferings=[${Object.keys(offerings.all).join(', ')}] current=${offerings.current?.identifier ?? 'none'}`); + const offering = offerings.current; + if (!offering) { + logger.error('[RC] presentProPaywall ABORT: no current offering (set one as current in RC)'); + throw new Error('No offering available'); + } + logger.log(`[RC] presentProPaywall: using offering=${offering.identifier} packages=${offering.availablePackages.length}`); + offering.availablePackages.forEach(p => + logger.log(`[RC] package=${p.identifier} product=${p.product?.identifier ?? 'NONE'} price=${p.product?.priceString ?? 'NONE'}`), + ); + // Prefer the lifetime package explicitly. Relying on availablePackages[0] is + // fragile: RC can reorder packages or add new ones (monthly/yearly) later. + const pkg = + offering.availablePackages.find(p => p.identifier === '$rc_lifetime') ?? + offering.availablePackages[0]; + if (!pkg) { + logger.error('[RC] presentProPaywall ABORT: no package in offering (no store product for this platform — check the package has an Android/iOS product)'); + throw new Error('No package available'); + } + logger.log(`[RC] presentProPaywall: purchasing package=${pkg.identifier} product=${pkg.product.identifier} price=${pkg.product.priceString}`); + + const { customerInfo } = await Purchases.purchasePackage(pkg); + const activeKeys = Object.keys(customerInfo.entitlements.active); + logger.log(`[RC] post-purchase activeEntitlements=[${activeKeys.join(', ') || 'none'}]`); + const isPro = customerInfo.entitlements.active[ENTITLEMENT_ID] !== undefined; + logger.log(`[RC] post-purchase isPro=${isPro} customerID=${customerInfo.originalAppUserId}`); + if (isPro) { + const ent = customerInfo.entitlements.active[ENTITLEMENT_ID]; + logger.log(`[RC] post-purchase entitlement isSandbox=${ent?.isSandbox} productID=${ent?.productIdentifier}`); + } + + if (isPro) { + await writeLicense(true); + setProInStore(true); + return true; + } + return false; + } catch (e: any) { + if (e?.userCancelled) { + logger.log('[RC] presentProPaywall: user cancelled'); + return false; + } + logger.error( + `[RC] presentProPaywall FAILED code=${e?.code} readable=${e?.readableErrorCode ?? 'n/a'} ` + + `msg=${e?.message ?? e} underlying=${e?.underlyingErrorMessage ?? 'n/a'}`, + ); + throw e; + } +} + +export async function restorePro(): Promise { + if (!isConfigured) { + logger.error('[RC] restorePro ABORT: SDK not configured'); + throw new Error('RevenueCat is not configured'); + } + logger.log('[RC] restorePro: start'); + const info = await Purchases.restorePurchases(); + const activeKeys = Object.keys(info.entitlements.active); + logger.log(`[RC] restorePro: activeEntitlements=[${activeKeys.join(', ') || 'none'}]`); + const isPro = info.entitlements.active[ENTITLEMENT_ID] !== undefined; + logger.log(`[RC] restorePro: isPro=${isPro}`); + await writeLicense(isPro); + setProInStore(isPro); + return isPro; +} + +export async function clearProForTesting(): Promise { + await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE }); + setProInStore(false); +} + +export async function resetProIdentityForTesting(): Promise { + if (!isConfigured) { + logger.log('[RC] resetProIdentityForTesting skipped: SDK not configured'); + return; + } + logger.log('[RC] resetProIdentityForTesting: start'); + logger.log('[RC] resetProIdentityForTesting: invalidating RC cache...'); + await Purchases.invalidateCustomerInfoCache(); + try { + const before = await Purchases.getCustomerInfo(); + const isAnonymous = before.originalAppUserId.startsWith('$RCAnonymousID:'); + logger.log(`[RC] resetProIdentityForTesting: customerID before=${before.originalAppUserId} anonymous=${isAnonymous}`); + logger.log(`[RC] resetProIdentityForTesting: entitlements before=[${Object.keys(before.entitlements.active).join(', ') || 'none'}]`); + logger.log(`[RC] resetProIdentityForTesting: allPurchases before=${JSON.stringify(before.allPurchaseDates)}`); + // logOut only works for identified users. Anonymous users can only be reset + // by deleting the app (which clears the anonymous ID from UserDefaults). + if (!isAnonymous) { + await Purchases.logOut(); + await Purchases.invalidateCustomerInfoCache(); + const after = await Purchases.getCustomerInfo(); + logger.log(`[RC] resetProIdentityForTesting: logOut done, customerID after=${after.originalAppUserId}`); + } else { + logger.log('[RC] resetProIdentityForTesting: anonymous user — skipping logOut (delete the app to get a fresh ID)'); + } + } catch (e: any) { + logger.error(`[RC] resetProIdentityForTesting: ${e?.message ?? e} — continuing`); + } + logger.log('[RC] resetProIdentityForTesting: clearing keychain...'); + await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE }); + setProInStore(false); + logger.log('[RC] resetProIdentityForTesting: done'); +} diff --git a/src/services/tools/extensions.ts b/src/services/tools/extensions.ts new file mode 100644 index 00000000..4fdf0d85 --- /dev/null +++ b/src/services/tools/extensions.ts @@ -0,0 +1,26 @@ +import type { ToolCall, ToolResult } from './types'; + +export interface ToolExtension { + id: string; + getSystemPromptHint(): string; + getOpenAISchemas?(): any[]; + parseToolCalls(text: string): ToolCall[]; + stripFromVisibleText(text: string): string; + canHandle(toolName: string): boolean; + execute(call: ToolCall): Promise; + enabledToolCount(): number; +} + +const extensions: ToolExtension[] = []; + +export function registerToolExtension(ext: ToolExtension): void { + if (!extensions.some(e => e.id === ext.id)) extensions.push(ext); +} + +export function getToolExtensions(): ToolExtension[] { + return extensions; +} + +export function _clearExtensionsForTesting(): void { + extensions.length = 0; +} diff --git a/src/services/tools/handlers.ts b/src/services/tools/handlers.ts index 0a4a897a..a48243f6 100644 --- a/src/services/tools/handlers.ts +++ b/src/services/tools/handlers.ts @@ -1,5 +1,6 @@ -import { Platform } from 'react-native'; +import { Platform, Linking } from 'react-native'; import DeviceInfo from 'react-native-device-info'; +import RNCalendarEvents from 'react-native-calendar-events'; import { ToolCall, ToolResult } from './types'; import logger from '../../utils/logger'; @@ -45,6 +46,27 @@ async function dispatchTool(call: ToolCall): Promise { if (!url) throw new Error('Missing required parameter: url'); return handleReadUrl(url); } + case 'send_email': { + const to = requireString(call, 'to'); + if (!to) throw new Error('Missing required parameter: to'); + return handleSendEmail(to, call.arguments.subject, call.arguments.body); + } + case 'create_calendar_event': { + const title = requireString(call, 'title'); + const startDate = requireString(call, 'start_date'); + if (!title) throw new Error('Missing required parameter: title'); + if (!startDate) throw new Error('Missing required parameter: start_date'); + return handleCreateCalendarEvent({ + title, + startDate, + endDate: call.arguments.end_date as string | undefined, + location: call.arguments.location as string | undefined, + notes: call.arguments.notes as string | undefined, + }); + } + case 'read_calendar_events': { + return handleReadCalendarEvents(call.arguments.start_date, call.arguments.end_date); + } default: throw new Error(`Unknown tool: ${call.name}`); } @@ -390,3 +412,75 @@ function formatBytes(bytes: number): string { if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; return bytes < 1024 ** 3 ? `${(bytes / 1024 ** 2).toFixed(1)} MB` : `${(bytes / 1024 ** 3).toFixed(1)} GB`; } + +async function handleSendEmail(to: string, subject?: string, body?: string): Promise { + const parts: string[] = []; + if (subject) parts.push(`subject=${encodeURIComponent(subject)}`); + if (body) parts.push(`body=${encodeURIComponent(body)}`); + const query = parts.length ? `?${parts.join('&')}` : ''; + const url = `mailto:${encodeURIComponent(to)}${query}`; + try { + await Linking.openURL(url); + } catch { + throw new TypeError('Could not open the mail app. Please ensure a mail client is configured on your device.'); + } + const subjectSuffix = subject ? ` (subject: "${subject}")` : ''; + return `Mail app opened with a draft to ${to}${subjectSuffix}.`; +} + +async function ensureCalendarPermission(readonly: boolean): Promise { + if (!(RNCalendarEvents as any)?.requestPermissions) { + throw new Error('Calendar package not available. Rebuild the app to use this tool.'); + } + const status = await RNCalendarEvents.requestPermissions(readonly); + if (status !== 'authorized') throw new Error('Calendar permission denied'); +} + +async function handleCreateCalendarEvent( + event: { title: string; startDate: string; endDate?: string; location?: string; notes?: string }, +): Promise { + const { title, startDate, endDate, location, notes } = event; + const start = new Date(startDate); + if (Number.isNaN(start.getTime())) { + throw new TypeError('Invalid start_date. Please provide a valid ISO 8601 date, e.g. 2025-06-01T10:00:00.'); + } + // end_date is optional; default to one hour after the start when omitted. + const end = endDate ? new Date(endDate) : new Date(start.getTime() + 60 * 60 * 1000); + if (Number.isNaN(end.getTime())) { + throw new TypeError('Invalid end_date. Please provide a valid ISO 8601 date, e.g. 2025-06-01T11:00:00.'); + } + // requestPermissions(false) asks for read/write on Android; iOS is always read/write. + await ensureCalendarPermission(false); + await RNCalendarEvents.saveEvent(title, { + startDate: start.toISOString(), + endDate: end.toISOString(), + ...(location ? { location } : {}), + // notes is iOS-only and description is Android-only, so set both from the same input. + ...(notes ? { notes, description: notes } : {}), + }); + const locationSuffix = location ? ` at ${location}` : ''; + return `Calendar event "${title}" saved from ${start.toLocaleString()} to ${end.toLocaleString()}${locationSuffix}.`; +} + +async function handleReadCalendarEvents(startDateStr?: string, endDateStr?: string): Promise { + await ensureCalendarPermission(true); + const startDt = startDateStr ? new Date(startDateStr) : new Date(); + if (Number.isNaN(startDt.getTime())) { + throw new TypeError('Invalid start date format.'); + } + const endDt = endDateStr ? new Date(endDateStr) : new Date(startDt.getTime() + 7 * 24 * 60 * 60 * 1000); + if (Number.isNaN(endDt.getTime())) { + throw new TypeError('Invalid end date format.'); + } + const events = await RNCalendarEvents.fetchAllEvents(startDt.toISOString(), endDt.toISOString()); + if (events.length === 0) { + return `No calendar events found between ${startDt.toDateString()} and ${endDt.toDateString()}.`; + } + return events.map(e => { + const s = new Date(e.startDate).toLocaleString(); + const en = e.endDate ? new Date(e.endDate).toLocaleString() : 'unknown'; + const loc = e.location ? `\n Location: ${e.location}` : ''; + const notes = e.notes ? `\n Notes: ${e.notes}` : ''; + return `- ${e.title}\n Start: ${s}\n End: ${en}${loc}${notes}`; + }).join('\n\n'); +} diff --git a/src/services/tools/registry.ts b/src/services/tools/registry.ts index d5c11dd3..26bea8f7 100644 --- a/src/services/tools/registry.ts +++ b/src/services/tools/registry.ts @@ -86,6 +86,76 @@ export const AVAILABLE_TOOLS: ToolDefinition[] = [ }, }, }, + { + id: 'send_email', + name: 'send_email', + displayName: 'Send Email', + description: 'Open the default mail app to send an email', + icon: 'mail', + parameters: { + to: { + type: 'string', + description: 'Recipient email address', + required: true, + }, + subject: { + type: 'string', + description: 'Email subject', + }, + body: { + type: 'string', + description: 'Email body', + }, + }, + }, + { + id: 'create_calendar_event', + name: 'create_calendar_event', + displayName: 'Create Calendar Event', + description: 'Add an event to the device calendar. Use whenever the user asks to schedule, add, book, or set a reminder for something at a date or time. Times are device local time.', + icon: 'calendar', + parameters: { + title: { + type: 'string', + description: 'Event title', + required: true, + }, + start_date: { + type: 'string', + description: 'Start date/time in ISO 8601 local format, e.g. 2025-06-01T10:00:00. Resolve relative dates ("tomorrow", "next Friday at 3pm") against the current date and time given in the system prompt.', + required: true, + }, + end_date: { + type: 'string', + description: 'End date/time in ISO 8601 local format. Optional - if omitted, the event defaults to one hour after the start.', + }, + location: { + type: 'string', + description: 'Event location', + }, + notes: { + type: 'string', + description: 'Additional notes for the event', + }, + }, + }, + { + id: 'read_calendar_events', + name: 'read_calendar_events', + displayName: 'Read Calendar Events', + description: 'Read upcoming events from the device calendar', + icon: 'calendar', + parameters: { + start_date: { + type: 'string', + description: 'Start of date range in ISO 8601 format. Defaults to today.', + }, + end_date: { + type: 'string', + description: 'End of date range in ISO 8601 format. Defaults to 7 days from start.', + }, + }, + }, ]; export function getToolsAsOpenAISchema(enabledToolIds: string[]) { diff --git a/src/stores/debugLogsStore.ts b/src/stores/debugLogsStore.ts index 7a20e50c..879cb467 100644 --- a/src/stores/debugLogsStore.ts +++ b/src/stores/debugLogsStore.ts @@ -1,7 +1,6 @@ import { create } from 'zustand'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -const STORAGE_KEY = '@debug_logs'; +const MAX_IN_MEMORY = 500; export interface DebugLogEntry { timestamp: number; @@ -11,30 +10,16 @@ export interface DebugLogEntry { interface DebugLogsState { logs: DebugLogEntry[]; - loaded: boolean; + addLog: (entry: DebugLogEntry) => void; clearLogs: () => void; - loadFromStorage: () => Promise; } -export const useDebugLogsStore = create((set, get) => ({ +export const useDebugLogsStore = create((set) => ({ logs: [], - loaded: false, - clearLogs: () => { - set({ logs: [] }); - AsyncStorage.removeItem(STORAGE_KEY).catch(() => {}); - }, - loadFromStorage: async () => { - if (get().loaded) return; - try { - const raw = await AsyncStorage.getItem(STORAGE_KEY); - if (raw) { - const logs: DebugLogEntry[] = JSON.parse(raw); - set({ logs, loaded: true }); - } else { - set({ loaded: true }); - } - } catch { - set({ loaded: true }); - } - }, + addLog: (entry) => set((state) => ({ + logs: state.logs.length >= MAX_IN_MEMORY + ? [...state.logs.slice(-(MAX_IN_MEMORY - 1)), entry] + : [...state.logs, entry], + })), + clearLogs: () => set({ logs: [] }), })); diff --git a/src/stores/remoteModelCapabilities.ts b/src/stores/remoteModelCapabilities.ts index cbf09ca0..73ab8d71 100644 --- a/src/stores/remoteModelCapabilities.ts +++ b/src/stores/remoteModelCapabilities.ts @@ -43,10 +43,25 @@ function extractOllamaCapabilities(data: Record): RemoteModelIn let contextLength = 4096; let supportsVision = false; + // Newer Ollama versions expose a top-level `capabilities` array (e.g. ["vision", "tools"]). + // Gemma 4 and similar models use this field instead of model_info keys. + let supportsToolCalling: boolean | undefined; + if (Array.isArray(data.capabilities)) { + const caps = data.capabilities as unknown[]; + supportsVision = caps.includes('vision'); + supportsToolCalling = caps.includes('tools'); + } + if (data.model_info && typeof data.model_info === 'object') { const parsed = parseModelInfoKeys(data.model_info as Record); if (parsed.contextLength > 0) contextLength = parsed.contextLength; - supportsVision = parsed.supportsVision; + if (!supportsVision) supportsVision = parsed.supportsVision; + } + + // projector_info is present for multimodal models when capabilities array is missing. + if (!supportsVision && data.projector_info && typeof data.projector_info === 'object') { + const projectorKeys = Object.keys(data.projector_info as Record); + supportsVision = projectorKeys.some(k => k.includes('vision') || k.includes('clip')); } if (contextLength === 4096 && typeof data.parameters === 'string') { @@ -63,7 +78,7 @@ function extractOllamaCapabilities(data: Record): RemoteModelIn /\.Think|\.Thinking|\.IsThinkSet/.test(template) || /^RENDERER\s/m.test(modelfile); - return { contextLength, supportsVision, supportsThinking }; + return { contextLength, supportsVision, supportsToolCalling, supportsThinking }; } /** diff --git a/src/utils/logger.ts b/src/utils/logger.ts index f17f31ae..1c0d1ce7 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -6,6 +6,13 @@ const RETAINED_LOG_LINES = 4000; let writeQueue = Promise.resolve(); +type LogListener = (entry: { timestamp: number; level: 'log' | 'warn' | 'error'; message: string }) => void; +let _logListener: LogListener | null = null; + +export function setLogListener(fn: LogListener): void { + _logListener = fn; +} + function getLogFilePath(): string { return `${RNFS.DocumentDirectoryPath}/${LOG_FILE_NAME}`; } @@ -51,6 +58,11 @@ function appendPersistentLog(level: 'log' | 'warn' | 'error', args: unknown[]): function capture(level: 'log' | 'warn' | 'error', args: unknown[]): void { appendPersistentLog(level, args); + if (_logListener) { + try { + _logListener({ timestamp: Date.now(), level, message: args.map(formatArg).join(' ') }); + } catch { /* listener must never break logging */ } + } } const logger = { diff --git a/tsconfig.json b/tsconfig.json index df3bf6f6..efd02be9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,5 +4,5 @@ "types": ["jest", "node"], }, "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["**/node_modules", "**/Pods"] + "exclude": ["**/node_modules", "**/Pods", "pro/**"] }