Phases 2 to 8 of the AI assistant overhaul plus fixes from real-model chats: one tool registry with native tool calls, server-kept chat logs with prompt caching, an FTS5 library index with opt-in meaning search, keys bound to their address and a guard on every AI route, page tools and opt-in web search, saved chats with streaming, Try again, a context meter and compaction, chat images, delete/category/tag permissions with Undo, constraint and test cards, card fixes and previews, questions with answers to click, the derivation NOT toggle (schema v10, community catalog 0021), request fixes for the newest Claude models, and OpenAI's Responses API. Review fixes: Try again clears the old turn's fixes and answers, category Undo removes only what it made, a failed delete rolls back in order, not_matches patterns are checked, busy chats refuse a second request, and chat search ignores stored JSON.
- Ollama gets num_ctx sized to each request (Settings: Most context to use);
Test connection warns when LM Studio or llama.cpp loaded too little context.
- Model size profiles (auto from the name): small models get a slim prompt,
smaller tool results and a lower temperature; history is fitted to budget.
- Chat: a plan field before calls, one retry on an unusable step, a check
nudge when module changes were proposed without a test run, proposed
creates get ids so later calls in the same answer can use them.
- Pack builder: reads the library's conventions, reuses library wildcards,
takes a sample prompt, style/model/anchor on the plan, a plan review
(rules + optional model review) with "Fix these in the plan", rules
ordered after what they read, consumer briefs for wildcards, retry with
the error, one repair rebuild, variety notes from the check run.
- Import / Export: "Tag and connect with AI" after a wildcard pack import.
- Suggested local models updated (heretic builds, Artemis v1.2).
- Docs, README, release notes and docs index updated.
Two backlog QoL fixes.
- duplicate_module dropped content_rating, so copying an 18+ module produced a
SFW copy that slipped out from under the filter. Forward it.
- Icon-only Buttons carried an aria-label (screen-reader name) but no title, so
a sighted user hovering a bare icon got nothing. Mirror the label into title
when there's no visible text — fixes every icon button at once.
Reported by the maintainer: the editor accepted the same source/target
combination more than once with contradictory modes and factors. Only one ever
applied. `combine_constraint_factor` files exceptions into a dict keyed by the
pair, so the last one in the list wins and every earlier one is discarded.
Verified against the pure math, and the alarming direction is real:
[boost x3, reduce x0.5] -> 0.5 not the combined 1.5
[reduce x0.5, boost x3] -> 3.0 order decides the result
[boost x3, exclude] -> EXCLUDE
[exclude, boost x3] -> 3.0 the exclusion is thrown away
That last line contradicts the documented model in CLAUDE.md, which says
constraints combine "with `exclude` (factor 0) absorbing". Within duplicate
exceptions it neither combines nor absorbs, and the deciding factor is list
position -- which no surface displays and nothing lets you edit.
Enforced at the AUTHORING boundary, not in `validate_payload`. `resolve` calls
that on every graph run, so a rule there would turn an already-stored payload
into a hard failure mid-execution -- strictly worse than the silent
misbehaviour it replaces, and unreachable for a user who cannot open the editor
without the graph running. Handlers may now expose an optional
`validate_authoring`, called by the module create/update paths and by bundle
child validation; existing rows stay executable.
Engine math is deliberately unchanged. Making duplicates combine would have
been defensible -- it is what the docs describe -- but it silently alters
re-weighting for anyone who already has them, and "you cannot create this
anymore" achieves the same end without touching anyone's output.
Scanned the maintainer's library first: 21 constraints, 0 duplicate pairs, so
nothing existing is affected either way.
`drift-store.ts` polls /wp/api/modules/hashes and /wp/api/bundles/hashes every
5 seconds. Both the SPA and the in-graph WP_Context subscribe, so a ComfyUI tab
with a Context node on the graph polls these whether or not the manager is ever
opened.
Measured against a live 2.13.1 install on 2026-08-06: 33,008 bytes per poll, no
ETag, no Cache-Control, no Last-Modified, and 0 of 39 observed requests served
from cache. That is ~24 MB/hour per open tab on an idle page. Invisible on
localhost; over Tailscale it was the largest thing this extension does.
Producing it was not cheap either, despite the docstring's "lightweight ...
without measurable cost" -- that described the response, not the work.
`ModuleRepository.list()` is `SELECT *` plus a json.loads of every payload in
the library, and the handler kept three fields per row and discarded the rest.
Two changes:
- Conditional responses. `json_ok_revalidated` emits an ETag plus
`Cache-Control: no-cache` -- store it, but revalidate -- and returns a bodied
304 when `If-None-Match` matches. Nothing changes for the client: the browser
serves the cached body as a 200, so `fetch().then(r => r.json())` is
untouched and the saving is entirely on the wire.
- A cheap stamp behind the ETag. `library_stamp()` is
COUNT/SUM(version)/MAX(updated_at) -- one aggregate row, no payloads. The
expensive map is built only when the stamp moves, which is the whole point:
computing an ETag from the response body would have done all the work anyway
and then thrown it away.
Also narrows the map builders. `list_hash_rows` selects the columns the drift
map needs instead of every column via `_row_to_module`/`_row_to_bundle`, which
parse `tags` (and `children`, for bundles) per row for a map that wants
neither. Bundles get the bigger win -- `payload_hash` is a stored column there,
so that path now parses nothing at all. Modules still parse `payload` because
their hash is computed rather than stored; removing that needs a schema
migration, and the stamp makes it moot in the steady state.
Stamp precision, both directions verified: `set_community_origin` writes
without a version bump and cannot change the map, so the ETag correctly holds.
A favourite toggle goes through `update()`, whose single statement always does
`version = version + 1`, so it does move the ETag and costs one refetch of an
identical map. Accepted rather than fixed -- a rare user action against a second
counter tracking payload-affecting columns only -- and pinned by a test that
says so, and says a future failure would be an improvement.
Workflow drift/missing now runs through the shared classifyOne verdict: a same-id, DIFFERENT-kind library row surfaces as a distinct 'clash' state (violet badge) instead of a misleading drift/missing dot, and Refresh is suppressed + guarded in mergeRefresh so it can never clobber an unrelated item. Hybrid content-key: the workflow path compares payload_hash (drift semantics unchanged) while the import picker keeps fingerprint; classifyOne is content-key agnostic. /wp/api/modules/hashes now returns {type, payload_hash} per id; drift-store map + AppLayout fingerprint + setLibraryHash updated to the object shape.
Publishing a local row to community never linked the row back to the
resulting post, so: no community pill, no update-available tracking,
and the re-publish version-bump flow was unreachable (origin slug was
always null -> publishMode could only ever be 'create').
Adds the writeback path:
- ModuleRepository / BundleRepository.set_community_origin(id, slug,
version): stamps community_post_slug + community_version_number
WITHOUT a version bump or fingerprint change -- origin metadata is
not runtime-affecting, so a published row stays byte-identical to
what consumers installed.
- wp_api: POST /wp/api/modules/{id}/community-origin + the bundles
twin. Validates {post_slug: str, version_number: int}.
- api client: modules.setCommunityOrigin + bundles.setCommunityOrigin.
- main.ts host bridge: new optional `markPublished(localId, kind,
slug, version)` method. Calls the right setCommunityOrigin, then
refreshes both catalogs so the pill appears without a reload.
Optional + try/catch-returns-false so a writeback miss never breaks
the embed's publish-success path. No ABI bump -- adding a method is
non-breaking; the embed guards on its presence.
Tests: set_community_origin stamps slug+version, does NOT bump
version/fingerprint, raises on missing row.
Pairs with the community embed commit that calls markPublished after
createPost / createVersion.
Wires the content_rating axis end-to-end on the SPA + HTTP layer:
- TS types: ModuleRow / BundleRow / *CreateInput / *UpdateInput +
InstallOrigin all gain a `content_rating?: 'safe' | 'nsfw'` field.
- install.ts: when `opts.origin.content_rating` is set, every entity
going in has its content_rating stamped -- the embed's community
install passes 'nsfw' through to the engine without needing a
follow-up fetch.
- IdentityCard: adds a Toggle in a new "Content rating" field. Same
component is the source for all 6 module editors + BundleEditor,
so the toggle lives in one place. A `showContentRating` opt-out
lets TemplateEditor hide the field (templates table doesn't carry
the column).
- All 6 module editors (Fixed, Wildcard, Combine, Constraint,
Derivation, Bundle) plumb a `contentRating` ref through load,
save, and IdentityCard binding.
- wp_api modules + bundles: POST/PUT endpoints accept content_rating
in the body and forward to the repository.
- ModuleListView: renders a new `wp-row-community-pill--nsfw` pill
on rows where row.content_rating === 'nsfw'. Uses the harmonized
primitive from b5ce9e4 -- the comment block there explicitly
seeded this variant.
Accumulated branch work that was uncommitted alongside the pair-badge feature in the prior commit. Captured as a single sweep commit so the branch can merge cleanly; future work should commit per-feature to avoid this.
- Cross-node drag MOVE-becomes-COPY race: ContextWidget.onDrop sync-claims
consumedBy before any await so the source widget's dragend reads the
set value. dragend filters by _uid (idempotent) instead of stale
srcIdx — fixes the secondary issue where the backup pendingHandoffs
queue + dragend double-removed an adjacent target wildcard.
- pendingHandoffs queue in drag-store: belt-and-suspenders source-side
cleanup that runs even when dragend skips (constraint rows inside the
bundle frame were the trigger).
- Conflict scanner constraint_orphan_target slot allocator: per-target
sorted local positions + remaining downstream count. Each constraint
claims smallest local slot > own index, else a downstream slot. Fixes
duplicated constraints with target downstream cross-node + correctly
flags when only one local target sits BEFORE the constraint.
- collectDownstreamWildcardUuids: per-instance occurrences (not Set-
deduped) so duplicated downstream targets each count as a separate
claimable slot.
- RichTextPreview + ConstraintInstanceModal: cross-Context refs fall
back to preview-resolver cache (sub_categories, option values,
options_by_id, hasNullOption) so the canvas modal renders the actual
wildcard name, sub-cat list, and chip suffix instead of "source"/
"target" placeholders + stale matrix keys.
- Preview-resolver schema extended with subCategories / optionValues /
optionsById / hasNullOption populated from embed-bundle response.
- Constraint save hydrate: wp_api modules + bundles endpoints fill
exception source_value / target_value from source_id / target_id by
looking up the wildcard's option list before validation. Heals
library rows that stored only ids.
- TestRunner constraint table uses RichTextPreview for column headers
+ row values so nested @{uuid} refs render as chips.
- Inverted null filter UI: SubcategoryFilterPicker "Exclude null"
checkbox + RefChip "!null" suffix render.
- Bundle size budget bumped 330 KB to 340 KB.
- Drop stale CHECKPOINT-*.md docs.
Lands the 15-bug catalogue from the 2026-05-19 creative-QA pass.
API layer (wp_api/):
- New _validators.py centralises meta + identifier + body-size checks
so modules and bundles share the same shape rules
- SPA catch-all now returns JSON 404 for unknown /wp/api/* paths
instead of the SPA shell (debugging hostile previously)
- create/update/import_from_workflow validate meta (name/desc/tags/
category_id) before hitting the repo; tag list now rejects non-str
entries (was silently stored, breaking the SPA type contract)
- IntegrityError on dangling category_id surfaces as 400, not 500
- Optional If-Match header on PUT enables opt-in optimistic
concurrency — stale version → 409 instead of silent overwrite
- Bundle name collisions auto-suffixed with (copy)/(copy N) mirroring
the modules-side forkModule rule
- Bundle children deduped by id at create + update time
- Deleting a module flags meta.orphaned=true on every bundle child
whose snapshot referenced it so the SPA can render a stale badge
Engine layer (engine/modules/*_handler.py):
- wildcard: reject empty + __dunder var_binding, cap at 64 chars,
reject duplicate option ids
- combine: reject empty template, cap template at 8000 chars, cap
output_var at 64, reject __dunder output_var
- fixed_values: cap value name length, reject __dunder names, reject
duplicate names across rows (was silent last-write-wins)
- derivation: reject duplicate rule ids (was nondeterministic for
disabled_rule_ids / rule_order_override lookups)
Tests:
- New test_qa_hardening.py exercises every endpoint-level fix
- Extended test_payload_validation.py parametrised rejection matrix
- New test_spa_fallback.py case for /wp/api/* JSON 404
655 pytest pass, 1665 vitest pass, ruff clean.
Lands the broader save-to-library rethink across the server + extension
surfaces. Closes Bugs 1-5 from the 2026-05-19 live QA report; Bug 6
(payload validation) was already plugged by the previous commit.
API contract (Phase B):
- PUT /wp/api/modules/{id} now accepts {payload, name, description,
tags, propagate_to_bundles} and persists all of them in one
transaction. Returns bundles_updated[] so the caller can surface
"this save touched N bundles" feedback.
- PUT /wp/api/modules/{id}/payload kept as a backwards-compat alias
that mirrors the same body shape (payload + meta + propagate). The
older route silently dropped meta; now it routes through the same
helpers as the canonical PUT.
- Bundle propagation: when a module write happens, every bundle whose
children[].id matches the updated module gets its frozen child
snapshot rewritten in lockstep (payload + meta) and its
payload_hash recomputed. Atomic via shared db_session.
- GET /wp/api/bundles?contains_module=<id> filters the list to bundles
that reference that module — drives the modal's "this save affects
N bundles" preview.
- 7 new pytest cases in test_save_to_library_unified.py covering meta
persistence on both PUT routes, opt-in/opt-out propagation, and the
bundles filter.
SPA (Phase C):
- New PushToLibraryModal.vue. Owns editable name/description/tags +
payload preview + bundles-affected list + propagate checkbox. Two
buttons: Save as new entry (always available — POST /modules) and
Update existing (greyed when payload_hash is empty). Replaces the
five per-kind onXxxSaveToLibraryClick handlers + their five copies
of the same PUT body that lived in ModuleEditModal.
- ContextWidget right-click menu entry becomes Push to library and
routes to the same modal. Predicate relaxed from
isMissingFromLibrary(m) to !!m.payload, so inline-created modules
(no payload_hash) can finally reach the library — they get the
fork-only path in the modal.
- The five instance modals canSaveToLibrary computeds drop the
isLibraryTracked + isModified gate. Button is visible whenever
there is a payload; the modal decides what to do with it. Tests
updated to match the new contract.
- 8 new vitest cases in PushToLibraryModal.test.ts covering field
seeding, button disabling, PUT/POST request shape, bundle list
rendering, and the propagate opt-out round-trip.
- Legacy saveToLibrary(m) function in ContextWidget is gone (no
callers since the menu predicate change). Its bypass POST to
import-from-workflow lost the meta-editing path the new modal
exposes, so deletion is net positive.
Size note: total extension bundle is now 306254 / 307200 bytes
gzipped — 946 bytes of headroom on the size gate. Future feature
work touching this surface needs to factor in budget.
Live QA pending: extension code needs ComfyUI restart to reload.
Bug 6 fix: wildcard + fixed_values handlers had no validate_payload
method, so the base no-op was silently accepting any shape on every
write boundary. Verified live: PUT /payload with {options: 'not-an-array'}
returned 200 and persisted the garbage; POST accepted {options: 42};
cross-type pollution (combine-shaped payload PUT to a wildcard) also
succeeded with 200.
Changes:
- engine/modules/wildcard_handler.py: validate_payload checks options
list shape + per-option value/weight types + non-negative weights +
optional sub_category strings + var_binding identifier + sub_categories
list-of-strings.
- engine/modules/fixed_values_handler.py: validate_payload checks values
list shape + per-value name/value types + identifier rule on non-empty
names.
- wp_api/modules.py: validates payload on POST /modules, PUT /modules/{id}
(when payload field is present), and POST /modules/import-from-workflow.
Shared _validate_payload_for_type helper.
- wp_api/bundles.py: validates every child snapshot's payload on POST
/bundles and PUT /bundles/{id} when children[] is in the body — closes
the side door that let bundle writes bypass per-module validation.
- tests/wp_api/test_payload_validation.py: 26 cases covering happy paths
+ malformed payloads per kind, cross-type pollution, import-from-
workflow, and bundle-children side door.
- tests/wp_api/test_bundles_crud.py: fixture combine child gained the
required output_var field (it was implicitly invalid before).
Plus docs/superpowers/specs/2026-05-19-save-to-library-rethink-design.md:
the broader four-phase plan that addresses Bugs 1-5 (meta drop, bundle
propagation, fork discoverability, modal button visibility, inline
modules can't save). Phase A (this commit) plugs the validation hole.
Phases B-D will land separately after design review.
Live QA reproduction order remains in the design doc.
Pipelines never paid off as a separate module kind, so peel out:
- Engine: drop pipeline_handler, remove "pipeline" from valid module
types in repositories + schema parity.
- Manager: delete Pipelines/PipelineEditor views, PipelineSteps +
PipelineStepPicker components and their tests. Router, sidebar,
topbar, dashboard, TestRunner, ImportExport, resolver and
bundleSelection repointed off the removed routes/types.
- wp_api: drop pipeline branch from modules.py + import_export.py
(and its test).
In its place, add a unified library view:
- src/manager/views/AllItems.vue — modules + bundles in one filter
panel with kind multi-select (OR across the 6 kinds). Reachable
via the Dashboard "View all" link.
- ModuleListView grew the slots/props AllItems needs; BundleAddChild
+ Dashboard + ImportExport updated alongside.
Misc polish along the way: kind-icons / theme tokens / display-prefs
touch-ups, ContextWidget + DebugViewer + AssemblerHelper small fixes,
MockModuleCard + ModulePickerModal tweaks, _shared widget cleanup.
Two bugs surfaced by manual QA after picker unblock:
1. Every freshly-picked non-wildcard module rendered a red "missing"
dot on the card. Root cause: GET /wp/api/modules/hashes hard-coded
`type="wildcard"` (5.5.5 spec §4.2 reasoned "catalog only contains
wildcards"), so when the in-graph WP_Context now embeds a combine /
derivation / constraint / fixed_values, the resulting payload_hash
never appeared in the libraryHashes map. The frontend predicate
`isMissingFromLibrary` then flagged every non-wildcard as missing.
Fix: drop the `type=` filter so the endpoint returns hashes for
every kind. Cost: ~50% larger payload for typical libraries; still
sub-10 KB for the largest libraries we've seen.
2. Picker enabled pipeline kind, contradicting the user-stated scope
that pipelines stay deferred until the modal grows a pipeline
preview. Fix: introduce a `HIDDEN_KINDS` set + `visibleModules`
computed that filters pipeline rows out of the picker entirely.
Pipeline tab dropped from the kind-tab strip. The defensive guard
in `isPickable` reads from the same set so any future programmatic
pick path inherits the gate.
Tests:
- Updated picker test to reflect 5 visible rows (pipeline filtered)
+ asserts the "Pipelines" tab and the literal pipeline row text are
absent from the rendered picker.
- Renamed + updated the backend hashes test to lock the new
every-kind contract; combine row now expected in the response map.
485 vitest + pytest green.
Dashboard count cards displayed "1" for every kind regardless of how
many rows the user actually had in the library. Root cause: the list
endpoint returned `total = len(items)` AFTER pagination, so any client
that passed `limit=1` for cheap polling (Dashboard does exactly this)
got `total=1` no matter what.
Adds `ModuleRepository.count(...)` that runs the same WHERE-clause
filters as `list()` but issues `SELECT COUNT(*)` with no LIMIT/OFFSET,
and reroutes both calls through a shared `_build_filter_clause` helper
so filter shape can never drift between the two paths.
Pinned by `test_list_total_ignores_limit` — seeds 3 wildcards, queries
`?type=wildcard&limit=1`, asserts items has 1 row but total is 3.