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
Paginationsays 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:checkruns what/healthruns- 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
initwrites it --rest-apialone 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
The analyst's download link, from the queue worker¶
Fixed¶
export_csvruns in the queue worker. ThereSiteUrlread the console's stand-in request —HTTP_HOST=localhostand the script's directory — and the link came out ashttp://localhostbin/Ai/export/…on every installation withoutAPP_URL.- The console now marks the request it invents (
PRAMNOS_CONSOLE_HOST), andSiteUrlanswers''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.//hostand/\hoststay text.
- The console now marks the request it invents (
- The
site_urlhealth 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 byController::exec().protected string $assistSubjectsays 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'sresources/help/, which ships a page for each of its screens; - front matter:
title,section,questionsandupdated; help:links between pages, and pictures beside them;- a search that folds Greek accents and the final sigma, and compares the first five letters.
- files in
/admin/Help(HelpController, scaffolded assrc/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
helptool answers from the pages, with their links. Markdown::toHtml($text, ['link' => …, 'image' => …])resolves links that are nothttp(s), and pictures.pf-chat.js:window.PfChat.init(element)runs a conversation placed later;pf-chat:readyis fired after each drawing;data-pf-chat-embeddedleaves 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.jsleft 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.
The analyst's links go where the question was asked¶
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_csvandhelptools build their links under it: absolute, in the area, with no setting.
- The question now carries the area's URL as its request saw it (
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(),ExportCsvToolandHelpTooltake the area's URL.
The analyst's scoped tools on MCP as well¶
Added¶
McpServiceProvider::registerDefaults(), and somcp:serve, serves the application's tools:- every entry of
'mcp' => ['tools' => [...]]; - those of
'llm' => ['admin_tools' => [...]]that implementScopedMcpTool, to tokens holding the scope. An unscoped analyst tool stays Ask's. McpServiceProvider::applicationTools()lists them. A framework tool keeps its name.
- every entry of
Documentation¶
- MCP Guide, A tool. LLM Guide: the analyst's tools and links. Upgrade Guide: one row.
Pagination says each link's page for a script¶
Added¶
- Every
Html\Paginationlink carriesdata-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 readslink.dataset.pageinstead of parsing thehref. Without it, the pager's handlers read the removedaltand 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.jshides them by their owndisplayand 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¶
helpis registered inFeatureRegistry. With it the side panel has its Help tab,/admin/Helpand its menu item answer, and the analyst has thehelptool and reads a page's help. Without it the panel is Ask alone andHelpControlleranswers 404.Help::enabled()says which.initturns it on in a new project without asking, and writessrc/Admin/Controllers/Help.phponly with it.--features=…lists it like the others.
Documentation¶
- Administration help and the side panel: The
helpfeature. 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'susers/edit.mdover an application'susers.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\Pagekeeps the request's first controller, and nothing cleared it between requests in one process. UnderTestClient, a page requested after/admin/Aiwas still theaiscreen, so its panel was the plain Ask link.Application::beginRequest()resets it with the admin area, for a web request and for eachTestClientrequest 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/Aiopens the settings, where the key is set. The admin menu's AI item stays.
Added¶
Chat::available()(with the asking person's routing) andAnalyst::available().NavItem'swhen:, 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()andupdateTableData(), on PostgreSQL, put ageometryvalue that is not alat, lonpair between the quotes ofST_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.mdkeeps only open items.
A generated model loads¶
Fixed¶
create:crud/create:modelwrotesave($autoGetValues = false, $debug = false)with no return type, andOrmModel::save()declares: static, so a generated model was a fatal at class load. The stub declares: staticand 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¶
WebhookServiceProviderread settings namedwebhook.secretand so on, which the documented'webhook' => [...]block inapp.phpnever 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 iswebhook, not an empty string.
health:check runs what /health runs¶
Fixed¶
- The command registered database, disk and memory only, so
site_url,session_storage,cacheand the rest were invisible from the console — where a missing site root matters most.HealthRegistry::registerBuiltIns()holds the one list;Applicationand 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()calledself::log(), so an application's logger overridinglog()was bypassed by the calls everybody makes. They callstatic::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.
Help links in an administration of its own¶
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=userson 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'inapp.php— the form the project's own rules give — went throughnew \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()withfailedaddedOR status >= 300ungrouped, so beside the account or endpoint filter it returned every account's failures. The condition is grouped.
CORS where init writes it¶
Fixed¶
- Security.
initwritescors_originsinside'api' => [...], andApiread 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-apiwith 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, soadminUrl(), 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¶
Validatorsplit every rule's parameters on,, soregex:/^[A-Z]{2,4}$/was cut at the comma and matched something else. Aregexpattern is one parameter now.starts_withandends_withchecked 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, soSELECT\n…was sent to the primary. It takes the first word, whatever whitespace follows.
A namespaced seeder runs¶
Fixed¶
db:seedbuilt 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¶
serveusedwww/index.phpwhatever the web root is called; it uses the project's web root.create:migration CreateUsersTableproduced the classCreateuserstable: a PascalCase name now keeps its words.create:apiprinted an address the routes are not at (api/v1/<Class>); it names the version group instead.mcp:token --usersays it takes a username too, as it does.- The generators'
--schemaand--tableoptions 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 nocreate:webhook.ModelFactory:state()takes an array.MigrationRunner::run(): a declined migration is not inran. A comment that said thedevelopmentsetting opens the debug toolbar.
The page cache's lock is one lock on Memcached too¶
Fixed¶
MemcachedAdapterreports atomic counting, so the page cache took its stampede lock withswap()— which Memcached inherited as a read-then-write: two requests could both take the lock and both render.swap()now usesaddwhen the key is absent andcaswhen 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 fromapp/themesandROOT/themesand then built every one againstapp/themes. Each is built from the directory it was found in.
The pager's window keeps its width at the ends¶
Changed¶
Paginationshowed the current page andadjacentseither side, cut at the ends: three numbers on page 1, five in the middle. It now shows2 × adjacents + 1numbers on every page — the side that runs out lends to the other — or every page when there are fewer.steadyWindow = falsekeeps the cut window.
The datepicker brings its stylesheet¶
Fixed¶
Html\Dateenqueued bootstrap-datepicker's script and not its CSS, so the calendar opened as an unstyled table.Documentregisters abootstrap-datepickerstyle — the 1.9.0bootstrap-datepicker3.standalone.min.css, from the CDN or, underdocumentAssetSource, fromplugins/datepicker/— andrender()enqueues it with the script.- The year bounds were only in jQuery UI's
yearRange, which bootstrap-datepicker ignores; they are passed asstartDate/endDatetoo. - jQuery UI's
dateFormatwasdd/mm/yyyy, which in its syntax is the year twice. It isdd/mm/yy.
A search source's own rule and title¶
Added¶
Search\Registry::register()takessearch:fn (string $term, ApiListSource $source)returning structured conditions (escaped), aWHEREbody,nullfor the source's own rule, orfalsefor no group at all. It is added tofilter, so a scope still holds, and a rule that builds to no condition drops the source rather than answering with the first rows.titleandsubtitle: a:columnpattern or a callable receiving the row. A pattern's columns are selected whenfieldsis not given.User::forgetCurrentUser()clears the usergetCurrentUser()cached for the request.Authcalls it on every login and logout, so a second sign-in in the same process is the onegetCurrentUser()reports.
A search source without a count¶
Added¶
'count' => falseon aSearch\Registrysource: noCOUNTbehind the group's total, which on aLIKE '%term%'reads the whole table on every keystroke. One row past the limit is fetched instead; the group'stotalisnulland its newmoresays whether there were more rows. The search box shows5+. Every group now carriesmore.Model::$apiListCountTotalandUser::$apiListCountTotal(defaulttrue): off, a paginated_getApiList()sends noCOUNTand reportstotalitems: 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 besidetool 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/(orhelp.site.paths), for a site's visitors. It never reads the administration's pages or the framework's.Help::site()andHelp::admin()are the two, each aHelpBook;Help::level()says which a request is on (the site's outside the administration area, where there is one) andHelp::book()returns it. SiteHelpController:/Helpon the site, open to every visitor.initwritessrc/Controllers/Help.phpandapp/help-site/with thehelpfeature.- 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.
An export link is not a card number¶
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 as7-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 withHelpApi::routes($r). It serves the site's pages to anybody, and the administration's with?level=adminto 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.initwith thehelpfeature routes the endpoint, setsfeatures.helpin 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.gateinapp.php) andHelpReader. A site page withrequires:, 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. Alsoorder:, 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'stools()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
goalfindsgoals.
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). APramnos\Assist\HelpButtonclass named there draws it instead, given the address, the label, the page found and the attributespf-assist.jsopens the panel by. An administrator's Ask is unchanged.HelpPanel.sveltetakescompact,buttonClass, and abuttonsnippet givenopen()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-iconrule from the theme'sstyle.cssinto its own.
Navigation, in the Theme Guide¶
Documentation¶
- The Theme Guide has a Navigation section. It covers:
- every
NavItemfield; - the four sections;
- what
getForUser()filters, in order; parentagainstgroup;- where an application registers its items;
- a sidebar template with groups, children, badges and the active item;
- what the registry leaves to the theme.
- every
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 namedshop,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, fromnamespaceand 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.
- the one class name