Added
- Search as you type: ten config keys, so the suggestions dropdown is reachable from Laravel (
config/scolta.php,.env.example,resources/views/components/search.blade.php,README.md; tag1consulting/scolta-php#247). The publishedscolta.jsnow suggests while a visitor types, merging their own recent searches ahead of content suggestions; the full pipeline — AI query expansion, the AI summary, follow-ups — still runs only on Enter, on the search button, or on selecting a suggestion. On by default and no index rebuild is needed: suggestions read the index the site already has. Ten new top-level keys, each with anenv()default byte-equal to the browser bundle's own fallback:sayt_enabled(true,SCOLTA_SAYT_ENABLED),sayt_min_chars(2, counted in graphemes so an emoji is one character; CJK sites commonly want1),sayt_debounce_ms(150),sayt_max_suggestions(6),sayt_recent_searches(true, onelocalStoragekey, nothing read or written when off),sayt_max_recent(3),sayt_expand(true),sayt_expand_per_minute(6),sayt_expansion_delay_ms(500) andsayt_suggestion_action(navigateorsearch).sayt_enabledset tofalserestores the pre-1.1.0 widget exactly: no dropdown node, no combobox ARIA roles on the input, no browser storage access, no suggest searches. The per-minute cap is not arbitrary: SAYT expansions share the AI flood budget with committed searches, since expansion, summarize and follow-up all count against the same per-IP limit, so an unbudgeted suggest path would spend a visitor's whole allowance on prefixes and starve the search they actually ran; over the cap the dropdown degrades to keyword-only suggestions until the window rolls. All ten are top-level keys, not asaytgroup and not scoring keys, for the same reasonhide_empty_facetsis:mergeConfigFrom()is a shallowarray_merge(), so a published config predating them still picks up the package defaults at the top level, while a published group would replace the package's group whole and take every default in it.php artisan config:cacheis the one case that does not:mergeConfigFrom()is skipped entirely when the configuration is cached, so a cached config built before this release needsconfig:cachere-run. The README says both. The Blade component hand-buildswindow.scolta, so all ten are added there too, withsayt_suggestion_actionpassed throughScoltaConfig::normalizedSaytSuggestionAction()so an unrecognized configured value reaches the browser asnavigaterather than as itself.flattenConfig()'sMAP_VALUED_KEYSdeliberately gains no entries — every SAYT setting is a scalar, and the flattener already passes scalars through — with a regression test at the flattener's own layer pinning that, because adding them there would look harmless and pass every emission test right up until someone grouped them. Covered bytests/ConfigTest.php(each key present, each default byte-equal to the browser fallback, each key top-level and nosaytgroup),tests/ScoltaConfigIntegrationTest.php(all ten surviveflattenConfig(),MAP_VALUED_KEYSunchanged, defaults and overrides reach the typed properties, string-valued env input casts to the declared types, unknown action normalizes) andtests/SearchComponentRenderTest.php(all ten emitted with their defaults, a published-configsayt_enabledoffalsereaching the browser, every other key overridden through the same published-config path, and the unknown-action normalization). The existing parity guard needed no change and is not passing vacuously: dropping a single emitted key fails it withtoBrowserConfig() emits saytMaxRecent but the Blade component does not. hide_empty_facetsconfig key, so the facet-visibility opt-out is reachable from Laravel (config/scolta.php,.env.example,README.md). New top-level key (defaulttrue,SCOLTA_HIDE_EMPTY_FACETS). The publishedscolta.jshas readinstanceConfig.hideEmptyFacetssince it was re-vendored, and the bundle treats an absent key as "hide" (only a literalfalsedisables it), so without a config key the opt-out was unreachable: an operator had to setwindow.scolta.hideEmptyFacetsby hand after the fact. Deliberately top-level rather than nested underscoring, because the service provider'smergeConfigFromis a shallowarray_merge: an operator whose published config predates this key still picks up the package default only for a top-level key.- The six specificity and co-occurrence ranking keys are now configurable (
config/scolta.php,.env.example,README.md).scoring.specificity_weighting(defaulttrue),scoring.specificity_floor(0.15),scoring.specificity_strong_match(0.55),scoring.specificity_cooccurrence(0.9),scoring.specificity_agreement_gate(0.45) andscoring.specificity_agreement_decay(1.0), each with aSCOLTA_SPECIFICITY_*environment variable per this config file's convention. The browser has read all six for some time and scolta-php now carries all six as typed properties, but Laravel exposed none, so every app was pinned to the hardcoded JS fallbacks. None of the six appears in anyScoltaConfig::PRESETSentry, so unlike the preset-overridable scoring keys they take concrete defaults rather than a barenull; anullhere would reachScoltaConfigas null and lose the documented default, whichtests/ConfigTest.phpnow pins. Defaults are byte-equal to the JS fallbacks, so no existing app's ranking moves. No mapping code is needed:ScoltaAiService::flattenConfig()hoistsscoringsub-keys to the top level generically and hands everything toScoltaConfig::fromArray(). - A Laravel-flavoured browser-config parity guard (
tests/SearchComponentRenderTest.php). Asserts that every keyScoltaConfig::toBrowserConfig()emits is also emitted by the Blade component, which hand-builds its config array rather than callingtoBrowserConfig()and so drifts silently every time scolta-php gains a browser key. That is exactly how the two bridges fixed below went missing. The JS-extraction form of this guard used in the other five packages is impossible here: this package ships no committedscolta.js(it publishes the bundle out of the composer-installed scolta-php at install time), so there is no in-repo artifact to parse. A reverse-direction assertion covers the other way, allowlisting the four documented platform additions (container,currentLanguage,allowedLinkDomains,disclaimer). What this form cannot catch is a key the browser reads that scolta-php itself does not emit; that gap is covered upstream by scolta-php's own parity test.
Fixed
scolta:buildaborted with a rawhash_init()error on an app that had not runkey:generate, naming neitherAPP_KEYnor the remedy (src/Commands/BuildCommand.php,src/Services/QueueRebuildDispatcher.php, newsrc/Support/HmacSecret.php).config('app.key')on an application without a generated key isstring(0) "", not null, and both call sites forwarded it unguarded into the PHP indexer, wherehash_init('sha256', HASH_HMAC, '')throws. Sophp artisan scolta:build --forcedied withIndex build failed: hash_init(): Argument #3 ($key) must not be empty when HMAC is requestedon the first command a new adopter runs, and because the message mentioned neither the setting nor the fix, it read as a Scolta bug rather than an unconfigured app. An emptyAPP_KEYnow resolves to null, so chunk integrity tagging is skipped and the build completes; CRC32 corruption detection is unaffected. The build is deliberately not failed. An index without an integrity tag is a working index, and hard-failing would block the demo and evaluation path for no security gain, since an operator with noAPP_KEYwas never getting a tag either way. Whitespace-only keys resolve to null on the same reasoning, and a key with real content beside whitespace is used verbatim rather than trimmed, so an index already built under a padded key keeps verifying. Both call sites are fixed, not just the reported one.QueueRebuildDispatcherreadsconfig('app.key')independently and forwards it toBuildCoordinatorand to every dispatchedProcessIndexChunkandFinalizeIndexjob, so a fix confined toBuildCommandwould have left the observer-driven queued rebuild throwing the same error inside a worker process, where nobody is watching for it. A warning namingAPP_KEYandphp artisan key:generateis now emitted on both paths, stating that integrity tagging is off and that CRC32 stays active: to the console on the synchronous path, and to the log on the queued path, since there is no console there and the log is where an operator debugging a queued rebuild is already looking. The message lives in one place (HmacSecret::EMPTY_APP_KEY_WARNING_LINES) so the two paths cannot drift into describing the same condition differently, and is held as separate short lines because Symfony hard-wraps at the terminal width and a wrap landing insidephp artisan key:generatewould break the one part the operator has to copy. This is complementary to the library-side fix in tag1consulting/scolta-php#249, not replaced by it. That change stops the library crashing for every adapter; this one makes the Laravel-specific failure actionable, and it is verified against an unpatched scolta-php so it does not depend on that release. Covered by the newtests/EmptyAppKeyBuildTest.php(11 cases: the sync build completing and publishing a Pagefind index withAPP_KEY=; the warning namingAPP_KEY,php artisan key:generate, the disabled integrity tag and CRC32; no warning when the key is set; a whitespace-only key treated as unset; the queued dispatch completing; bothProcessIndexChunkand the chainedFinalizeIndexcarrying null rather than''; the queued path logging the warning; a configured key still reaching the workers; and'0', which is falsy in PHP, surviving as real key material, which is why the coercion teststrim()rather than using?:). Reported as #110.- The search widget never received
filterFieldDescriptionsorhideEmptyFacets, so both features were dead on Laravel (resources/views/components/search.blade.php).scolta.jsreadsinstanceConfig.filterFieldDescriptionsto label filter groups andinstanceConfig.hideEmptyFacetsto control zero-count facet visibility. scolta-php emits both fromtoBrowserConfig(), but the Blade component hand-builds its$scoltaConfigarray and omitted both, so filter descriptions never appeared and the facet opt-out could not be reached at all. Root cause is not the omission but that nothing could catch it: the component duplicatestoBrowserConfig()by hand with no assertion tying the two together, so a key added upstream simply never arrived here and no test noticed. Both are now emitted at the top level (siblings ofsiteName, not insidescoring), and the new parity assertion intests/SearchComponentRenderTest.phpfails if the component falls behindtoBrowserConfig()again. - Follow-up questions no longer fail validation when the conversation carries its AI-Overview context.
FollowUpControllercapped each message at 10,000 characters and the whole conversation at 50,000, but the search UI seeds the follow-up conversation with the full AI-Overview context (the same search-result excerpts the summarize endpoint accepts, routinely 10k to 50k characters) as the first user turn, followed by the summary and then the question. That first turn blew the per-message cap, so every follow-up on an AI search was rejected and the UI rendered "Follow-up unavailable. Please try again." The controller was stricter than the layer it feeds:AiEndpointHandlerin scolta-php has always allowed 100,000 characters per message and 400,000 in total. Both controller constants are now aligned with the handler's (FOLLOW_UP_MAX_MESSAGE_CHARS,FOLLOW_UP_MAX_TOTAL_CHARS) so the two validation layers cannot disagree and reject the application's own legitimate payload. The message-count cap (25) andscolta.max_follow_upsare unchanged. Covered by the updated size-cap assertions inFollowUpValidationTestplus a new regression test that posts a conversation seeded with a ~48k-character first turn.
Changed
tag1/scolta-phpis now required at^1.1.0, andextra.branch-aliasmapsdev-mainonto1.1.x-dev(composer.json,composer.lock).resources/views/components/search.blade.phpreads elevenScoltaConfigmembers that do not exist in scolta-php 1.0.5:hideEmptyFacetsand the ninesayt*properties, plus a call tonormalizedSaytSuggestionAction(). The old^1.0.5constraint admitted that version, and the method call makes it fatal rather than merely degraded: against 1.0.5 the ten property reads emit a PHP warning each and shipnullto the browser, and the method call then raisesError: Call to undefined method. The branch alias had said1.0.x-devsince the 0.3 series while this line has been1.1.0-devsince 2026-07-28, so a consumer resolvingdev-mainfrom a VCS repository was aliased onto the 1.0 line and would not have satisfied the new constraint. This matters here in particular because several demos and sml3 pin adapter dev branches. Composer's lock content hash coversextra, so the lock is regenerated with it.- The managed Amazee.ai gateway is enabled explicitly, through the settings page or the provision command, instead of on the first AI request (
src/ScoltaServiceProvider.php,src/Http/Controllers/AmazeeSettingsController.php,src/Commands/AmazeeProvisionCommand.php,resources/views/amazee-settings.blade.php,config/scolta.php,README.md). Two surfaces enable it, each from an operator action: the Amazee.ai settings page andphp artisan scolta:amazee:provision. Both are unchanged in mechanics. What changed is everything else: theScoltaAiServicefactory now only reads a connection one of those surfaces established, and establishes none of its own, so serving a request can never turn AI on.⚠️ Upgrade note: an application that expected AI features to appear without configuring anything must now enable the gateway once, from either surface; an already-stored connection keeps working untouched and needs no action. With noSCOLTA_API_KEYand no stored connection the factory builds a key-less client, so/api/scolta/v1/expand,/summarizeand/follow-upreturn HTTP 200 with the query unexpanded and no summary — the same graceful degrade as any unconfigured provider, never a 4xx — and keyword search is unaffected either way. Moving off the gateway now clears it. Configuring an explicitSCOLTA_API_KEYclears the stored connection and both recovery markers (the auth-failure marker and the persistent re-authentication marker), and switching off from the settings page does the same. Leaving them behind was its own small bug: the explicit key already won at the factory, so the stored connection was unreachable, yet/healthand the settings page kept describing it, and a "reconnect your account" prompt kept showing for credentials the operator had deliberately moved off. The clear costs one indexed lookup that finds nothing on a site that never connected. One consequence worth stating: a stored connection whose model names never resolved is no longer re-resolved from a request path. It still degrades safely (no key is injected, so the gateway never receives the shipped dated default and answers 200 rather than 400), and re-running either enable surface resolves and stores the model names. Both surfaces now state the offer in the same words, held asAmazeeProvisionCommand::OFFER_LINEwith a test asserting the settings page carries that exact sentence, so the two cannot drift into describing the same offer differently. Covered by the newtests/AiServiceOptInResolutionTest.php(resolving with nothing configured stores nothing and yields a degrading client; a stored connection drives the gateway; an explicit key clears the connection and both markers),tests/Http/AmazeeSettingsOptInTest.php(switching off clears the connection and both markers; the page states the offer and reports each state) andtests/Commands/AmazeeProvisionCommandTest.php(the command states the offer, stands aside for an explicitly configured provider, and rejects a malformed address), plustests/AiProviderOptInTest.phpandtests/AmazeeUnresolvedModelTest.php, which replace the former structural suites: no provisioning reference survives anywhere in the service provider, both enable surfaces keep their provisioner call, and the key is injected only once a real model name is stored. - Pagefind index chunks are preloaded while the user types (tag1consulting/scolta-php#232, issue #191). Scolta runs no search until Enter or the search button, so every submitted search also paid for fetching the alphabetical index chunk(s) for the typed term. The search input now hands the term to
pagefind.preload()— the chunk-resolution half of a search, which bails out before scoring — behind a 150 ms trailing debounce, a 2-character floor, a repeat-term skip and a feature-detect onpreload; failures are swallowed, so a cache warm can never break the search box.
Note: scolta-laravel serves
scolta.jsdirectly from the scolta-php dependency (no committed copy), so this ships automatically once the scolta-php dependency is updated to a release containing it.AssetStatusvalidates the published copy against the dependency'sassets/js/scolta.js.sha256, so runphp artisan vendor:publish --tag=scolta-assets --forceafter the upgrade or the status check will report drift. No Laravel-side code changed.
Known limitations
- Facet counts do not exactly match the filtered result list once AI query expansion has run (tag1consulting/scolta-php#265). The panel recounts after expansion, which it did not do before this release, and on every corpus measured it is strictly closer to the list than the previous behaviour was. It is not yet exact. It reaches this package through the
scolta.jsserved from the scolta-php dependency. Unexpanded queries are exact. Targeted for 1.1.1; see the scolta-php issue for the diagnosis and the fixes considered.