Scolta 1.0.0-rc1
First stable release — all features from 0.3.x promoted to 1.0 API surface.
Fixed
scolta.jsinitPagefind()now uses a module-levelpagefindInstanceguard to prevent callingpagefind.init()more than once per page. Pagefind uses a SharedWorker that persists across navigations; callinginit()a second time (e.g. on Drupal behavior re-attachment after a language switch) corrupts the WASM pointer permanently, causing "No pointer" errors and zero search results for the remainder of the tab session. The guard is stored in the outer IIFE scope so it spans allcreateInstance()calls on the same page.- Summarize endpoint no longer returns HTTP 400 on large result sets. The
contextparameter validation limit has been raised from 50,000 to 100,000 characters. The client truncates to 49,000 characters before sending, so this server-side limit acts as a safety net only. IndexBuildOrchestratornow reports failure when the atomic swap or post-build sanity check fails. Two silent failure modes are closed: (1)atomicSwap()now throwsRuntimeExceptionif anyrename()call returns false, so a failed filesystem rename can no longer cause a false-success result; (2) after the swap,verifyOutputHasFragments()checks that the pagefind output directory contains at least one.pf_fragmentfile when pages were processed — zero fragments after a non-empty build is treated as a hard failure. Both errors surface through the existing try/catch and are returned assuccess: falsewith a descriptiveerrorstring. (scolta-php#46)ContentExporter::filterItems()no longer crashes onCachedContentReferenceobjects during re-index. When a prior build's timestamp manifest exists,gather()yields a mix ofContentItemandCachedContentReferenceobjects (cache-hit markers for unchanged posts).filterItems()previously accessed$item->bodyHtmlon every item, which is not a property ofCachedContentReference. Addedinstanceof CachedContentReferencetype guard to pass cached items through without inspection. Fixes PHP fatal error on WordPress and any other platform on any site with a prior build.- AI summary
max_tokensraised from512to1024. The previous limit caused the AI to truncate mid-markdown on multi-result summaries — the last result was particularly vulnerable. The new default halves truncation frequency. The value is now configurable viaScoltaConfig::$aiSummaryMaxTokens. cleanBrokenMarkdown()added toscolta.jsbeforeformatSummary()renders markdown to HTML. Mirrors the existing PHPMarkdownRenderer::cleanBrokenLinks()salvage logic: unclosed markdown links become bold text, unclosed bold/italic/backtick delimiters are closed. Prevents truncated AI output from producing broken HTML in the browser.AiClientnow auto-appends/v1/chat/completionsto OpenAIbase_urlvalues that have no path. LiteLLM proxies (including Amazee.ai) return a base URL without a path component. Passing that URL directly caused405 Method Not Allowederrors. When thebase_urlhas no path (or only/), the standard OpenAI chat completions path is appended automatically. URLs that already contain a path are used as-is.AmazeeClient::provisionTrial()andsignIn()now parse the nested API response format, and token validation is removed from the provisioning path. The Amazee.ai/auth/generate-trial-accessendpoint now returns credentials nested under akeyobject, andsignInwraps the access token under atokenobject. Both methods now check for the nested format first and fall back to the legacy flat format for backwards compatibility. The post-provisioningvalidateToken()call is removed fromprovisionTrial()because the/auth/meendpoint no longer exists on Amazee.ai's LiteLLM proxy — the token issued by the provisioning API is trusted directly.scolta.jsresult URLs resolved against pagefind base path instead of site root.pagefindBasewas stored as an absolute URL (with origin), butpagefind.jsreturns root-relative URLs after applying itsbaseUrl(a path, not an origin). The startsWith check never matched, so the pagefind prefix was never stripped — result links pointed to/wp-content/uploads/scolta/pagefind/product/…instead of/product/….pagefindBaseis now stored as a path-only value by stripping the origin vianew URL().pathnamewhen the pagefind path is absolute.Adapter install — DrupalCI job failed because the release-validation workflow replaced the entirerepositoriesarray (wiping thepackages.drupal.org/8Drupal Packagist entry) instead of only swapping the local path repo for the GitHub VCS repo. The PHP inline script now filters out only thepath-type scolta-php entry and appends the VCS repo, leaving other repository sources (including the Drupal Packagist) intact.- Search status message text no longer overflows on narrow viewports.
.scolta-results-headernow carriesoverflow-wrap: break-wordandword-wrap: break-word, and its first-child<span>gainsmin-width: 0so the flex item can shrink below its content size. Long messages (e.g. "— no exact matches found, showing partial matches") wrap correctly at 320 px, 768 px, 1024 px, and 1440 px viewport widths. (#51) - Memory profile documentation claimed "peak RSS ≤ 96 MB" for the conservative profile, but 96 MB is Scolta's internal allocation budget — total process RSS also includes the PHP runtime baseline (typically ~60 MB for Laravel CLI, ~80 MB for WordPress, ~130 MB for Drupal) and I/O overhead. PHPDoc for
MemoryBudget::conservative(),MemoryBudgetSuggestion::suggest()reason strings, andcheckProfileFit()warning messages now use "internal allocation budget" rather than "peak RSS". The README Memory and Scale section is updated with a platform baseline table and corrected estimates for total expected RSS per profile. The balanced and aggressive README comments ("~200 MB peak RSS" and "~384 MB peak RSS") are corrected to match the actual internal budget values (384 MB and 1 GB). (scolta-php#47) HealthCheckerno longer reportsai_configured: truewhen the API key is whitespace-only. The previous!empty()check treated strings containing only spaces or tabs as configured. Changed totrim() !== ''so only non-empty, non-whitespace keys (including Amazee.ai tokens stored as the API key) report as configured.- AI endpoints return HTTP 200 with empty data instead of 503 when no API key is configured. A new
ApiKeyMissingExceptionis thrown byAiClientwhen no API key is set.AiEndpointHandlercatches it specifically — before the generic exception handler — and returns a graceful empty response:handleSummarizereturns{}(no summary shown),handleExpandQueryreturns the original query (no expansion), andhandleFollowUpreturns an empty response. Sites without AI configured no longer produce 503 console errors on every search. Thescolta.jsfetch catch blocks also now suppressTypeError(network unreachable / offline) silently rather than logging a warning or showing an error state, so search works normally when the AI endpoint is unreachable. (#50) - Tests added for CATEGORY and VARIETY rules in the summarize prompt template, and for
{SITE_NAME}placeholder presence in all three templates. Both CMS adapters delegate toDefaultPrompts::getTemplate()at runtime; these tests lock in the template contracts so prompt drift is caught immediately if the canonical text is removed or emptied. (#49) scolta.jsresult links no longer double the path on subdirectory installs. Result display URLs now preferdata.meta?.url(the verbatim URL stored indata-pagefind-metaby the binary indexer) over Pagefind's resolveddata.url. Pagefind's JS client resolves stored root-relative paths against the pagefind base directory when buildingdata.url, which on a subdirectory install (/drupal/web/) produces paths like/drupal/web/sites/default/files/scolta-pagefind/drupal/web/node/42. Usingdata.meta?.urlavoids this resolution entirely;resolveUrl(data.url)is kept as the fallback for the PHP indexer path wheredata.meta.urlis undefined. (scolta-drupal#40)IndexBuildOrchestrator::build()now returnserror: 'memory_abort'whenMemoryTelemetryfires. The catch block detectsRuntimeExceptionmessages containing "exceeds safe threshold" and returns a structuredStatusReportwitherror: 'memory_abort', the number of chunks already committed, and the committed page count from the build manifest. Framework adapters can now programmatically distinguish a memory abort from other failures and spawn a fresh--resumeprocess automatically.- Memory telemetry now measures actual RSS instead of PHP's allocator-reported memory.
MemoryTelemetryreads VmRSS and VmHWM from/proc/self/statuson Linux, falling back tomemory_get_usage(true)/memory_get_peak_usage(true)when/procis unavailable (macOS, Windows). Also reads cgroup v2/v1 memory limits (/sys/fs/cgroup/memory.max,/sys/fs/cgroup/memory/memory.limit_in_bytes) to determine the effective ceiling — on containerised/shared hosting the cgroup limit is often lower thanmemory_limit, and either one can SIGKILL the process. The 90% abort threshold and the heap-full guard inIndexBuildOrchestratornow use actual RSS against the effective limit.StatusReport.peakMemoryBytesnow reports the RSS high-water mark (VmHWM) rather than PHP's monotonicmemory_get_peak_usage(true). IndexBuildOrchestratortail chunk now callsgc_collect_cycles(). The regular chunk loop already calledgc_collect_cycles()after each commit; the tail chunk (items that don't fill a complete chunk) was missing it. Circular reference chains from the last batch of ContentItem objects now get collected before the merge phase begins.- WordPress gatherer now flushes all relevant cache groups and calls
gc_collect_cycles(). Previously only flushed thepostscache group;post_meta,terms, andterm_relationshipsaccumulated across the entire build. Also addedgc_collect_cycles()between batches (matching the Drupal gatherer's existing cleanup pattern) and awp_cache_flush()fallback for WordPress < 6.1. IndexBuildOrchestrator::build()now detects a full-heap condition after indexing and defers the merge to a fresh process. After all chunks are committed,memory_get_usage(true)(total allocated PHP segments, not live objects) is compared against the PHPmemory_limit. If segments reach ≥75% of the limit, the heap is too fragmented to run the multi-pass pre-merge safely — even a 12 KB allocation can fail. The build returns early witherror: 'index_only_complete'and the chunks remain intact on disk. Framework adapters (e.g.drush scolta:finalize) can then run the merge in a fresh process with a clean heap.InvertedIndexBuildernow caps stored positions at 200 per weight bucket per page. Common stop words ("the", "and", "of") appear hundreds of times per Wikipedia article. Without a cap, serializing merged position arrays across thousands of pages during the multi-pass pre-merge required 25+ MB of contiguous heap per common term, triggering OOM in constrained environments (512 MB PHP memory limit). 200 positions per weight bucket is sufficient for phrase-proximity scoring; additional occurrences are counted inwordCountbut their positional data is discarded. Title positions are not capped (titles are short).IndexBuildOrchestrator::build()now callsgc_collect_cycles()after each chunk commit. Framework adapters (Drupal, WordPress, Laravel) use Drupal entities and similar OOP graphs that contain circular references. PHP's reference-counting GC does not reclaim circular reference groups automatically — they accumulate until the cycle collector runs. Explicitly running it after each chunk ensures circular references from the just-committed chunk are reclaimed before the next chunk begins, keeping live memory bounded on long-running builds.- Chunk accumulator no longer holds
bodyHtmlacross the full chunk.IndexBuildOrchestrator::build()andPhpIndexer::processChunk()previously stored the fullContentItem(includingbodyHtml, which can be several MB for large articles) in the chunk accumulator for the entire chunk duration. WithchunkSize=10, this kept up to 10 full HTML bodies in memory simultaneously even thoughbodyHtmlis no longer needed aftertokenizeItem(). The chunk now stores a slim proxy containing only the six scalar metadata fields thatbuildFromTokenData()actually uses (id,url,date,siteName,language,filters), sobodyHtmlis freed as soon as the generator advances to the next page. - PageWordCache write buffer now flushes on byte budget, not just entry count. When pages contain thousands of tokens (e.g. long encyclopedia articles), a single
serialize()call on the write buffer could try to allocate 30+ MB and exhaust the PHP memory limit. The write buffer now tracks an estimated byte footprint per entry (token count × 80 bytes + content length) and flushes when either the entry count or the byte budget is reached. The byte budget comes fromMemoryBudget::tokenCacheChunkBytes(): 4 MB for conservative, 16 MB for balanced, 64 MB for aggressive. ThePageWordCacheconstructor default is also 4 MB, so code that constructs the cache without a budget is protected automatically. - Stemmer memoization cache is now capped at 100,000 entries. The cache grew without bound across a build, accumulating every unique word form encountered in the corpus. For large multi-subject corpora (e.g. Wikipedia with 500k+ unique word forms), this added tens of megabytes of permanent heap growth per build. The cap allows the most common words — always encountered first — to stay memoized, while rare long-tail vocabulary is re-stemmed on each occurrence with negligible performance impact.
- Memory-budget abort threshold now uses current live RSS, not monotonic peak.
MemoryTelemetrypreviously computed the threshold percentage frommemory_get_peak_usage(), which only ever increases. On large-corpus builds with many small chunks (e.g. Wikipedia with--chunk-size=10), the accumulated peak from earlier chunks crossed 90% of the PHP memory limit long before actual live memory was under pressure, causing a false-positive abort. The threshold now usesmemory_get_usage()(current live memory) instead, so the abort fires only when the process is genuinely close to its limit. - Fixed OOM on large corpora caused by pageWordCache holding all token data in RAM. The page-word cache previously deserialized the entire cache file (~2+ GB for large sites) into a single PHP array at construction. Fix: replaced the monolithic in-memory cache with a chunked disk-backed architecture. A lightweight manifest (hash → chunk number, ~5 MB for 44k pages) stays in memory; chunk files are loaded on demand one at a time. Peak cache memory drops from O(corpus size) to ~15 MB regardless of site size. Includes automatic migration from the legacy single-file format. Extracts duplicated cache logic from IndexBuildOrchestrator and PhpIndexer into a shared PageWordCache class.
- Search result links now point to the correct page URL. Pagefind's
fullUrl()prepends its own base path (e.g./sites/default/files/scolta-pagefind/) to every stored root-relative URL before returningdata.url. The newresolveUrl()helper strips that prefix back off, so result links navigate to/faculty/xinstead of/sites/default/files/scolta-pagefind/faculty/x.pagefindBaseis computed once ininitPagefind()as the directory parent of thepagefind/folder. - Language facets now appear when using the Pagefind binary indexer.
initPagefind()only loaded the page's primary language index; each per-language Pagefind index only contains its own language's pages, sosearch.filters.languagehad a single value andrenderFilterssuppressed it.initPagefind()now readspagefind-entry.jsonafter init and callspagefind.mergeIndex()for every other language. The index path is passed as an absolute URL (new URL(basePath, window.location.href).href) to bypass Pagefind's same-index dedup guard, which compares string prefixes and would silently skip relative-path calls to the same index. - Language and content-type facets now appear in the search sidebar.
computeFilterCounts()was readingr.data.filterson individual Pagefind result objects, which do not carry afiltersproperty. Pagefind only exposes aggregate filter counts at the search-response level (search.filters).doSearch()now assignsfilterCounts = primarySearch.filters || {}directly from the Pagefind search response, so facet checkboxes render correctly for all indexed filter dimensions (language, site, content_type, etc.). InvertedIndexBuildernow emitslanguageas a Pagefind filter dimension. The PHP indexer builds filter metadata from$item->siteNameand$item->filters, but never included$item->language. The binary path worked becausePagefindExporteremitsdata-pagefind-filter="language:..."HTML attributes which Pagefind reads at crawl time. The PHP-only path skips HTML entirely, so language was silently dropped from every index entry.$item->languageis now merged into the filters array alongsidesite, making language faceting functional for the PHP indexer path.
Added
ScoltaConfig::$aiSummaryMaxTokens(default1024) controls the AI token budget for summary responses. Passai_summary_max_tokensin config arrays (Drupal, Laravel, WordPress) to override.AiEndpointHandlerreads the value fromScoltaConfigviaAiControllerTrait::createHandler().scolta.js.sha256checksum file for cross-repo sync verification. scolta-drupal and scolta-wp CI verify their committed copies match this checksum so drift fails CI automatically.AutoProvisioner::ensureAiAvailable()provisions a free Amazee.ai trial on first use. CMS install hooks and lazy-init paths call this static method with aConfigStorageInterfaceand an optional$hasExplicitApiKeyflag. It is idempotent: it skips provisioning when an explicit API key is already configured, or when credentials are already stored. On success, credentials are persisted via the storage adapter and an optional$onModelsResolvedcallback is called so the CMS can save the resolved model names in its own config system. Provisioning failures are caught internally and returned asfalseso the install path degrades gracefully.AmazeeClient::provisionTrial()andAmazeeTrialProvisioner::provision()now accept an optional$emailparameter (defaulting to'') for anonymous provisioning.scolta.jsauto-filters search results by the current page language. A new optionalcurrentLanguageconfig key inwindow.scoltaaccepts a 2-letter ISO language code. When set, every fresh search is pre-filtered to that language; users can uncheck the language facet in the sidebar to search all languages. Falls back to the<html lang>attribute whencurrentLanguageis not provided. URL filter params (f_language=...) take precedence over both — a shared or bookmarked URL always wins. CMS adapters passcurrentLanguageat render time so results are automatically scoped to the page's content language. The auto-filter is guarded byai_languages: it only activates when the detected language is in the configured list and the list has more than one entry, so monolingual sites are unaffected and a detected language without indexed content never produces empty results.IndexerResolverclass for explicit indexer selection logging. Adapters can construct anIndexerResolverwith aPagefindBinaryand PSR-3LoggerInterfaceand callresolve('php'|'binary'|'auto')to get the effective backend ('php'or'binary') with anotice-level log message emitted at selection time. When'binary'is configured but no binary is found, the resolver falls back to'php'and logs"[scolta] Falling back to PHP indexer: binary not available.". When'auto'is used, it probes the binary and logs which backend was auto-detected.IndexBuildOrchestrator::build()now also emits"[scolta] Using PHP indexer."at the start of every PHP-path build. (#48)- Amazee.ai auto-configuration:
AmazeeModelResolverpicks the best available Claude model for each inference role. After a successful trial provision, the provisioner callsresolve()against the provisioned LiteLLM/model/infoendpoint to select the highest-versioned Sonnet (ai_model) and Haiku (ai_expansion_model) available in the account. Models are returned inProvisioningResultvia new nullable$aiModeland$aiExpansionModelfields. The resolver degrades gracefully (returnsnullfor both) if the endpoint is unreachable or returns no matching models.AmazeeTrialProvisioneraccepts an optional?AmazeeModelResolver $modelResolverparameter. AmazeeTrialProvisioneraccepts an optional$hasExistingProvidercallable. When provided and returns true,provision()returnsProvisioningResult::skippedExistingProvider()without making any API calls.ProvisioningResultgains threeSTATUS_*constants (STATUS_PROVISIONED,STATUS_SKIPPED_EXISTING_PROVIDER,STATUS_FAILED) and a$statusstring property (defaulting toSTATUS_PROVISIONEDfor backward compatibility).- Timestamp-based rebuild optimization: skip unchanged entities entirely. Two new classes enable gatherers to bypass
loadMultiple()/WP_Queryfor entities whose changed timestamp has not changed since the last indexed build:TimestampManifest— disk-backed manifest (timestamp-manifest.phpin the state directory) mapping entity key →{ts, items[{hash, id, url, date, siteName, language, filters}]}.put()stores a new/updated entry,markSeen()prevents it from being pruned, andpruneAndSave()atomically removes entries for deleted entities and persists the manifest.CachedContentReference— marker object yielded by gatherers for unchanged entities. Carries the pre-computed content hash and all metadata needed to build a chunk entry without re-loading the entity body.
IndexBuildOrchestratornow exposesgetTimestampManifest()and handlesCachedContentReferencein the build loop. The build loop checksinstanceof CachedContentReference: on a token-cache hit the reference is added to the chunk andmarkSeen()is called; on a cache miss the entry is silently dropped, causingpruneAndSave()to remove it so the entity is treated as changed on the next build.pruneAndSave()is now called on both thePageWordCacheandTimestampManifestin all three build-completion paths (deferred merge, successful merge, and the implicit prune that follows). On a 44k-page site with 10 changed pages this reduces rebuild time from minutes to seconds.- Amazee.ai integration: core PHP library (
Tag1\Scolta\AiProvider\Amazee\). Seven new classes supporting the full trial provisioning and account upgrade flows for Amazee.ai's LiteLLM-compatible API:AmazeeClient— HTTP client for the Amazee.ai control-plane (provisioning, sign-in, region listing, private key creation, token validation). Uses the existing Guzzle dependency; no new composer requirements.AmazeeApiException— thrown on API errors; carries the HTTP status code.AmazeeBudgetExceededException— thrown byBudgetAwareProviderDecoratorwhen Amazee.ai rejects a request with "Budget has been exceeded!".ConfigStorageInterface— abstract storage backend implemented by each CMS adapter (Drupal, WordPress, Laravel) to persist LiteLLM credentials.ProvisioningResult/UpgradeResult— immutable value objects returned by the provisioning and upgrade flows.AmazeeTrialProvisioner— orchestrates trial provisioning: calls the API then stores credentials.AmazeeAccountUpgrader— orchestrates the three-step upgrade flow: request OTP → sign in → provision private key.BudgetAwareProviderDecorator— wrapsAiClientand converts Amazee budget-exceeded errors intoAmazeeBudgetExceededException.
- Multi-dimensional filter faceting in the search sidebar. Language, site, content type, and any other Pagefind filter dimensions are now rendered as separate grouped checkboxes instead of a single hardcoded "Site" filter. Language codes are mapped to full display names (English, Spanish, etc.) via a
LANGUAGE_NAMESmap. Dimensions with only one unique value are hidden; visible dimensions are ordered language first, site second, then others alphabetically. Filter state is preserved in the URL viaf_<dim>=<value>query parameters and restored on page load and browser back/forward navigation. - Page-word cache for smart rebuilds.
PhpIndexer::processChunk()andIndexBuildOrchestrator::build()now consult a per-item token cache keyed bycontentHash(item)(URL + body SHA-256). Items whose content has not changed since the last build skip HTML cleaning and tokenization entirely — only new or modified pages are re-tokenized. The cache is serialized to{stateDir}/page-word-cache.phpand automatically pruned to the set of items seen in each build, so deleted pages never accumulate stale entries. Pass$force = true(or--forcefrom a CLI command) to bypass cache lookups while still repopulating the cache with fresh token data. InvertedIndexBuilder::tokenizeItem()— new public method that extracts a single item's token data (title, body, URL tokens; word count; cleaned title; content string) without building the index. Returnsnullfor items whose body is too short to index. The return value is safe to serialize to a persistent cache.InvertedIndexBuilder::buildFromTokenData()— new public method that assembles a partial inverted index from pre-tokenized item data. Item metadata fields that are not part of the content hash (date, siteName, filters) are always read from the currentContentItem, not from cached data.
Changed
- Added
extra.branch-alias(dev-main→1.0.x-dev) so consumers can resolve this package with^1.0@devfrom a VCS repository. indexer: autonow always uses the PHP indexer. Previouslyautotried the Pagefind binary first and fell back to PHP. The PHP indexer works on all PHP hosting environments withoutexec()or Node.js, uses less memory, and supports fast incremental re-indexing. Useindexer: binaryto keep the old binary-first behaviour.