Skip to content

13 October 2026

Thirty-eight entries today:

  • Response::send() leaves an earlier status line alone
  • Recording sends is an application setting; a missing SMTP host says so
  • Pagination reads a page number from a URL whatever it is
  • Closing a connection another instance closed is a no-op
  • A table's footer search reads nothing undeclared and stays within its table
  • A date field is ten characters wide, and its form-control is the project's to drop
  • A model in JSON is its data
  • Reading — and taking — a column's search value before the Datasource runs
  • A multiple-choice column filter reads as its values
  • A user that did not load is saved as a new one
  • Language models: a client, a tool loop, a call log and one admin screen
  • /admin/Ai shows the application's own sections
  • An empty order is no order
  • Conversations with a model: memory, background questions, schedules and consent
  • A conversation's assistant: its bag, its clock, and a limit only on adding
  • Screens for conversations: a JSON API, a page, a widget and three Svelte components
  • A resync keeps the components a project changed; conversation screens under llm/
  • The conversation screen: a waiting line, word of an answer, and the application's own formats
  • The administrators' analyst: questions about the installation, from its own figures
  • A reserved word is quoted wherever a column is named
  • A push is recorded at the moment it was sent, in every zone
  • Ask: the analyst's own page at /admin/Ai, and a conversation page that answers in place
  • The language-model tables are read under the installation's prefix
  • A page loading a framework script the project never published says so
  • An application's own chat tables take a conversation outside any organisation
  • A migration that made its table is not declined for want of its hypertable
  • Every framework migration runs on an installation with a table prefix
  • MariaDB: dropping a CHECK, and a column's CHECK after its COMMENT
  • The framework's queries name its tables as its models do
  • A queue worker's task sees the settings as they are now
  • A guest inside the administration area signs in and comes back to it
  • The analyst counts days in the application's zone, and its rule about people is the application's
  • A table in an answer is readable in every theme
  • Example questions above a new conversation
  • The analyst writes its own SQL, and files to download
  • A conversation reads like one: whose, when, and a copy button
  • A short export shows in the answer; a table copies into a spreadsheet
  • Charts in an answer

Response::send() leaves an earlier status line alone

Fixed

  • Response::send() called http_response_code() unconditionally. On PHP 8.5 that call, after a header('HTTP/1.1 404 Not Found') a legacy controller had written, has no effect and logs "Calling http_response_code() after header('HTTP/...') has no effect" — once per such response. It now sets the status only when it differs from the one in effect.

Documentation

  • Framework Guide, Response API reference: what send() does with a status already set, and how a front controller passes a controller's own status into make().

Recording sends is an application setting; a missing SMTP host says so

Added

  • 'mail' => ['record' => false] in app/app.php turns off the mails row that every Email::send() writes. An application that keeps its own row per message got two — one measured sixteen pairs in its first hundred rows — and the static Email::sendMail(), which builds its own Email, had no way to turn it off. $email->recordToMails = false still does it for one message.

Fixed

  • With no smtp_host, a send failed with Symfony's "The mailer DSN is invalid." — the DSN built from an empty host is smtp://user:pass@:0, so the message pointed at the credentials. It now fails with "SMTP not configured: smtp_host is empty."

Documentation

  • Email Guide: the record setting beside the audit log, and the missing-host error under SMTP settings. Upgrade Guide: two rows.

Pagination reads a page number from a URL whatever it is

Fixed

  • new Html\Pagination($pages, $page, $url) took int for both numbers, so a non-numeric page taken from the URL — /page/abc from a crawler — was a TypeError and a broken page. Both accept int|string|null and are read with (int): '3' is page 3, anything else page 1.

Documentation

  • HTML Components Guide, Pagination: how the numbers are read.

Closing a connection another instance closed is a no-op

Fixed

  • Database::close() called mysqli_close() / pg_close() on whatever link it held. Two instances given the same link without clone — a legacy and a modern handle over one connection — both own it, so the second destructor threw "mysqli object is already closed": a fatal in the log at shutdown, after the response had gone, twenty times a minute on one production site. A link that is already closed now makes close() return false.

Documentation

  • Database API Guide, Cloning a Database: a link shared some other way.

Fixed

  • Html\Datatable with footerTextSearch emitted a blur handler that restored asInitVals[…], which nothing declared: every empty footer box that lost focus threw "ReferenceError: asInitVals is not defined". The focus/blur pair imitated a placeholder by writing the label into the value; the boxes carry a placeholder attribute, so it is gone.
  • The footer keyup was bound with $("tfoot input"), every footer box on the page, so with two tables typing under one filtered both. It is bound to its own table's footer.

Documentation

  • HTML Components Guide, Filtering one column: the boxes filter their own table only.

A date field is ten characters wide, and its form-control is the project's to drop

Added

  • component_classes.date (default form-control): the class Html\Date puts on its field. '' leaves it off. Bootstrap's .form-control is display:block; width:100%, so outside a grid column the field spanned the page — fifty-one administration views in one application — and the class was hard-coded.

Fixed

  • The field carries size="10", so with no class it is ten characters wide rather than the browser's default. Its classes are escaped.

Documentation

  • HTML Components Guide, Date and time: the width and the class.

A model in JSON is its data

Fixed

  • Model implements JsonSerializable, returning getData(). json_encode() walked a model's public properties, and $controller leads to the application and back to the model: "Recursion detected", false, and a 500 from every API response holding a model one level down. On one production site that was a user's record (its package, a model in a field) and their tickets (departments and the user, arrays of models).

Documentation

  • API Guide, getData() and the columns it returns: what a model becomes in JSON. Upgrade Guide: one row.

Reading — and taking — a column's search value before the Datasource runs

Added

  • Datasource::columnSearch(int $column) returns a column's search value from DataTables 1.10 (columns[N][search][value]) or 1.9 (sSearch_N) alike. render() translated the first into the second only when it ran, so an application building its own condition beforehand — one has about 470 reads of sSearch_N — found nothing on a 1.10 request.
  • Datasource::takeColumnSearch(int $column) reads it and keeps render() from applying it as a LIKE on the column as well, for a filter the application turns into its own condition.

Documentation

  • HTML Components Guide, Filtering one column: both methods.

A multiple-choice column filter reads as its values

Fixed

  • A <select multiple> column filter posts an array — fnFilter(jQuery(this).val(), N), sent as columns[N][search][value][]=…. Datasource::columnSearch() read it as '', dropping the filter, and the 1.10 translation cast it to the word Array, which render() applied as LIKE '%Array%'. Both now read it as its non-empty values joined with commas — '3,7' — the form a 1.9 client sent the same choice in.

Documentation

  • HTML Components Guide, Filtering one column: a multiple choice.

A user that did not load is saved as a new one

Fixed

  • new User($id) for an id with no row kept _isnew = 0 and the default userid of 1, so its save() ran UPDATE users … WHERE userid = 1 — the Guest row, or nothing — and then wrote its details against that id. An application creating accounts from new User($_SESSION['uid']) lost every sign-up after upgrading, with nothing logged. A user that did not load is now new, and saving it inserts a row with its own id.
  • A save whose UPDATE finds no row — the user was deleted after loading — writes nothing, details included, and records why in _errors. Zero affected rows alone is not taken as missing: MySQL reports that for an unchanged row too, so the row is looked for first.

Documentation

  • Authentication Guide, Loading Users: what a user that did not load is. Upgrade Guide: one row.

Language models: a client, a tool loop, a call log and one admin screen

Added

  • Pramnos\Llm, behind a new llm feature: Client::ask() and askWithTools() over the OpenAI-compatible providers (OpenAI, DeepSeek, Gemini, Mistral built in; an application adds and overrides them in app.php), with Tools::fromMcp() turning MCP tools into the loop's tools. The loop forbids tools on the last round and tells the model not to claim what no tool did; a due tool is forced before the end, and a verifier can send a wrong answer back once. Answers are plain arrays.
  • Policies an application plugs in, each defaulting to "allow": Policy\Limits (plans, quotas; told what each round spent), Policy\Consent, Policy\Router (the request's organisation, its own provider or key) and Policy\DataRule — data a tool tags (Tools::tag()) that may only go to some providers moves the answer to one that may take it, or is withheld.
  • llm_calls: every call, failures included, with its cost frozen at write time from editable unit prices (llm_prices, seeded) and null for a model nobody priced. llm:prune keeps 90 days and is scheduled daily. Billing\OpenAiCosts reads what OpenAI billed.
  • /admin/Ai (admin.ai): providers, keys (encrypted, never printed back), models, features, prices, OpenAI billing and the call log — the one screen for these settings, which an application extends with its own sections ('admin_sections', Llm\Admin\Section) rather than a second screen. init --features=…,llm writes the admin wrapper.
  • The settings and the table keep the names an application that already ran this code uses (llm_*, llm_calls); a table that exists is kept, and gains tokens, filled from each row's usage.

Documentation

  • New: the Language models guide. Console Guide: llm:.

/admin/Ai shows the application's own sections

Fixed

  • The sections an application declares in 'llm' => ['admin_sections' => [...]] were saved but never drawn. The controller handed them to the view as sections, which is also View's own protected property: the assignment went through __set, and the template, running in the view's scope, read the real — empty — property. The variable is appSections. Any view variable named after a property of View has the same problem (sections, path, model, _layout, …); the test that caught this renders the template through a real View.

An empty order is no order

Fixed

  • Model::_getList($filter, '') ordered by primary key descending, as null does. An empty string is the caller saying "no order" — the framework's first _getList() read it that way, and wrappers declared getList($filter = '', $order = '') relied on it — so every such list came back upside down: one application's invoices, newest at the bottom for years, newest at the top after upgrading. '' now adds no ORDER BY; null is still newest first.

Documentation

  • ORM Guide: what the order argument's two empty values mean. Upgrade Guide: one row.

Added

  • Pramnos\Llm\Chat: conversations kept per person (Conversations, Scope), what a model remembers about them (Memory), questions answered by the queue worker (Asks, a chat_ask task the worker knows without being told) and questions asked again on a schedule (Routines, run every minute by llm:routines, telling the owner with RoutineReady). What a conversation is about — prompt, tools, checks, what an answer produced — is an application's Chat\Assistant; BaseAssistant answers every hook with a default.
  • Policy\StoredConsent: agreement to a provider kept in ai_consents, checked only for a provider marked requires_consent.
  • Pramnos\Llm\DocumentText (Word, OpenDocument, text, Markdown and email to words, and their pictures) and Pramnos\Email\MailMessage (an email read into its parts).
  • Tables chat_conversations, chat_messages, chat_memory, chat_asks, ai_consents and ai_routines, under the llm feature; an application that already has them keeps them, and chat_conversations and chat_messages gain tags and extra.
  • Queue\Worker::FRAMEWORK_HANDLERS: the framework's own task types, answered without being registered; an application registering the same type wins.

Documentation

  • Language models guide: Conversations. Email Guide: Reading an email.

A conversation's assistant: its bag, its clock, and a limit only on adding

Fixed

  • What Assistant::question() put in the turn's bag was lost before tools() and answered() ran: the turn was built again after it. The bag is carried over.
  • Routines asked routineRefusal() the same question to offer the tools, before every run and when one was added, so a limit on how many may be active either stopped every run once reached or was not enforced. It is now routineRefusal(Scope $scope, bool $adding = false), true when one is created or resumed — resuming a paused routine adds an active one too.

Added

  • Assistant::timezone(Scope): the clock a schedule said in words is meant on, for the schedule_task tool. BaseAssistant answers the server's zone.

What breaks in subclasses

  • An assistant implementing Chat\Assistant directly — not through BaseAssistant — needs timezone(Scope $scope): string, and its routineRefusal() must take bool $adding = false.

Screens for conversations: a JSON API, a page, a widget and three Svelte components

Added

  • Pramnos\Llm\Chat\Api\Routes::register($router) mounts the conversation API — conversations, asking (queued, or answered in the request under answer_in_request), following a queued question, memory, scheduled questions and agreement — at /chat…, with paths an application can move. Chat\Api\ChatController carries the hooks: refusal(), beforeAsk(), storePicture(), extra(), limits(). A refusal for want of agreement says consent_required, so a screen asks on the spot.
  • /chat for an application without a SPA: Chat\Web\ChatPageController, views in the three themes, assets/js/pf-chat.js to wait for an answer in place — and a page that refreshes itself without JavaScript — plus partials/chat_widget for a question from any page. init --features=…,llm writes src/Controllers/Chat.php; the user menu gains Ask AI.
  • Chat\Markdown::toHtml(): a model's Markdown as HTML, escaped first.
  • For a Svelte SPA with the llm feature: Chat.svelte, Scheduled.svelte and AiConsent.svelte, with lib/chat.js, lib/markdown.js (marked and DOMPurify, added to package.json) and their tests — at init, and by project:resync --spa-components --all. Chat is extended through props and snippets (extra, canAsk, onRefusal, message, composer, aside, t) rather than edited.

Documentation

  • Language models guide: Screens for conversations. Upgrade Guide: one row, for the menu item.

A resync keeps the components a project changed; conversation screens under llm/

Fixed

  • project:resync --spa-components replaced every shared component that differed from the framework's — a DataTable keyed for duplicate rows, a Field with its own debounce — and printed updated. What init, create:crud and the resync write is now recorded by hash in app/shipped-files.json; a file changed since, or never recorded, is reported kept (locally changed) or kept (not written by the framework) and left alone. --overwrite takes the framework's copy anyway.
  • The conversation screens shipped at components/Chat.svelte, lib/markdown.js, __tests__/Chat.test.js — paths an application's own chat screen and tests already used. They are now under components/llm/, lib/llm/ and __tests__/llm/.
  • The conversation API and page checked the assistant's connection before the router ran, so an organisation on its own provider or key was told nothing was set up, or asked to agree to the installation's provider. They check the routed one: Conversations::connection().

Added

  • Agreement per feature: GET/POST/DELETE /chat/consent?feature=… (chat by default), each on the provider the router sends that feature to; AiConsent's kind. A feature the application does not have is a 422.
  • AiConsent's notice(consent) snippet — and Chat's consentNotice — for the agreement's wording, which is the application's legal text.
  • ChatController::routinesAnswer($scope, $answer), to decorate the scheduled-questions list.

Documentation

  • Console Guide: what --spa-components replaces, and --overwrite. Language models guide: the llm/ paths, per-feature agreement, the notice, the routed provider. Upgrade Guide: one row.

The conversation screen: a waiting line, word of an answer, and the application's own formats

Added

  • Chat.svelte: waitingLine(ms) for what shows while an answer is worked on, onanswer(messages) after each answer is read back, a bindable question, a placeholder of its own, formatDate, numberFormat, and conversationHref(id) to make the conversation list links.
  • What it remembers: an empty memory says Nothing remembered yet. — its own key, so an application can say what is worth telling it — and a remembered preference is marked as one.

Documentation

  • Language models guide: the new props.

The administrators' analyst: questions about the installation, from its own figures

Added

  • /admin/Ai/analyst: administrators ask about the installation — accounts, sessions, organisations, mailing lists, email, push, the queue, logs, health and model spend — and a model answers from Pramnos\Llm\Analyst's read-only tools, behind the AI screen's usertype. The page is the conversation page at the analyst's addresses ($this->urls, $this->heading; data-page for pf-chat.js).
  • Analyst\Redactor: email addresses, tokens, Luhn-valid card numbers, phone numbers and IP addresses masked in every tool result; aggregates never name a person, and an email subject is listed only when it was sent to three or more. The user lookup is tagged personal.
  • 'llm' => ['admin_tools' => [...]] for an application's own figures, and 'llm' => ['analyst' => ['assistant' => …]] for an AnalystAssistant that knows the business.
  • chat_conversations, chat_memory, chat_asks and ai_routines gain space (chat by default); Chat\Scope takes it as a third argument and reads within it, so an administrator's analyst never appears in their Ask AI. A queued analyst question is answered by the analyst.
  • Chat\Attachments::uploaded(), TakesQuestions::consentState() and recordConsent(), shared by the conversation page and the analyst.

Documentation

  • Language models guide: The administrators' analyst, and under Policies, that an explicit organization_id => null is kept by a router. Policy\Router's own docblock says the same.

What breaks in subclasses

  • A subclass of Chat\Web\ChatPageController or Chat\Api\ChatController that declares its own consentState() or recordConsent() with another signature: rename it — the trait now has both.

A reserved word is quoted wherever a column is named

Fixed

  • select(['usage']) on llm_calls failed on MySQL: usage is reserved there and was not in the builder's list of reserved words. It is now, with the other MySQL-only reserved words found as column names (rank, row, lock, read, schema, system, …). A reserved word is also quoted when qualified (c.usage) and as an alias (tokens AS order); a mixed-case alias and a raw expression are left as written.

Documentation

  • Query Builder Guide: Reserved words.

A push is recorded at the moment it was sent, in every zone

Fixed

  • Push\Log wrote sent as PHP's local time without an offset into a timestamptz, which the database read in its own zone — on a PHP in Europe/Athens and a database on UTC, every push was recorded hours in the future, and the windows of stats() and prune() shifted with it. All three now use UTC with the offset.

Documentation

  • Push Guide: what sent holds. Upgrade Guide: one row, for rows written before.

Ask: the analyst's own page at /admin/Ai, and a conversation page that answers in place

Changed

  • /admin/Ai is Ask, the administrators' analyst; the language-model settings are /admin/Ai/settings, a link from it. /admin/Ai/analyst and /admin/Ai?show=problems redirect. The analyst's actions are ask, asked, delete, forget and consent, behind the same ability and usertype as the settings.

Added

  • /admin/Ai/api/…: the analyst in the conversation API's shape, for a screen of the application's own — the session's token in X-CSRF-Token for anything but GET.
  • Analyst::headerButton(): an Ask link beside the admin search, for those who may open it; in new projects' layouts. Analyst::floor() reads the application's Admin\Controllers\Ai.
  • pf-chat.js asks in place on /chat and on Ask: files picked, dropped or pasted as chips, a thinking line with the application's waiting_lines, and the answered page swapped in without a reload. The views draw which tools an answer read, the document a question carried, the ⚙ with model, limits and memory, the context bar, and a ✕ per conversation.
  • Chat\Attachments::fromJson(), shared by the conversation API and the analyst's.

Documentation

  • Language models guide: The administrators' analyst and The page, without a SPA; the settings' address throughout. Upgrade Guide: one row.

What breaks in subclasses

  • An Admin\Controllers\Ai that overrides display() overrode the settings: it is settings() now.

The language-model tables are read under the installation's prefix

Fixed

  • Pramnos\Llm queried chat_conversations, chat_messages, chat_memory, chat_asks, ai_consents, ai_routines and llm_calls by their bare names, while their migrations create them with the installation's prefix. On an installation with one, /admin/Ai answered 500 (Table '….chat_conversations' doesn't exist), and so did every conversation, the call log and the scheduled questions. Every query names them with #PREFIX#.

Added

  • TablePrefixInQueriesTest also reads the migrations: a table a framework migration after the baseline creates under a plain name must be queried with #PREFIX#. It had derived prefixed tables only from names written with #PREFIX# somewhere, which a feature that never did was invisible to. organizations, notifications and queuestats are enumerated as not yet converted.
  • An integration test that builds the llm tables with a prefix, on MySQL and PostgreSQL, and goes through each.

Documentation

  • Language models guide: the tables carry the installation's prefix.

A page loading a framework script the project never published says so

Fixed

  • The conversation pages load assets/js/pf-chat.js, which composer update does not put in a project's web root; an application that updated got the pages as plain forms and a 404 nobody saw. assetUrl() now logs a warning the first time a page asks for one of the framework's pf-*.js that the web root lacks, naming project:resync --js --all.

Added

  • The framework_scripts health check: degraded, with the files, while a shipped pf-*.js is missing from the web root. Application\FrameworkScripts answers which are shipped and which are missing.
  • A test that every assetUrl('assets/js/pf-…') in the framework's views and layouts names a script project:resync --js publishes.

Documentation

  • Health Guide: framework_scripts. Language models guide: publishing pf-chat.js after an upgrade. Upgrade Guide: one row — run project:resync --js --all.

An application's own chat tables take a conversation outside any organisation

Fixed

  • The framework's llm migrations leave a table that exists, so an application that had its own chat_* and ai_* tables with organization_id NOT NULL kept that constraint — and the analyst, which always writes outside an organisation, failed on its first question: /admin/Ai answered 500. A migration (2026_10_13_000012) makes the column nullable on those six tables where it is not, keeping its type.

Added

  • The llm_tables health check: with the llm feature, degraded while a conversation table is missing, lacks space, or refuses a null organization_id, naming each.

Documentation

  • Language models guide: what the migrations do to an application's own tables. Health Guide: llm_tables. Upgrade Guide: one row — run migrate.

A migration that made its table is not declined for want of its hypertable

Fixed

  • On a backend without TimescaleDB, the auth migrations that create a table and then convert it — twofactor_attempts, user_activity_log, user_consents, data_processing_records, gdpr_requests, tokenactions — were declined, "nothing was applied", after creating the table; the next migrate, finding it, recorded them as applied. They now treat the conversion as the extra it is, and are applied the first time.
  • ifCapable() on an engine (ENGINE_POSTGRESQL, ENGINE_MYSQL, ENGINE_MARIADB) no longer declines the migration when the engine is another: PostgreSQL-only indexes skipped on MariaDB are not work left undone.

Added

  • SchemaBuilder::whenCapable(): run when capable, otherwise nothing, and the migration is not held back — for an extra that something else repairs when the capability arrives.

Documentation

  • Schema Builder Guide: ifCapable() or whenCapable(). Hypertable Guide: the migration pattern uses whenCapable().

Every framework migration runs on an installation with a table prefix

Fixed

  • Nineteen framework migrations named tables in raw SQL — public.applications, `usertokens`, applications.oauth2_application_grants, ALTER TABLE users — and so failed, or skipped their work, on the first installation with a table prefix; the applications group, oauth2_application_grants and its views, user_organizations, user_app_authorizations, the consent trigger, the RBAC functions and the slow-call views among them. Every table in a migration's raw SQL is now named through $schema->quoteTable(); oauth2_application_grants is built with the schema builder outright.
  • SchemaBuilder resolved the table being built and not the one a foreign key referenced, so ->on('applications') named an unprefixed table, and ->on('authserver.x') a database on MySQL. It resolves both.
  • add_missing_foreign_keys_to_existing_tables and add_missing_indexes_to_existing_tables used schema('public'), which on MySQL becomes {prefix}public_*, and looked constraints and indexes up by bare name. A foreign key between columns of different types — INT against an increments()'s INT UNSIGNED — is skipped with the reason instead of failing the batch; new MySQL installations get the referencing columns unsigned, so the keys are created.
  • CorsMiddleware::fromApplicationSettings() was raw SQL over the same names; it is a query-builder query. On PostgreSQL it could read neither column: cors_origins is a TEXT[], which never parsed, so every application allowed *, and cors_enabled came back 'f', which PHP reads as true.

Added

  • PrefixedFrameworkMigrationsTest: every framework migration on a fresh MySQL and PostgreSQL installation with a prefix — nothing fails, nothing is declined but for a missing capability.
  • TablePrefixInQueriesTest refuses a framework table named in a migration's raw SQL, which reaches the statements a fresh run does not: down() and the other dialect's branch.

Documentation

  • Migration Guide: Raw SQL Migrations — what the builder names, and naming tables in what it does not. Schema Builder Guide: the referenced table, and matching an increments() on MySQL. Upgrade Guide: two rows — CORS on PostgreSQL, and migrations on a prefixed installation.

MariaDB: dropping a CHECK, and a column's CHECK after its COMMENT

Fixed

  • allow_jwt_bearer_in_oauth2_application_grants and open_webhook_event_types dropped their CHECK with DROP CHECK, which MariaDB refuses; and MariaDB keeps a CHECK written on a column as part of the column, named after it, which DROP CONSTRAINT does not reach either. Both go through the new SchemaBuilder::dropCheck().
  • create_oauth2_application_grants_table writes its CHECK named and table-level, chk_oauth2_grant_type, so the next migration can drop it on every database.
  • The schema grammar wrote a column's CHECK before its COMMENT, which MariaDB refuses; the COMMENT now comes first.

Added

  • SchemaBuilder::dropCheck($table, $name).
  • A mariadb service (MariaDB 10.11), profile-gated, and ./dockertest --mariadb, which starts it and runs the mariadb group: the grants migrations and every framework migration on a prefixed installation. Excluded from the main suite.

Documentation

  • Testing Guide: MariaDB, when a change is dialect-sensitive. Schema Builder Guide: Dropping a CHECK constraint.

The framework's queries name its tables as its models do

Fixed

  • About forty queries over organizations, applications, queueitems, notifications and queuestats named the table bare, while the models over the same tables — through Model::resolveTableName() — and the migrations that create them use the installation's prefix. On a prefixed installation the queue screen and queue counts, the organisation names, lists and role screens, a user's active tokens, the OAuth joins and account erasure read tables that do not exist, beside the ones the models wrote to. They name them with #PREFIX#.
  • Four raw statements among them are query-builder queries: a user's active tokens and the device-flow and session user lookups (which also named users and usertokens bare), the queue's average run time, and the status tool's queue counts.

Added

  • TablePrefixInQueriesTest exempts no table any more.
  • PrefixedRuntimeTablesTest: on a prefixed MySQL and PostgreSQL installation, an organisation's name, a user's tokens with their application, and the queue's counts.

Documentation

  • Query Builder Guide: Table prefixes — a model prefixes its table, the builder does not. Upgrade Guide: one row.

A queue worker's task sees the settings as they are now

Fixed

  • queue:process --daemon read the settings once and kept them for its whole life, so a provider or API key an administrator saved was never seen by the tasks that followed — "API key saved", then "no API key". Before each task it claims, the worker calls Settings::refresh() and Llm\Connection::forget(). An idle worker reads nothing.

Added

  • Settings::refresh(): forgets what was read from the database, keeps the settings file's keys, and reads the table again on the next getSetting().

Documentation

  • Framework Guide, Runtime settings: a long-lived process. Queue Guide: A task sees the settings as they are now.

A guest inside the administration area signs in and comes back to it

Fixed

  • A guest on a protected controller inside the area was sent to login?return=Ai — the path with the area's prefix taken off — and after signing in landed on the site's /Ai. Both the area's own guard and a controller's addAuthAction() refusal now return to the whole path, /admin/Ai.

Added

  • AdminArea::useLogin($url): the area's own sign-in page, in place of the site's login. AdminArea::loginUrl() and loginRedirect($requestUri) build the redirect.

Documentation

  • Routing Guide, administration area: where the guest returns, and useLogin(). Upgrade Guide: one row.

The analyst counts days in the application's zone, and its rule about people is the application's

Fixed

  • The analyst's prompt said dates are UTC, and its tools counted UTC days — so for an installation in Athens "yesterday" ran from 03:00 to 03:00. A day is now a day in Analyst::timezone(): 'llm' => ['analyst' => ['timezone' => …]], or the application's default zone. Each answer's window names its zone.
  • The rule about people was written into the prompt, so an installation whose administrators find accounts by name could only contradict it — and the model refused, quoting it.

Added

  • AnalystAssistant::peoplePolicy(), the framework's rule by default. Analyst::timezone(). Window::from() takes the zone as a third argument.

Documentation

  • LLM Guide, The administrators' analyst: the zone, and peoplePolicy().

A table in an answer is readable in every theme

Fixed

  • Markdown::toHtml() drew a bare <table>, and the Bootstrap conversation page styled none of it, so the columns ran together. A table now carries table table-sm, its cells' spacing and rules inline for a theme with no such class, and a scroll of its own. It is drawn that way by the renderer, so the conversation views a project already copied need no resync.

Documentation

  • LLM Guide, The page, without a SPA.

Example questions above a new conversation

Added

  • 'llm' => ['chat' => ['suggestions' => […]]] for /chat, and ['analyst' => ['suggestions' => […]]] for Ask: buttons above a conversation not yet started, in each theme. pf-chat.js puts the one clicked in the question box — data-pf-chat-suggestion, no inline script. An existing project takes the views and the script with project:resync; a view it changed is kept.

Documentation

  • LLM Guide, The page, without a SPA.

The analyst writes its own SQL, and files to download

Added

  • Three analyst tools, off until the application turns them on — 'llm' => ['analyst' => ['sql' => true, 'export' => true]]:
    • schema: tables, columns, keys, and the application's notes about them ('schema' => […]);
    • sql: one SELECT, up to 200 rows to the model;
    • export_csv: up to 100,000 rows to a CSV file with a byte-order mark. The model gets the link, the count and five rows.
  • Analyst\ReadOnlySql guards every statement:
    • the read-only lexer;
    • no function that waits, locks or reads files;
    • a read-only transaction, rolled back;
    • a time limit on each engine (sql_seconds, 30 by default);
    • a LIMIT when the statement has none.
  • Analyst\Exports keeps the files for seven days, under names short enough to survive redaction. /admin/Ai/export/<name> serves a file only to the administrator it was made for.
  • Database\ReadOnlyConnection: the database_readonly_dsn account, shared by db-inspect and the analyst.
  • Analyst::tools() and mcpTools() take the administrator's id. AiController::adminId().

Documentation

  • LLM Guide, Its own SQL, and files to download.

A conversation reads like one: whose, when, and a copy button

Changed

  • On /chat and Ask, a question and an answer were the same kind of card, so the reader could not tell their own words from the model's. Now:
    • a question is a bubble on the right, and an answer is text on the left;
    • each sits under "You", or the assistant's name, with its time;
    • the time is <time datetime="…Z">, shown by pf-chat.js in the reader's zone as "today 22:50" or with its date, and in UTC without script;
    • ⧉ Copy under an answer copies its Markdown;
    • a question asked in place is drawn the same way.
  • A project takes the views and the script with project:resync.

Added

  • 'llm' => ['chat' => ['name' => …]] and ['analyst' => ['name' => …]].
  • Chat\Web\Stamp.

Documentation

  • LLM Guide, The page, without a SPA.

A short export shows in the answer; a table copies into a spreadsheet

Added

  • export_csv returns a list of up to 'llm' => ['analyst' => ['export_inline' => 100]] rows whole (rows_inline), with a hint to show it as a table beside the link. A longer one is still the link, the count and five rows.
  • export_csv takes an optional title. The file is named <admin>-<title>-<16 hex>, with Greek transliterated, and downloads as <title>-<date>.csv.
  • ⧉ Copy table over each table in an answer: its rows, tab-separated, which a spreadsheet pastes as cells.

Fixed

  • Under a Bootstrap 3 admin theme the question's bubble had no background, since bg-body-tertiary rounded-4 is Bootstrap 5. The bubble, and one asked in place (data-question-style), now carries an inline fallback.

Documentation

  • LLM Guide, Its own SQL, and files to download and The page, without a SPA.

Charts in an answer

Added

  • A fenced block with the info string chart holds one JSON spec. The fields are type (bar, hbar, line, area, pie, doughnut), labels, series of numbers, and optionally title, subtitle, x, y, stacked and min.
  • Markdown::toHtml() makes such a block <div data-pf-chart> with the code inside, and leaves an invalid spec as code. Markdown::chart() checks a spec.
  • pf-chat.js draws a chart as SVG, with no library: axes with round steps, a key, and tooltips. window.PfChat.chart hands the drawing to an application's own library.
  • Markdown::CHARTS: what the model is told. The analyst's prompt carries it through AnalystAssistant::charts().

Fixed

  • pf-chat.js started the page before the vars below the call had their values. It now starts last.

Documentation

  • LLM Guide, Charts.