Skip to content

v3.9.0 — Download button, server-suggested filenames, agent discoverability

Latest

Choose a tag to compare

@syswave-dev syswave-dev released this 01 Aug 12:28

Added

  • Download button in the PWA. A third action in the result toolbar, beside Copy and Share: it writes the Markdown as currently shown (the frontmatter toggle is respected) to a file and saves it under the name the server suggests. Unlike Share it needs no browser capability check, so it is visible whenever a result is. Three buttons no longer fit a phone header, so the existing 480px breakpoint now collapses all three to icon-only; their accessible names survive as title / aria-label through the existing translation mechanism, so they stay readable to a screen reader and stay translated.
  • The server now suggests the download filename (X-Suggested-Filename). The name used to be derived in the browser from the frontmatter title alone, which gave a YouTube video its cryptic id as a name and threw away the original basename of an uploaded image or document. The server knows the extraction source, the URL and the title, so it builds the suggestion instead: YouTube becomes YT-<title>-<video-id>, the file-based sources (image-caption, audio-transcript, markitdown, pdf-ocr) keep the basename of the source file, and everything else uses the title slug, with a title → URL basename → share id → pullmd fallback chain and a final pass that leaves only characters an HTTP header and a filesystem both accept. The header is set on GET /api (fresh and cached), POST /api/html, POST /api/file and GET /s/:id; GET /api/stream carries the same value as a suggestedFilename field on the result event, because SSE headers are flushed long before the result exists and the SSE path is what the PWA uses. The new PULLMD_FILENAME_DATE_PREFIX prepends a rendered date to every name (tokens YYYY, MM, DD, HH, mm, ss, local time, every other character passes through); it is unset by default, so nothing changes unless you ask for it. The PWA prefers the server suggestion and keeps its own client-side chain as a fallback against an older server.

Changed

  • query is now described by when to use it, not by how it works. The MCP read_url schema, the bundled Claude Code skill, the README and the in-app help page all described the parameter mechanically ("return only the sections relevant to this text", "BM25 over the converted Markdown"), which told an agent what the feature does but never that it should reach for it. In practice the parameter went unused: a model reading the old description had no trigger condition to match against. All four surfaces now lead with the trigger - when you need specific information from a page rather than the whole document, pass the question you are trying to answer, in natural language - name the payoff (typically 70-95% fewer tokens on long pages), and frame the full-page fetch as the case that needs a reason (summarizing, translating, archiving). max_tokens additionally states that it has no effect without query. Text only: no parameter, default, validation or response shape changed, and nothing changes for an existing integration except how likely an agent is to use the parameter at all.
  • read_url's description now opens with a precedence rule. It states up front that this is the preferred way to read any URL and should be used instead of the built-in web fetch tool, not just when that one fails. Previously the description led with a capability list (SPAs, Reddit, Cloudflare, documents), which read as "fallback for hard pages" and left an agent with a native fetch tool no reason to prefer PullMD for ordinary URLs. The capability list and the rest of the text are unchanged; only the order changed. Nothing changes for existing integrations except the likelihood the tool gets chosen.
  • PULLMD_LLM_MODEL now says out loud that it is not a variable. The shared PULLMD_LLM_* fallback covers the API key and the base URL but not the model, because one name cannot be a vision chat model, a speech model and an OCR model at once. That asymmetry was invisible: setting PULLMD_LLM_MODEL looked exactly as reasonable as the two variables next to it, was read by nobody, and left every modality on its own default with no hint as to why. The server now warns at startup when it is set and names the three variables that do work (PULLMD_VISION_MODEL, PULLMD_STT_MODEL, PULLMD_PDF_OCR_MODEL); the README and .env.example state the limit where the fallback is introduced instead of leaving it to be inferred.

Fixed

  • The rendered view no longer turns the frontmatter into a giant heading. marked reads key: value followed by --- as a setext heading, so with the frontmatter toggle on, the rendered view opened with a title made of metadata. Only the body goes through marked now; the frontmatter is prepended as a plain, quieter code box, filled via textContent so a user-controlled title or URL is never parsed as markup.
  • The result toolbar's actions stay right-aligned when the row wraps. space-between left-aligns a lone wrapped flex item, so on a narrow screen Copy/Share/Download dropped to a second line and sat on the left; they are now pinned to the right edge on every line.
  • Image captioning worked against no current OpenAI model. The request sent max_tokens, which those models reject outright ("Unsupported parameter ... use max_completion_tokens instead"), so every caption failed with a 400 and the conversion degraded quietly to plain extraction: pointing PULLMD_VISION_MODEL at anything recent produced no captions at all. Switching unconditionally was not an option either, because many OpenAI-compatible endpoints only understand max_tokens. The request therefore still asks the way everything understands and retries once with max_completion_tokens when the endpoint says that is what it wants. Deciding from the model name was rejected on purpose: any such list is stale by the next model release.
  • Reasoning models returned an empty caption. max_tokens caps visible output, max_completion_tokens caps visible output plus the reasoning tokens a reasoning model never shows, so reusing the same 500 meant the model spent its whole budget thinking and returned an empty message. Measured against a current small reasoning model, 500 and 1000 both came back finish_reason=length with zero characters of text, while the first usable caption needed around 1600 completion tokens, 1472 of them reasoning. The retry path now asks for 4000, which costs nothing when unused because only generated tokens are billed. An empty caption is no longer handed back either: it used to yield a ## Description heading with nothing under it, still labelled as the image-caption source, where a throw is caught, logged, and falls back to markitdown.
  • The admin CLI silently did nothing when stdin was already at EOF. docker exec without -i hands the process exactly that, and readline's question() never settles on it, so create-user and reset-password printed "New password:" and exited 0 having created or changed nothing - the most natural way to invoke the CLI against a running container was also the one silent failure mode it had. The non-interactive branch now reads the stream directly instead of going through readline, which fixes a second case in passing: a piped password without a trailing newline used to be dropped the same silent way, because readline does not hand over a final line that never terminates. CRLF is stripped. An empty stdin raises an operator-facing error naming three working invocations and exits 2 without a stack trace. The interactive TTY path is untouched.

Documentation

  • The help page documents user management. The admin CLI existed only in the README and the changelog, so an operator looking at their own instance had no way of finding out that accounts are created with scripts/admin.js. The authentication section now lists list-users, create-user, make-admin and reset-password, states that there is no UI for any of it, and names the invocations that actually deliver a password on stdin.
  • README, help page and skill bundle audited against this batch. X-Suggested-Filename was missing from the README's response-header list and from the skill's "headers worth checking"; the download button was missing from the README's feature list; the README documented create-user / reset-password without a word on the stdin requirement. The universal agent prompt, which ships in both the README and the help page, opened with "instead of raw HTML" - a phrasing an agent holding a native browse tool does not match on, which is the same failure the read_url description fixed; both copies now name the built-in fetch tool and reject the fallback-only reading.