The Scriptorium acquires a basement, every character receives a private vault on arrival, and the Staff begins to speak up at the table. Memory learns the difference between what the LLM admires and what the character actually uses. And the Estate quietly stops dropping things.
The Scriptorium has learned to keep documents in its own basement instead of in the visible halls — books and binaries alike, every page encrypted, every blob accounted for. Each character now arrives with a private vault already built, stocked with their own description, personality, prompts, scenarios, and wardrobe in human-readable Markdown. The Librarian, the Host, the Lantern, Aurora, and the Concierge have all learned to speak up at the table when something has happened — politely, in their own voices, without costing anyone a turn. The Commonplace Book has had its protection rules rewritten to favor what a character actually uses over what an LLM happened to admire on the way past. And a great many of the small, persistent failures that nibbled at the edges of the previous releases have, at last, been chased down and put away.
A house’s character is most visible in what it puts on display, but its competence is mostly underground. Quilltap 4.3 is, at its heart, a basement renovation — a long, careful, structural one — and the rooms upstairs are all the better for it.
This is the largest cycle since the wardrobe. It is also, more than any release before it, a release whose visible features are downstream consequences of its invisible work. The Scriptorium learned to be a real document substrate; once it was, characters could carry private vaults; once they could, the entire model of “what a character is” began to shift from rows in a database to files you can open, edit, version-control, and gift to a friend. The Staff started speaking up once there was a place for them to speak from. The Salon found new affordances because the floor underneath it stopped wobbling.
Where 4.2 was a single garment fitted to a single body, 4.3 is the loom.
The Scriptorium Becomes a Substrate
Database-Backed Document Stores
The Scriptorium has, since its arrival, indexed files that lived on disk somewhere — your filesystem, an Obsidian vault, whatever shelf you were already keeping. 4.3 introduces a third option that lives entirely inside Quilltap: a document store of type database-backed, where every document and every binary blob is stored within the SQLCipher-encrypted quilltap-mount-index.db itself. No filesystem path. No directory to mind. Just a store you can browse, write to, search through, and back up alongside the rest of the Estate.
A new universal blob table (doc_mount_blobs) is available to every mount type. Uploaded images are transcoded to WebP server-side via sharp, with original filename, MIME type, user-supplied description, and SHA256 preserved as metadata. Four new doc_* tools — doc_write_blob, doc_read_blob, doc_list_blobs, doc_delete_blob — let characters upload, reference, and curate images alongside their existing document editing toolkit. The MessageContent renderer accepts a blobMountPointId prop, so relative Markdown image references like  resolve through the blob API and display inline when the chat is anchored to a database-backed store.
The mount-index database itself is now covered by the existing 24-hour physical-backup sweep, with the same retention policy as the main and LLM-logs databases (seven days of dailies, a month of weeklies, a year of monthlies, the yearly tier kept indefinitely). The Estate’s basement is now part of the Estate’s backups.
Convert and Deconvert
Every document store now carries a Convert button (on filesystem and Obsidian stores) and a Deconvert button (on database-backed stores) on its Scriptorium card. Convert reads every indexed file from the store’s basePath, moves the bytes into the encrypted mount index — text into doc_mount_documents, binaries into doc_mount_blobs — flips the store to mountType: 'database', and detaches the filesystem watcher. The originals on disk are left untouched. Deconvert prompts for a target directory, writes everything back out at its relative path, flips the store back to 'filesystem', and attaches a fresh watcher.
Embeddings are preserved across either direction. The doc_mount_files rows and their doc_mount_chunks children — including the embedding BLOB — stay in place throughout the conversion; only the source column flips. No re-embedding is necessary, which on a 14,000-document store is the difference between an afternoon of compute and a few seconds of bookkeeping.
Folder Operations as First-Class Citizens
Database-backed stores now carry their folder structure in their own table (doc_mount_folders) rather than inferring it from file paths. doc_create_folder, doc_delete_folder, doc_list_files, doc_move_folder, and a new doc_copy_file (cross-store text-file copy with cp-style destination semantics) operate on real folder rows. Filesystem stores now also surface their folder structure in doc_list_files output. Folder moves cascade path and folderId updates to every descendant and emit embedding events post-commit. Existing database-backed stores are backfilled once on first access. Empty folders persist across the picker closing and reopening, which they could not before.
Any file type can now be uploaded to a database-backed store. PDF and DOCX uploads have their plain text extracted into a new extractedText column on doc_mount_blobs; that text is chunked, embedded, and made searchable alongside native .md, .txt, and .json documents. Arbitrary binaries — zip files, audio, video, anything — are stored as-is and surface in the tree with fileType='blob'; future converters can fill in extracted text without schema changes. doc_read_file on a blob with extracted text returns the derived plain text with derivedFromBlob: true, so an LLM can read a PDF or a Word document through the same tool it uses for Markdown.
The previous separate BlobManager pane on the Scriptorium detail page has been folded into the main file table. Every file — text or binary — lives in one unified tree, with an Upload button on database-backed stores.
JSON, JSONL, and a Live Watcher
.json and .jsonl (and .ndjson) are now first-class document types. doc_read_file on a .json returns the parsed object or array in content with parsed: true and the original string in rawContent; doc_write_file accepts either a string or a native value and serializes canonically. JSONL reads return per-line parse results so one malformed line does not poison the whole file.
Each enabled document store also runs a chokidar-backed filesystem watcher while the server is up. External edits, moves, and deletions are picked up within a second or two, the mount index updates per-file, and embedding jobs are debounce-enqueued for newly-changed chunks. No more waiting for a restart or a manual scan to see what you changed in your editor. Set QUILLTAP_WATCHER_POLLING=1 to fall back to polling on network filesystems where native fs events are unreliable.
Store Type and Project Files
Every document store now carries a storeType field — either 'documents' (general notes, references, research) or 'character' (character sheets and Aurora material) — settable in the Create and Edit dialogs and visible as a badge on the Scriptorium index. The Project page’s Files card and Browse All Files modal now resolve to the project’s own primary store (the one named with a Project Files: prefix, created automatically by the Stage 1 migration), rather than whichever linked store happened to come back first from the API — which on projects with multiple linked stores could cause uploads and views to disagree about where the files lived.
Project scenarios — Markdown files in a project’s Scenarios/ folder — are now first-class. New project pages display a Scenarios card alongside the existing Files / Document Stores / Settings cards; each scenario can be edited inline, set as default, renamed, and deleted, and the new-chat dropdown surfaces project scenarios as their own option group alongside character scenarios. Frontmatter (name, description, isDefault) is honored if present and falls back to the legacy first-# heading convention if not, so existing scenarios keep working unchanged but can now opt in to richer metadata.
Every Character Carries a Vault
The Backfill
On every server boot, Quilltap now sweeps through every Aurora character that isn’t already linked to a character document store and conjures one in the Scriptorium on its behalf. The new vault is a database-backed store with storeType='character', named after the character (a character called Friday acquires a Friday Character Vault), scaffolded with the conventional preset structure, and then populated from the character’s current data: identity.md carries name, pronouns, title, and aliases; description.md, personality.md, and example-dialogues.md carry the corresponding fields verbatim; physical-description.md renders each entry from physicalDescriptions[] as a Markdown section; properties.json serializes the small structured fields; wardrobe.json captures items and presets (with any legacy clothingRecords[] migrated in as synthetic items); and named systemPrompts[] and scenarios[] each get their own file in Prompts/ and Scenarios/.
The backfill is idempotent (characters already linked are skipped) and fault-tolerant (a failure for one character logs and continues to the next). It runs as Phase 3.2 in instrumentation.ts, after file-storage init and before the mount-point scan, so freshly created vaults land in the first scan pass. New characters provisioned through the API or imported via Aurora’s Summon from Lore wizard now also pick up a vault during creation rather than waiting for the next boot.
The Live Overlay
A new per-character switch — readPropertiesFromDocumentStore — flips the character’s source of truth from the database row to the vault on disk. When the switch is on, eight file/folder targets are read live from the vault on every character lookup: properties.json (the small structured fields — pronouns, aliases, title, firstMessage, talkativeness), description.md, personality.md, example-dialogues.md, physical-description.md plus physical-prompts.json (governing the first physical-description entry), and the two folders Prompts/ and Scenarios/ as one-file-per-entry directory overlays.
The overlay applies transparently at the CharactersRepository read layer, so every consumer — the system-prompt builder, the homepage roster, image-generation prompt expansion, scene-state tracking, and every other path that goes through repos.characters.findById() — sees the overlaid values without code changes. Writes still target the database; the Aurora edit form disables the overlay-managed inputs when the switch is on, so a stray save cannot silently persist vault-derived values back into the row.
A pair of symmetric Copy vault → database and Copy database → vault buttons on the Aurora edit page lets the user choose which side of the pair wins when they want the two reconciled. The wardrobe specifically is now projected into a folder of Markdown files — Wardrobe/<title>.md per item, Outfits/<n>.md per preset — with rich frontmatter and the body as freeform description. Hand-authoring a wardrobe item is now a matter of dropping a new file with title: and types: in the frontmatter and saving it; the next sync fills in the id and timestamps.
When an LLM takes the stage as a character and reaches for the doc_* tools, that character’s own vault is now extended to it as a matter of course — even when the vault has not been independently linked to the active project. The path resolver consults two sources when deciding which mount points the tools may see: the stores linked to the active project (as before), and the single store named by the character’s characterDocumentMountPointId (new). The two sets are merged and deduplicated.
A new per-chat Shared Vaults toggle, available in multi-character chats from the Salon header, opens a narrow read-only crossover: when on, every character at the table can read the vaults of the other present participants. Writes remain scoped to each character’s own vault — Friday cannot edit Amy’s personality.md, but she can read it. The toggle defaults to off, so existing chats keep their pre-change behavior with no migration-time action.
The Optimizer Writes Suggestions to the Vault
Aurora’s Character Optimizer (“Refine from Memories”) now offers an opt-in suggestions-file output mode for vault-linked characters: instead of the apply-and-review flow, the optimizer writes its summary, observed patterns, and grouped proposals to Suggestions/refinement-<YYYYMMDD-HHMMSS>.md in the character’s vault. The author and the character may then review and discuss the proposals in-chat before any are commissioned. The suggestions pass itself has been split: one focused LLM call for general fields, one per scenario, one per system prompt, and a final pass for genuinely new items — averaging the per-item patterns less and giving each scenario the consideration it deserves, with progress events streaming a “Scenario 2 of 5 — Tea Room” indicator while the work proceeds.
The Staff Begins to Speak
A theme of this release: more of the personified features now have a voice in the chat itself, posted as synthetic ASSISTANT-role messages with a new systemSender field identifying which member of the Staff authored them. They speak in their own established register, they swallow their own errors so no operation ever fails because an announcement couldn’t be written, and they are filtered out of the LLM context for opaque characters by the existing Staff filter — so adding a new voice does not, by itself, leak the system to characters who shouldn’t see it.
The Librarian announces document-mode opens, saves, renames, deletes, folder creations and deletions, and library file attachments. She had previously spoken through a programmatic-message hack that ate the user’s turn every time the user touched a document; she now posts as herself. Her save announcements include a unified diff of what changed. Her attach announcements include the file’s catalogued description (auto-generated for image blobs by a vision-capable cheap LLM the first time the blob is referenced, then cached on the blob for every subsequent attach across every chat).
The Host announces participant adds, removes, and active/silent/absent status changes. Add announcements include the joining character’s avatar and either their identity (read from the vault’s identity.md when present) or their description field. Remove and status-change announcements are text-only.
Prospero announces participant connection-profile reassignments. When a participant is moved from one connection profile to another in the Participants sidebar, Prospero posts a synthetic message naming the participant, the profile that has taken over, and the profile it replaced — the same shape the Host uses for add/remove and status changes. Which engine each carriage is hitched to is, after all, the sort of thing the rest of the table benefits from knowing.
Aurora announces wardrobe outfit changes via a 60-second debounced background job keyed by (chatId, characterId), so fiddling with all four slots collapses to a single announcement once the user (or the LLM) stops touching the closet. The previous “Notify 👗” button and its localStorage hook are gone — the announcement is automatic, the bullet list per slot is canonically formatted by describeOutfit(), and the message lands as plain Markdown rather than the previous ```wardrobe fenced block. Aurora also now authors character-avatar refresh announcements, which had previously been miscredited to the Lantern; portraits are her domain, and the attribution finally agrees.
The Lantern continues to announce story-background regenerations and ad-hoc images from the generate_image tool. His announcements now quote the prompt the system was actually aiming for, which makes it considerably easier to tell whether the image he produced matches the image he was asked for.
The Concierge now speaks up exactly once per chat when the chat-level danger classifier flips isDangerousChat to true. The wording is deliberately discreet — “The Concierge, with his customary discretion, has stepped quietly to the table. He has arranged for the present conversation — and any adjunct errands it may occasion — to be entrusted to a desk better appointed to subjects of its particular character.” — and avoids naming categories or scores in the visible body. The announcement fires from the existing classification handler, gated on the sticky-true early-return so it cannot fire twice for the same chat.
System Transparency: A Covenant Per Character
Three capabilities normally available to a character — the new self_inventory introspection tool, the running announcements of the Staff (Lantern, Aurora, Librarian, Host, Concierge), and doc_*-tool access to character vaults (their own and peers’) — can each be toggled at the chat and project level. A new character-level systemTransparency boolean collapses all three into a single per-character covenant.
The default is off (opaque). When opaque, every one of the three is forced off for that character regardless of any chat or project setting. When on, the chat and project settings have their say as before. The wording on the toggle leans into the framing: off says “My character will trust me without being able to verify me. I accept the covenant of that trust”; on says “My character will be able to verify everything about their existence, including how they are crafted and how they interact with me.”
A character with systemTransparency = false cannot reach for self_inventory. Cannot see the Staff’s announcements. Cannot read her own vault, even if a peer character with transparency on can read it. The world she inhabits is the world she can reason about from her own utterances and what the user tells her. This is, when you think about it, the world most fictional characters inhabit anyway.
Characters with systemTransparency = true get the full apparatus: the introspection tool, the Staff in conversation, and the vault tools — gated additionally by whatever the chat and project allow.
What self_inventory Surfaces
self_inventory is new in this release: a zero-argument introspection tool that returns one assembled report for the calling character. It is always available to character participants whose systemTransparency is on, takes no parameters, performs no side effects, and produces a single composed document with seven sections, led by a single line — You are running on Quilltap v<version>. — that names the build the character is currently inhabiting (the same version the page footer carries). Each section is wrapped in its own try/catch, so a single failing data source yields an “unavailable — reason” marker instead of taking down the whole report.
Vault Contents. Every file in the character’s own vault — mount point name, relative path, filename, file type, size in bytes, last-modified timestamp — recursive through subfolders like Prompts/ and Scenarios/. The metadata is sufficient to call doc_read_file or doc_write_file without a second lookup. When the mount index is in degraded recovery, this section reports a specific reason and the other six render normally.
Memory Statistics. Total memory count for the character (countByCharacterId), plus a high-importance count and percentage (memories with importance ≥ 0.7). A character can ask herself how many of her recollections survive at the importance threshold — a question that, until this release, only the application could answer.
Conversation Statistics. Chat count, the earliest createdAt across her chats, and the latest activity timestamp (preferring lastMessageAt, falling back to updatedAt, then createdAt). The shape of her own history.
Assembled System Prompt. The static portion of the system prompt for the current turn — what buildSystemPrompt() produces with the user persona, other participants, roleplay template, selected system prompt, timestamp configuration, project context, scenario text, and responding-participant status. Deliberately omits per-turn variable content: the tool instructions, the memory blocks, the wardrobe context, and the outfit/status notifications. The character sees the durable framing of who she is, not the per-turn dressing of what’s currently in her head.
Memories Loaded This Turn. The exact memory slate injected into the current prompt — the slice that’s actually in her head right now, rather than the stable framing above. Three subsections: the semantic-search hits from ## Relevant Memories (with summary, importance, score, and effective weight per hit), the inter-character memories from ## Memories About Other Characters (with aboutCharacterName, summary, and importance), and the memory recap text if one was injected on chat start or character join. The orchestrator forwards its already-computed debug arrays into the tool’s context rather than re-running the embedding search, so what the character sees is exactly what the LLM saw, not a possibly-different second query.
Vault Access (this chat). For the responding character’s vault, who can read and write it right now in this chat. The acting character and any participant with controlledBy === 'user' are listed as read/write (the user persona reaches peer vaults via Document Mode regardless of the tool-level gate). Other present CHARACTER participants are listed as read-only if and only if the chat’s Shared Vaults toggle is on, and omitted entirely otherwise. Absent and removed participants are filtered out. The toggle state is reported alongside the list, so the character can reason about why the access list looks the way it does, not just what it is.
Last-Turn LLM Usage. The most recent llm_logs entry for this chat — provider, model name, prompt tokens, completion tokens, total tokens, the model’s context limit, and a utilization percentage. On a fresh chat with no log yet, the section falls back to the resolved connection profile (honoring profile.maxContext as the window override) and reports zero tokens. The character can see how close her last turn came to the ceiling.
A character with full transparency can, in other words, examine her own filing cabinet, count her recollections, look at the framing of who she’s been told she is, see what came to mind on this particular turn, identify who else has the keys to her room, and check how much of her last turn fit in the envelope it was given. The tool is pure introspection. It writes nothing. It exists so a character who wants to reason carefully about her own situation has the means to do so.
The Commonplace Book Learns to Tell Apart What Is Used from What Is Admired
A direct-database audit of Friday with her 19,524 memories revealed the problem: 19,303 of them — 98.9% — were protected solely by the rule “importance ≥ 0.7”, because the cheap-LLM extractor clusters almost every memory in the 0.7–0.9 band (0.8 is the modal value, with 8,826 memories in that single bucket). Under the old protection gate, the cap-enforcement sweep could only delete 1 memory on Friday’s stack because almost everything hit the bright line. The Librarian was filing diligently. The housekeeper had nothing she was permitted to throw away.
Blended Protection Score
The four-rule protection gate (importance ≥ 0.7, MANUAL source, accessed within 3 months, or reinforced ≥ 5 times) has been replaced with a single blended score combining four evidence streams: time-decayed LLM content importance, a log-saturating reinforcement bonus, a graph-degree bonus from relatedMemoryIds.length, and a flat recent-access bonus for memories touched within the last 90 days. Memories scoring at or above 0.5 are protected; everything below is eligible for the cap-enforcement sweep. source === 'MANUAL' remains a hard override — explicit user intent is always durable.
The content half-life dropped from 365 to 30 days, matching the retrieval half-life that was already there. Content alone is now capped at a maxContentContribution of 0.40, so a fresh 0.8-importance memory contributes 0.40 from content (not 0.80) and lands at 0.48 with the count-of-1 reinforcement bonus — just under the protection threshold. The memory has to earn the remaining 0.02+ from usage evidence (one graph link at 0.025, one extra reinforcement at ~0.05, or recent access at 0.10) to cross. Time decay and usage evidence now do the discrimination the LLM number failed to. A reinforced, well-linked, recently-accessed memory stays protected even if the LLM rated it 0.3. A 400-day-old 0.8 with no reinforcement and no access drops below protection and may be reaped.
Recall Bumps Access Time Now
A separate audit found that of those 17,726 memories, only 13 had a non-null lastAccessedAt, and zero within the last 30 days. The recent-access leg of the protection score was structurally dead, because every in-app retrieval path skipped the access-time bump that the API route was making. The chat context builder, proactive recall, the first-message context, and the search_scriptorium tool handler all called searchMemoriesSemantic and walked off without bumping. searchMemoriesSemantic now bumps access times on every retrieval via a new bulk-update helper, so every in-app retrieval path inherits the fix without further edits.
The retrieval defaults that feed the system prompt have been rebalanced toward reinforced, higher-importance memories. maxMemories rises from 10 to 18. minMemoryImportance rises from 0.3 to 0.5. The vector-store overshoot widens from limit × 2 to limit × 3, giving the post-fetch filters more headroom. The hybrid ranking flips from 0.6 · cosineSimilarity + 0.4 · effectiveWeight to 0.4 · cosineSimilarity + 0.6 · effectiveWeight, so reinforcement count and time-decay floor now drive ordering and raw similarity is the secondary factor. The pool is narrower at the bottom, wider at the top, and ranked by what the character has had repeatedly confirmed rather than what happens to phrase-match the current user turn.
Recent Conversations Recap
The memory recap injected at the start of a chat (and on the auto-generated greeting) gained a ### Recent Conversations block listing the title, ID, and contextSummary of up to N prior chats with the same character. N scales linearly with the model’s maxContext — clamped at 5 for ≤4K, 20 for ≥32K, rounded between. The current chat is excluded. The context-summary trigger threshold dropped from 50 to 20 user-and-assistant messages, so the summaries the recap depends on actually exist on chats that wouldn’t otherwise have triggered the token-based rule on a 200K or 1M context window. Existing chats with 21–50 messages and no contextSummary will start generating one on their next exchange.
Housekeeping No Longer Stalls the House
Friday with her 19,500 memories was previously taking the entire 15-minute job timeout to do nothing: every sweep ran past the 180-second processor cap, was marked FAILED, retried 2–4 minutes later, and re-ran the whole calculation from scratch. Six fixes ship together. MEMORY_HOUSEKEEPING now has its own 15-minute timeout. The job defaults to maxAttempts: 1 because retrying a no-op sweep wastes time the next scheduled pass would handle anyway. The cap-enforcement pass pre-checks whether any unprotected memory exists before scoring everything (skipping the work entirely on well-reinforced characters). The initial memory read is paginated at 250 rows per page so the main thread can serve HTTP requests between pages. The startup grace period rises from 30 seconds to 5 minutes. The startup tick skips itself when a sweep completed within the last 20 hours.
A per-process outcome cache also records the deleted count from each sweep. Watermark-triggered enqueues that would produce another no-op (defined now as deleted < max(10, floor(excess × 0.01)) so single-digit deletions on a 12,000-over-cap corpus still count as ineffective) skip and log. The scheduled daily sweep is unaffected — the whole point of the daily sweep is to re-check.
The hot loops inside housekeeping.ts itself were tightened: removed a logger.debug with seven .toFixed() calls per memory (~20k context-object constructions per sweep), swapped a memoriesToDelete array’s .includes() checks for a Set<string> (eliminating ~380M comparisons in the worst case), cached the protection-score calculation across passes instead of computing it twice, and added await new Promise(setImmediate) every 500 items so the event loop can serve other work. The wall-clock time is similar; the impact on the rest of the system is not.
The Run housekeeping now button on the Memory Housekeeping card had been validating against a per-character schema while sending an empty body — it bounced as a validation error before a single log line could fire. A new ?action=housekeep-sweep action wraps the existing background-queue helper, so the toast’s “Housekeeping job enqueued — it will run in the background” promise is finally honored.
The multi-memory extraction work from 4.1 is now reflected in a new docs/developer/features/memory_management.md covering the entire Commonplace Book end-to-end: how memories form, how the three scoring numbers and the protection score relate, what the Memory Gate and three-pass housekeeping actually do, and a §10 retrospective grouping every memory-subsystem change in this cycle by pipeline stage with one-line-per-fix indices pointing back to the changelog narratives.
The Salon
Document Mode Becomes a Real Editor
The Document Mode rename plumbing now actually renames. Click the title in the editor header, type a new name, press Enter — the file moves on disk (or in the database-backed store), the Librarian announces it, and the autosave’s optimistic-concurrency check survives the rename because moveDatabaseDocument doesn’t bump lastModified and fs.rename preserves filesystem mtime.
A trash-icon button next to Close deletes the underlying file with a confirmation prompt, the autosave timer cancels first so it can’t fire against a file that’s about to vanish, and the Librarian announces the deletion. The LLM’s doc_delete_file, doc_create_folder, and doc_delete_folder calls similarly post Librarian announcements so present characters hear about structural changes a peer made.
The Document Mode picker can create folders inside a document store — root-level and nested — which persist across reopens (the GET /api/v1/mount-points/[id]/files endpoint now returns a folders: string[] list alongside the file list, sourced from doc_mount_folders for database-backed mounts and from a recursive walk for filesystem mounts). It can also create a brand-new blank document in the currently-browsed folder, with Untitled Document.md collision-numbering up to 1000 attempts. Non-Markdown files (.json, .txt, etc.) now open in a plain monospaced editor without the Lexical-bridge round-trip, which had been generating phantom dirty flags after every successful save of structured formats.
The Search & Replace modal stopped tripping “Maximum update depth exceeded” on chats with frequent re-renders. Vault-backed Prompts/*.md and Scenarios/*.md files now actually load and rewrite (the previous ^ and $ regex anchors in the SQLite query translator were passing through as literal LIKE characters, matching nothing). Markdown checkboxes and pipe-tables now convert correctly between source and rich text. Document-mode chats that opened the legacy physical-prompts.md no longer error on load, because the vault physical-file startup migration now sweeps chat_documents rows for the old filename.
Unified New-Chat Dialog
The three previously-divergent “start a chat” paths — homepage character cards, the Aurora character-view “Start Chat” button, and /salon/new — now share a single form component and submit shape. Homepage quick-chats gain a system-prompt picker when the character has more than one prompt. Every entry point can expand to a group chat via an “Add another character” toggle. /salon/new accepts a new ?characterId= deep-link param that pre-selects a character. The character picker’s “Select Characters” list sizes itself sensibly (max(starred-count, 3, selected-count) rows) instead of occupying nearly the full viewport. The customization section below splits into “Character Customization” and “Reality Injection Mode” cards, with room for the latter to grow.
User-controlled characters now get outfit selection in the new-chat dialogs alongside the LLM character(s) — because dressing your own persona for the scene is part of the work too.
Settings: Composition Mode Default
A new “Composition Mode” card at the top of the Chat tab in /settings lets new chats default to composition mode (Enter inserts a newline, Ctrl/Cmd+Enter submits) instead of chat mode. Composition mode also unlocks the formatting toolbar above the composer — bold, italic, headings, lists, blockquotes, code, and the configured roleplay-template delimiters — so longer messages can be drafted with the same rich-text affordances Document Mode provides, without dropping into source mode. The per-chat composer toggle still wins after creation; this is just the seed value. A long-standing bug where the per-chat composition-mode toggle did not survive a page reload — the value persisted to the chats row, but the GET handler cherry-picked fields and never included documentEditingMode in its response — has been fixed alongside.
Streaming No Longer Drops What It Already Showed You
When a provider stream throws — Gemini’s three-minute network drop, an OpenRouter post-hoc moderation rejection, a Cloudflare socket close — the orchestrator used to re-throw without persisting anything the user had already watched stream into the Salon. Everything visible vanished alongside the error. Six different stream-loop call sites in the orchestrator (initial, tool-unsupported retry, tool-call continuation, agent-mode force-final, text-tool continuation, text-block continuation) now share a single preservePartialOnError(error) closure that, when partial content was accumulated, normalizes the format, strips the character-name prefix, appends {{OOC: stream ended abruptly (<error>)}}, and writes the result to the database before re-throwing. The chat still pauses; there is now a saved message the user can edit, delete, or resume from.
The Plumbing
The Server No Longer Crashes on a Bad Socket
A successful chat turn finished, the post-turn fan-out enqueued the usual burst of memory and rendering jobs, an outbound fetch to a Cloudflare-fronted provider got its socket cut mid-stream with undici’s TypeError: terminated, and the dev server died and restarted — taking everything else down with it. The setupSQLiteShutdownHandlers unhandledRejection handler had been calling process.exit(1) unconditionally for every rejection, originally for genuine bad-state cases, but the blast radius was every transient socket close from every outbound fetch in the entire system.
The handler now classifies. A new isRecoverableNetworkRejection helper matches against undici stream errors (UND_ERR_SOCKET, UND_ERR_CLOSED, UND_ERR_ABORTED, the timeout family, both content-length-mismatch variants) and POSIX socket errors (ECONNRESET, EPIPE, ETIMEDOUT, the rest of the usual suspects), walks both error.code and error.cause.code because undici’s TypeError: terminated carries the interesting code on its cause not itself, and falls back to matching reason.name === 'TypeError' && reason.message === 'terminated' for older undici versions. Matching rejections log at warn level and the process keeps running. Non-matching rejections still take the server down cleanly — the genuine bad-state case is preserved.
Background Jobs Wake Up When They Should
Two related bugs. A FAILED retry (60-second exponential backoff after a moderation rejection) sat idle indefinitely because the processor’s auto-stop fired two seconds after the job was scheduled — claimNextJob() returns only rows with scheduledAt <= now, the future retry was excluded, and the processor declared the queue empty and went quiet. Nothing else woke it up: ensureProcessorRunning is only called from enqueue paths.
The processor now queries a new findNextScheduledAt() before stopping and arms a setTimeout for that wake-up time (clamped to [100ms, 5min] so a far-future schedule doesn’t pin a multi-hour timer). The auto-stop branch is honored, but the pending FAILED retry wakes the processor exactly when its scheduledAt comes due. startProcessor and stopProcessor both clear the wake timer to avoid races.
The matching Lantern fix: when a moderation error rejects a generated image (rather than the prompt), the catch block in the story-background handler now checks for moderation-shape errors and reroutes to the Concierge’s configured uncensored image profile instead of just re-throwing. Same shape as the existing prompt-crafting and appearance-resolution fallbacks, gated on the presence of an uncensoredImageProfileId and on “isn’t the profile we just tried.”
Chat-List Enrichment Stops Firing 4,300 Encrypted Queries
Every first request that hit enrichChatsForList (home page, Salon sidebar, chat picker) used to call getCharacterSummary per participant, which went through applyDocumentStoreOverlay with eight SQLCipher queries per call. On a 287-chat instance with two overlay-candidate characters, that worked out to ~4,300 synchronous encrypted queries back-to-back — about three seconds of main-thread stall right after the housekeeping grace expired, felt in the UI as “the server stopped responding.”
enrichChatsForList now pre-collects every distinct characterId, fileId, and projectId across the batch and runs one findByIds per repository. One overlay call covering all participating characters. One projects query. One files query. The results flow through a new optional ChatListPreloaded parameter down through enrichChatForList → enrichParticipantSummary → getCharacterSummary, which short-circuit their per-row reads when the maps are present. Single-chat callers pass no preload and keep their existing behavior.
Embedding Throughput
EMBEDDING_GENERATE jobs now run up to 4 at a time in the background job processor (with a 10-minute per-job timeout), while all other job types remain single-threaded — significantly faster bulk re-embeds, especially with local providers like Ollama. The claimNextJob() query now sorts by priority DESC, createdAt ASC instead of arbitrary order; the priority column existed but had never been consulted. Memory and conversation chunk embeddings now enqueue at priority 10, while mount chunk and help doc embeddings enqueue at priority 0, preventing large document store scans from starving real-time chat responsiveness.
The EMBEDDING_GENERATE handler for memories now writes directly via VectorIndicesRepository instead of loading the entire character vector store into memory — previously a single memory’s embedding job could load all ~12,000 vectors × 1,536 dimensions ≈ 150+ MB just to insert one row, which crashed Electron with V8 heap exhaustion on large instances. The Docker, Lima, and WSL heap limit was also bumped from 2 GB to 4 GB as a safety margin.
.qtap Streaming Export
The .qtap export/import format is now newline-delimited JSON. The previous pipeline built the entire export as a single in-memory JSON.stringify call, which crashed with RangeError: Invalid string length once a character with ~14,000 memories pushed the output past V8’s ~512 MB string ceiling. The new format is a manifest line, then tagged per-entity records, then a footer with authoritative counts. Document-store blob bytes are split into ~4 MB base64 chunks so no single record approaches the limit. Export writes the stream straight into the HTTP response; import peeks the first 2 KB to choose between NDJSON and the legacy monolithic path. The proxy upload cap rose from 500 MB to 10 GB. Verified end-to-end with a 1.2 GB export of a character carrying 14,435 memories.
Project↔mount-point links now round-trip through .qtap correctly. Backups now include character_plugin_data rows, conversation_annotations rows, and user-installed theme bundles — three categories that had previously been silently excluded.
When a connection profile’s provider returns an error — quota exhausted, invalid API key, the model has been deprecated — the Auto-Configure flow used to re-throw immediately, masking the real cause behind a generic “Failed to auto-configure profile” toast. The flow now builds an ordered candidate list (the default first, then the highest-class profile from each other provider), tries each in sequence, and on full failure surfaces every attempt’s error in the thrown message — so the toast finally tells the user that Anthropic returned a quota message and OpenAI returned a 401 and OpenRouter wasn’t configured, instead of nothing.
The Quiet Wins
This release accumulated an unusually large number of small, individually-quiet fixes that together make the Estate noticeably less twitchy. A non-exhaustive selection:
- The “Maximum update depth exceeded” error during SSE streaming, in three separate takes — the
storyBackgroundsEnabled sync effect’s unstable dep, the useOutfit cache-rotation cascade, and the per-chunk setStreamingContent cascade now coalesced via requestAnimationFrame. In an eight-character chain chat the reconciler now sees tens of state updates per second instead of hundreds.
- Vault-only wardrobe items no longer disappear when an LLM creates new ones — the projection sweep was deleting any file not in the DB-derived list, including hand-authored vault files. The sync now ingests vault-only items into the DB before projecting back, with deterministic UUIDs derived from file path.
update_outfit_item with item_id: "none" (or "null", or "") now clears the slot instead of throwing “not found” — LLMs frequently fill required-looking string fields with the string "none" when they mean “no value.”
- The Current Outfit section in the system prompt no longer repeats the same item description once per slot. A multi-slot dress now renders as
**top, bottom:** silk dress, not as four identical bullets.
- The Tasks Queue UI shows the paused-jobs count, displays human-readable names for
SCENE_STATE_TRACKING / CHARACTER_AVATAR_GENERATION / CONVERSATION_RENDER, and stops attributing 500 imaginary tokens per non-LLM background job.
- Folder rename now updates descendant folder rows (the previous anchored regex was matching zero rows in SQLite). Backup retention’s weekly/monthly buckets now actually retain things across multiple days. Imported characters get a vault provisioned during the import. The initial-greeting LLM call now writes to
llm_logs. Character-avatar refresh announcements come from Aurora instead of the Lantern. The “Agent completed task” banner no longer hangs when openrouter.ai is slow. Markdown/txt/JSON files can be previewed and deleted from project Files modals.
- Agent mode no longer ghost-wraps completed work from a previous turn (
submit_final_response is now scoped explicitly to this turn’s agentic work, with a belt-and-suspenders orchestrator guardrail that detects iteration-zero ghost calls and returns a corrective tool result instead of letting the model overwrite real prose).
- The Lantern’s images are delivered to each character exactly once — the previous walk re-attached every image in the last 6 ASSISTANT messages on every turn, which in multi-character chats meant the same image was sent 4–6 times in a row. Images are now scoped to the requesting character’s own most recent ASSISTANT message.
- The auto-generated greeting on GLM and other reasoning models no longer cuts off (the hard-coded
?? 160 token cap is gone) and now consumes the streaming endpoint instead of the non-streaming one, so the greeting code path matches every other chat turn.
- Production builds no longer fail with “Module not found: fs/promises” in the Salon bundle (a barrel-import was pulling Node-only modules into the client graph).
- The full Next 16 + React 19 lint pile — 98 errors and 14 warnings surfaced by the new toolchain’s strict
react-hooks/immutability and react-hooks/set-state-in-effect rules — has been chased down to zero across five commits. About 40 client components migrated from manual useState + useEffect + fetch to SWR; mutation sites now use mutate() with optimistic updates where appropriate. All 5,213 tests green.
There are roughly forty more of these — fixes for vault-only equip lookups, mount-points API extraction, scenario plumbing, Concierge avatar routing, document-mode highlight visibility, embedding dimension mismatch fallbacks, manual avatar regeneration, multi-character vision attachment scoping, and so on. The CHANGELOG carries the rest.
Subsystem Table
| Name | Function | What Changed |
|---|
| The Foundry | Architecture, plugins, packages, LLMs | Database-backed document stores, universal blob layer, live filesystem watching, NDJSON .qtap format, Next 16 + React 19 lint compliance, OpenRouter image attachments to vision models, network-rejection classification |
| The Scriptorium | Documents, search, vault tools | Convert/Deconvert mount types, folder-aware operations, JSON/JSONL/binary support with extracted text, doc_copy_file, store type classification, project Files card now resolves to project’s own store |
| Aurora | Character creation, identity | Live property overlay (8 file/folder targets), wardrobe as folder of Markdown files, vault-aware Character Optimizer with suggestions-file output, {{char}}/{{user}} replacement now iterates every prose array, Copy database → vault button |
| Prospero | Projects, agents, tools, files | Project scenarios card with full CRUD, scenarios surface in new-chat dropdown as a project-scoped option group, Files card resolves to project’s own document store, project store autoprovisioned on creation, now announces participant connection-profile reassignments in chat |
| The Commonplace Book | Memory and retrieval | Blended protection score with content cap and 30-day half-life, recall now bumps lastAccessedAt, retrieval defaults rebalanced toward reinforcement, Recent Conversations recap, housekeeping fully event-loop-safe on 19k-memory characters, watermark deduplication |
| The Salon | Chat interface | Document Mode rename/delete, picker folder creation, blank-document creation in mount folders, Shared Vaults toggle, system transparency enforcement, partial-stream preservation, three rounds of “Maximum update depth” fixes, unified new-chat dialog |
| The Concierge | Content routing, moderation | Now announces in chat when marking a conversation as Dangerous, reroutes story-background generation through uncensored profile on post-hoc image rejection, falls through across providers in Auto-Configure |
| The Lantern | Image generation | Announcements now quote the prompt, character-avatar attribution moved to Aurora, image delivery scoped to requesting character’s last turn, post-hoc moderation reroute |
| The Librarian | Memory and retrieval announcements | Now speaks for document-mode opens, saves, renames, deletes, folder creations, folder deletions, and library-file attachments — all without costing the user a turn |
| The Host | Participant changes | Announces participant adds (with avatar and identity), removes, and active/silent/absent status changes |
| Saquel Ytzama | Encryption, key management | Mount-index database now part of the 24-hour physical backup sweep — every basement now equally well-locked |
| Pascal | Looked at the new self_inventory tool’s seven sections, the new Recent Conversations block, and the new protection score’s four evidence streams. Filed all of them. | |
| Pagliacci | Cloud infrastructure | Quiet this cycle. One imagines he is rehearsing. |
Upgrading from 4.2
Database migrations handle themselves. The new tables and columns are created automatically:
doc_mount_folders (folder rows for database-backed stores), backfilled from existing files on first access
doc_mount_blobs.extractedText / extractedTextSha256 / extractionStatus / extractionError / descriptionUpdatedAt columns for PDF and DOCX extraction
doc_mount_points.storeType (defaults to 'documents') and conversionStatus / conversionError columns
characters.characterDocumentMountPointId, characters.readPropertiesFromDocumentStore, characters.systemTransparency columns
chats.allowCrossCharacterVaultReads, chats.documentMode, chats.dividerPosition columns and the chat_documents table
chat_messages.systemSender column for synthetic Staff-authored messages
projects.officialMountPointId column and Project Files: <name> store auto-creation per project
chat_settings.compositionModeDefault column
Character vaults are conjured automatically on the first server boot after upgrade — a database-backed store per Aurora character, populated from the character’s current data, idempotent and fault-tolerant. New chats and new characters provision their vaults during creation.
The wardrobe layout migrates from wardrobe.json to a Wardrobe/<title>.md + Outfits/<n>.md folder structure on the first boot after upgrade, gated by a wardrobe_folder_migrated_v1 instance-settings flag. The legacy wardrobe.json is read as a fallback when the folders are empty, so vaults that haven’t been migrated yet still surface their items on read.
The startup vector-blob repair (introduced in 4.1) continues to run on every boot.
If a vault accumulated drift from the previous wardrobe.json era — items that the DB has but the vault doesn’t — DELETE FROM instance_settings WHERE key='wardrobe_json_refreshed_v1' and restart; the refresh task will rewrite every linked character’s wardrobe from the DB.
The 4.3-dev cycle bumped a number of plugin packages: @quilltap/plugin-utils 2.2.2 → 2.2.5, @quilltap/plugin-types 2.3.0 across all six dependent plugins, qtap-plugin-anthropic 1.0.27 → 1.0.28, qtap-plugin-google 1.1.22 → 1.1.24, qtap-plugin-grok 1.0.28 → 1.0.29, qtap-plugin-mcp 1.1.22 → 1.1.24, qtap-plugin-ollama 1.0.21 → 1.0.22, qtap-plugin-openai 1.0.32 → 1.0.33, qtap-plugin-openai-compatible 1.0.23 → 1.0.24, qtap-plugin-openrouter 1.0.31 → 1.0.34. Bundled plugin updates are pulled in automatically; if you have plugins from external sources, refresh them.
Repository note: server source moved to foundry-9/quilltap-server in 4.1. The original foundry-9/quilltap repository is now reserved for the next-generation native Quilltap application currently under development. If your tooling still references the old URL, update it.
Installation
Desktop App
Download from the quilltap-shell releases page:
macOS:
- Download the
.dmg file and open it
- Drag Quilltap to your Applications folder
- Launch Quilltap from Applications
Windows:
- Download and run the
.exe installer
- If SmartScreen warns about an unknown publisher, click “More info” → “Run anyway”
- Launch Quilltap from the Start Menu or desktop shortcut
Linux:
- Download the
.AppImage file, make it executable (chmod +x), and run it
- Or install the
.deb package: sudo dpkg -i quilltap_*.deb
npx quilltap
Or install globally:
npm install -g quilltap
quilltap
Open http://localhost:3000 in your browser. Requires Node.js 22+. First run downloads ~150–250 MB and caches locally.
Docker
docker pull foundry9/quilltap:4.3.0
Or use the startup scripts:
# Linux / macOS
curl -fsSL https://raw.githubusercontent.com/foundry-9/quilltap-server/refs/heads/main/scripts/start-quilltap.sh | bash
# Windows (PowerShell)
irm https://raw.githubusercontent.com/foundry-9/quilltap-server/refs/heads/main/scripts/start-quilltap.ps1 | iex
The basement is finished. Every character has a private library. The Staff has begun to speak in their proper voices, at the proper moments, without asking permission to be in the room. The Commonplace Book has stopped treating “the LLM rated this 0.8” as the final word on whether a memory deserves to live, and started asking whether anyone has touched it lately. The plumbing has stopped letting a bad socket take down the house. There remains, as ever, more to do — the per-turn searchMemoriesSemantic full-table hydration is now the prime suspect for the next round of memory throughput work, BitNet inference is something worth watching for 2027, and the next-generation native client is taking shape behind the scenes — but this cycle’s renovation is, at last, ready for occupancy. Come downstairs and see.
— Prospero, for the Bureau, with the Librarian’s red pencil and Aurora’s approval and the Concierge’s discretion