db = new WpdbConnection($wpdb); $this->clock = new SystemClock(); $this->options = new WpOptionStore(); $this->redactor = new Redactor(); $this->policy = Policy::withDefaults(); $this->modules = new ModuleRegistry($this->options); $this->queue = new Queue($this->db, $this->clock, $this->redactor, [$this->modules, 'acceptsJobType']); $this->ledger = new Ledger($this->db, $this->options, $this->clock); $this->alerts = new \Aiwend\Usage\ExhaustionAlerts($this->options, $this->clock); $this->audit = new AuditLog($this->db, $this->redactor, $this->clock); $this->sessions = new SessionService($this->db, $this->clock); $this->bots = new BotRepository($this->db, $this->clock); $this->modules->register(new ChatModule($this)); $this->modules->register(new KnowledgeModule($this)); $this->modules->register(new ContentStudioModule($this)); $this->modules->register(new WooCommerceAiModule($this)); $this->modules->register(new AiFormsModule($this)); $this->modules->register(new McpModule($this)); $this->modules->onChange(function (Module $m, bool $enabled): void { $enabled ? $this->queue->resumeTypes($m->jobTypes()) : $this->queue->suspendTypes($m->jobTypes()); }); } public static function instance(): self { return self::$instance ??= new self(); } public function boot(): void { $this->maybeMigrate(); add_action('rest_api_init', function (): void { (new AdminController($this))->register(); }); add_action(self::CRON_HOOK, [$this, 'runJobs']); $this->cronDiagnostics()->register(); (new PrivacyIntegration($this))->register(); ContentStudioRetention::register(); WooCommerceAiRetention::register(); AiFormsRetention::register($this); AiFormsModule::placeholder($this); (new AdminUi($this))->register(); (new ProviderConnections($this))->register(); // Root-cause fix: running a recurring event before it is due (e.g. `wp cron event run`) // makes WordPress reschedule it to now + interval. If it was scheduled in the same second, // that is the SAME slot, and the unschedule that follows deletes it. For our hook, a // not-yet-due reschedule moves one interval past the current slot instead. add_filter('pre_reschedule_event', static function ($pre, $event) { if ($pre !== null || !is_object($event) || ($event->hook ?? '') !== self::CRON_HOOK) { return $pre; } $interval = (int) ($event->interval ?? 0); if ($interval > 0 && (int) $event->timestamp >= time()) { return wp_schedule_event((int) $event->timestamp + $interval, (string) $event->schedule, self::CRON_HOOK, (array) $event->args); } return null; }, 10, 2); // Recovery (not a root-cause fix): restore the job schedule if it was lost. Logged. add_action('init', function (): void { $this->ensureSchedule('recovered'); }); add_action('init', [$this, 'ensureCapabilities']); add_action('init', [$this, 'upgradeCapabilities']); if (is_multisite()) { // WordPress drops only its own tables when a site is deleted; this adds the site's Aiwend tables to that list. add_filter('wpmu_drop_tables', [$this, 'dropTablesOfDeletedSite'], 10, 2); } add_action('admin_notices', [$this, 'setupNotices']); $this->modules->boot(); if (!$this->modules->isEnabled('chat')) { // Keep posts clean while chat is disabled: no output, no assets, no routes. add_shortcode(ChatWidget::SHORTCODE, static fn(): string => ''); } } public static function activate(): void { $plugin = self::instance(); (new Schema($plugin->db))->migrate(); self::grantDefaultCapabilities(); $plugin->ensureSchedule('activated'); } /** Option marking that this site's roles received the default capabilities (removed again by the opt-in uninstall with every aiwend_* option). */ public const CAPS_OPTION = 'aiwend_caps_granted'; /** Version of the default capability set: 1 = original five; 2 = adds aiwend_publish_knowledge (Phase 96); 3 = adds aiwend_use_content_studio (Stage E1); 4 = adds aiwend_use_woocommerce_ai (Stage E2); 5 = adds aiwend_manage_forms (Stage E3). A lower stored value is brought up to date on the next signed-in request. */ public const CAPS_VERSION = 6; private static function grantDefaultCapabilities(): void { foreach (Capabilities::defaultRoleGrants() as $roleName => $caps) { $role = get_role($roleName); if ($role !== null) { foreach ($caps as $cap) { $role->add_cap($cap); } } } update_option(self::CAPS_OPTION, self::CAPS_VERSION); } /** * Network activation runs activate() once, for the main site only, and every site has its own roles. A site that has never been granted * the default capabilities gets them on the first request of a signed-in user, once, so its administrators can use the plugin (phase 81 multisite finding). * Single sites are unaffected: activate() always ran for them. */ public function ensureCapabilities(): void { if (is_multisite() && is_user_logged_in() && !get_option(self::CAPS_OPTION)) { self::grantDefaultCapabilities(); // The signed-in user was loaded before the grant: refresh their capabilities so this very request already works. wp_get_current_user()->get_role_caps(); } } /** * Phase 96: a site whose roles were granted an OLDER capability set (stored value below CAPS_VERSION) gets the new capabilities, * once, on the first request of a signed-in user after the update, so administrators receive aiwend_publish_knowledge without a * reactivation. Adding a capability to a role that has it is harmless; sites never granted anything are left to ensureCapabilities(). */ public function upgradeCapabilities(): void { $granted = (int) get_option(self::CAPS_OPTION, 0); if ($granted > 0 && $granted < self::CAPS_VERSION && is_user_logged_in()) { // Only what this version ADDED: capabilities an owner removed on purpose (for example editors' aiwend_manage_knowledge) stay removed. foreach (Capabilities::defaultRoleGrants() as $roleName => $caps) { $role = get_role($roleName); if ($role !== null) { foreach ([2 => Capabilities::PUBLISH_KNOWLEDGE, 3 => Capabilities::USE_CONTENT_STUDIO, 4 => Capabilities::USE_WOOCOMMERCE_AI, 5 => Capabilities::MANAGE_FORMS, 6 => Capabilities::USE_MCP] as $since => $added) { if ($since <= $granted) { continue; } // added by a version this site already received if (in_array($added, $caps, true)) { $role->add_cap($added); } } } } update_option(self::CAPS_OPTION, self::CAPS_VERSION); wp_get_current_user()->get_role_caps(); } } /** Sites handled per query, and the most time a web request may spend on a network-wide deactivation (a one-off administrator action; see networkDeactivationBudget()). */ private const NETWORK_DEACTIVATION_PAGE = 100; private const NETWORK_DEACTIVATION_SECONDS = 15; /** * Deactivation stops scheduled work; data and capabilities stay (see docs/uninstall.md). * * WordPress runs this hook once, for the site it runs in. On a network-wide deactivation ($networkWide) the other sites' recurring * events are removed here too; without that they would stay scheduled for a plugin that is no longer active. */ public static function deactivate(bool $networkWide = false): void { $plugin = self::instance(); $plugin->cronDiagnostics()->deactivating(); wp_clear_scheduled_hook(self::CRON_HOOK); $detail = CronDiagnostics::context(); if ($networkWide && is_multisite()) { [$others, $complete] = $plugin->clearScheduleOnOtherSites(); $detail .= sprintf(' network_other_sites=%d complete=%s', $others, $complete ? 'yes' : 'no'); } $plugin->cronDiagnostics()->record('cron.cleared', 'deactivated', $detail); } /** * Seconds the network pass may take, or null for no limit. WP-CLI has no request timeout, so `wp plugin deactivate aiwend --network` * always reaches every site. In a web request the budget also stays at half of PHP's max_execution_time or less: WordPress saves the * deactivation only after this hook returns, so running out of time inside it would leave the plugin network-active. * * @param mixed $maxExecutionTime ini_get('max_execution_time') */ public static function networkDeactivationBudget(bool $cli, $maxExecutionTime): ?float { if ($cli) { return null; } $limit = is_numeric($maxExecutionTime) ? (float) $maxExecutionTime : 0.0; return $limit > 0 ? min((float) self::NETWORK_DEACTIVATION_SECONDS, $limit / 2) : (float) self::NETWORK_DEACTIVATION_SECONDS; } /** * Removes this plugin's recurring event (and nothing else) from every site except the current one, a page of sites at a time and within * a time budget (networkDeactivationBudget()), so a very large network cannot stall the deactivation request. Sites beyond the budget * keep an event that does nothing while the plugin is inactive: no callback is registered, and at its next run WordPress fails to * reschedule it (the custom schedule is unknown, one "invalid_schedule" line in the PHP error log) and removes it. A reactivation * (ensureSchedule keeps exactly one) and the opt-in deleting uninstall also deal with it. * * @return array{0:int, 1:bool} number of other sites cleared, and whether every site was reached */ private function clearScheduleOnOtherSites(): array { $current = get_current_blog_id(); $budget = self::networkDeactivationBudget(defined('WP_CLI') && WP_CLI, ini_get('max_execution_time')); $deadline = $budget === null ? null : microtime(true) + $budget; $cleared = 0; $offset = 0; do { $ids = get_sites(['fields' => 'ids', 'number' => self::NETWORK_DEACTIVATION_PAGE, 'offset' => $offset, 'orderby' => 'id', 'order' => 'ASC']); foreach ($ids as $id) { if ((int) $id === $current) { continue; } if ($deadline !== null && microtime(true) > $deadline) { return [$cleared, false]; } switch_to_blog((int) $id); try { wp_clear_scheduled_hook(self::CRON_HOOK); $cleared++; } finally { restore_current_blog(); } } $offset += self::NETWORK_DEACTIVATION_PAGE; } while (count($ids) === self::NETWORK_DEACTIVATION_PAGE); return [$cleared, true]; } /** * Filter `wpmu_drop_tables`: when WordPress deletes a site, its Aiwend tables are dropped with its own. Only the deleted site's tables * are listed (exact allowlist names built from THAT site's prefix, whatever site the request runs in); the main site is never listed; * shared material (the key file, network options, other sites) is not touched. Tables that are already gone are harmless * (WordPress runs DROP TABLE IF EXISTS), so repeating the deletion or deleting a site that never had Aiwend is safe. * * @param mixed $tables * @param mixed $siteId * @return mixed */ public function dropTablesOfDeletedSite($tables, $siteId) { global $wpdb; if (!is_array($tables) || !is_numeric($siteId)) { return $tables; } $siteId = (int) $siteId; if ($siteId < 1 || $siteId === (int) get_main_site_id()) { return $tables; } return \Aiwend\Lifecycle\SiteTables::extend($tables, $wpdb->get_blog_prefix($siteId)); } /** * Ensures exactly one recurring job event exists. Schedules one when missing (logging why) * and removes duplicates that concurrent recovery in several processes could create. * Jobs themselves live in the queue table, so a missing event delays work but never loses it. */ public function ensureSchedule(string $reason): void { $count = CronDiagnostics::count(); if ($count === 0) { wp_schedule_event(time() + 60, 'aiwend_minute', self::CRON_HOOK); $this->cronDiagnostics()->record('cron.scheduled', $reason, $reason === 'recovered' ? 'event was missing; ' . CronDiagnostics::context() : CronDiagnostics::context()); $count = CronDiagnostics::count(); } if ($count > 1) { $first = wp_next_scheduled(self::CRON_HOOK); foreach ((array) _get_cron_array() as $ts => $hooks) { if (isset($hooks[self::CRON_HOOK]) && (int) $ts !== (int) $first) { wp_unschedule_event((int) $ts, self::CRON_HOOK); } } $this->cronDiagnostics()->record('cron.deduplicated', 'ok', "removed " . ($count - 1) . ' extra event(s)'); } } public function cronDiagnostics(): CronDiagnostics { return $this->cronDiagnostics ??= new CronDiagnostics($this); } public function runJobs(): void { $this->cronDiagnostics()->runStarted(); $this->scheduleDailyJobs(); $worker = new Worker($this->queue, 'wpcron-' . getmypid() . '-' . wp_generate_password(6, false)); $worker->handle('core.audit_retention', function (): void { $this->audit->purgeOlderThan($this->retentionDays('aiwend_audit_retention_days')); ContentStudioRetention::sweep(get_current_blog_id(), $this->clock->now()); // Content Studio pending proposals and failed briefs (Stage E1), also when the module is off WooCommerceAiRetention::sweep(get_current_blog_id(), $this->clock->now()); AiFormsRetention::sweep($this, $this->clock->now()); // AI Forms expired submissions (Stage E3), also when the module is off }); // Modules add their own handlers; a disabled module adds none and its jobs stay suspended. do_action('aiwend_register_job_handlers', $worker); $result = $worker->runBatch(10, 120); // Evidence for Diagnostics: "the scheduler ran" is not "the waiting work was done" (a run that ran nothing while indexing jobs were // still ready points at the worker, not at WP-Cron timing). Counts only; no content. update_option(CronDiagnostics::LAST_RESULT_OPTION, ['at' => time(), 'ran' => $result['ran'], 'failed' => $result['failed'], 'waiting' => $this->queue->waiting(get_current_blog_id(), KnowledgeModule::INGEST)], false); } /** Most indexing jobs one "Process now" press may start, and the seconds after which it starts no new one. */ public const PROCESS_NOW_JOBS = 3; public const PROCESS_NOW_SECONDS = 20; /** Seconds a manual run blocks another one (a courtesy against repeated clicks; the queue's own claim already prevents double work). */ public const PROCESS_NOW_COOLDOWN = 10; /** * "Process now": the administrator-triggered, bounded run of knowledge indexing for sites where WP-Cron is late (a quiet site, a disabled or * blocked scheduler). It is the same worker and the same idempotent handler as the scheduled run, restricted to knowledge.ingest jobs of this * site, at most PROCESS_NOW_JOBS jobs and PROCESS_NOW_SECONDS seconds. Two presses at once cannot process a job twice: Queue::claim is a * compare-and-set on the job's state and lease. The caller has already passed the capability and nonce checks. * * @return array{status:string, ran:int, failed:int, remaining:int} status: ran (nothing left), partial (more waiting), none (nothing was waiting), busy */ public function processKnowledgeNow(): array { $site = get_current_blog_id(); $type = KnowledgeModule::INGEST; if (!$this->modules->isEnabled('knowledge')) { return ['status' => 'none', 'ran' => 0, 'failed' => 0, 'remaining' => 0]; } $waiting = $this->queue->waiting($site, $type); if ($waiting === 0) { return ['status' => 'none', 'ran' => 0, 'failed' => 0, 'remaining' => 0]; } if (get_transient('aiwend_process_now') !== false) { return ['status' => 'busy', 'ran' => 0, 'failed' => 0, 'remaining' => $waiting]; } set_transient('aiwend_process_now', 1, self::PROCESS_NOW_COOLDOWN); $worker = new Worker($this->queue, 'manual-' . getmypid() . '-' . wp_generate_password(6, false)); do_action('aiwend_register_job_handlers', $worker); $result = $worker->runBatch(self::PROCESS_NOW_JOBS, 60, [$type], self::PROCESS_NOW_SECONDS); $remaining = $this->queue->waiting($site, $type); return ['status' => $remaining > 0 ? 'partial' : 'ran', 'ran' => $result['ran'], 'failed' => $result['failed'], 'remaining' => $remaining]; } /** Provisional engineering default, not a legal requirement (brief §9). */ public function retentionDays(string $option): int { return max(1, (int) $this->options->get($option, 30)); } /** One retention job per day; the date-based idempotency key prevents duplicates. */ private function scheduleDailyJobs(): void { $site = get_current_blog_id(); $day = gmdate('Ymd'); $this->queue->enqueue($site, 'core.audit_retention', [], 'daily-' . $day); if ($this->modules->isEnabled('chat')) { $this->queue->enqueue($site, 'chat.retention', [], 'daily-' . $day); } if ($this->modules->isEnabled('knowledge')) { $this->queue->enqueue($site, 'knowledge.repair', [], 'repair-daily-' . $day); } } public function currentPrincipal(): Principal { $siteId = get_current_blog_id(); $userId = get_current_user_id(); if ($userId <= 0) { return Principal::anonymous($siteId); } $caps = array_values(array_filter(Capabilities::all(), static fn(string $c): bool => current_user_can($c))); return Principal::user($siteId, $userId, $caps); } /** Where a key file may be (see KeyLocation); wp-config.php constants always take precedence over what it holds. */ public function keyLocation(): \Aiwend\Secrets\KeyLocation { return \Aiwend\Secrets\KeyLocation::forWordPress(); } /** A key file written by an earlier test build, found wherever it already is. Read-only: Aiwend never creates, moves or converts key files. */ public function keyFile(): \Aiwend\Secrets\KeyFile { return \Aiwend\Secrets\KeyFile::forLocation($this->keyLocation()); } /** Option holding the check value of the salts-derived key (a keyed hash of a constant string; it reveals nothing about the key). */ public const SALT_CHECK_OPTION = 'aiwend_key_check'; /** * The key derived from the WordPress salts (SaltKey), or null when the salts are missing/placeholders or have CHANGED since the key was * first used: a different derived key is never used silently, because everything encrypted with the old one would become unreadable * while new data used the new one. The check value is stored on first use (an option, never a file). * * @return array{key: ?string, state: 'ok'|'unavailable'|'changed'} */ public function saltKey(): array { $raw = apply_filters('aiwend_use_salt_key', true) ? SaltKey::derive() : null; // filter: a site can opt out of the salts-derived default key if ($raw === null) { return ['key' => null, 'state' => 'unavailable']; } $check = SaltKey::checkValue($raw); $stored = get_option(self::SALT_CHECK_OPTION, ''); if (!is_string($stored) || $stored === '') { update_option(self::SALT_CHECK_OPTION, $check, false); return ['key' => $raw, 'state' => 'ok']; } return hash_equals($stored, $check) ? ['key' => $raw, 'state' => 'ok'] : ['key' => null, 'state' => 'changed']; } /** The key ring for this site: wp-config.php constants, an existing key file (read-only), then the salts-derived default. */ private function keyRing(): KeyRing { return KeyRing::fromEnvironment($this->keyFile(), null, fn(): ?string => $this->saltKey()['key']); } /** * What the administrator needs to know about the encryption key, without ever touching its value: * state 'ready' | 'missing' | 'unusable' (a key file exists but cannot be used, or the salts changed: nothing is replaced automatically); * source 'constants' | 'file' | 'both' | 'salts' | 'none'. * * location (only when a key file exists): 'outside' | 'configured' | 'content'. * * @return array{state:string, source:string, file_exists:bool, location:?string, problem:?string, salt_state:string, configured:bool} problem: why the key cannot be used (KeyFile::problem(), or 'salts_changed') */ public function keyStatus(): array { $file = $this->keyFile(); $salt = $this->saltKey(); $source = KeyRing::source($file, $salt['key'] !== null); $configured = $source !== 'salts' && $source !== 'none'; try { $this->keyRing(); $state = 'ready'; } catch (MissingKeyException) { $state = ($file->exists() || $salt['state'] === 'changed') ? 'unusable' : 'missing'; } $problem = null; if ($state === 'unusable') { $problem = $file->exists() ? $file->problem() : 'salts_changed'; } return ['state' => $state, 'source' => $source, 'file_exists' => $file->exists(), 'location' => $file->exists() ? $this->keyLocation()->kindOf($file->directory()) : null, 'problem' => $problem, 'salt_state' => $salt['state'], 'configured' => $configured]; } /** Null when no key is configured: credential features fail closed. */ public function connections(): ?ConnectionRepository { if ($this->connections === null) { try { $this->connections = new ConnectionRepository($this->db, new Vault($this->keyRing()), $this->redactor, $this->clock); } catch (MissingKeyException) { return null; } } return $this->connections; } public function chat(): ChatService { return $this->chat ??= (new ChatService( $this->db, $this->policy, $this->sessions, $this->bots, $this->ledger, $this->audit, $this->clock, fn(array $bot): ModelAdapter => $this->adapterFor($bot), (int) $this->options->get('aiwend_session_budget_units', 20000), // Grounding is active only while the knowledge module is enabled. $this->modules->isEnabled('knowledge') ? new Grounding($this->retriever(), $this->sources()) : null, new \Aiwend\Chat\OutputGuard($this->linkAllowlist()), // With Knowledge off, an assistant that has knowledge attached refuses instead of // silently answering without it. fn(int $siteId, int $botId): bool => $this->sources()->botHasSources($siteId, $botId), fn(array $bot): string => $this->effectiveModel($bot), $this->alerts, new \Aiwend\Usage\ProviderBreaker($this->db, $this->clock, $this->options), $this->connectionHealth(), ))->withProviderGate(fn(array $bot): ?string => $this->publicChatRefusal($bot)); } /** * Release 0.9.0: the error code a visitor's chat request for this assistant is refused with, or null. It resolves the CURRENT effective * provider (explicit connection, or the site default for an inheriting assistant) on every call and is used by the chat request path, * the widget render decision and the admin explanation, so all three always agree. Default for xAI: refused, unless the wp-config.php * constant AIWEND_XAI_ALLOW_PUBLIC_CHAT allows this site (see Provider\XaiPublicChat). Other providers: never refused here. * * @param array $bot */ public function publicChatRefusal(array $bot): ?string { $provider = $this->ai()->resolve($bot)['provider'] ?? null; $siteId = (int) ($bot['site_id'] ?? get_current_blog_id()); return \Aiwend\Provider\XaiPublicChat::blocks(is_string($provider) ? $provider : null, \Aiwend\Provider\XaiPublicChat::optedInForSite($siteId)) ? \Aiwend\Provider\XaiPublicChat::ERROR_CODE : null; } /** * Explicit link policy: hosts whose plain hyperlinks may appear in answers. The site's * own host plus hosts an administrator lists (Settings). Retrieved documents never add * hosts implicitly; resource loading (images, HTML) is never allowed for any host. * * @return list */ public function linkAllowlist(): array { $hosts = [(string) wp_parse_url(home_url(), PHP_URL_HOST)]; foreach ((array) $this->options->get('aiwend_link_allowlist', []) as $h) { $h = strtolower(trim((string) $h)); if (preg_match('/^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)+$/', $h)) { $hosts[] = $h; } } return array_values(array_unique(array_filter($hosts))); } /** Deleting an assistant and, if chosen, its conversations (phase 111). */ public function assistantDeletion(): \Aiwend\Chat\AssistantDeletion { return $this->assistantDeletion ??= new \Aiwend\Chat\AssistantDeletion($this->db, $this->clock, $this->sessions); } public function sources(): SourceRepository { if ($this->sources === null) { $this->sources = new SourceRepository($this->db, $this->clock); $this->sources->onEligible(fn(int $siteId, int $sourceId) => $this->syncSource($siteId, $sourceId)); } return $this->sources; } /** * Embeddings: the deterministic lexical embedder until a real embedding provider is * selected (M2 provider work). Vectors record the model id, so switching later * re-embeds instead of mixing. */ public function embedder(): Embedder { return $this->embedder ??= new MockHashEmbedder(); } /** * This site's embedding configuration (lexical unless semantic was configured, and even then lexical in effect: see * EmbeddingConfig::SEMANTIC_ENABLED). Nothing in the plugin consumes it yet; retrieval is unchanged. */ public function embeddingConfig(): \Aiwend\Knowledge\EmbeddingConfig { return (new \Aiwend\Knowledge\EmbeddingConfigStore($this->options))->get(get_current_blog_id()); } /** Index generations (internal lifecycle; not used by retrieval until a semantic rollout is approved). */ public function indexGenerations(): \Aiwend\Knowledge\IndexGenerations { return $this->indexGenerations ??= new \Aiwend\Knowledge\IndexGenerations($this->db, $this->clock); } /** * Whether index generations may run here: 'off' everywhere in production (there is no production embedder factory and the semantic * gate is off); 'test' only when the test harness constant is set AND a safe test embedder factory was injected through the * aiwend_test_embedder_factory filter. Nothing else can turn it on. */ public function generationMode(): string { return $this->testEmbedderFactory() !== null ? 'test' : 'off'; } private function testEmbedderFactory(): ?\Aiwend\Knowledge\EmbedderFactory { if (!defined('AIWEND_ITEST') || AIWEND_ITEST !== true) { return null; } $factory = apply_filters('aiwend_test_embedder_factory', null); return $factory instanceof \Aiwend\Knowledge\EmbedderFactory ? $factory : null; } /** @return array{admin:\Aiwend\Knowledge\GenerationAdmin, jobs:?\Aiwend\Knowledge\GenerationJobs} */ private function generationStack(): array { if ($this->generation !== null) { return $this->generation; } $mode = $this->generationMode(); $claims = new \Aiwend\Knowledge\GenerationClaims($this->db, $this->clock); $control = new \Aiwend\Knowledge\GenerationControl($this->options); $builder = new \Aiwend\Knowledge\GenerationBuilder( $this->db, $this->sources(), $this->indexGenerations(), $this->testEmbedderFactory() ?? new \Aiwend\Knowledge\UnavailableEmbedderFactory(), \Closure::fromCallable(new SourceContent($this)), $this->clock, 5, 250, 20, null, $claims, null, $this->ledger ); $jobs = new \Aiwend\Knowledge\GenerationJobs($this->queue, $builder, $this->indexGenerations(), new \Aiwend\Knowledge\EmbeddingConfigStore($this->options), $control); $admin = new \Aiwend\Knowledge\GenerationAdmin( $this->db, $this->clock, $this->indexGenerations(), $builder, $claims, $jobs, $this->ledger, new \Aiwend\Knowledge\EmbeddingConfigStore($this->options), $control, $mode ); return $this->generation = ['admin' => $admin, 'jobs' => $mode === 'off' ? null : $jobs]; } /** * Source lifecycle -> generations (see GenerationSync). Where generations cannot run (production: mode 'off') this returns before * building anything, so the system stays inert and creates no job. Privacy invalidation is not here: it is synchronous, in the * source events themselves. */ public function syncSource(int $siteId, int $sourceId): void { if ($this->generationMode() === 'off') { return; } (new \Aiwend\Knowledge\GenerationSync( $this->db, $this->indexGenerations(), $this->generationStack()['jobs'], new \Aiwend\Knowledge\GenerationControl($this->options), $this->ledger, $this->clock ))->sourceChanged($siteId, $sourceId); } public function generationAdmin(): \Aiwend\Knowledge\GenerationAdmin { return $this->generationStack()['admin']; } public function generationRuntime(): \Aiwend\Knowledge\GenerationRuntime { return new \Aiwend\Knowledge\GenerationRuntime($this->generationStack()['jobs']); } public function ingestor(): Ingestor { return new Ingestor( $this->db, $this->sources(), new Extractor(), new Chunker(), $this->embedder(), $this->clock, 120, static function (int $sourceId, int $batchNo): void { do_action('aiwend_ingest_batch', $sourceId, $batchNo); }, onIndexed: fn(int $siteId, int $sourceId) => $this->syncSource($siteId, $sourceId) ); } public function retriever(): Retriever { return new Retriever($this->db, $this->embedder(), true); } public function purger(): Purger { return new Purger($this->db, $this->clock); } /** * Only the mock adapter exists in M1. It requires an explicit wp-config * constant AND a non-production environment type, so a live site never * answers with mock output and its test scenario tags never activate. * Nothing in a request can enable it. */ public function mockProviderEnabled(): bool { return self::demoOn(self::demoConstantIsTrue(), (bool) $this->options->get(self::DEMO_OPTION, false), wp_get_environment_type(), $this->siteHost()); } public function hasAssignedProvider(): bool { foreach ($this->bots->all(get_current_blog_id()) as $bot) { if ($this->ai()->resolve($bot)['mode'] !== 'demo') { return true; // explicit connection, or following the site default } } return false; } public const DEMO_OPTION = 'aiwend_simulated_demo'; /** True when the development-only wp-config constant (not the setting) turns the demo on. */ public function demoForcedByConstant(): bool { return self::demoForcedFor(self::demoConstantIsTrue(), wp_get_environment_type()); } private static function demoConstantIsTrue(): bool { return defined('AIWEND_DEV_MOCK_PROVIDER') && constant('AIWEND_DEV_MOCK_PROVIDER') === true; } private function siteHost(): string { return strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST)); } /** The developer constant never works on a production installation. */ public static function demoForcedFor(bool $constantIsTrue, string $environmentType): bool { return $constantIsTrue && $environmentType !== 'production'; } /** Where the administrator setting is honoured: a local or development environment, or a local host name. */ public static function demoAllowedFor(string $environmentType, string $host): bool { if (in_array($environmentType, ['local', 'development'], true)) { return true; } return in_array($host, ['localhost', '127.0.0.1', '[::1]', '::1'], true) || (bool) preg_match('/\.(localhost|test|local|internal)$/', $host); } /** Whether the simulated demo exists on a site at all (what makes an assistant that uses it "ready"). */ public static function demoOn(bool $constantIsTrue, bool $setting, string $environmentType, string $host): bool { return self::demoForcedFor($constantIsTrue, $environmentType) || ($setting && self::demoAllowedFor($environmentType, $host)); } /** * Simulated demo mode (an administrator setting, for local acceptance testing without a * provider) is honoured only where a visitor cannot mistake it for a live assistant: * a non-production environment type, or a site address on a local host name. * On any other site the setting has no effect and the screen says why. */ public function demoAllowedHere(): bool { return self::demoAllowedFor(wp_get_environment_type(), $this->siteHost()); } private ?AiSetup $ai = null; /** Site AI configuration: default connection/model, connection metadata, resolution. */ public function ai(): AiSetup { return $this->ai ??= new AiSetup($this); } /** * The adapter for an assistant's effective AI: its explicit connection, the site default * (inherit), or the simulated demo. No silent fallback: a connection problem never switches * to another provider or to the demo. */ private function adapterFor(array $bot): ModelAdapter { $resolved = $this->ai()->resolve($bot); if ($resolved['mode'] === 'demo') { if (!$resolved['ready']) { throw new \Aiwend\Chat\ChatException('provider_not_configured', 503); } $delay = defined('AIWEND_DEV_MOCK_DELAY_MS') ? max(0, min(2000, (int) constant('AIWEND_DEV_MOCK_DELAY_MS'))) : 60; return new MockAdapter(true, $delay); } if (!$resolved['ready'] || $resolved['connection'] === null) { throw new \Aiwend\Chat\ChatException('provider_not_configured', 503); } try { return $this->ai()->adapter((int) $bot['site_id'], $resolved['connection']); } catch (\Throwable) { throw new \Aiwend\Chat\ChatException('provider_not_configured', 503); } } /** Recent failures observed on live requests, per saved connection (see ConnectionHealth). */ public function connectionHealth(): \Aiwend\Usage\ConnectionHealth { return new \Aiwend\Usage\ConnectionHealth($this->options, $this->clock); } /** The model sent to the provider for this assistant (explicit, or the site default). */ public function effectiveModel(array $bot): string { $resolved = $this->ai()->resolve($bot); return $resolved['model'] ?? (string) $bot['model']; } /** @return array{engine:string, version:string, status:string} */ public function databaseCompatibility(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned custom table; WordPress has no API for it; diagnostic read that must be current return Compatibility::database((string) $wpdb->get_var('SELECT VERSION()')); } public function setupNotices(): void { if (!current_user_can(Capabilities::MANAGE)) { return; } // Phase 90: notices belong on Aiwend's own screens, not on every page of the dashboard. The missing-key explanation is no // longer a site-wide warning: it appears where it can be acted on (the key card on AI Connections) and as a step of the Overview // checklist, and the simulated demo does not need a key. $screen = function_exists('get_current_screen') ? get_current_screen() : null; if ($screen === null || !str_contains((string) $screen->id, 'aiwend')) { return; } if ($this->upgradeNeedsAttention()) { echo '

' . esc_html__('Aiwend could not finish updating its database tables. Aiwend keeps trying about once a minute. If this message stays, ask your host or developer to check the PHP error log for the line starting “Aiwend: the database upgrade did not complete”, and to confirm the database user may alter tables.', 'aiwend') . '

'; } $db = $this->databaseCompatibility(); // Only a confirmed problem is shown (a server older than the declared minimum). A newer or not individually verified server, or an unreadable // version, is not a defect: its details are in Diagnostics. if (Compatibility::needsAttention($db['status'])) { echo '

' . esc_html(sprintf( /* translators: 1: database engine, 2: version, 3: minimum MySQL version, 4: minimum MariaDB version */ __('Aiwend needs MySQL %3$s or newer, or MariaDB %4$s or newer. This site runs %1$s %2$s, which is older, so some features may not work. Ask your host to upgrade the database.', 'aiwend'), $db['engine'], $db['version'], Compatibility::MIN_MYSQL, Compatibility::MIN_MARIADB )) . '

'; } } private function maybeMigrate(): void { // A transient failure, or a migrator holding the lock, must not take the site down: the guard records a safe trace for the // administrator and retries at most once a minute (every step can be re-run). if ((new UpgradeGuard($this->db, $this->options, $this->clock))->run() === UpgradeGuard::FAILED) { $failure = (new UpgradeGuard($this->db, $this->options, $this->clock))->failure(); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- operators need to know an upgrade is pending; class name only, no data error_log('Aiwend: the database upgrade did not complete (' . ($failure['error'] ?? 'error') . ') and will be retried.'); } } /** True when the last database upgrade attempt failed and none has succeeded since. */ public function upgradeNeedsAttention(): bool { return (int) $this->options->get(UpgradeGuard::VERSION_OPTION, 0) < Schema::VERSION && (new UpgradeGuard($this->db, $this->options, $this->clock))->failure() !== null; } }