Skip to content

14 October 2026

Forty-six entries today:

  • The analyst's download link, from the queue worker
  • A side panel on every administration page: Ask about it, and its help
  • The examples go as soon as a question is sent
  • The analyst's links go where the question was asked
  • The analyst's scoped tools on MCP as well
  • Pagination says each link's page for a script
  • The examples go on Bootstrap too
  • Help pages are a feature
  • The side panel's conversation runs once, and the application's help page comes first
  • Each request's own page, where one process serves many
  • Nothing offers the AI until there is a key
  • A geometry value cannot leave its literal
  • Every guide checked against the code
  • A generated model loads
  • The webhook provider reads its documented configuration
  • health:check runs what /health runs
  • A logger's own log() is what the helpers reach
  • The AI's settings from Settings
  • Help links in an administration of its own
  • assetUrl() from a subdirectory
  • A migration cutoff in its own shape is read
  • An account's failed pushes are its own
  • CORS where init writes it
  • --rest-api alone is a yes
  • An application that is the administration says so
  • A regex keeps its commas; starts_with takes any of its values
  • A read on its own line goes to the replica
  • A namespaced seeder runs
  • Adminer's own login works when autologin is off
  • Small corrections in the commands
  • Docblocks that described what the code does not do
  • The page cache's lock is one lock on Memcached too
  • A theme is built from where it was found
  • The pager's window keeps its width at the ends
  • The datepicker brings its stylesheet
  • A search source's own rule and title
  • A search source without a count
  • What the model writes beside a tool call is kept
  • Help for the site's visitors, apart from the administration's
  • An export link is not a card number
  • Help in a SPA
  • Site help by plan, one page for several screens, and the assistant's help tool
  • Help search finds the page that lists the question, and the SPA panel draws it
  • The site's help button: an icon, or the application's own
  • Navigation, in the Theme Guide
  • How a controller finds its model

Fixed

  • export_csv runs in the queue worker. There SiteUrl read the console's stand-in request — HTTP_HOST=localhost and the script's directory — and the link came out as http://localhostbin/Ai/export/… on every installation without APP_URL.
    • The console now marks the request it invents (PRAMNOS_CONSOLE_HOST), and SiteUrl answers '' for it, as it already did for a console with no request at all.
    • Without a site root, Exports::url() builds the link from the host's root (/admin/Ai/export/…).
    • Markdown::toHtml() links a path from the site's root, in the same tab. //host and /\host stay text.
  • The site_url health check says, while exports are on, that their links need it.

Documentation

  • LLM Guide, Its own SQL, and files to download. Upgrade Guide: one row.

A side panel on every administration page: Ask about it, and its help

Added

  • The admin header's Ask opens a panel beside the page, with two tabs:
    • Ask is the analyst, about this page. A question carries the screen, the action, the address, the title, and the record the page shows.
    • Help is the page's help.
  • Without the analyst the button is Help; with neither, there is no button. The panel is drawn by Analyst::headerButton(), so every scaffolded header has it. It keeps its tab and conversation from page to page.
  • Assist\Page: the request's first controller is recorded by Controller::exec().
    • protected string $assistSubject says what one record of a screen is; the framework's screens declare theirs.
    • Page::about() names a record from an action.
    • Page::fromInput() checks what comes back from the browser.
    • Page::prompt() is what the analyst is told.
  • Assist\Help, the help pages:
    • files in app/help/**/*.md (or 'help' => ['paths' => …]), before the framework's resources/help/, which ships a page for each of its screens;
    • front matter: title, section, questions and updated;
    • help: links between pages, and pictures beside them;
    • a search that folds Greek accents and the final sigma, and compares the first five letters.
  • /admin/Help (HelpController, scaffolded as src/Admin/Controllers/Help.php): the pages by section, a search, one page, and its pictures. The menu item appears where the wrapper exists.
  • The analyst's help tool answers from the pages, with their links.
  • Markdown::toHtml($text, ['link' => …, 'image' => …]) resolves links that are not http(s), and pictures.
  • pf-chat.js:
    • window.PfChat.init(element) runs a conversation placed later;
    • pf-chat:ready is fired after each drawing;
    • data-pf-chat-embedded leaves the address bar alone.

Documentation

  • New guide: Administration help and the side panel. LLM Guide: From any administration page. Upgrade Guide: one row.

The examples go as soon as a question is sent

Fixed

  • pf-chat.js left the example questions on screen until the answer arrived. They are hidden as the question is sent, by the button or Enter, and come back with it when it is refused.

Fixed

  • The queue worker runs the site's application, which has no admin area configured, so an export link came out as /Ai/export/… — the site's home page.
    • The question now carries the area's URL as its request saw it (Desk::extra(), area).
    • The analyst's export_csv and help tools build their links under it: absolute, in the area, with no setting.

Added

  • AdminArea::address($path, $base): an address in the area from a job. It uses the given base, else the area's URL inside it, else the configured prefix under the site root.
  • Exports::url(), Help::url(), Analyst::tools(), mcpTools(), ExportCsvTool and HelpTool take the area's URL.

The analyst's scoped tools on MCP as well

Added

  • McpServiceProvider::registerDefaults(), and so mcp:serve, serves the application's tools:
    • every entry of 'mcp' => ['tools' => [...]];
    • those of 'llm' => ['admin_tools' => [...]] that implement ScopedMcpTool, to tokens holding the scope. An unscoped analyst tool stays Ask's.
    • McpServiceProvider::applicationTools() lists them. A framework tool keeps its name.

Documentation

  • MCP Guide, A tool. LLM Guide: the analyst's tools and links. Upgrade Guide: one row.

Added

  • Every Html\Pagination link carries data-page="N", the page it goes to: the numbered links, and first, previous, next and last with the page each leads to. It comes last among the link's attributes. A script reads link.dataset.page instead of parsing the href. Without it, the pager's handlers read the removed alt and asked for /page/undefined.

Documentation

  • HTML Components Guide, Pagination: reading the page from a script.

The examples go on Bootstrap too

Fixed

  • The examples' row is laid out with an inline display:flex, and [hidden] loses to it, so on Bootstrap they stayed on screen after a question was sent. pf-chat.js hides them by their own display and puts it back for a refused question. The side panel hides Ask's list and settings the same way. Checked in Chromium against the Bootstrap view.

Help pages are a feature

Changed

  • help is registered in FeatureRegistry. With it the side panel has its Help tab, /admin/Help and its menu item answer, and the analyst has the help tool and reads a page's help. Without it the panel is Ask alone and HelpController answers 404. Help::enabled() says which.
  • init turns it on in a new project without asking, and writes src/Admin/Controllers/Help.php only with it. --features=… lists it like the others.

Documentation

  • Administration help and the side panel: The help feature. Upgrade Guide: one row.

The side panel's conversation runs once, and the application's help page comes first

Fixed

  • The panel placed the conversation and then loaded pf-chat.js, whose own start found it; the panel then ran it a second time. Every button — Copy table most visibly — and every listener was there twice. init() now runs once per drawing (data-pf-chat-ready); a conversation swapped in after an answer is a new drawing.
  • Help::forScreen('users', 'edit') took the framework's users/edit.md over an application's users.md. The application's page wins now, whichever of the two it wrote.

Each request's own page, where one process serves many

Fixed

  • Assist\Page keeps the request's first controller, and nothing cleared it between requests in one process. Under TestClient, a page requested after /admin/Ai was still the ai screen, so its panel was the plain Ask link. Application::beginRequest() resets it with the admin area, for a web request and for each TestClient request alike.

Nothing offers the AI until there is a key

Changed

  • Without a provider's key every question fails, so nothing invites one:
    • Ask AI is left out of the user menu, and the chat widget draws nothing;
    • the admin header has no Ask, and the side panel no Ask tab;
    • /admin/Ai opens the settings, where the key is set. The admin menu's AI item stays.

Added

  • Chat::available() (with the asking person's routing) and Analyst::available().
  • NavItem's when:, a closure asked each time the menu is drawn; an item is hidden while it answers false or throws.

Documentation

  • LLM Guide, The administrators' analyst. Administration help and the side panel. Upgrade Guide: one row.

A geometry value cannot leave its literal

Fixed

  • Security. Database::insertDataToTable() and updateTableData(), on PostgreSQL, put a geometry value that is not a lat, lon pair between the quotes of ST_GeomFromText('…') as it came. A ' in it ended the literal, and the rest ran as SQL — from a form field or an API body. The quote is now doubled. A ['latitude' => …, 'longitude' => …] array's values are cast to numbers.

Every guide checked against the code

Documentation

  • Every guide was read against the code it describes, and corrected where they disagreed: examples calling methods that do not exist or with the wrong arguments (the Routing, API, Schema Builder, ORM, Testing, Theme, Logging and Document Output guides most), settings and options that nothing reads, wrong defaults, and broken anchors. History in a guide's prose — dates, "used to", incident stories, comparisons with removed classes — is gone; the current behaviour and its reasons stay. Roadmap.md keeps only open items.

A generated model loads

Fixed

  • create:crud / create:model wrote save($autoGetValues = false, $debug = false) with no return type, and OrmModel::save() declares : static, so a generated model was a fatal at class load. The stub declares : static and returns $this. The generator's test now loads the class it writes, not only lints it. Upgrade Guide: one row.

The webhook provider reads its documented configuration

Fixed

  • WebhookServiceProvider read settings named webhook.secret and so on, which the documented 'webhook' => [...] block in app.php never is; the handler was not built ("secret must not be empty"). It reads the block, and a setting answers where the block has no such key. An unset log channel is webhook, not an empty string.

health:check runs what /health runs

Fixed

  • The command registered database, disk and memory only, so site_url, session_storage, cache and the rest were invisible from the console — where a missing site root matters most. HealthRegistry::registerBuiltIns() holds the one list; Application and the command both use it. A detail that is a list or a map is printed as JSON. Upgrade Guide: one row.

A logger's own log() is what the helpers reach

Fixed

  • The level helpers and logPrepend()/logJson()/logError() called self::log(), so an application's logger overriding log() was bypassed by the calls everybody makes. They call static::log().

The AI's settings from Settings

Changed

  • Until a provider's key is set the admin menu has no AI (Ask could only fail), and Settings links to the AI settings — AI: providers & keys — in each theme, so the key is always one click away.

Fixed

  • An administration that is an application of its own, at /admin/ with no area prefix, built its help links on the site's URL: /Help/page?p=users on the public site. AdminArea::address() uses the serving application's URL (URL) in a web request with no area configured. AdminArea::useServingUrl() sets it where the constants cannot say it.

assetUrl() from a subdirectory

Fixed

  • From www/admin/index.php, assetUrl('assets/js/pf-chat.js') looked for the file beside the script, found nothing, and gave no ?v= — so a CDN kept serving the old script after a fix. It now looks in each directory above, up to the document root, then in the project's web root.

A migration cutoff in its own shape is read

Fixed

  • 'migration_cutoff' => '2020_01_02_000000' in app.php — the form the project's own rules give — went through new \DateTime(), did not parse, and left no cutoff: a legacy installation would have replayed the baseline migrations it already has. A migration timestamp is now read as it is; a date-time still works.

An account's failed pushes are its own

Fixed

  • Privacy. Push\Log::recent() with failed added OR status >= 300 ungrouped, so beside the account or endpoint filter it returned every account's failures. The condition is grouped.

CORS where init writes it

Fixed

  • Security. init writes cors_origins inside 'api' => [...], and Api read only the top level, so the list was ignored and every origin was allowed. Both places are read now (Api::corsOrigins()), the top level first. Upgrade Guide: one row.

--rest-api alone is a yes

Fixed

  • init --rest-api with no value asked the question it had just been given the answer to. On its own it means yes.

An application that is the administration says so

Added

  • AdminArea::serve($prefix, $minUserType): an administration with a front controller of its own (www/admin/index.php) declares that it is the area. Every request it serves is inside it, so adminUrl(), help links and the side panel are built on the area's URL. detect() only activates the area when the site's routing strips a prefix, so such an application linked its help to the public site.

Documentation

  • Routing Guide: An administration with a front controller of its own. Administration help and the side panel.

A regex keeps its commas; starts_with takes any of its values

Fixed

  • Validator split every rule's parameters on ,, so regex:/^[A-Z]{2,4}$/ was cut at the comma and matched something else. A regex pattern is one parameter now. starts_with and ends_with checked only the first listed value; they pass on any of them, as their message says.

A read on its own line goes to the replica

Fixed

  • Database::isWriteQuery() took the statement up to its first space, so SELECT\n… was sent to the primary. It takes the first word, whatever whitespace follows.

A namespaced seeder runs

Fixed

  • db:seed built the class name from the file name alone, so a seeder declaring a namespace was "not found". The file's namespace is used.

Adminer's own login works when autologin is off

Fixed

  • With 'devpanel' => ['adminer_autologin' => false] Adminer showed its login form and then dropped what it posted, so nobody could sign in. The operator's own username and password are kept now — on the application's server and driver, whatever the form names, and never with a password kept in a cookie. With autologin on, a posted login is still dropped.

Small corrections in the commands

Fixed

  • serve used www/index.php whatever the web root is called; it uses the project's web root.
  • create:migration CreateUsersTable produced the class Createuserstable: a PascalCase name now keeps its words.
  • create:api printed an address the routes are not at (api/v1/<Class>); it names the version group instead.
  • mcp:token --user says it takes a username too, as it does.
  • The generators' --schema and --table options were declared with an argument's mode.

Docblocks that described what the code does not do

Documentation

  • Broadcastable: its methods broadcast when the model calls them, not on save by themselves.
  • ConsoleCommandsTool, McpServe: no count of generators or tools, and no create:webhook.
  • ModelFactory: state() takes an array. MigrationRunner::run(): a declined migration is not in ran. A comment that said the development setting opens the debug toolbar.

The page cache's lock is one lock on Memcached too

Fixed

  • MemcachedAdapter reports atomic counting, so the page cache took its stampede lock with swap() — which Memcached inherited as a read-then-write: two requests could both take the lock and both render. swap() now uses add when the key is absent and cas when it is held, retrying a lost race. When it cannot be done, it answers "held just now".
  • A prefixed clear('') walked a category list nothing wrote, and cleared nothing. A category is listed the first time a key in it is tracked.

A theme is built from where it was found

Fixed

  • Theme::getThemeObjects() listed themes from app/themes and ROOT/themes and then built every one against app/themes. Each is built from the directory it was found in.

The pager's window keeps its width at the ends

Changed

  • Pagination showed the current page and adjacents either side, cut at the ends: three numbers on page 1, five in the middle. It now shows 2 × adjacents + 1 numbers on every page — the side that runs out lends to the other — or every page when there are fewer. steadyWindow = false keeps the cut window.

The datepicker brings its stylesheet

Fixed

  • Html\Date enqueued bootstrap-datepicker's script and not its CSS, so the calendar opened as an unstyled table. Document registers a bootstrap-datepicker style — the 1.9.0 bootstrap-datepicker3.standalone.min.css, from the CDN or, under documentAssetSource, from plugins/datepicker/ — and render() enqueues it with the script.
  • The year bounds were only in jQuery UI's yearRange, which bootstrap-datepicker ignores; they are passed as startDate / endDate too.
  • jQuery UI's dateFormat was dd/mm/yyyy, which in its syntax is the year twice. It is dd/mm/yy.

A search source's own rule and title

Added

  • Search\Registry::register() takes search: fn (string $term, ApiListSource $source) returning structured conditions (escaped), a WHERE body, null for the source's own rule, or false for no group at all. It is added to filter, so a scope still holds, and a rule that builds to no condition drops the source rather than answering with the first rows.
  • title and subtitle: a :column pattern or a callable receiving the row. A pattern's columns are selected when fields is not given.
  • User::forgetCurrentUser() clears the user getCurrentUser() cached for the request. Auth calls it on every login and logout, so a second sign-in in the same process is the one getCurrentUser() reports.

A search source without a count

Added

  • 'count' => false on a Search\Registry source: no COUNT behind the group's total, which on a LIKE '%term%' reads the whole table on every keystroke. One row past the limit is fetched instead; the group's total is null and its new more says whether there were more rows. The search box shows 5+. Every group now carries more.
  • Model::$apiListCountTotal and User::$apiListCountTotal (default true): off, a paginated _getApiList() sends no COUNT and reports totalitems: null.

What the model writes beside a tool call is kept

Fixed

  • Llm\Client::askWithTools() returned only the last round's text. A model that wrote its whole answer and called a tool in the same turn had that answer dropped, and the stored reply began with its postscript. The reply is now every round's text, in order, a blank line apart; the verifier reads all of it, and the call log records it beside tool calls:. A rejected answer is still replaced whole by the corrected one.

Help for the site's visitors, apart from the administration's

Added

  • A second level of help pages: app/help-site/ (or help.site.paths), for a site's visitors. It never reads the administration's pages or the framework's. Help::site() and Help::admin() are the two, each a HelpBook; Help::level() says which a request is on (the site's outside the administration area, where there is one) and Help::book() returns it.
  • SiteHelpController: /Help on the site, open to every visitor. init writes src/Controllers/Help.php and app/help-site/ with the help feature.
  • On a site page the header's button shows the site's page for the screen, to a visitor too; inside the administration nothing changes. The static Help:: methods stay the administration's.

Fixed

  • The analyst's redactor took a run of digits inside an id for a payment card when it passed the Luhn check. An export link's random hex, 7-d71592725381644e, came out as 7-d[card]e, and the link opened nothing — rarely, and so also as an intermittent test failure. A card number is no longer matched when it runs into letters.

Help in a SPA

Added

  • Pramnos\Assist\HelpApi: the help pages as JSON. GET /help (list or ?q= search), /help/page?p= and /help/screen?screen=&action=, mounted with HelpApi::routes($r). It serves the site's pages to anybody, and the administration's with ?level=admin to whoever may open its Help screen. A page's HTML links between pages as ?help=<slug> and carries its pictures inline, because an <img> has no bearer token.
  • components/HelpPanel.svelte, with its test, among a Svelte SPA's shared components: the help page for the screen on show, drawn in a side panel.
  • init with the help feature routes the endpoint, sets features.help in the SPA shell and puts the panel in the header.

Site help by plan, one page for several screens, and the assistant's help tool

Added

  • HelpGate (help.site.gate in app.php) and HelpReader. A site page with requires:, or a block between <!-- requires: x --> and <!-- /requires -->, exists only for a reader the gate allows. That covers the list, search, a page, a screen's page and the assistant. It fails closed: no gate, or a gate that throws, refuses. Help::site()->for($reader) reads as a given reader; without it, as the request's.
  • Front matter screens: [a, b]: one page for several screens, after a page named after the screen. Also order:, which sorts within a section. Lists may be written on one line.
  • Pramnos\Llm\Chat\Tools\SiteHelpTool: the site's help for an application's own assistant. It returns one page per call, gated by the asking scope and cut to fit one tool result. SiteHelpTool::tool($scope, $link) gives the array an assistant's tools() lists.

Help search finds the page that lists the question, and the SPA panel draws it

Fixed

  • Help search ranked "How do I add a goal?" below five long pages that used "how" and "add" often, while the Goals page listed the question word for word. There were three causes, and each is fixed:
    • Common English and Greek words are ignored.
    • Each word is weighed by how rare it is across the pages.
    • A four-letter word meets the longer one it begins, so goal finds goals.

A question listed under a page's questions: now puts that page first; one whose every word is in such a question puts it nearly first. This applies to both levels. - HelpPanel.svelte drew an empty title over an empty page. lib/api.js hands over the API's {status, data} as it came, and the panel read the page from the top level. It reads data. Its test mocked the client and so hid this; it now runs the real client against a stubbed fetch. A project's copy is taken with project:resync --spa-components.

The site's help button: an icon, or the application's own

Added

  • 'help' => ['site' => ['button' => 'icon']] draws the site's help button as a small, muted question mark (.pf-help-icon, in the three theme stylesheets). A Pramnos\Assist\HelpButton class named there draws it instead, given the address, the label, the page found and the attributes pf-assist.js opens the panel by. An administrator's Ask is unchanged.
  • HelpPanel.svelte takes compact, buttonClass, and a button snippet given open() and the page.
  • A project made before takes the component with project:resync --spa-components. The stylesheet is the project's own, so it copies the .pf-help-icon rule from the theme's style.css into its own.

Documentation

  • The Theme Guide has a Navigation section. It covers:
    • every NavItem field;
    • the four sections;
    • what getForUser() filters, in order;
    • parent against group;
    • where an application registers its items;
    • a sidebar template with groups, children, badges and the active item;
    • what the registry leaves to the theme.

These were documented only in the classes' docblocks.

How a controller finds its model

Fixed

  • Controller::getModel() retried a lower-case name by upper-casing every occurrence of it in the class name, the namespace included. In an application named shop, getModel('shop') looked for \Ns\Shop\Models\Shop, which a PSR-4 loader looks for in a directory that is not there. Only the class's own name is upper-cased now.

Documentation

  • The Framework Guide's How a controller finds its model covers:
    • the one class name getModel() builds, from namespace and the application's name;
    • the as-written and upper-cased attempts;
    • that the administration area and the framework are not searched;
    • case sensitivity and the model's default table;
    • the known gaps, with their workarounds.