v3.3.1 - 2026-10-16 Type: patch Ask PacketSnitch can hand a payload it pulled out of a stream back to Conv as a real file, drive the Stats timeline from a chat request, and follow a multi-step request through to the end — with a new Chat noise level slider deciding how much of that it narrates. The catalog server reports Paddle purchases to GA4 and sends branded purple portal emails, and release notes are now readable in Settings → About. This cut also carries the Developer "Free mode" simulation, the PostgreSQL catalog backend, and the investigation playbook that the previous release did not pick up. ✨ Features Stream-aware payload reload ("Reload in Conv") — eight of the stream decoders (HTTP, FTP, IMAP, SMTP, POP3, NNTP, TFTP, WebSocket) can now hand the whole data chunk they carry back to the Conv tab as a one-click Reload in Conv button instead of stopping at a per-packet view of it. A file body, an RFC 5322 message, or an IMAP literal spans many packets, so a decoder whose only input is a single packet cannot produce it; the new extractStreamPayloads hook (in src/ui/decoders/protocols/payloads.js) is how a decoder asks Conv for the reassembled conversation. Conv injects the accessors, the decoder returns { payloads: [...] } or nothing, and the extracted bytes load into Conv as their own file — so a ZIP opens in the archive decoder instead of an opaque blob, and the declared type becomes the decoder hint. A payload with no bytes is not a payload, so a decoder that finds nothing returns null and no button is rendered. Each payload is bounded (16 MiB, flagged truncated rather than silently cut; 64 entries per stream). Covered by tests/decoderstreampayloads.test.js and tests/decoderstreampayloadsui.test.js. Encapsulated-data decoding, wired end to end — the same hook was taught to the individual protocol decoders, so content pulled out of an HTTP body, an FTP data connection, an IMAP literal, an SMTP/POP3/NNTP message, or a TFTP transfer arrives in Conv with the right filename and extension hint and decodes as the file it actually is. This closes the "encapsulated content" gap that previously left an analyst with raw hex and a note that the bytes might be a file. Play and control the Stats timeline from chat — Ask PacketSnitch can drive the Stats → Map timeline animation the same way the on-screen Play/Reset buttons and speed slider do. The new stats.timeline-control action accepts play, pause, toggle, reset, seek (timeMs), speed, and status; it opens the Map subtab itself when it is not rendered and re-reads the live timeline handle on every call, so a stale handle can never drive a detached canvas. An autonomous step cannot answer the timeline's "set network anchors first" dialog, so the AI entry point passes an auto-confirm flag and takes the documented corner-pin fallback, while the user-facing button still shows the dialog. Covered by tests/statstimelinecontrol.test.js. Chat noise level — a new Chat noise level slider (Settings → LLM, under Rat Flocking; settings.llm.chatVerbosity, 0–3) decides how much play-by-play Ask PacketSnitch posts. Level 0 is quiet (the final answer only); level 1 (default) adds a line for a notable route and for a multi-step plan starting or finishing; level 2 adds one line per executed step; level 3 is diagnostic (every route, plus recovery/regroup, reflection, and follow-through bookkeeping). The levels are strict supersets, an unknown notice kind is never silenced (fail-open), and a silenced line skips both the bubble and the pacing hold so a quiet run is not slower than a chatty one. Terminal outcomes — the answer itself, and anything the user has to answer or confirm — stay ungated. Every progress line routes through one predicate, shouldPostChatNotice in src/ui/ask-shortcuts.js. A multi-step request is never short-circuited — isMultiStepShapedRequest (src/ui/ask-shortcuts.js) detects sequencing connectors ("then", "after that"), enumerated lists, and comma-joined imperatives, and gates every chat shortcut at a single choke point, so a request like "load X into Conv, then unzip it and decode Y" always reaches the classifier that plans and orders every step instead of firing the first matching shortcut and dropping the rest. The flock follow-up is guarded at its head the same way. Covered by tests/askshortcuts.test.js. Follow-through on multi-step requests — the router prompt now counts a request's distinct goals and emits an optional multiStep hint, and after the planned steps drain a single follow-through pass (askFollowThroughCompletion) asks whether every goal was actually satisfied. complete:false with corrective steps folds them back in for one more run; complete:false with no steps is an honest give-up; the pass is capped at one corrective plan. The common map-focus dead end is handled on three layers — the router prompt resolves a host role before guessing an IP, the dead-end recovery prompt warns against repeating a guessed IP, and the follow-through pass supplies the real availableStatsLocations list. Disable with settings.llm.followThroughEnabled; the adaptive between-step reflection stays independent under settings.llm.planStepReflectEnabled. Pure helpers are unit-tested in tests/askpacketsnitchadaptiveplan.test.js. Every capability is documented in one place — the Ask PacketSnitch router prompt no longer explains individual tools. Each registered action's description is the tool's prompt content, and the router carries only the intent taxonomy, the universal reasoning rules, and each action's derived argument list. Two consequences: an action with no description is invisible to the model, and a stale one is worse than none — so tests/featureactions.test.js pins the "every AI-callable action has a description" invariant, and stats.focus-location (historically AI-callable but missing from the allow-list) is now listed in config/action-policy.json alongside stats.timeline-control. GA4 ecommerce purchase reporting (catalog server) — the catalog server now reports a confirmed Paddle purchase to Google Analytics 4 as a purchase event over the Measurement Protocol, mapping client id, transactionid, value, currency, and the item list in buildga4purchasepayload. It is reported from the server rather than a browser because Paddle hosts the checkout, so no page of ours ever renders for the purchase — a client-side event on our own Paddle.js page would also miss Pro/Enterprise purchases. Each transaction is claimed once (claimga4purchaseevent) so a retried webhook cannot mint a second event; a Measurement Protocol event with no sessionid is otherwise unattributed. Configured with PSCATALOGGA4MEASUREMENTID + PSCATALOGGA4APISECRET, a master PSCATALOGGA4ENABLED switch, a PSCATALOGGA4DEBUG echo mode, and a request timeout. Covered by tests/testcatalogga4purchases.py. Branded portal emails — the catalog server's sign-in and email-verification messages now share one purple email shell (renderemailshell), so the two can no longer drift apart. The logo is shown from the deployed asset on packetsnitch.com and attached inline as a multipart/related part, so it renders wherever the HTML is viewed — including a webmail browser view, which cannot resolve a cid: reference — while still carrying its own copy for clients that block remote images and for offline readers. A missing or corrupt asset degrades to the text wordmark instead of breaking a sign-in email, and the footer carries the support address plus a tel:-linked phone number in both the HTML and the plain-text alternative. Outbound mail now comes from no-reply@packetsnitch.com (the older noreply@ and oxasploits.com references are retired). Covered by tests/testcatalogemailtemplate.py and tests/testcatalogemailpairing.py. Release notes in the About terminal — Settings → About now fetches the notes for the version it is running (https://dl.packetsnitch.com/ps/ /RELEASE) and renders them in the terminal readout, degrading to the version-only line when the fetch fails or the bridge is missing (a dev window whose main-process bundle predates the handler) instead of throwing. A single dependency-free converter, src/release-notes-text.js, turns the markdown into the plaintext the publisher writes; its Python twin (gen-release-notes.py) is pinned to the same fixtures, so the file the app downloads and the file the pipeline ships are byte-identical for the same markdown. Covered by tests/aboutreleasenotes.test.js and tests/releasenotestext.test.js. One website-path resolver — the website is a separate git repository that .gitignore excludes: a sibling PacketSnitch-Website/ during local development, or the CI clone under docs/. That layout knowledge now lives in one place, scripts/website-path.js (resolveWebsiteDir), shared by the release publisher, forge.config.js, and the new npm run sync:llm-docs refresher instead of being re-derived in each. Covered by tests/websitepath.test.js. Settings → Dev subtab with a "Free mode" simulation — developer-licensed installs get a new Settings subtab that is hidden for every other tier. Its "Free mode" toggle makes the install behave like a plain Free install — Pro features gated, licensed themes locked, the storefront showing Buy buttons — so the locked paths can be tested in place instead of on a second machine. The Developer license itself is untouched: the toggle only changes what every gate reads, and turning it off restores full access. Rules that keep it a testing aid: the toggle is refused unless the catalog reports the developer tier, a license reconcile that moves the install off developer drops the simulation, and the subtab's visibility keys off the server-reported tier so free mode can always be turned back off. The shared gate (src/license-gate.js) owns the behaviour for both processes, the toggle persists in settings.dev.freeModeEnabled, and flipping it re-derives the license badge, storefront preview, theme dropdown (a theme that just became unavailable falls back), and the Pro-feature gates in one step. Coverage in tests/devfreemode.test.js. PostgreSQL catalog backend — the catalog server (themes, licenses, Paddle checkout, admin portal) can now store its data in PostgreSQL instead of a local SQLite file, which is what a hosted or managed database deployment needs. Select it with --db-backend postgres --db-dsn (PSCATALOGDBBACKEND / PSCATALOGDBDSN, or dbbackend / dbdsn in the YAML); --db-schema picks the namespace and it is created on first boot. Every endpoint, CLI subcommand and Paddle/portal flow is unchanged, and the SQLite default is untouched. Moving an existing catalog across is one command — python3 scripts/migrate-catalog-to-postgres.py --sqlite catalog.sqlite3 --dsn copies every table read-only from the source, resets the identity sequences so the next insert cannot collide with a migrated id, and verifies the row counts per table (with --dry-run, --truncate and a public-schema guard). Investigation playbook — asking PacketSnitch to investigate a capture now runs an ordered script rather than letting the model bumble around hoping to run into something interesting. The phases are: inventory the capture; check out every file artifact (artifacts.list, extraction.list-carved-files, extraction.hash-file); send the threat-relevant extensions through threat intel (threat-intel.lookup-hash, conv.load-extraction-into-threat-intel); decompress every compressed artifact — ZIP/tar/CAB/7z/RAR, single-stream gzip/bzip2/lzma/lzo/brotli, and compressed HTTP bodies — and recurse back into the earlier phases on what comes out; load the unencrypted transmissions (HTTP, FTP, SMTP, IMAP, POP3, Telnet, IRC, LDAP, DNS, SNMP, TFTP, SMB, database wire protocols) into Conv and decode them; and collect every secret in the artifact store, then try it as a key against the encrypted entities in the capture (TLS/SSL, PGP, OpenSSH, WiFi, encrypted archives) to catch reused credentials. A phase is not finished while any of its items is unchecked. Same script for both investigator paths — the LLM rat flock renders the full playbook (rules, phases, and the extension / protocol / compression reference tables) into its discover, drill, verify, and maze-walk prompts, and the deterministic investigator loop records the playbook on the run, blubs it, and measures completion against it. A flocked investigation previously inherited the chat reply prompt ("state what you did"), which is why it narrated instead of investigating; it now gets the playbook as its base prompt. Investigations only — the playbook is scoped to the investigate- actions. Ordinary chat, summaries, questions, and notes keep their existing prompts, and a plain question no longer spins up parallel rats. New module src/investigation-playbook.js; coverage in tests/investigationplaybook.test.js (29 tests). GA4 build/host context — every GA4 event now carries four low-cardinality, non-identifying dimensions: appversion, osplatform (OS family), osarch (CPU architecture), and applocale (language tag). The renderer stamps them in src/gtm.js track() once the async runtime probe resolves (setRuntimeContext), and the main-process Measurement Protocol relay computes the same values in getGa4RuntimeContext() so headless and CLI batches agree with the renderer. Because the context is applied after the caller's props are filtered, an event cannot spoof it — RUNTIMECONTEXTKEYS removes those keys from caller-supplied props first. Per-tab activity: time and bytes — a new tabactivity event (local pipeline: tab.activity) is emitted whenever the active tab or subtab changes, and once more at unload, carrying the dwell time and two separate byte counters: ingestBytes (capture payload the renderer ingested while that view was active) and toolBytes (bytes the view's own tools processed, e.g. Conv converting a payload). They are reported separately on purpose — capture ingest happens to whichever tab is on screen, while tool output is something the tab does. Ingest accounting uses the incremental staging plan so a cumulative backend snapshot is not counted over and over. The dwell is clamped to 12h per window so a sleep/resume cannot ship an absurd value. Implemented in src/ui/main-frontend.js (flushTabActivity, setActiveTabForActivityTracking, recordTabIngestBytes, recordTabToolBytes) with emitters trackTabActivity in src/gtm.js and src/metrics.js. Analytics settings subtab removed — GA4 is driven entirely by the master Send anonymous usage metrics switch, so a dedicated Analytics subtab was redundant. Its read-only GA4 Measurement ID and API Secret fields now live at the bottom of the Privacy panel, and SETTINGSSUBTABANALYTICS, the settings-gtm-ga-panel element, and the settings-analytics-consent-status line are gone. Metrics endpoint allow-list extended — src/metrics/ps-metrics.py (SAFEPROPKEYS) now accepts ingestBytes, toolBytes, osplatform, osarch, and applocale so the new dimensions survive to the NDJSON sink. Covered by tests/testmetricsserver.py, tests/gtmanalytics.test.js, tests/ga4relayrouting.test.js, and the new tests/tabactivitytracking.test.js. 🐛 Fixes A Free install can no longer be dropped onto — or pop a dialog from — a tab it is locked out of. defaultTab defaults to stats, whose toolbar entry point was licensed, so a brand-new Free install opened the app on the "Professional Feature Locked" dialog before the user had done anything. The Stats workspace is free and only its geolocation Map subtab (plus the timeline on it) is licensed, so the gate moved off the main tab and onto the subtab itself, and a locked tab is filtered out of both the landing-tab resolution and the session-restore dispatch (resolveDefaultLandingTab, isLicenseLockedLandingTab) instead of being opened. Internal re-renders no longer gate at all — only the user's own click can show the dialog — and a saved session that left the Map or Conv → Threat Intel subtab open restores onto Statistics / Conversions instead of re-opening it. Covered by tests/tablicensegating.test.js. Crypt → Hashes "Reverse Hash" did nothing. The button (and the "Identify Hash Types" beside it) had no click listener — the ids were in src/index.html but nowhere in the renderer — and the shared handler hard-coded the Conv → Hashes element ids, so it would have written results into the hidden Conv pane anyway. Both buttons are wired, the handler resolves the mirrored id pair, and the hashes.com reverse / identifier lookups now run the licensed-feature pre-flight so a Free install gets the "Professional Feature Locked" dialog (with a Go to Store button) instead of an inert click. The locked-feature dialog is actionable. Every Pro gate now shows the same generic copy — "This feature requires a Professional or Enterprise licensed install. Head over to the store to purchase one." (shared as PROFEATUREMESSAGE by src/license-gate.js, the renderer and the main-process IPC errors) — with a Cancel button and a Go to Store button that opens Settings → Storefront. Storefront license-tier previews never loaded in a packaged build — themes-fetch-local-preview reads the tier images from process.resourcesPath, but the extraResource entry shipped the whole src/assets/ directory, which electron-packager places at resources/assets/ (basename only), so the lookup ENOENT-ed on resources/ps-community.png every time the storefront opened. The three images (ps-community.png, ps-pro.png, ps-enterprise.png) are now listed individually so they land at the resources root the handler expects. Admin portal Logs tab keeps your place while tailing — the Logs tab auto-refreshes on a timer (default 5s), and every tick used to end with an unconditional scrollTop = scrollHeight on all four panes, so scrolling up to read a line was futile: the next refresh yanked the view back to the bottom mid-read. Each pane now follows the tail only while it is parked at the bottom. A pane you have scrolled up in has its exact scroll offset captured before the new text is written and restored afterwards, so an auto-refresh tick cannot move it; scroll back to the end (or retype the filter / change the line limit) to resume tailing. Covered by tests/catalogadminlogsscroll.test.js. npm run make aborted with ENOENT: lstat 'docs/PacketSnitch-Website/assets/llm' — the LLM documentation bundles listed under packagerConfig.extraResource still pointed at the pre-relocation website path, and electron-forge lstats every entry while finalizing the package, so packaging failed outright before the installers were built. The curated bundles (packetsnitch-help.txt, packetsnitch-dev.txt) are now committed at src/data/llm/ so the packager always has a source, with the website checkout preferred when present; scripts/website-path.js resolves either layout (repo-root PacketSnitch-Website/ or the CI clone under docs/) for the packager, the release publisher, and the new npm run sync:llm-docs refresher. The offline docs fallback in src/main.js now resolves the packaged resources/llm/ directory instead of an unreachable .webpack/docs/... path, which also had it silently dead in every build. The Linux backend build no longer drags in the system icon theme — the backend is a headless analyzer that never plots, but matplotlib still reaches the dependency graph (scipy pulls it in for its .plot() helpers and scapy imports it lazily), and its GTK backend makes PyInstaller's hook-gi.repository.Gtk collect the entire system icon and theme trees — on a typical Linux desktop roughly 230k files / 1.5 GB of /usr/share/icons. That payload, not any single entry, is what broke the onefile build: the package ballooned past 800 MB and every launch extracted ~2.2 GB / ~440k inodes into a fresh /tmp/MEIxxxxxx, exhausting a tmpfs /tmp so that a late symlink() failed with ENOSPC and the bootloader aborted. The build now passes --hook-config gi:icons=[] gi:themes=[] to opt out of the bulk collection; the GTK typelibs themselves are still collected, so this is a pure payload reduction with no runtime behaviour change. Legacy catalogs could not be upgraded to a PostgreSQL-ready schema — migrateinstallstiercheck rebuilds the installs table (SQLite cannot ALTER a CHECK constraint) and hard-coded the current seven-column shape, which broke every catalog built before the demo/portal columns existed in two ways: the positional INSERT INTO installs SELECT FROM installsoldtiercheck raised table installs has 7 columns but 12 values were supplied, and the rebuilt table's licensetier still defaulted to 'free' while ensureinstall inserts demo explicitly, so the next lazy registration raised CHECK constraint failed: licensetier IN (...). The rebuild now derives its column set from the live table and copies by name, so a pre-trial (7-column) and a demo-era (12-column) catalog both open cleanly, keep every value, and accept demo / developer afterwards. Covered by tests/testcatalogschemamigration.py (three new cases, each failing against the old implementation). A paid tier was reported as owned but 403'd on download — /catalog and /licenses advertise every catalog item as owned: true for professional / enterprise / developer, and the renderer's backfillMissingOwnedThemes walks exactly those entries, but /themes/ /download gated on the raw per-theme licenses row. An install that had just been upgraded — or a free seat inheriting Enterprise through the customer fan-out — therefore saw a Download button that failed. The endpoint now gates on themeiseffectivelyowned, the helper the catalog was documented around; the legacy 403 for a free install with no license is unchanged. Covered by four HTTP-level cases in tests/testcatalogadminapi.py. Stale test expectations and a dead suite in the backend run — five catalog tests still asserted the pre-trial contract (unknown install → free) and the pre-narrowing fan-out (a professional sibling promoting a free seat); demo is the first-sight tier now, free is the post-trial tier the poller writes, and professional is seat-scoped by design, so the assertions were corrected to the documented behaviour. tests/testcataloglistablegate.py loaded ps-catalog.py from the retired src/PacketSnitch-Pro/Servers/Catalog/ path and raised FileNotFoundError at import, aborting the whole pytest run before any test executed; tests/testbackendlegacyoutputdir.py exercised _resolveLegacyOutputDirectory and friends, none of which has ever existed in src/backend/snitch.py, and was removed. v3.3.0 - 2026-10-12 Type: minor Google Analytics 4 product analytics with a locked destination, a storefront license-tier preview and SALE badge, catalog operator tooling (item upsert, seat listing, seat reassignment, payment-gated license reconciliation), and a smarter Ask PacketSnitch that learns flock techniques, resolves a host by its role on the heatmap, and unpacks whole archives in one click. ✨ Features Google Analytics 4 product analytics (opt-in, locked destination) — the desktop app now reports anonymous, allow-listed UI events to the maintainers' GTM/GA4 property when the single Privacy → Analytics consent switch (privacy.metricsEnabled) is on. src/gtm.js wraps GTM and gtag with a sanitized event API (track, trackPageView, trackTabSwitch, trackFeatureUse, trackTiming, trackError, trackException): every event name is validated against ^[a-z][a-z0-9](\.[a-z][a-z0-9])?$, every property is filtered through an allow-list (GTMSAFEPROPKEYS), numeric fields are clamped, and error text is scrubbed before it leaves the process. Because the renderer is served from a file:///app:// origin where the ga cookie does not persist, gtag's identity is pinned to the install UUID instead of minting a throwaway user on every launch. The synthetic page path is rooted at a distinct /PS-App/ prefix so the client's invented paths (/PS-App/stats, /PS-App/data-tools/conversions) are separable in one GA4 regex from the marketing site's real paths, and the same dimension is namespaced psapp.PS-App. for the local metrics pipeline. There is deliberately no per-feature toggle: GTM/GA load only under the master consent switch, and the GTM container id, GA4 measurement id, and write-only GA4 API secret are compiled-in locked constants that normalizeSettings re-asserts on every read — a hand-edited settings.json cannot redirect telemetry and the Analytics panel shows all three read-only. The main-process relay (sendMetricsToGa4, GA4 Measurement Protocol) forwards the same scrubbed events; the API secret is never logged and never echoed to the renderer. The metrics endpoint (src/metrics/ps-metrics.py) mirrors the same allow-list (including the new pagepath, pagetitle, pagelocation, and featurename keys). Covered by tests/gtmanalytics.test.js and tests/cspgtmallowlist.test.js. Storefront license-tier preview + SALE badge — the Storefront panel's license section now renders a tier preview by default, mapping the active tier to a bundled image (demo/free → community, professional → pro, enterprise → enterprise) served as a data URI through a new themes-fetch-local-preview IPC handler; the images ship as an extraResource. get-install-uuid now routes through ensureLicenseInstallUuid() so the UUID is generated on first look instead of returning empty. Catalog items (themes and licenses) can be flagged on sale — a new sale column in the catalog API drives a red SALE badge on the storefront and is toggleable from the catalog admin portal and via the new ps-catalog.py add-item --sale / upsert-item --sale flags. Catalog operator tooling: item upsert, seat listing, seat reassignment — ps-catalog.py gains an upsert-item subcommand that updates an existing catalog item in place (preserving fields and themejson unless explicitly overridden, so an operator can flip metadata such as --sale without re-supplying the artifact) or inserts a new one; list-seats prints every install UUID paired with a Paddle customer (by --customer ctm… or --email, with --json) as an operator debugging view of enterprise multi-seat accounts; and the self-service portal gains POST /portal/reassign (move a seat-scoped license from one install UUID to another, gated on a valid portal session whose UUID is one of the two endpoints and refused for Enterprise, which is customer-wide) plus POST /portal/logout (courtesy stateless sign-out). The customer-group fan-out now promotes every UUID under a customer to the group's highest tier — a single Enterprise license makes free seats read enterprise — with the deliberate exception of an install that has no paddlecustomerid. The catalog serve command also prints the help page instead of a traceback when its bind address is already in use. Payment-gated license reconciliation — license tier upgrades are now granted only when a completed payment exists: a paddlecustomerid alone (minted when a buyer enters a ZIP code for tax estimation) no longer grants a paid tier — a paddlesubscriptionid must be present, on both the webhook path and the reconciliation path. A periodic reconcileallinstalls sweep pulls Paddle transactions, maps active subscriptions to their customer tiers, and corrects drifted rows (unpaid licences revert to free, demo installs convert to free when their trial expires in the poller), with detailed reconciliation logging. Host-role resolution for map focus (\"open the attacking host on the heatmap\") — the Stats heatmap locator (stats.focus-location) used to require a literal target (IP, hostname, city/country, point id, or coordinates), so a request that named a host by its role failed with "No heatmap point matches the requested host/location". The chat path now builds an investigation digest from the cached rat-flock result — the findings, resolved goals, and final report — crossed with the mapped heatmap points: it inventories the IPs/hostnames the investigation mentioned, resolves the roles attacker, victim, exfiltration-destination, server, client, and dns by taking the host named nearest the role keyword in the same clause (so "the attacker A exploited the victim B" does not resolve both roles to A), and scores the mapped points that carry those hosts. The digest is embedded in both the router prompt and the adaptive next-step prompt, so multi-step requests keep the role→host mapping across steps; when no investigation is cached the prompt tells the model to clarify rather than invent a host. stats.focus-location accepts a new { role } argument (and infers one from a free-text field), and the immediate heatmap shortcut fires the role path directly once the role actually resolves. When a role cannot be resolved, the error now names the closest mapped locations and the role vocabulary instead of a bare dead end. Covered by tests/askinvestigationdigest.test.js. Inexact location names no longer fail the heatmap locator — matchStatsLocationHeatmapPoint is now two-pass: an exact pass (IPs, hostnames, and point ids stay strictly exact) and, only when nothing matches, a fuzzy pass over city/country/label that ignores case, punctuation, and token order ("new york" → "New York City"; "united states of america" → "United States"). Partial or wrong identifiers never match fuzzily, and empty fields never act as wildcards. Flock goal learning (shared techniques + resolved answers) — a rat that reaches its goal now writes down how it got there. Each rat can declare a solved goal in its output with a SOLVED: / TECHNIQUE: / ANSWER: block; the technique (the replayable steps) is recorded on the scratchpad and, once resolved, mirrored into a durable cross-run store at /flock-goals.json keyed by a normalized goal tag. Every prompt handed to a rat (maze-walk, discover, drill, verify, synthesize) carries a "Goals already reached by the flock" block listing the proven techniques — ranked by confidence, tagged with their provenance (this flock vs. a prior flock) — so a later rat copies the path rather than re-deriving it. Reached goals also raise the scratchpad usefulness score, so convergence is detected sooner and the flock spends fewer turns. Techniques that keep failing are retired after repeated failures (markGoalTechniqueStale) so a stale path stops steering rats. A cancelled or crashed flock still harvests the goals it did resolve. The chat surfaces each reached goal (🎯) and the number of techniques inherited from earlier investigations, and the fallback synthesis/report builders list the reached goals with their answers and techniques. Covered by tests/flockgoallearning.test.js. Extract All for archives (UI + AI) — the Conv Extraction subtab now has an Extract All button next to Decompress and Browse Archive*: it unpacks every safe file member of the detected ZIP/tar/CAB/7z/RAR archive in one run and registers each in the Artifact Store (bounded to 50 members; directory entries and members without a sanitized safe path are skipped; a running "N of M" progress line replaces the per-entry clicks). The same primitive is exposed to the AI as the extraction.extract-all registry action — reachable through the capability catalog and the planner prompt — so an investigation can unpack a whole archive without looping extraction.extract-entry by hand. Extraction stays zip-slip safe: every member is written only to its sanitized safe path and the backend rejects any entry whose path escapes the archive root. Covered by new cases in tests/featureactions.test.js and tests/extractionpanel.test.js. 🐛 Fixes Artifact-store cache refreshes on extraction and on a timer — the keystore panel's file-artifact cache now reloads whenever an extraction registers a new artifact, and a cancellable interval timer (createIntervalTimer, rebuilt once a minute) keeps the cached manifest in step, so an artifact the AI just carved is immediately resolvable by name without a manual rescan. The timer is torn down on session reload/clear alongside the other keystore caches. GA4 timing/feature/error dimensions were being dropped — featurename, the timingcomplete fields (timingcategory, timingvariable, timingvalue, timing_label), and the exception fields were missing from the allow-list, so those events shipped with no measurements; they are now permitted, clamped, and namespaced (see the analytics feature above).