diff --git a/README.md b/README.md index d72e351..f6fff27 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,27 @@ Discord bot + WebUI with role-based access, plugin management, and self-update s ## Quick start -Requires Node.js 18+. +Requires Node.js 18+ on Windows or Linux. 1. Install dependencies: ``` npm install ``` + Do not use `--ignore-scripts`: Lumi uses the native `better-sqlite3` module, + which must download or build a binary for your Node.js version. If Node was + upgraded after installation, run `npm rebuild better-sqlite3` or remove + `node_modules` and run `npm install` again. Linux source builds may also need + Python and a C/C++ compiler toolchain. 2. Run with auto-restart: ``` npm run run ``` 3. Open `http://localhost:3000/setup` and enter your Discord app + bot settings. +Before starting Lumi, you can check the runtime and native dependency with +`npm run verify:preflight`. Run the complete syntax and focused test suite with +`npm run verify:all`; it stops at the first failure and prints the failing check. + You can also seed local configuration with a `.env` file. Use `.env.example` as the template; `.env` is ignored by git. @@ -54,11 +63,23 @@ Recovery mode can be started with `LUMI_SAFE_MODE=1 npm run run`, ## Twitch bot Configure Twitch chat settings in **Admin → Settings**: + - `twitch_bot_username` - `twitch_bot_oauth` (OAuth token) - `twitch_channels` (comma-separated) -Custom commands can target Discord, Twitch, or both from **Admin → Commands**. +Custom commands can target any enabled chat platform from **Admin → Commands**. +Static commands send one template. Random Reply commands choose among weighted +messages and can optionally roll a number for conditions such as `<20` or +`>=10`; insert that roll with `{{core.command.rng}}`. Admin-only Dynamic commands +accept JavaScript or Python snippets that return a reply. Lumi wraps simple +snippets automatically while preserving explicit `run(ctx)` and module exports. +Previews use deterministic mock data and never send external network requests; +network calls, if intentionally used by an admin-authored command, occur only in +the real command runtime. + +Admins can create reusable permission-aware `{{custom.*}}` text values under +**Admin → Settings → Reusable text values**. ## Users and linking @@ -75,6 +96,13 @@ incomplete values fall back safely to the selected built-in base theme. Developer and modding conventions are documented in [`docs/lumi-ui.md`](docs/lumi-ui.md). +## OBS overlays + +Admins can create live, token-protected OBS Browser Sources under **Admin → OBS +overlays**, with server-controlled or fixed scenes and optional local OBS +WebSocket or zero-credential OBS Browser Bridge synchronization. See +[`docs/obs-overlays.md`](docs/obs-overlays.md). + ## Notes - Auto-update uses `git pull` from the configured remote + branch. diff --git a/TODO.md b/TODO.md index a644a7c..0b10094 100644 --- a/TODO.md +++ b/TODO.md @@ -4,6 +4,30 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K ## Current Local State / Source of Truth +### P0 OBS Overlay System + +Implemented locally on 2026-07-17: admin-only overlay management, separate public +Browser Source rendering, encrypted and revocable overlay/scene tokens, live +scoped event updates with reconnect recovery, scene and module-instance CRUD and +ordering, optional OBS WebSocket control/synchronization, connector provider +interfaces, focused verification, and operator documentation. + +The editor now also includes an OBS-style live canvas with source selection, +eight resize handles, drag/nudge positioning, edge/center/source snapping, +anchors, contextual source fields, text layout/overflow controls, and external +website-overlay health checks plus manual and automatic refresh. Video and audio +sources now include playback controls and OBS-routable sound; web sources include +isolated custom frame CSS, cropping, and zoom. An optional OBS Browser Bridge now +syncs supported OBS scene, transition, canvas, output, permission, and source +state without WebSocket credentials, with a one-action setup importer. + +Remaining work: + +- Build and authenticate the future trusted Lumi streaming-computer client. +- Add moderator-to-overlay assignment UI and persistence when the assignment + model is defined; centralized overlay capability hooks are ready and continue + to deny moderators and all secret access by default. + ### OKF Knowledge System Current state on `experimental-okf` as of 2026-06-21: standalone OKF plugin work is implemented locally and not pushed. The plugin provides SQLite-backed entries, sanitized Markdown, logged-in browsing, role-gated details, admin/editor management, per-user OKF permission grants through the shared core user lookup, workflow actions, version history, version restore, role-preview tooling, role-aware Lumi AI context integration through the generic AI context provider hook, a file-backed Markdown knowledge retrieval layer, generated core/plugin OKF baseline files, admin-editable community OKF files, and admin-created correction OKF files from reviewed core feedback. @@ -192,6 +216,33 @@ Current state on `experimental-okf` as of 2026-06-21: the core feedback system i ## Active Work +Audited on 2026-07-17 after completing the exported P0-P3 queue. Only the items +in this section remain active; the detailed planning checklists below are kept +as historical design context and are no longer an open-work list. + +- Build and authenticate the future trusted Lumi streaming-computer client. +- Add moderator-to-overlay assignments after the ownership model is defined; + the centralized overlay capability hooks already fail closed. +- Add Twitch live state, follower/subscriber statistics, and scheduled stream + placeholders only after a background provider cache exists. Rendering must + never make provider API calls. +- Revisit a persistent OKF index when there are at least 2,000 active files and + generated-fixture or production warm-search p95 exceeds 100 ms. The + 2026-07-17 benchmark measured about 45 ms p95 at 1,000 files and 87 ms at + 2,000 files, so the current mtime/size parse cache remains appropriate. +- Continue live Lumi AI controller tuning only when real usage provides evidence + that the current heuristics need adjustment. +- Consider Discord.js 14 as a separately planned compatibility upgrade; the + current v13 integration is verified and a major upgrade should not be mixed + into unrelated security fixes. +- Broaden browser-matrix checks for clipboard image copy/read behavior. Upload, + paste, native-menu, URL-copy, and file-picker fallbacks are implemented. +- Add deeper provider-specific homepage failure detection only where a safe + cached API or browser signal exists. Cross-origin iframe CSP/X-Frame-Options + failures cannot be reliably preflighted without creating an SSRF path. + +## Historical Planning Checklists (Implemented or Superseded) + ### OKF Knowledge System: File-Backed Knowledge Storage - Add richer category/tag management and Markdown preview/editor polish. @@ -621,6 +672,25 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no ## Done +### 2026-07-17 + +- 2026-07-17: Completed exported P0 preflight/security work: deterministic preflight/all verifiers, shared atomic retrying file writes with diagnostics, shared WebUI authentication guards, OKF privilege/field filtering, direct dependency audit remediation, and signature-validated bounded uploads with cleanup and protected downloads. +- 2026-07-17: Completed exported P1 update/recovery work: protected-data preservation tests, rollback-safe repository/tool updates, shared async update actions, clearer update language, and activation validation inside tool-install transactions. +- 2026-07-17: Completed Lumi AI feedback and work-history tasks: answer ratings/comments/context snapshots, admin review/corrections, correction links/inventory, sanitized grouped Codex exports including legacy rows, and controller/retrieval diagnostics coverage. +- 2026-07-17: Completed OKF usability and retrieval work: live Markdown previews, role-safe contributor navigation, category/tag filtering, deterministic generated files, improved specific-query ranking, generic-help suppression, and correction discovery. +- 2026-07-17: Completed core feedback hardening: merge/unmerge with canonical support aggregation, richer route/plugin/target filters, stronger duplicate similarity, attachment lifecycle/security, clipboard fallbacks, and expanded correction redaction. +- 2026-07-17: Completed the trusted placeholder migration audit, fixed deterministic async replacement, added resolver timeouts/fallbacks, documented cache-only platform data status, and added the admin-managed permission-aware `{{custom.*}}` registry. +- 2026-07-17: Completed homepage embed reliability: Twitch multi-parent player/chat layouts and minimum sizing, YouTube cached connected-channel live lookup and valid-video live chat, live-only fall-through, responsive dual-frame rendering, and clearer setup warnings. +- 2026-07-17: Completed the major UI wording/action audit: task-focused labels, contextual helper text, consistent destructive styling/confirmations, and removal of generic Save/Update button labels from primary forms. +- 2026-07-17: Completed custom-command expansion: automatically wrapped JavaScript/Python snippets, preserved explicit/module handlers, Random Reply mode, optional RNG conditions, integer weights, the `{{core.command.rng}}` value, migration-safe storage, validation, and previews. +- 2026-07-17: Hardened Dynamic command previews after release audit: safe built-ins remain available, harmless blocked-word text no longer causes false failures, host-constructor escapes remain disabled, external preview network requests are blocked, and long previews now expand inline without altering punctuation or whitespace. +- 2026-07-17: Benchmarked file-backed OKF search with generated 1,000- and 2,000-file fixtures, including visibility and changed/deleted-file assertions. Persistent indexing is deferred until the documented 2,000-file/100-ms-p95 threshold is crossed. +- 2026-07-17: Added the optional OBS Browser Bridge provider using OBS-injected page bindings: scoped heartbeat/state reporting, active-instance election, page-permission diagnostics, scene synchronization and commands, transitions/output/canvas/source status, profile/scene-collection capability reporting, and a non-destructive OBS setup importer. No repo push yet. +- 2026-07-17: Connected destructive controls across core and plugins to the shared timed-confirmation contract, moved generic Lumi AI and Auto VC deletes onto explicit protected routes, added timed update-restore requests, and added a repository-wide destructive-action verifier. No repo push yet. +- 2026-07-17: Extended OBS overlays with video/media and audio sources, play-once/loop/manual behavior, volume/start/speed/video-fit controls, live playback restart, website-alert autoplay audio, and isolated web-frame CSS plus crop/zoom controls. No repo push yet. +- 2026-07-17: Added the visual overlay editor follow-up locally: simplified user-facing language, contextual help, a responsive sticky preview canvas, drag/resize/nudge transforms, Ctrl/Cmd-toggle snapping to canvas and source lines, nine anchors, conditional source controls, horizontal/vertical text alignment and overflow modes, and sandboxed third-party website sources with live manual refresh plus bounded automatic health recovery. No repo push yet. +- 2026-07-17: Implemented the P0 OBS overlay system locally: multi-overlay and scene management, fixed and active-scene Browser Sources, encrypted revocable tokens, live scoped updates with reconnect recovery, reusable overlay module instances, optional OBS WebSocket testing/control/mapping/sync with loop prevention, future trusted-client provider interfaces, focused tests, and documentation. No repo push yet. + ### 2026-06-25 - 2026-06-25: Continued Lumi AI runtime CUDA-preference work locally: Windows NVIDIA-compatible installs now prefer the managed CUDA llama.cpp runtime over Vulkan, the runtime manifest supports CUDA dependency archives, runtime downloads validate/reuse complete partial archives, install metadata records the backend variant/dependencies, and `/plugins/lumi_ai` shows the total runtime download size. No repo push yet. diff --git a/docs/obs-overlays.md b/docs/obs-overlays.md new file mode 100644 index 0000000..3d7e155 --- /dev/null +++ b/docs/obs-overlays.md @@ -0,0 +1,164 @@ +# OBS overlays + +Admins manage overlays at **Admin → OBS overlays**. Management pages use the +normal Lumi session, role checks, layout, forms, lists, modals, and confirmation +flow. Browser Source pages use a separate transparent document with no Lumi +navigation or authenticated website data. + +## Editing an overlay + +The editor uses the same basic model as OBS: scenes contain ordered sources, and +the selected source has a red box with resize handles. On a wide screen the +preview stays beside the settings; on smaller screens it moves above them. + +- Drag a source to move it. +- Drag any corner or edge handle to resize it. +- Use arrow keys for small moves, or Shift plus an arrow for larger moves. +- Snapping uses canvas edges, center lines, and other source edges/midpoints. +- Hold Ctrl on Windows/Linux or Cmd on macOS to temporarily reverse the Snap + setting. +- Center, Fit to canvas, and Reset box provide familiar transform shortcuts. + +Every source can use any corner, edge midpoint, or center as its anchor. Text +sources have separate horizontal and vertical alignment plus wrapping, shrinking, +clipping, single-line, and scrolling ticker behavior. The editor only shows fields +that apply to the selected source type. + +## Private OBS links + +Each overlay has one server-controlled URL. It follows the active overlay scene. +Each scene also has a fixed URL that always renders that scene. Both URL forms +contain 256-bit random bearer tokens instead of database IDs. Tokens are stored +encrypted for admin copy actions and as SHA-256 hashes for public lookup. +Regenerating a URL immediately revokes the old token. + +Treat every Browser Source URL as a password. Only admins can view, copy, preview, +or regenerate it. Public state responses contain render data only; they do not +contain other URLs, tokens, OBS credentials, or website session data. + +Browser Sources receive scoped live-change events through Lumi's existing event +service. The render page fetches a fresh authorized snapshot after each event and +after every reconnect, so temporary network loss does not require an OBS refresh. + +## Scenes and sources + +An overlay always keeps at least one scene. Admins can create, edit, duplicate, +order, enable, activate, and delete scenes. Current built-in source types are: + +- Text +- Image URL +- Video / media URL +- Audio URL +- Website or alert overlay + +Module instances can be created, edited, duplicated, ordered, enabled, and +deleted. New module types can call `registerOverlayModuleType(...)` in +`src/services/overlay-modules.js`; providers normalize their configuration and +select the safe built-in `text`, `image`, `video`, `audio`, or `web` render type while the overlay +service retains the same instance model. + +### Video and audio + +Video sources support browser-playable media such as WebM and MP4; audio sources +support browser-playable formats such as Ogg, MP3, and WAV. Actual codec support +follows the Chromium/CEF build used by the browser or OBS. + +Playback can start once when the overlay scene loads, loop continuously, or wait +for manual playback. Sources also provide volume, start offset, playback speed, +optional player controls, and a live **Restart playback** action. Video adds mute +and contain/cover/stretch sizing. Activating another Lumi scene removes the old +scene's media elements, which stops their playback; returning to the scene applies +its configured start behavior again. + +Website overlays can play their own alert audio because their iframe receives +autoplay permission. To route video, audio, and website-alert sound through an +independent OBS mixer channel, enable **Control audio via OBS** in the Lumi Browser +Source properties, then configure monitoring/output in OBS as desired. + +### External website and alert overlays + +Website sources support third-party browser overlays such as alert and chat +overlays. They run in a sandbox and do not receive the Lumi admin page or its +login data. The **Refresh now** action sends a live refresh command to every open +OBS copy of that source. + +Automatic recovery is optional. When enabled it watches for a page that never +finishes loading, can check public website replies for HTTP errors, empty content, +unexpected content types, and obviously malformed pages, retries at 5, 15, and 45 +second-style bounded intervals, and periodically reloads the source to avoid stale +content. Retry count and refresh interval are configurable. Local/private website +addresses are periodically refreshed but are not fetched by the Lumi server for +security; browser load-timeout recovery still applies. + +Website sources also support per-edge percentage cropping, zoom, and isolated +custom frame CSS. Custom CSS is scoped to the source frame so it cannot restyle +the Lumi render page or other sources. Standard browser cross-origin security +does not allow parent-page CSS to select elements inside a third-party iframe; +use crop and zoom to hide unwanted portions of an external site. Frame-level CSS +can target `iframe`, for example `iframe { filter: saturate(1.2); }`. + +## OBS WebSocket + +The connector is disabled by default. Enable OBS WebSocket in OBS, keep its +password enabled, and use the default local endpoint +`ws://127.0.0.1:4455` when Lumi runs on the streaming computer. If Lumi runs on +another trusted machine, use a private LAN/VPN address and firewall rules. Do not +port-forward or publicly expose OBS WebSocket. + +The admin page can test the connection, read OBS scenes, change the current OBS +scene, and map OBS scene names to overlay scenes. Sync can run in either direction +or bidirectionally. Source tagging and a short outbound suppression window prevent +sync loops. Connector errors never stop overlay rendering, and optional reconnect +uses bounded exponential backoff. + +OBS passwords use authenticated AES-256-GCM encryption derived from Lumi's stored +session secret. They are never returned by public APIs or rendered for +non-admins. + +## OBS Browser Bridge + +The optional **OBS Browser Bridge** uses the `window.obsstudio` bindings that OBS +injects into Browser Sources. It does not need an OBS WebSocket address or +password. Select the provider, save it, then load the private Lumi overlay URL in +OBS. The render page reports a heartbeat every five seconds and immediately after +relevant OBS events. + +Use the minimum Page permission needed: + +- **Read access to user information** reads the current scene, scene list, + transitions, canvas dimensions, output status, and Browser Source activity. +- **Advanced access to OBS** is needed only when Lumi should change OBS scenes. +- Full access is not requested or used by the current bridge. + +Lumi elects one recently active Browser Source instance per overlay as the bridge, +targets commands to that instance, and deduplicates command IDs. Existing +scene-direction checks and outbound suppression prevent synchronization loops. If +the source stops reporting, Lumi marks the bridge as waiting instead of breaking +overlay rendering. Leave OBS's **Shutdown source when not visible** option off if +the bridge must remain available outside the source's active scene. + +The **Apply detected OBS setup** action adds missing OBS scenes, maps same-name +Lumi scenes, adopts the detected OBS canvas size, and selects the current OBS +scene. It does not delete existing Lumi scenes. OBS Browser bindings do not expose +profile or scene-collection names, so those fields are explicitly reported as +unavailable; the OBS WebSocket provider can read them when that metadata is +required. + +## Connector extension point + +`src/services/overlay-connectors.js` owns the provider registry. A provider +creates a connector with these operations: + +- `connect()` and `disconnect()` +- `status()` +- `listScenes()` and `getActiveScene()` +- `setActiveScene(sceneName)` +- `onSceneChanged(listener)` and `onClosed(listener)` + +The reserved `trusted_lumi_client` provider is intentionally unavailable. A +future trusted desktop client can implement this interface to transport OBS state +and commands without changing overlay routes, mappings, or synchronization rules. +Plugins can register providers, module types, or future scoped access providers +through `global.lumiFrameworks.overlays`. + +Run focused verification with `npm run verify:overlays`. diff --git a/docs/placeholder-system.md b/docs/placeholder-system.md index f62482c..f280e1a 100644 --- a/docs/placeholder-system.md +++ b/docs/placeholder-system.md @@ -27,6 +27,8 @@ Use namespaced double-brace tokens: - `{{core.main.bot_name}}` - `{{core.main.command_prefix}}` +- `{{core.command.rng}}` +- `{{custom.stream.title}}` - `{{user.public.display_name}}` - `{{platform.discord.guild.member_count}}` - `{{okf.file.community.currency.primary_name}}` @@ -35,23 +37,27 @@ Use namespaced double-brace tokens: Avoid unqualified names for new work. Compatibility aliases may exist for old templates, but the catalog should present canonical namespaced tokens. -Custom admin-defined placeholders, if added later, should live under the -`{{custom.*}}` namespace and be managed from a trusted admin settings page. +Admin-managed values live under `{{custom.*}}`. Admins create them under +Settings > Reusable text values; names are validated, cannot collide with +system/plugin namespaces, and are stored in the update-preserved settings +database. ## Core Namespaces Current core-owned namespaces include: - `core.main.*`: safe Lumi core settings such as bot/site name and command prefix +- `core.command.*`: values created for one command execution, such as a Random Reply RNG roll +- `custom.*`: admin-managed reusable values with explicit user, moderator, or admin visibility - `user.public.*`: safe viewer/triggering-user display information - `platform.discord.guild.*`: safe Discord guild statistics from the configured guild - `platform.twitch.channel.*`: safe locally configured Twitch channel/runtime values - `platform.youtube.channel.*`: safe locally configured or hydrated YouTube channel/runtime values - `okf.file.*`: file-backed OKF frontmatter values, with legacy aliases such as `{{community.currency.primary_name}}` -API-backed platform statistics such as Twitch follower/subscriber counts or -future stream schedules should be registered here only after the integration has -a reliable server-side fetch/cache layer and clear sensitivity rules. +Each platform namespace exposes a public-safe `data_status` value. Current values are `live_cache`, `connected`, `configured_not_connected`, or `unavailable`, depending on the integration. Discord statistics read only the connected client's guild cache; placeholder rendering never triggers a Discord fetch. + +Twitch follower/subscriber counts and Twitch/YouTube scheduled-start values remain intentionally unavailable. The current Twitch integration is chat-only, and the YouTube runtime does not maintain those statistics. Add them only after a background refresh service stores value, fetched-at time, expiry/stale state, and last error. Rendering must read that cache without a provider API call and return a safe unavailable/stale result when credentials or rate limits prevent refresh. ## Sensitivity @@ -91,9 +97,25 @@ Security is based on the intersection of: - plugin availability - runtime context +Current trusted field policies: + +| Field ID | Destination | Editor | Output | Allowed values | +| --- | --- | --- | --- | --- | +| `core.custom_commands.static_response` | Static and Random Reply custom-command replies | Moderator | User | Public-safe `core.main.*`, `core.command.*`, `custom.*`, and `user.public.*` | +| `plugin.throne_wishlist.message_template` | Throne event announcements | Admin | User | Public-safe Throne event placeholders | +| `okf.markdown` | SQLite/community OKF Markdown | OKF contributor | Selected role | Permission-filtered core, platform, and visible OKF values | + +The current WebUI contains no inline JSON placeholder catalogs; double-brace editors use these server-owned IDs. Welcome Messages and Auto VC retain their older `{username}` and `[username]` runtime token formats for compatibility. They are not treated as generic placeholders until their event-context renderers and saved templates are migrated together; adding only autocomplete would advertise tokens those runtimes cannot safely resolve. + An admin editing a user-visible template is not enough to allow admin-only placeholders. The output audience still limits what can render. +Custom values use the same catalog, validation, preview, and runtime checks as +code-registered placeholders. Values marked for moderators or admins do not +appear in lower-privilege catalogs and cannot render into user-visible command +replies. The `custom` namespace is reserved for this registry; plugins must use +their own `plugin.*` namespace. + ## Plugin Registration Plugins should register placeholders during backend plugin initialization using @@ -130,3 +152,5 @@ Templates must be checked: Unauthorized placeholders should fail closed. User-visible output should show a generic unavailable marker or leave legacy unknown tokens unchanged, depending on the existing renderer contract, but it must not reveal the restricted value. + +Async resolvers are bounded by a short timeout (1.5 seconds by default). Duplicate and adjacent tokens are resolved by source position, and resolver output is inserted literally rather than scanned again. A failed, unavailable, forbidden, or timed-out value therefore affects only that token and renders the caller's safe fallback. diff --git a/docs/recovery-mode.md b/docs/recovery-mode.md index 5e8f0d3..9bde4fd 100644 --- a/docs/recovery-mode.md +++ b/docs/recovery-mode.md @@ -2,7 +2,9 @@ Failed updates should leave an administrator with a recovery path. Lumi writes a recovery marker before update files are applied and keeps snapshots available for -manual revert. +manual revert. If an update throws after replacement begins, Lumi first attempts +to restore the new snapshot automatically. Failed automatic restoration keeps +the backup and records both errors for safe-mode recovery. ## Recovery Marker @@ -50,5 +52,6 @@ Admins can: - clear a stale marker after verifying startup, - retry normal startup. -Rollback is never automatic. Major-version rollback remains blocked unless the -snapshot is explicitly marked rollback safe. +Manual major-version rollback remains blocked unless the snapshot is explicitly +marked rollback safe. The automatic rollback attempted for the same failed +update is allowed because it restores the immediately preceding state. diff --git a/docs/updates.md b/docs/updates.md index fbaa576..4b35804 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -61,10 +61,34 @@ Manual revert is available for core and individual plugins. Revert actions are limited to the previous-version snapshot for that target. Major-version rollback is blocked unless the snapshot or manifest explicitly marks rollback as safe. +If a repository or ZIP update fails after file replacement begins, Lumi +automatically attempts to restore the snapshot. The failed recovery marker and +backup are retained when automatic restore cannot finish, so safe mode can show +the actionable error instead of silently deleting the recovery copy. + +## Preserved Local Data + +Core updates, ZIP updates, plugin updates, and restores leave these local areas +in place: + +- `data/`, including the database, feedback files, AI models/runtimes, tool + settings, caches, and local exports; +- plugin-owned `data/` directories; +- `knowledge/community/` and `knowledge/corrections/`; +- files under generated knowledge folders unless they explicitly declare both + `generated: true` and `editable: false`; +- local configuration, storage, uploads, logs, secrets, environment files, and + the plugin directory during core-only updates. + +Full core updates remove stale replaceable code before copying the new version. +Plugin code is prepared in a staging directory and swapped only after the new +files are ready; plugin data is moved into the replacement as part of that +transaction. + ## ZIP Fallback Core and plugin ZIP updates remain available, but they are hidden under -**Show advanced ZIP update options**. ZIP updates create snapshots and recovery +**Manual ZIP updates**. ZIP updates create snapshots and recovery markers. They may bypass repo metadata and compatibility checks unless the ZIP contains valid manifest data, so use them as a manual fallback. diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index ff26472..6ca6c13 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -9,7 +9,6 @@ category: Core tags: core, routes, commands, settings generated: true editable: false -updated_at: "2026-06-25T12:06:11.231Z" --- # lumi-bot Lumi is the core web UI and bot runtime. @@ -97,6 +96,8 @@ Version: 0.1.9 - POST /admin/feedback/export - POST /admin/feedback/:id/export - POST /admin/feedback/:id/create-okf-correction +- POST /admin/feedback/:id/merge +- POST /admin/feedback/:id/unmerge - POST /admin/feedback/:id - POST /admin/feedback/:id/finalize - POST /admin/feedback/:id/reopen @@ -213,7 +214,7 @@ Version: 0.1.9 - Inputs: file upload: multipart form file data; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service - Response format: API response; exact schema was not detected statically. - Access: logged-in session required or used -- Side effects: writes or mutates server-side state; writes files +- Side effects: writes or mutates server-side state - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. API consumers should expect JSON unless the response format says otherwise. ### GET /api/feedback/notifications @@ -508,9 +509,9 @@ Version: 0.1.9 - Purpose: Handles feedback id attachments attachmentId. - Inputs: path params: `attachmentId`, `id` -- Response format: static file response +- Response format: file download - Access: admin access expected; logged-in session required or used -- Side effects: writes or mutates server-side state +- Side effects: Usually read-only. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. ### GET /health @@ -831,7 +832,7 @@ Version: 0.1.9 ### GET /admin/feedback - Purpose: Renders the admin feedback WebUI page. -- Inputs: query: `area`, `category`, `date_from`, `date_to`, `needs_action`, `scope`, `severity`, `sort`, `status`, `submitter` +- Inputs: query: `area`, `category`, `date_from`, `date_to`, `needs_action`, `plugin`, `route`, `scope`, `severity`, `sort`, `status`, `submitter`, `target` - Response format: HTML page rendered from an EJS view - Access: admin access expected - Side effects: Usually read-only. @@ -864,6 +865,24 @@ Version: 0.1.9 - Side effects: writes or mutates server-side state - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. +### POST /admin/feedback/:id/merge + +- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path. +- Inputs: path params: `id`; body: `target_feedback_id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected; logged-in session required or used +- Side effects: writes or mutates server-side state +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /admin/feedback/:id/unmerge + +- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path. +- Inputs: path params: `id`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service +- Response format: HTTP redirect after handling the request +- Access: admin access expected; logged-in session required or used +- Side effects: writes or mutates server-side state +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. + ### POST /admin/feedback/:id - Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path. @@ -1255,18 +1274,18 @@ Version: 0.1.9 - Purpose: Processes the admin update action and stores or applies submitted form data. - Inputs: No request parameters detected by static analysis. -- Response format: HTTP redirect after handling the request +- Response format: Form/action response; exact format was not detected statically. - Access: admin access expected -- Side effects: writes or mutates server-side state; may restart or stop runtime processes +- Side effects: writes or mutates server-side state; publishes or streams live WebUI events - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. ### POST /admin/check-update - Purpose: Processes the admin check update action and stores or applies submitted form data. - Inputs: No request parameters detected by static analysis. -- Response format: HTTP redirect after handling the request +- Response format: Form/action response; exact format was not detected statically. - Access: admin access expected -- Side effects: writes or mutates server-side state +- Side effects: Action route; side effects were not detected statically. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. ### POST /admin/restart @@ -1281,12 +1300,12 @@ Version: 0.1.9 # Lumi Bot Discord bot + WebUI with role-based access, plugin management, and self-update support. ## Quick start -Requires Node.js 18+. +Requires Node.js 18+ on Windows or Linux. 1. Install dependencies: ``` npm install ``` -2. Run with auto-restart: - ``` - npm run run - ``` + Do not use `--ignore-scripts`: Lumi uses the native `better-sqlite3` module, + which must download or build a binary for your Node.js version. If Node was + upgraded after installation, run `npm rebuild better-sqlite3` or remove + `node_modules` and run `npm install` again. Linux source builds may also need diff --git a/knowledge/plugins/auto-vc.md b/knowledge/plugins/auto-vc.md index 0a5f047..ad09bd0 100644 --- a/knowledge/plugins/auto-vc.md +++ b/knowledge/plugins/auto-vc.md @@ -9,7 +9,6 @@ category: Plugin tags: plugin, auto-vc generated: true editable: false -updated_at: "2026-06-25T12:06:11.231Z" --- # Auto VC Auto-create managed voice channels from lobby rooms. @@ -21,6 +20,7 @@ Default state: enabled - /plugins/auto-vc - GET /plugins/auto-vc - POST /plugins/auto-vc/settings +- POST /plugins/auto-vc/settings/remove - POST /plugins/auto-vc/bans - POST /plugins/auto-vc/unban ## Route Reference @@ -45,9 +45,18 @@ Default state: enabled ### POST /plugins/auto-vc/settings - Purpose: Processes the auto-vc plugin action for settings. -- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service -- Response format: HTTP redirect after handling the request -- Access: admin access expected; logged-in session required or used +- Inputs: No request parameters detected by static analysis. +- Response format: Form/action response; exact format was not detected statically. +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. +- Side effects: writes or mutates server-side state +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/auto-vc/settings/remove + +- Purpose: Processes the auto-vc plugin action for settings remove. +- Inputs: No request parameters detected by static analysis. +- Response format: Form/action response; exact format was not detected statically. +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. - Side effects: writes or mutates server-side state - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion. diff --git a/knowledge/plugins/birthday.md b/knowledge/plugins/birthday.md index e5d6601..626fe58 100644 --- a/knowledge/plugins/birthday.md +++ b/knowledge/plugins/birthday.md @@ -9,7 +9,6 @@ category: Plugin tags: plugin, birthday generated: true editable: false -updated_at: "2026-06-25T12:06:11.231Z" --- # Birthday Birthday profiles, announcements, lookup commands, and optional birthday currency gifts. diff --git a/knowledge/plugins/economy-framework.md b/knowledge/plugins/economy-framework.md index e785470..a4acbd4 100644 --- a/knowledge/plugins/economy-framework.md +++ b/knowledge/plugins/economy-framework.md @@ -9,7 +9,6 @@ category: Plugin tags: plugin, economy-framework generated: true editable: false -updated_at: "2026-06-25T12:06:11.231Z" --- # Economy Framework Cross-platform currency framework with shared balances and extensible hooks. diff --git a/knowledge/plugins/economy-games.md b/knowledge/plugins/economy-games.md index cc2dccc..8806c76 100644 --- a/knowledge/plugins/economy-games.md +++ b/knowledge/plugins/economy-games.md @@ -9,7 +9,6 @@ category: Plugin tags: plugin, economy-games generated: true editable: false -updated_at: "2026-06-25T12:06:11.231Z" --- # Economy Games Cross-platform mini-games that use the Economy currency framework. diff --git a/knowledge/plugins/expression-interaction.md b/knowledge/plugins/expression-interaction.md index 9553354..3cffc29 100644 --- a/knowledge/plugins/expression-interaction.md +++ b/knowledge/plugins/expression-interaction.md @@ -9,7 +9,6 @@ category: Plugin tags: plugin, expression-interaction generated: true editable: false -updated_at: "2026-06-25T12:06:11.231Z" --- # Expression Interaction Express yourself through interactions with other users, such as hugging, bonking, comforting, etc diff --git a/knowledge/plugins/lumi-ai.md b/knowledge/plugins/lumi-ai.md index d416c87..2a8250b 100644 --- a/knowledge/plugins/lumi-ai.md +++ b/knowledge/plugins/lumi-ai.md @@ -9,7 +9,6 @@ category: Plugin tags: plugin, lumi_ai generated: true editable: false -updated_at: "2026-06-25T12:06:11.231Z" --- # Lumi AI Managed local AI provider and scoped WebUI assistant for Lumi. @@ -39,6 +38,7 @@ Default state: enabled - POST /plugins/lumi_ai/logs/:name/delete - POST /plugins/lumi_ai/runtime/:action - GET /plugins/lumi_ai/diagnostics/download +- GET /plugins/lumi_ai/work-history/:id/export - POST /plugins/lumi_ai/assistant/message - POST /plugins/lumi_ai/assistant/feedback - GET /plugins/lumi_ai/assistant/jobs/:id @@ -61,9 +61,12 @@ Default state: enabled - GET /plugins/lumi_ai/improvement_center - POST /plugins/lumi_ai/improvement_center/settings - POST /plugins/lumi_ai/improvement_center/reviews/:id +- POST /plugins/lumi_ai/improvement_center/reviews/:id/delete +- POST /plugins/lumi_ai/improvement_center/reviews/:id/archive - POST /plugins/lumi_ai/improvement_center/reviews/:id/implement - POST /plugins/lumi_ai/improvement_center/corrections/save - POST /plugins/lumi_ai/improvement_center/corrections/:id +- POST /plugins/lumi_ai/improvement_center/corrections/:id/delete - POST /plugins/lumi_ai/improvement_center/evals - POST /plugins/lumi_ai/improvement_center/evals/:id/delete - POST /plugins/lumi_ai/improvement_center/evals/run @@ -258,6 +261,15 @@ Default state: enabled - Side effects: writes or mutates server-side state - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. +### GET /plugins/lumi_ai/work-history/:id/export + +- Purpose: Renders or serves the lumi_ai plugin page for work history id export. +- Inputs: path params: `id` +- Response format: plain or HTML response +- Access: admin access expected; logged-in session required or used +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. + ### POST /plugins/lumi_ai/assistant/message - Purpose: Processes the lumi_ai plugin action for assistant message. @@ -270,7 +282,7 @@ Default state: enabled ### POST /plugins/lumi_ai/assistant/feedback - Purpose: Processes the lumi_ai plugin action for assistant feedback. -- Inputs: body: `assistant_answer`, `feedback_kind`, `feedback_tag`, `model`, `optional_correction`, `route_used`, `timestamp`, `user_message` +- Inputs: body: `assistant_answer`, `context_snapshot`, `feedback_kind`, `feedback_tag`, `model`, `optional_correction`, `rating`, `route_used`, `timestamp`, `user_message` - Response format: Form/action response; exact format was not detected statically. - Access: logged-in session required or used - Side effects: Consumes submitted data; state mutation happens in called helpers if present. @@ -456,6 +468,24 @@ Default state: enabled - Side effects: writes or mutates server-side state - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. +### POST /plugins/lumi_ai/improvement_center/reviews/:id/delete + +- Purpose: Processes the lumi_ai plugin action for improvement center reviews id delete. +- Inputs: path params: `id` +- Response format: Form/action response; exact format was not detected statically. +- Access: logged-in session required or used +- Side effects: Action route; side effects were not detected statically. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/lumi_ai/improvement_center/reviews/:id/archive + +- Purpose: Processes the lumi_ai plugin action for improvement center reviews id archive. +- Inputs: path params: `id`; body: `review_notes` +- Response format: Form/action response; exact format was not detected statically. +- Access: logged-in session required or used +- Side effects: writes or mutates server-side state +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. + ### POST /plugins/lumi_ai/improvement_center/reviews/:id/implement - Purpose: Processes the lumi_ai plugin action for improvement center reviews id implement. @@ -483,6 +513,15 @@ Default state: enabled - Side effects: writes or mutates server-side state - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Input length or numeric bounds are enforced by helper functions in the handler. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. +### POST /plugins/lumi_ai/improvement_center/corrections/:id/delete + +- Purpose: Processes the lumi_ai plugin action for improvement center corrections id delete. +- Inputs: path params: `id` +- Response format: Form/action response; exact format was not detected statically. +- Access: logged-in session required or used +- Side effects: Action route; side effects were not detected statically. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. + ### POST /plugins/lumi_ai/improvement_center/evals - Purpose: Processes the lumi_ai plugin action for improvement center evals. diff --git a/knowledge/plugins/moderation.md b/knowledge/plugins/moderation.md index a85ef6b..1f700bb 100644 --- a/knowledge/plugins/moderation.md +++ b/knowledge/plugins/moderation.md @@ -9,7 +9,6 @@ category: Plugin tags: plugin, moderation generated: true editable: false -updated_at: "2026-06-25T12:06:11.231Z" --- # Moderation Center Cross-platform moderation actions, notes, and sanctions. diff --git a/knowledge/plugins/okf.md b/knowledge/plugins/okf.md index 4a01bb2..0203646 100644 --- a/knowledge/plugins/okf.md +++ b/knowledge/plugins/okf.md @@ -9,7 +9,6 @@ category: Plugin tags: plugin, okf generated: true editable: false -updated_at: "2026-06-25T12:06:11.231Z" --- # OKF Knowledge Role-gated knowledge, facts, and Q&A entries for Lumi communities. @@ -21,6 +20,7 @@ Default state: enabled - /plugins/okf - GET /plugins/okf - GET /plugins/okf/admin +- POST /plugins/okf/admin/preview - POST /plugins/okf/admin/community - POST /plugins/okf/admin/community/:slug - POST /plugins/okf/admin/entries @@ -58,6 +58,15 @@ Default state: enabled - Side effects: Usually read-only. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. +### POST /plugins/okf/admin/preview + +- Purpose: Processes the okf plugin administration action for preview. +- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service +- Response format: JSON response +- Access: OKF editor or manager permission required; admin access expected +- Side effects: Action route; side effects were not detected statically. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion. + ### POST /plugins/okf/admin/community - Purpose: Processes the okf plugin administration action for community. diff --git a/knowledge/plugins/quotes.md b/knowledge/plugins/quotes.md index 0cecad3..287e6df 100644 --- a/knowledge/plugins/quotes.md +++ b/knowledge/plugins/quotes.md @@ -9,7 +9,6 @@ category: Plugin tags: plugin, quotes generated: true editable: false -updated_at: "2026-06-25T12:06:11.231Z" --- # Quotes Store, search, and manage community quotes. diff --git a/knowledge/plugins/sample-plugin.md b/knowledge/plugins/sample-plugin.md index b8c6681..e9d2f63 100644 --- a/knowledge/plugins/sample-plugin.md +++ b/knowledge/plugins/sample-plugin.md @@ -9,7 +9,6 @@ category: Plugin tags: plugin, sample-plugin generated: true editable: false -updated_at: "2026-06-25T12:06:11.231Z" --- # Sample Plugin Example plugin with a simple page. diff --git a/knowledge/plugins/throne-wishlist.md b/knowledge/plugins/throne-wishlist.md index 37192f9..d663e98 100644 --- a/knowledge/plugins/throne-wishlist.md +++ b/knowledge/plugins/throne-wishlist.md @@ -9,7 +9,6 @@ category: Plugin tags: plugin, throne_wishlist generated: true editable: false -updated_at: "2026-06-25T12:06:11.231Z" --- # Throne Wishlist Throne wishlist webhook integration with verified payloads, debug viewer, and cross-platform event messages. diff --git a/knowledge/plugins/welcome-messages.md b/knowledge/plugins/welcome-messages.md index 3c4d5d7..c8e0c86 100644 --- a/knowledge/plugins/welcome-messages.md +++ b/knowledge/plugins/welcome-messages.md @@ -9,7 +9,6 @@ category: Plugin tags: plugin, welcome_messages generated: true editable: false -updated_at: "2026-06-25T12:06:11.231Z" --- # Welcome Messages Randomized Discord welcome and welcome-back messages with safe pronoun preferences. diff --git a/package-lock.json b/package-lock.json index ae9cbf4..71b4f6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "ejs": "^3.1.10", "express": "^4.19.2", "express-session": "^1.18.1", - "multer": "^1.4.5-lts.1", + "multer": "^2.2.0", + "obs-websocket-js": "^5.0.8", "tmi.js": "^1.8.5" }, "engines": { @@ -55,6 +56,15 @@ "node": ">=16.9.0" } }, + "node_modules/@msgpack/msgpack": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz", + "integrity": "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -219,9 +229,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -232,7 +242,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -243,9 +253,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -349,50 +359,20 @@ } }, "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", "engines": [ - "node >= 0.8" + "node >= 6.0" ], "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", - "readable-stream": "^2.2.2", + "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, - "node_modules/concat-stream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/concat-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/concat-stream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -429,10 +409,10 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", "license": "MIT" }, "node_modules/date-fns": { @@ -659,6 +639,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -669,14 +655,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -695,7 +681,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -773,16 +759,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -904,9 +890,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -988,11 +974,14 @@ "node": ">= 0.10" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } }, "node_modules/jake": { "version": "10.9.4", @@ -1012,9 +1001,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/math-intrinsics": { @@ -1099,9 +1088,9 @@ } }, "node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -1119,18 +1108,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -1144,22 +1121,22 @@ "license": "MIT" }, "node_modules/multer": { - "version": "1.4.5-lts.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", - "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", - "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", - "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" }, "engines": { - "node": ">= 6.0.0" + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/napi-build-utils": { @@ -1209,15 +1186,6 @@ } } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1230,6 +1198,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obs-websocket-js": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/obs-websocket-js/-/obs-websocket-js-5.0.8.tgz", + "integrity": "sha512-QDnQJMr5wuCoYugK02ggZ1/cvESs4KJDEK+UhGg0Ry35jnY8tK1Xr3KoPjKyoD6sd8G+WdtIdzuY+yUyPtogQQ==", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^2.7.1", + "crypto-js": "^4.1.1", + "debug": "^4.3.2", + "eventemitter3": "^5.0.1", + "isomorphic-ws": "^5.0.0", + "type-fest": "^3.11.0", + "ws": "^8.13.0" + }, + "engines": { + "node": ">16.0" + } + }, + "node_modules/obs-websocket-js/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/obs-websocket-js/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1270,9 +1279,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/picocolors": { @@ -1307,12 +1316,6 @@ "node": ">=10" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1337,12 +1340,13 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -1503,14 +1507,14 @@ "license": "ISC" }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1522,13 +1526,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -1734,6 +1738,18 @@ "node": "*" } }, + "node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1827,10 +1843,11 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -1846,15 +1863,6 @@ "optional": true } } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } } } } diff --git a/package.json b/package.json index cc39400..6fb2d69 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,15 @@ "scripts": { "start": "node src/main.js", "run": "node run.js", - "verify:webui": "node scripts/verify-webui.js" + "verify:all": "node scripts/verify-all.js", + "verify:preflight": "node scripts/verify-preflight.js", + "verify:safe-files": "node scripts/verify-safe-files.js", + "verify:uploads": "node scripts/verify-upload-security.js", + "verify:web-auth": "node scripts/verify-web-auth.js", + "verify:destructive-actions": "node scripts/verify-destructive-actions.js", + "verify:overlays": "node scripts/verify-overlays.js", + "verify:webui": "node scripts/verify-webui.js && node scripts/verify-destructive-actions.js", + "benchmark:okf": "node scripts/benchmark-okf-search.js" }, "engines": { "node": ">=18" @@ -19,7 +27,8 @@ "ejs": "^3.1.10", "express": "^4.19.2", "express-session": "^1.18.1", - "multer": "^1.4.5-lts.1", + "multer": "^2.2.0", + "obs-websocket-js": "^5.0.8", "tmi.js": "^1.8.5" } } diff --git a/plugins/auto-vc/index.js b/plugins/auto-vc/index.js index fe65643..0cea484 100644 --- a/plugins/auto-vc/index.js +++ b/plugins/auto-vc/index.js @@ -54,7 +54,7 @@ module.exports = { res.send(html); }); - router.post("/settings", (req, res) => { + const saveSettings = (req, res) => { if (!req.session.user || !req.session.user.isAdmin) { return res.status(403).render("error", { title: "Access denied", @@ -69,7 +69,10 @@ module.exports = { message: "Auto VC settings saved." }; res.redirect("/plugins/auto-vc"); - }); + }; + + router.post("/settings", saveSettings); + router.post("/settings/remove", saveSettings); router.post("/bans", (req, res) => { if (!req.session.user || !(req.session.user.isAdmin || req.session.user.isMod)) { diff --git a/plugins/auto-vc/views/auto-vc.ejs b/plugins/auto-vc/views/auto-vc.ejs index 1cf03e4..cd50643 100644 --- a/plugins/auto-vc/views/auto-vc.ejs +++ b/plugins/auto-vc/views/auto-vc.ejs @@ -164,7 +164,7 @@ <% if (isAdmin) { %>

Lobby setup

-
+

Rate limits

@@ -371,11 +371,11 @@ @@ -437,6 +437,7 @@ const addButton = document.getElementById("add-lobby"); const container = document.getElementById("lobby-sections"); const template = document.getElementById("lobby-template"); + const settingsForm = document.querySelector("[data-auto-vc-settings]"); if (!addButton || !container || !template) { return; } @@ -492,6 +493,7 @@ field.disabled = true; }); updateHeaders(); + if (settingsForm) settingsForm.action = "/plugins/auto-vc/settings/remove"; }; const openModal = (card) => { diff --git a/plugins/birthday/views/birthday-admin.ejs b/plugins/birthday/views/birthday-admin.ejs index eeb7a3f..a013f79 100644 --- a/plugins/birthday/views/birthday-admin.ejs +++ b/plugins/birthday/views/birthday-admin.ejs @@ -13,7 +13,7 @@ Discord client<%= diagnostics.discordAvailable ? (diagnostics.discordReady ? "Ready" : "Available") : "Unavailable" %> Announcement channel<%= diagnostics.channel.message %> Economy<%= diagnostics.economyAvailable ? "Available" : "Unavailable" %> - Current plugin date<%= diagnostics.currentDate.year %>-<%= String(diagnostics.currentDate.month).padStart(2, "0") %>-<%= String(diagnostics.currentDate.day).padStart(2, "0") %> + Today in the selected timezone<%= diagnostics.currentDate.year %>-<%= String(diagnostics.currentDate.month).padStart(2, "0") %>-<%= String(diagnostics.currentDate.day).padStart(2, "0") %>
@@ -37,8 +37,8 @@
@@ -53,8 +53,8 @@
@@ -69,7 +69,7 @@ <% } else { %> <% } %> -

Only regular guild text channels are accepted.

+

Choose a regular text channel from this Discord server.

@@ -109,7 +109,7 @@
- +
@@ -117,9 +117,9 @@ -
+ - +
<% }) %> @@ -158,7 +158,7 @@
- +
@@ -166,9 +166,9 @@ -
+ - +
<% }) %> diff --git a/plugins/birthday/views/profile-birthday.ejs b/plugins/birthday/views/profile-birthday.ejs index 39e1f62..f0d55d0 100644 --- a/plugins/birthday/views/profile-birthday.ejs +++ b/plugins/birthday/views/profile-birthday.ejs @@ -22,8 +22,8 @@
<% if (birthday) { %> -
- + +
<% } %> <% } else { %> diff --git a/plugins/economy-framework/index.js b/plugins/economy-framework/index.js index e1094fa..a49d45b 100644 --- a/plugins/economy-framework/index.js +++ b/plugins/economy-framework/index.js @@ -6,6 +6,10 @@ const express = require("express"); const multer = require("multer"); const EventEmitter = require("events"); const { ensureUserForIdentity } = require("../../src/services/users"); +const { + cleanupUploadedFiles, + validateUploadedFile +} = require("../../src/services/upload-security"); const PLUGIN_ID = "economy-framework"; const LEGACY_STEM = ["echo", "nomy"].join(""); @@ -218,6 +222,7 @@ module.exports = { fs.mkdirSync(uploadDir, { recursive: true }); const upload = multer({ dest: uploadDir, + limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 10, parts: 12 }, fileFilter: (_req, file, cb) => { if (file.mimetype === "image/png") { return cb(null, true); @@ -225,6 +230,31 @@ module.exports = { cb(new Error("Only PNG files are allowed.")); } }); + const currencyIconUpload = (req, res, next) => upload.single("currency_icon")(req, res, (error) => { + if (error) { + cleanupUploadedFiles(req.file); + req.file = null; + req.currencyIconUploadError = error.code === "LIMIT_FILE_SIZE" + ? "Currency icons must be 2 MB or smaller." + : error.message; + return next(); + } + try { + if (req.file) { + validateUploadedFile(req.file, { + allowedTypes: ["png"], + maxBytes: 2 * 1024 * 1024, + requireExtension: true, + label: "currency icon" + }); + } + } catch (validationError) { + cleanupUploadedFiles(req.file); + req.file = null; + req.currencyIconUploadError = validationError.message; + } + next(); + }); const router = web.createRouter(); router.use("/assets", express.static(uploadDir)); @@ -464,18 +494,18 @@ module.exports = { res.redirect(`/plugins/${PLUGIN_ID}`); }); - router.post("/settings/icon", upload.single("currency_icon"), (req, res) => { + router.post("/settings/icon", (req, res, next) => { if (!req.session.user?.isAdmin) { return deny(res); } - if (!req.file) { - req.session.flash = { type: "error", message: "Upload a PNG icon." }; + next(); + }, currencyIconUpload, (req, res) => { + if (req.currencyIconUploadError) { + req.session.flash = { type: "error", message: req.currencyIconUploadError }; return res.redirect(`/plugins/${PLUGIN_ID}`); } - const ext = path.extname(req.file.originalname || "").toLowerCase(); - if (ext && ext !== ".png") { - fs.rmSync(req.file.path, { force: true }); - req.session.flash = { type: "error", message: "Only PNG files are allowed." }; + if (!req.file) { + req.session.flash = { type: "error", message: "Upload a PNG icon." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const filename = `currency-${Date.now()}-${crypto.randomUUID()}.png`; diff --git a/plugins/economy-framework/views/economy.ejs b/plugins/economy-framework/views/economy.ejs index f0eaa1f..42d1258 100644 --- a/plugins/economy-framework/views/economy.ejs +++ b/plugins/economy-framework/views/economy.ejs @@ -203,7 +203,7 @@
- PNG only. Used across the WebUI. + PNG only, up to 2 MB. Used across the WebUI.
@@ -454,8 +454,8 @@ <% events.forEach((event) => { %>
  • <%= event.name %> (<%= event.amount %>) -
    - + +
  • <% }) %> @@ -523,7 +523,7 @@
    Weights are used only when "Weighted" is selected.
    diff --git a/plugins/expression-interaction/views/expression.ejs b/plugins/expression-interaction/views/expression.ejs index 403e354..0d10d87 100644 --- a/plugins/expression-interaction/views/expression.ejs +++ b/plugins/expression-interaction/views/expression.ejs @@ -196,7 +196,7 @@ <% } else { %> -
    +
    <% } %> @@ -244,7 +244,7 @@
    - +
    diff --git a/plugins/lumi_ai/backend/ai_provider.js b/plugins/lumi_ai/backend/ai_provider.js index 4ea569d..5896a25 100644 --- a/plugins/lumi_ai/backend/ai_provider.js +++ b/plugins/lumi_ai/backend/ai_provider.js @@ -522,6 +522,15 @@ class AiProvider { force_through_reason: gateDecision?.forced ? gateDecision.reason_code : null, internal_generated_length: initialText.length + String(finalText || "").length, fallback_reason: fallbackReason, + feedback_context_snapshot: buildFeedbackContextSnapshot({ + contextBlocks, + correctionContext, + repoContext, + contextDiagnostics, + controllerDecision, + exposedTools: toolExposure.exposed, + selectedTool + }), stage_timings: { deterministic_ms: gateDecision?.deterministic_ms || 0, gate_ms: gateDecision?.gate_ms || 0, @@ -841,6 +850,49 @@ function redactPrompt(value) { .slice(0, 6000); } +function buildFeedbackContextSnapshot({ + contextBlocks = [], + correctionContext = [], + repoContext = [], + contextDiagnostics = [], + controllerDecision = {}, + exposedTools = [], + selectedTool = null +} = {}) { + const retrieval = summarizeContextDiagnostics(contextDiagnostics); + return { + retrieval: { + query: safeFeedbackText(retrieval.query, 500), + candidate_count: retrieval.candidate_count, + selected_count: Array.isArray(contextBlocks) ? contextBlocks.length : 0, + depth: String(controllerDecision.okf_retrieval || "none").slice(0, 40) + }, + okf: safeFeedbackList(contextBlocks, 4), + corrections: safeFeedbackList(correctionContext, 3), + repository: safeFeedbackList(repoContext, 3), + tools: [...new Set([ + ...exposedTools.map((tool) => tool?.tool_id || tool?.id).filter(Boolean), + selectedTool + ].filter(Boolean).map((value) => String(value).slice(0, 120)))].slice(0, 20) + }; +} + +function safeFeedbackList(values, limit) { + return (Array.isArray(values) ? values : []) + .slice(0, limit) + .map((value) => safeFeedbackText(typeof value === "string" ? value : JSON.stringify(value), 1200)) + .filter(Boolean); +} + +function safeFeedbackText(value, limit) { + const cwd = process.cwd(); + return redactPrompt(value) + .replaceAll(cwd, "[repo]") + .replaceAll(cwd.replaceAll("\\", "/"), "[repo]") + .replace(/\b[A-Za-z]:[\\/][^\r\n"']+/g, "[local path]") + .slice(0, limit); +} + module.exports = { AiProvider, isInScope, @@ -849,5 +901,6 @@ module.exports = { isExactHelpShortcut, normalizeHistory, normalizeInferenceDiagnostics, - resolveOutputBudget + resolveOutputBudget, + buildFeedbackContextSnapshot }; diff --git a/plugins/lumi_ai/backend/cache.js b/plugins/lumi_ai/backend/cache.js index 2c5f7d0..e2fa8cf 100644 --- a/plugins/lumi_ai/backend/cache.js +++ b/plugins/lumi_ai/backend/cache.js @@ -1,6 +1,7 @@ const fs = require("fs"); const crypto = require("crypto"); const { resolveData } = require("./paths"); +const { writeJsonAtomicSync } = require("../../../src/services/safe-files"); class SafeAnswerCache { constructor(getConfig) { @@ -57,13 +58,7 @@ class SafeAnswerCache { } write(store) { - const tmp = `${this.file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`; - try { - fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`); - fs.renameSync(tmp, this.file); - } finally { - fs.rmSync(tmp, { force: true }); - } + writeJsonAtomicSync(this.file, store); } } diff --git a/plugins/lumi_ai/backend/config_manager.js b/plugins/lumi_ai/backend/config_manager.js index ac2cb2b..34879ea 100644 --- a/plugins/lumi_ai/backend/config_manager.js +++ b/plugins/lumi_ai/backend/config_manager.js @@ -1,4 +1,5 @@ const fs = require("fs"); +const { writeJsonAtomicSync } = require("../../../src/services/safe-files"); const { resolveData, ensureDataDirs } = require("./paths"); const { DEFAULT_SCOPE, normalizeScope } = require("./scope_manager"); const { DEFAULT_RATE_LIMITS, mergeLimits } = require("./rate_limits"); @@ -99,9 +100,7 @@ function readJson(name, fallback) { } function writeJson(name, value) { const file = resolveData("config", name); - const tmp = `${file}.tmp`; - fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`); - fs.renameSync(tmp, file); + writeJsonAtomicSync(file, value); } function getConfig() { const config = readJson("ai_config.json", DEFAULT_CONFIG); diff --git a/plugins/lumi_ai/backend/corrections.js b/plugins/lumi_ai/backend/corrections.js index 378c106..140e1cd 100644 --- a/plugins/lumi_ai/backend/corrections.js +++ b/plugins/lumi_ai/backend/corrections.js @@ -58,7 +58,8 @@ class CorrectionStore { verified_at: null, active: false, created_at: new Date().toISOString(), - updated_at: new Date().toISOString() + updated_at: new Date().toISOString(), + linked_okf_path: null }; const store = this.read(); store.entries.unshift(entry); @@ -142,6 +143,14 @@ class CorrectionStore { return { total: store.entries.length, active }; } + linkOkf(id, correctionPath) { + return this.mutate(id, (entry) => ({ + ...entry, + linked_okf_path: clean(correctionPath, 1000), + updated_at: new Date().toISOString() + })); + } + match(input, limit = 4) { if (this.getConfig()?.improvement?.corrections_enabled === false) return []; const role = normalizeRole(input.role); diff --git a/plugins/lumi_ai/backend/diagnostics.js b/plugins/lumi_ai/backend/diagnostics.js index 884243f..d55b1e7 100644 --- a/plugins/lumi_ai/backend/diagnostics.js +++ b/plugins/lumi_ai/backend/diagnostics.js @@ -2,6 +2,7 @@ const fs = require("fs"); const path = require("path"); const AdmZip = require("adm-zip"); const { resolveData, ensureDataDirs } = require("./paths"); +const { writeJsonAtomicSync } = require("../../../src/services/safe-files"); function redact(value) { if (Array.isArray(value)) return value.map(redact); @@ -26,7 +27,7 @@ function createDiagnostic(input) { function persistDiagnostic(input) { ensureDataDirs(); const diagnostic = createDiagnostic(input); - fs.writeFileSync(resolveData("diagnostics", "latest_runtime_diagnostic.json"), `${JSON.stringify(diagnostic, null, 2)}\n`); + writeJsonAtomicSync(resolveData("diagnostics", "latest_runtime_diagnostic.json"), diagnostic); fs.appendFileSync(resolveData("diagnostics", "runtime_diagnostics.jsonl"), `${JSON.stringify(diagnostic)}\n`); return diagnostic; } diff --git a/plugins/lumi_ai/backend/downloader.js b/plugins/lumi_ai/backend/downloader.js index 14f0ba8..4a41e15 100644 --- a/plugins/lumi_ai/backend/downloader.js +++ b/plugins/lumi_ai/backend/downloader.js @@ -4,6 +4,7 @@ const path = require("path"); const { spawn } = require("child_process"); const AdmZip = require("adm-zip"); const { resolveData } = require("./paths"); +const { writeJsonAtomicSync } = require("../../../src/services/safe-files"); class DownloadManager { constructor(onEvent) { @@ -144,7 +145,7 @@ class DownloadManager { job.state = "installing"; await replaceDirectoryContents(staging, finalDir); - fs.writeFileSync(path.join(finalDir, "lumi-runtime.json"), `${JSON.stringify({ + writeJsonAtomicSync(path.join(finalDir, "lumi-runtime.json"), { backend: runtimeMetadata?.backend || "cpu", backend_variant: runtimeMetadata?.backend_variant || null, version: runtimeMetadata?.version || null, @@ -155,7 +156,7 @@ class DownloadManager { size: dependency.size || 0 })), installed_at: new Date().toISOString() - }, null, 2)}\n`); + }); } finally { if (installCallbackStarted && typeof afterInstall === "function") { await afterInstall({ job, finalDir, staging, runtimeMetadata }); diff --git a/plugins/lumi_ai/backend/feedback.js b/plugins/lumi_ai/backend/feedback.js index 7cf22e4..2aa645c 100644 --- a/plugins/lumi_ai/backend/feedback.js +++ b/plugins/lumi_ai/backend/feedback.js @@ -1,5 +1,5 @@ const fs = require("fs"); -const crypto = require("crypto"); +const { writeJsonAtomicSync } = require("../../../src/services/safe-files"); const { resolveData } = require("./paths"); const { roleOf } = require("./permissions"); @@ -13,7 +13,10 @@ const FEEDBACK_TAGS = Object.freeze([ "should_clarify", "bad_code", "wrong_scope", - "wrong_tool_usage" + "wrong_tool_usage", + "bad_retrieval", + "bad_response", + "missing_knowledge" ]); const FEEDBACK_KINDS = Object.freeze(["instruction_based", "strict_correction"]); @@ -36,11 +39,14 @@ class FeedbackStore { platform: clean(input.platform, 80) || "webui", model: clean(input.model, 200), timestamp: validDate(input.timestamp) || new Date().toISOString(), + rating: input.rating === "up" || input.feedback_tag === "good" ? "up" : "down", feedback_tag: tag, feedback_kind: FEEDBACK_KINDS.includes(input.feedback_kind) ? input.feedback_kind : "instruction_based", optional_correction: clean(input.optional_correction, 16000), + context_snapshot: normalizeContextSnapshot(input.context_snapshot), + linked_okf_correction: null, status: "pending", submitted_by: String(actor?.id || "anonymous"), reviewed_by: null, @@ -59,8 +65,10 @@ class FeedbackStore { return entry; } - list({ page = 1, pageSize = 20, status = "" } = {}) { - const filtered = this.read().entries.filter((entry) => !status || entry.status === status); + list({ page = 1, pageSize = 20, status = "", viewerRole = "admin" } = {}) { + const filtered = this.read().entries + .filter((entry) => roleRank(viewerRole) >= roleRank(entry.role)) + .filter((entry) => !status || entry.status === status); return paginate(filtered, page, pageSize); } @@ -85,7 +93,7 @@ class FeedbackStore { } setStatus(id, status, actor, notes = "") { - if (!["pending", "flagged", "verified", "approved", "rejected"].includes(status)) { + if (!["pending", "flagged", "verified", "approved", "rejected", "reviewed", "archived"].includes(status)) { throw new Error("Invalid review status."); } return this.mutate(id, (entry) => ({ @@ -116,6 +124,15 @@ class FeedbackStore { })); } + linkOkfCorrection(id, correctionPath, actor) { + return this.mutate(id, (entry) => ({ + ...entry, + linked_okf_correction: clean(correctionPath, 1000), + reviewed_by: entry.reviewed_by || String(actor.id), + reviewed_at: new Date().toISOString() + })); + } + delete(id) { const store = this.read(); const before = store.entries.length; @@ -180,13 +197,7 @@ function paginate(rows, pageValue, pageSizeValue) { } function atomicJson(file, value) { - const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`; - try { - fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`); - fs.renameSync(tmp, file); - } finally { - fs.rmSync(tmp, { force: true }); - } + writeJsonAtomicSync(file, value); } function clean(value, max) { @@ -202,11 +213,42 @@ function validDate(value) { return Number.isNaN(date.getTime()) ? null : date.toISOString(); } +function normalizeContextSnapshot(value) { + const snapshot = value && typeof value === "object" ? value : {}; + const retrieval = snapshot.retrieval && typeof snapshot.retrieval === "object" ? snapshot.retrieval : {}; + return { + retrieval: { + query: clean(retrieval.query, 500), + candidate_count: safeCount(retrieval.candidate_count), + selected_count: safeCount(retrieval.selected_count), + depth: clean(retrieval.depth, 40) + }, + okf: cleanList(snapshot.okf, 4, 1200), + corrections: cleanList(snapshot.corrections, 3, 1200), + repository: cleanList(snapshot.repository, 3, 1200), + tools: cleanList(snapshot.tools, 20, 120) + }; +} + +function cleanList(value, limit, maxLength) { + return (Array.isArray(value) ? value : []).slice(0, limit).map((entry) => clean(entry, maxLength)).filter(Boolean); +} + +function safeCount(value) { + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, Math.min(100000, Math.floor(number))) : 0; +} + +function roleRank(role) { + return { user: 1, mod: 2, admin: 3 }[normalizeRole(role)] || 1; +} + module.exports = { FEEDBACK_KINDS, FEEDBACK_TAGS, FeedbackStore, improvementAccess, + normalizeContextSnapshot, paginate, atomicJson }; diff --git a/plugins/lumi_ai/backend/metrics.js b/plugins/lumi_ai/backend/metrics.js index feb2738..4daf684 100644 --- a/plugins/lumi_ai/backend/metrics.js +++ b/plugins/lumi_ai/backend/metrics.js @@ -1,5 +1,6 @@ const fs = require("fs"); const { resolveData } = require("./paths"); +const { writeJsonAtomicSync, writeTextAtomicSync } = require("../../../src/services/safe-files"); const historyFile = () => resolveData("metrics", "history.jsonl"); const stateFile = () => resolveData("metrics", "summary.json"); @@ -118,7 +119,7 @@ function record(entry) { }); summary.slow_requests = summary.slow_requests.slice(0, 25); } - fs.writeFileSync(stateFile(), JSON.stringify(summary, null, 2)); + writeJsonAtomicSync(stateFile(), summary); fs.appendFileSync(historyFile(), `${JSON.stringify({ timestamp:new Date().toISOString(), ...entry })}\n`); } function report() { @@ -176,6 +177,10 @@ function workHistoryPage(query = {}, page = 1, pageSize = 20) { .filter((work) => matchesWorkFilters(work, filters)); return paginateRows(grouped, page, pageSize); } +function workHistoryExport(workId) { + const work = groupWorkRows(readHistoryRows()).find((entry) => entry.work_id === String(workId || "")); + return work ? buildWorkHistoryExport(work) : null; +} function slowRequestsPage(page = 1, pageSize = 15) { const safePage = Math.max(1, Number.parseInt(page, 10) || 1); const safeSize = Math.max(1, Math.min(100, Number.parseInt(pageSize, 10) || 15)); @@ -202,12 +207,12 @@ function applyRetention() { if (!rows.length) return; const keepIds = retainedWorkIds(groupWorkRows(rows), retention); if (!keepIds) return; - const kept = rows.filter((row) => { - const id = row.request_id || row.work_id; - return !id || keepIds.has(id); + const kept = rows.filter((row, index) => { + const id = rowWorkId(row, index); + return keepIds.has(id); }); if (kept.length === rows.length) return; - fs.writeFileSync(historyFile(), kept.map((row) => JSON.stringify(row)).join("\n") + (kept.length ? "\n" : "")); + writeTextAtomicSync(historyFile(), kept.map((row) => JSON.stringify(row)).join("\n") + (kept.length ? "\n" : "")); } function maybeApplyRetention() { if (Math.random() > 0.02) return; @@ -225,9 +230,8 @@ function readHistoryRows() { } function groupWorkRows(rows) { const groups = new Map(); - for (const row of rows) { - const id = row.request_id || row.work_id; - if (!id) continue; + rows.forEach((row, index) => { + const id = rowWorkId(row, index); if (!groups.has(id)) { groups.set(id, { work_id: id, @@ -237,6 +241,8 @@ function groupWorkRows(rows) { source: row.origin || row.platform || "webui", user_id: row.user_id || "", role: row.role || "", + model: row.model || row.model_id || "", + provider: row.provider || row.backend || "", internal_mode: row.controller_complexity || "", okf_retrieval: row.okf_retrieval_depth || "", reason_code: row.controller_reason_code || row.gate_reason_code || row.reason_code || "", @@ -260,6 +266,8 @@ function groupWorkRows(rows) { group.source = row.origin || row.platform || group.source; group.user_id = row.user_id || group.user_id; group.role = row.role || group.role; + group.model = row.model || row.model_id || group.model; + group.provider = row.provider || row.backend || group.provider; group.internal_mode = row.controller_complexity || group.internal_mode; group.okf_retrieval = row.okf_retrieval_depth || group.okf_retrieval; group.reason_code = row.controller_reason_code || row.gate_reason_code || row.reason_code || group.reason_code; @@ -283,7 +291,7 @@ function groupWorkRows(rows) { else if (row.delivered_length != null) group.delivered_tokens = estimateTokensFromChars(row.delivered_length); group.duration_ms = Math.max(group.duration_ms, validOrZero(row.total_ms ?? row.duration_ms)); group.events.push(workEvent(row)); - } + }); return [...groups.values()] .map((group) => { if (!group.processed_tokens) group.processed_tokens = group.prompt_tokens + group.final_tokens; @@ -339,10 +347,104 @@ function compactWorkData(row) { "controller_complexity", "okf_retrieval_depth", "answer_style", "source_profile", "controller_confidence", "okf_match_count", "context_block_count", "prompt_tokens", "generated_tokens", "final_tokens", "delivered_tokens", "final_reply_length", - "delivered_length", "truncated", "delivery_action", "fallback_reason", "rejected_reason", "error_code" + "delivered_length", "truncated", "delivery_action", "fallback_reason", "rejected_reason", "error_code", + "model", "model_id", "provider", "backend", "okf_query", "okf_candidate_count", "okf_limit", + "okf_provider_count", "deterministic_ms", "gate_ms", "queue_ms", "prompt_eval_ms", "generation_ms", "total_ms" ]; return Object.fromEntries(keys.filter((key) => row[key] != null).map((key) => [key, row[key]])); } +function rowWorkId(row, index) { + if (row.request_id || row.work_id) return String(row.request_id || row.work_id); + const timestamp = String(row.timestamp || "unknown").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").slice(0, 60); + return `legacy-${timestamp || "row"}-${index}`; +} + +function buildWorkHistoryExport(work) { + const cleanPrompt = sanitizeWorkExportText(work.prompt || "Prompt was not recorded for this legacy entry.", work, 6000); + const lifecycle = (work.events || []).map((event) => ({ + timestamp: event.timestamp || null, + type: event.type || "event", + status: event.status || null, + summary: sanitizeWorkExportText(event.summary, work, 500), + tokens: validOrZero(event.tokens), + data: sanitizeWorkEventData(event.data, work) + })); + const retrieval = lifecycle + .filter((event) => event.type === "okf_retrieval") + .map((event) => ({ + query: event.data.okf_query || null, + depth: event.data.okf_retrieval_depth || work.okf_retrieval || null, + candidates: validOrZero(event.data.okf_candidate_count), + selected: validOrZero(event.data.okf_match_count), + fallback_reason: event.data.fallback_reason || null + })); + const errors = lifecycle + .filter((event) => event.type === "error" || event.status === "failed" || event.data.error_code) + .map((event) => ({ code: event.data.error_code || null, summary: event.summary })) + .slice(0, 10); + return { + schema: "lumi.ai.work.codex.v1", + generated_at: new Date().toISOString(), + objective: `Investigate Lumi AI work item ${work.status || "unknown"}: ${cleanPrompt.slice(0, 240)}`, + work: { + id: String(work.work_id || "legacy"), + prompt: cleanPrompt, + source: sanitizeWorkExportText(work.source, work, 80), + role: ["user", "mod", "admin"].includes(work.role) ? work.role : "unknown", + status: work.status || "unknown", + route: lifecycle.map((event) => event.data.route_used).find(Boolean) || null, + model: sanitizeWorkExportText(work.model, work, 160) || null, + provider: sanitizeWorkExportText(work.provider, work, 120) || null, + controller: { + mode: work.internal_mode || null, + reason: sanitizeWorkExportText(work.reason_code, work, 160) || null, + okf_retrieval: work.okf_retrieval || null + }, + tokens: { + processed: validOrZero(work.processed_tokens), + prompt: validOrZero(work.prompt_tokens), + final: validOrZero(work.final_tokens), + delivered: validOrZero(work.delivered_tokens) + }, + duration_ms: validOrZero(work.duration_ms), + flags: [ + work.has_error && "error", + work.has_refusal && "refusal", + work.has_fallback && "fallback", + work.has_truncation && "truncation", + work.has_okf_context && "okf_context" + ].filter(Boolean), + retrieval, + errors, + lifecycle + }, + privacy: { + mode: "task_focused", + removed: ["user identity", "session data", "secrets", "local paths", "raw diagnostics", "tool payloads"] + } + }; +} + +function sanitizeWorkEventData(value, work) { + const data = value && typeof value === "object" ? value : {}; + return Object.fromEntries(Object.entries(data).map(([key, item]) => [ + key, + typeof item === "string" ? sanitizeWorkExportText(item, work, 500) : item + ])); +} + +function sanitizeWorkExportText(value, work, maxLength) { + let text = String(value || ""); + if (work.user_id) text = text.replaceAll(String(work.user_id), "[user]"); + return text + .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[email]") + .replace(/\b((?:token|secret|password|cookie|authorization|api[_ -]?key|client[_ -]?secret)\s*[:=]\s*)\S+/gi, "$1[redacted]") + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]") + .replace(/\b[A-Za-z]:[\\/][^\r\n"']+/g, "[local path]") + .replace(/\/(?:home|Users|mnt|var|tmp)\/[^\s"']+/g, "[local path]") + .trim() + .slice(0, maxLength); +} function normalizeWorkFilters(query = {}) { return { q: String(query.work_q || query.q || "").trim().toLowerCase(), @@ -474,6 +576,9 @@ module.exports = { history, historyPage, workHistoryPage, + workHistoryExport, + buildWorkHistoryExport, + groupWorkRows, slowRequestsPage, paginateRows, isValidTiming, diff --git a/plugins/lumi_ai/backend/repo_indexer.js b/plugins/lumi_ai/backend/repo_indexer.js index ef53e33..143e14a 100644 --- a/plugins/lumi_ai/backend/repo_indexer.js +++ b/plugins/lumi_ai/backend/repo_indexer.js @@ -2,6 +2,7 @@ const fs = require("fs"); const path = require("path"); const { spawnSync } = require("child_process"); const { PLUGIN_ROOT, resolveData } = require("./paths"); +const { writeJsonAtomicSync } = require("../../../src/services/safe-files"); const INDEX_FILE = "index.json"; const REPOSITORY_URL = "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi"; @@ -52,7 +53,7 @@ function refreshIndex(root = repoRoot()) { documents }; const target = resolveData("repo_index", INDEX_FILE); - fs.writeFileSync(target, `${JSON.stringify(index, null, 2)}\n`); + writeJsonAtomicSync(target, index); return index; } @@ -71,7 +72,7 @@ function refreshPublicIndex() { const index = refreshIndex(staging); index.source = "public"; index.root = REPOSITORY_URL; - fs.writeFileSync(resolveData("repo_index", INDEX_FILE), `${JSON.stringify(index, null, 2)}\n`); + writeJsonAtomicSync(resolveData("repo_index", INDEX_FILE), index); return index; } finally { const resolved = path.resolve(staging); diff --git a/plugins/lumi_ai/backend/tool_installer.js b/plugins/lumi_ai/backend/tool_installer.js index 791287a..eed0d13 100644 --- a/plugins/lumi_ai/backend/tool_installer.js +++ b/plugins/lumi_ai/backend/tool_installer.js @@ -3,6 +3,7 @@ const path = require("path"); const crypto = require("crypto"); const { resolveData } = require("./paths"); const { assertToolId } = require("./tool_repo_client"); +const { runFileOperationWithRetries } = require("../../../src/services/safe-files"); const REQUIRED_FIELDS = Object.freeze([ "tool_id", @@ -46,12 +47,12 @@ class ToolInstaller { return this.stageAndSwap(toolId, false); } - async update(toolId) { + async update(toolId, options = {}) { if (!this.local(toolId)) throw new Error("AI tool plugin is not installed."); - return this.stageAndSwap(toolId, true); + return this.stageAndSwap(toolId, true, options); } - async stageAndSwap(toolId, updating) { + async stageAndSwap(toolId, updating, options = {}) { assertToolId(toolId); const id = crypto.randomUUID(); const stageBase = path.join(this.stagingRoot, id); @@ -64,23 +65,37 @@ class ToolInstaller { const remoteMetadata = validateToolDirectory(stageDir, toolId); if (updating) preserveConfiguredPaths(target, stageDir, remoteMetadata); let movedCurrent = false; + let installedReplacement = false; try { if (updating) { - fs.renameSync(target, backup); + fileOperation(() => fs.renameSync(target, backup), target, "tool_backup"); movedCurrent = true; } - fs.renameSync(stageDir, target); + fileOperation(() => fs.renameSync(stageDir, target), target, "tool_install"); + installedReplacement = true; + if (typeof options.afterSwap === "function") await options.afterSwap({ target, backup, metadata: remoteMetadata }); } catch (error) { - fs.rmSync(target, { recursive: true, force: true }); - if (movedCurrent && fs.existsSync(backup)) fs.renameSync(backup, target); + if (installedReplacement && fs.existsSync(target)) { + try { fileOperation(() => fs.rmSync(target, { recursive: true, force: true }), target, "tool_remove_failed_replacement"); } + catch (cleanupError) { error.message = `${error.message} The failed replacement remains at ${target}: ${cleanupError.message}`; } + } + if (movedCurrent && fs.existsSync(backup) && !fs.existsSync(target)) { + try { + fileOperation(() => fs.renameSync(backup, target), target, "tool_restore"); + movedCurrent = false; + } catch (restoreError) { + error.message = `${error.message} The previous tool remains recoverable at ${backup}: ${restoreError.message}`; + } + } throw error; } - fs.rmSync(backup, { recursive: true, force: true }); + if (fs.existsSync(backup)) fileOperation(() => fs.rmSync(backup, { recursive: true, force: true }), backup, "tool_commit_cleanup"); return { tool_id: toolId, metadata: remoteMetadata, installed: true, updated: updating }; } finally { fs.rmSync(stageBase, { recursive: true, force: true }); - if (fs.existsSync(backup) && !fs.existsSync(target)) fs.renameSync(backup, target); - else fs.rmSync(backup, { recursive: true, force: true }); + if (fs.existsSync(backup) && !fs.existsSync(target)) { + try { fileOperation(() => fs.renameSync(backup, target), target, "tool_final_restore"); } catch {} + } } } @@ -94,6 +109,15 @@ class ToolInstaller { } } +function fileOperation(operation, target, operationName) { + return runFileOperationWithRetries(operation, { + target, + operation: operationName, + attempts: 8, + delayMs: 15 + }); +} + function validateToolDirectory(directory, folderName = path.basename(directory)) { const metadataFile = path.join(directory, "tool_info.json"); const readmeFile = path.join(directory, "readme.md"); diff --git a/plugins/lumi_ai/backend/tool_loader.js b/plugins/lumi_ai/backend/tool_loader.js index a77d647..240eedd 100644 --- a/plugins/lumi_ai/backend/tool_loader.js +++ b/plugins/lumi_ai/backend/tool_loader.js @@ -1,5 +1,6 @@ const fs = require("fs"); const path = require("path"); +const { writeJsonAtomicSync } = require("../../../src/services/safe-files"); const { resolveData } = require("./paths"); const { registerManagedTool } = require("./tool_registry"); @@ -272,15 +273,7 @@ class ToolLoader { } writeState(state) { - fs.mkdirSync(path.dirname(this.stateFile), { recursive: true }); - const temporary = `${this.stateFile}.${process.pid}.tmp`; - fs.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`); - try { fs.renameSync(temporary, this.stateFile); } - catch (error) { - if (!["EEXIST", "EPERM"].includes(error.code)) throw error; - fs.rmSync(this.stateFile, { force: true }); - fs.renameSync(temporary, this.stateFile); - } + writeJsonAtomicSync(this.stateFile, state); } } diff --git a/plugins/lumi_ai/backend/tool_manager.js b/plugins/lumi_ai/backend/tool_manager.js index 060bbac..91b82be 100644 --- a/plugins/lumi_ai/backend/tool_manager.js +++ b/plugins/lumi_ai/backend/tool_manager.js @@ -93,8 +93,14 @@ class ToolManager { const wasEnabled = this.loader.isEnabled(toolId); if (wasEnabled) await this.loader.disable(toolId, { persist: false }); try { - const result = await this.installer.update(toolId); - if (wasEnabled) await this.loader.enable(toolId, { persist: false }); + const result = await this.installer.update(toolId, { + afterSwap: wasEnabled + ? async () => { + const activated = await this.loader.enable(toolId, { persist: false }); + if (!activated?.loaded) throw new Error(activated?.message || "The updated AI tool could not be loaded."); + } + : null + }); this.loader.setEnabled(toolId, wasEnabled); return result; } catch (error) { diff --git a/plugins/lumi_ai/backend/tool_repo_client.js b/plugins/lumi_ai/backend/tool_repo_client.js index 13bf8e3..68cfc43 100644 --- a/plugins/lumi_ai/backend/tool_repo_client.js +++ b/plugins/lumi_ai/backend/tool_repo_client.js @@ -1,5 +1,6 @@ const fs = require("fs"); const path = require("path"); +const { writeJsonAtomicSync } = require("../../../src/services/safe-files"); const { spawnSync } = require("child_process"); const { resolveData } = require("./paths"); @@ -191,10 +192,7 @@ class ToolRepoClient { } writeCache(value) { - fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true }); - const temporary = `${this.cacheFile}.${process.pid}.tmp`; - fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); - fs.renameSync(temporary, this.cacheFile); + writeJsonAtomicSync(this.cacheFile, value); } } diff --git a/plugins/lumi_ai/backend/tool_settings.js b/plugins/lumi_ai/backend/tool_settings.js index 8d46d19..602bdc6 100644 --- a/plugins/lumi_ai/backend/tool_settings.js +++ b/plugins/lumi_ai/backend/tool_settings.js @@ -1,5 +1,6 @@ const fs = require("fs"); const path = require("path"); +const { writeJsonAtomicSync } = require("../../../src/services/safe-files"); class ToolSettings { constructor(options = {}) { @@ -41,11 +42,7 @@ class ToolSettings { } } const file = settingsFile(local.dir); - fs.mkdirSync(path.dirname(file), { recursive: true }); - const temporary = `${file}.${process.pid}.tmp`; - fs.writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 }); - try { fs.chmodSync(temporary, 0o600); } catch {} - fs.renameSync(temporary, file); + writeJsonAtomicSync(file, next, { mode: 0o600 }); return this.describe(toolId); } @@ -57,16 +54,7 @@ class ToolSettings { Object.entries(schema).map(([key, field]) => [key, normalizeValue(field.default, field, key)]) ); const file = settingsFile(local.dir); - fs.mkdirSync(path.dirname(file), { recursive: true }); - const temporary = `${file}.${process.pid}.tmp`; - fs.writeFileSync(temporary, `${JSON.stringify(values, null, 2)}\n`, { mode: 0o600 }); - try { fs.chmodSync(temporary, 0o600); } catch {} - try { fs.renameSync(temporary, file); } - catch (error) { - if (!["EEXIST", "EPERM"].includes(error.code)) throw error; - fs.rmSync(file, { force: true }); - fs.renameSync(temporary, file); - } + writeJsonAtomicSync(file, values, { mode: 0o600 }); return this.describe(toolId); } diff --git a/plugins/lumi_ai/backend/training_export.js b/plugins/lumi_ai/backend/training_export.js index ea236e0..7628fd3 100644 --- a/plugins/lumi_ai/backend/training_export.js +++ b/plugins/lumi_ai/backend/training_export.js @@ -1,6 +1,7 @@ const fs = require("fs"); const path = require("path"); const { resolveData } = require("./paths"); +const { writeTextAtomicSync } = require("../../../src/services/safe-files"); class TrainingExporter { constructor({ feedback, corrections, outputDir }) { @@ -26,7 +27,7 @@ class TrainingExporter { input: "", output: entry.preferred_answer }); - fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length ? "\n" : "")); + writeTextAtomicSync(file, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length ? "\n" : "")); return { file, filename: path.basename(file), count: rows.length, format }; } } diff --git a/plugins/lumi_ai/index.js b/plugins/lumi_ai/index.js index 9a259c9..8ada9a9 100644 --- a/plugins/lumi_ai/index.js +++ b/plugins/lumi_ai/index.js @@ -1,6 +1,7 @@ const fs = require("fs"); const path = require("path"); const express = require("express"); +const { writeJsonAtomicSync } = require("../../src/services/safe-files"); const { ensureDataDirs, resolveData } = require("./backend/paths"); const { getConfig, saveConfig, getRuntimeState } = require("./backend/config_manager"); const { detectHardware, estimateAllocation, performanceTuningHints } = require("./backend/hardware"); @@ -871,6 +872,16 @@ module.exports = { }); return res.download(file); }); + + router.get("/work-history/:id/export", (req, res) => { + if (!req.session.user?.isAdmin) return denied(res); + const payload = metrics.workHistoryExport(req.params.id); + if (!payload) return res.status(404).send("Work history item not found."); + const safeId = String(req.params.id || "work").replace(/[^a-z0-9_-]+/gi, "-").slice(0, 80) || "work"; + res.set("Cache-Control", "private, no-store"); + res.attachment(`lumi-ai-work-${safeId}.json`); + return res.send(`${JSON.stringify(payload, null, 2)}\n`); + }); router.post("/assistant/message", async (req, res) => { const permission = canUseAssistant({ user: req.session.user, @@ -956,7 +967,8 @@ module.exports = { origin: originContext.origin, platform: originContext.platform, model: result.model_id || requestConfig.selected_model_id, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), + context_snapshot: result.feedback_context_snapshot || null } }; } catch (error) { @@ -1005,9 +1017,11 @@ module.exports = { platform: "webui", model: req.body.model, timestamp: req.body.timestamp, + rating: req.body.rating, feedback_tag: req.body.feedback_tag, feedback_kind: req.body.feedback_kind, - optional_correction: req.body.optional_correction + optional_correction: req.body.optional_correction, + context_snapshot: req.body.context_snapshot }, req.session.user); return res.status(201).json({ success: true, id: entry.id }); } catch (error) { @@ -1269,7 +1283,8 @@ module.exports = { reviews: feedbackStore.list({ page: req.query.review_page, pageSize: 15, - status: cleanText(req.query.status, 30) + status: cleanText(req.query.status, 30), + viewerRole: access.role }), corrections: correctionStore.list({ page: req.query.correction_page, pageSize: 15 }), evalCases: evalStore.list({ page: req.query.eval_page, pageSize: 15 }), @@ -1298,6 +1313,8 @@ module.exports = { const access = improvementAccess(req.session.user, config); if (!access.allowed) return deniedImprovement(res); try { + const review = feedbackStore.get(req.params.id); + if (!canReviewAiFeedback(review, access)) return deniedImprovement(res); const action = cleanText(req.body.action, 30); if (action === "flag" && access.can_flag) { feedbackStore.setStatus(req.params.id, "flagged", req.session.user, req.body.review_notes); @@ -1307,12 +1324,12 @@ module.exports = { feedbackStore.setStatus(req.params.id, "approved", req.session.user, req.body.review_notes); } else if (action === "reject" && access.can_approve) { feedbackStore.setStatus(req.params.id, "rejected", req.session.user, req.body.review_notes); + } else if (action === "reviewed" && access.can_approve) { + feedbackStore.setStatus(req.params.id, "reviewed", req.session.user, req.body.review_notes); } else if (action === "edit" && access.can_edit) { feedbackStore.edit(req.params.id, req.body, req.session.user); } else if (action === "export" && access.can_export) { feedbackStore.markExportApproved(req.params.id, req.session.user); - } else if (action === "delete" && access.can_delete) { - feedbackStore.delete(req.params.id); } else { return deniedImprovement(res); } @@ -1322,6 +1339,28 @@ module.exports = { } }); + router.post("/improvement_center/reviews/:id/delete", (req, res) => { + const access = improvementAccess(req.session.user, config); + if (!access.can_delete) return deniedImprovement(res); + try { + feedbackStore.delete(req.params.id); + return improvementFlash(req, res, "success", "Review delete completed."); + } catch (error) { + return improvementFlash(req, res, "error", error.message); + } + }); + + router.post("/improvement_center/reviews/:id/archive", (req, res) => { + const access = improvementAccess(req.session.user, config); + if (!access.can_delete) return deniedImprovement(res); + try { + feedbackStore.setStatus(req.params.id, "archived", req.session.user, req.body.review_notes); + return improvementFlash(req, res, "success", "Feedback record archived."); + } catch (error) { + return improvementFlash(req, res, "error", error.message); + } + }); + router.post("/improvement_center/reviews/:id/implement", (req, res) => { const access = improvementAccess(req.session.user, config); if (!access.can_implement) return deniedImprovement(res); @@ -1342,12 +1381,22 @@ module.exports = { } else if (target === "training_export") { feedbackStore.markExportApproved(review.id, req.session.user); } else { - correctionStore.createFromFeedback(review, { + const correction = correctionStore.createFromFeedback(review, { ...req.body, target, explicitly_safe: req.body.explicitly_safe === "on", enabled: req.body.enabled === "on" }, req.session.user); + if (target === "correction") { + try { + const okfEntry = saveAiFeedbackOkfCorrection(review, correction, req.body, req.session.user); + correctionStore.linkOkf(correction.id, okfEntry.path); + feedbackStore.linkOkfCorrection(review.id, okfEntry.path, req.session.user); + } catch (error) { + correctionStore.delete(correction.id); + throw error; + } + } } return improvementFlash(req, res, "success", "Approved feedback was promoted. Save Corrections before it becomes active."); } catch (error) { @@ -1380,8 +1429,6 @@ module.exports = { }); } else if (action === "toggle" && access.can_edit) { correctionStore.setEnabled(req.params.id, req.body.enabled === "on"); - } else if (action === "delete" && access.can_delete) { - correctionStore.delete(req.params.id); } else { return deniedImprovement(res); } @@ -1391,6 +1438,17 @@ module.exports = { } }); + router.post("/improvement_center/corrections/:id/delete", (req, res) => { + const access = improvementAccess(req.session.user, config); + if (!access.can_delete) return deniedImprovement(res); + try { + correctionStore.delete(req.params.id); + return improvementFlash(req, res, "success", "Correction delete completed. Save Corrections to activate changes."); + } catch (error) { + return improvementFlash(req, res, "error", error.message); + } + }); + router.post("/improvement_center/evals", (req, res) => { const access = improvementAccess(req.session.user, config); if (!access.can_run_evals) return deniedImprovement(res); @@ -1555,6 +1613,42 @@ function boundedNumber(value, min, max, fallback) { function cleanText(value, max) { return String(value || "").trim().slice(0, max); } + +function canReviewAiFeedback(review, access) { + if (!review || !access?.allowed) return false; + const rank = { user: 1, mod: 2, admin: 3 }; + return (rank[access.role] || 0) >= (rank[review.role] || 1); +} + +function saveAiFeedbackOkfCorrection(review, correction, values, actor) { + const saveCorrection = global.lumiFrameworks?.okf?.saveCorrection; + if (typeof saveCorrection !== "function") { + throw new Error("Enable the OKF plugin before creating a searchable correction file."); + } + const title = cleanText(values.okf_title, 180) || `Assistant correction: ${cleanText(review.user_message, 120)}`; + const visibility = ["user", "mod", "admin"].includes(correction.min_role) ? correction.min_role : "user"; + const safePrompt = cleanText(review.user_message, 3000); + const safeAnswer = cleanText(correction.corrected_answer, 10000); + return saveCorrection({ + slug: `lumi-ai-${review.id}`, + id: `correction.lumi_ai.${review.id}`, + title, + status: "active", + priority: 150, + visibility, + category: "Lumi AI correction", + tags: `lumi ai, assistant feedback, correction, ${review.feedback_tag || "reviewed"}`, + source_feedback_id: `lumi-ai:${review.id}`, + source_feedback_url: `/plugins/lumi_ai/improvement_center#review-${review.id}`, + body: [ + "## When this applies", + safePrompt, + "", + "## Approved response guidance", + safeAnswer + ].join("\n") + }, actor); +} function flash(req, res, type, message) { req.session.flash = { type, message }; return res.redirect(`/plugins/${PLUGIN_ID}`); @@ -2061,7 +2155,7 @@ function writeCommandsManifest(pluginDir, config) { aliases: triggers.slice(1) }] }; - fs.writeFileSync(path.join(pluginDir, "cmds.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + writeJsonAtomicSync(path.join(pluginDir, "cmds.json"), manifest); } module.exports.shouldAutoResume = shouldAutoResume; diff --git a/plugins/lumi_ai/public/assistant.css b/plugins/lumi_ai/public/assistant.css index 916cfd2..033cff4 100644 --- a/plugins/lumi_ai/public/assistant.css +++ b/plugins/lumi_ai/public/assistant.css @@ -51,6 +51,8 @@ .lumi-ai-confirm { display: flex; gap: 8px; margin-top: 8px; } .lumi-ai-confirm button { padding: 5px 9px; border-radius: 5px; border: 1px solid var(--border); cursor: pointer; } .lumi-ai-feedback { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; padding-top: 8px; border-top: 1px solid var(--border); color: var(--ink-soft); font-size: 11px; } +.lumi-ai-feedback-quick-actions, .lumi-ai-feedback-details { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 6px; } +.lumi-ai-feedback-details[hidden] { display: none; } .lumi-ai-feedback select, .lumi-ai-feedback input, .lumi-ai-feedback button { min-height: 29px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface-2); color: var(--ink); padding: 4px 7px; font: inherit; } .lumi-ai-feedback input { flex: 1 1 180px; } .lumi-ai-feedback button { cursor: pointer; font-weight: 700; } diff --git a/plugins/lumi_ai/public/assistant.js b/plugins/lumi_ai/public/assistant.js index b650c01..c0c142e 100644 --- a/plugins/lumi_ai/public/assistant.js +++ b/plugins/lumi_ai/public/assistant.js @@ -279,11 +279,29 @@ const controls = document.createElement("form"); controls.className = "lumi-ai-feedback"; controls.setAttribute("aria-label", "Rate this Lumi Assistant reply"); + const quickActions = document.createElement("div"); + quickActions.className = "lumi-ai-feedback-quick-actions"; + const helpful = document.createElement("button"); + helpful.type = "button"; + helpful.className = "button subtle"; + helpful.textContent = "Helpful"; + helpful.setAttribute("aria-label", "Mark this reply as helpful"); + const needsWork = document.createElement("button"); + needsWork.type = "button"; + needsWork.className = "button subtle"; + needsWork.textContent = "Needs work"; + needsWork.setAttribute("aria-label", "Tell Lumi what needs work"); + quickActions.append(helpful, needsWork); + const details = document.createElement("div"); + details.className = "lumi-ai-feedback-details"; + details.hidden = true; const select = document.createElement("select"); select.setAttribute("aria-label", "Feedback tag"); for (const tag of [ - "good", "bad", + "bad_retrieval", + "bad_response", + "missing_knowledge", "wrong_link", "hallucinated", "too_generic", @@ -316,9 +334,11 @@ const submitFeedback = document.createElement("button"); submitFeedback.type = "submit"; submitFeedback.textContent = "Send feedback"; - controls.append(select, kind, correction, submitFeedback); - controls.addEventListener("submit", async (event) => { - event.preventDefault(); + details.append(select, kind, correction, submitFeedback); + controls.append(quickActions, details); + const sendFeedback = async ({ tag, rating, correctionText = "", feedbackKind = "instruction_based" }) => { + helpful.disabled = true; + needsWork.disabled = true; submitFeedback.disabled = true; try { const response = await trackedFetch(`${endpoint}/assistant/feedback`, { @@ -326,18 +346,36 @@ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...context, - feedback_tag: select.value, - feedback_kind: kind.value, - optional_correction: correction.value.trim() + rating, + feedback_tag: tag, + feedback_kind: feedbackKind, + optional_correction: correctionText }) }); const data = await readResponseJson(response); if (!response.ok) throw new Error(data.error || "Feedback could not be saved."); - controls.replaceChildren(document.createTextNode("Feedback saved.")); + controls.replaceChildren(document.createTextNode("Thanks—feedback saved.")); } catch (error) { + helpful.disabled = false; + needsWork.disabled = false; submitFeedback.disabled = false; submitFeedback.textContent = error.message || "Try again"; } + }; + helpful.addEventListener("click", () => sendFeedback({ tag: "good", rating: "up" }), { signal: listeners.signal }); + needsWork.addEventListener("click", () => { + details.hidden = false; + needsWork.setAttribute("aria-expanded", "true"); + select.focus(); + }, { signal: listeners.signal }); + controls.addEventListener("submit", async (event) => { + event.preventDefault(); + await sendFeedback({ + tag: select.value, + rating: "down", + correctionText: correction.value.trim(), + feedbackKind: kind.value + }); }, { signal: listeners.signal }); item.append(controls); }; diff --git a/plugins/lumi_ai/public/improvement-center.js b/plugins/lumi_ai/public/improvement-center.js index 902519d..cc1efaf 100644 --- a/plugins/lumi_ai/public/improvement-center.js +++ b/plugins/lumi_ai/public/improvement-center.js @@ -9,9 +9,4 @@ if (closer) closer.closest("dialog")?.close(); }); - document.addEventListener("submit", (event) => { - const form = event.target.closest("[data-improvement-confirm]"); - if (!form) return; - if (!window.confirm(form.dataset.improvementConfirm || "Continue?")) event.preventDefault(); - }); })(); diff --git a/plugins/lumi_ai/public/tool-manager.js b/plugins/lumi_ai/public/tool-manager.js index 50fcebf..1c8767d 100644 --- a/plugins/lumi_ai/public/tool-manager.js +++ b/plugins/lumi_ai/public/tool-manager.js @@ -219,6 +219,7 @@ form.dataset.confirmMode = "modal"; form.dataset.confirmTitle = `Delete ${tool.display_name || tool.tool_id}?`; form.dataset.confirmText = "The installed AI tool files will be removed. Shared Lumi AI data and unrelated plugins are not affected."; + form.dataset.confirmLabel = "Delete tool"; const remove = button("Delete", "danger"); remove.type = "submit"; form.append(remove); diff --git a/plugins/lumi_ai/tests/verify-tools.js b/plugins/lumi_ai/tests/verify-tools.js index 94954b5..04d917c 100644 --- a/plugins/lumi_ai/tests/verify-tools.js +++ b/plugins/lumi_ai/tests/verify-tools.js @@ -92,6 +92,37 @@ async function run() { assert.equal(validateToolDirectory(path.join(pluginsDir, "lumi_ai_weather")).version, "1.1.0"); assert.equal(fs.readFileSync(path.join(pluginsDir, "lumi_ai_weather", "data", "local.json"), "utf8"), "preserve"); + const installedWeather = path.join(pluginsDir, "lumi_ai_weather"); + createTool(path.join(remoteDir, "lumi_ai_weather"), metadata("lumi_ai_weather", "1.2.0"), backendSource("updated again")); + remoteTools.set("lumi_ai_weather", metadata("lumi_ai_weather", "1.2.0")); + const originalRename = fs.renameSync; + fs.renameSync = (source, destination) => { + if (source === installedWeather && path.basename(destination).startsWith(".lumi_ai_weather.backup-")) { + const error = new Error("simulated locked tool directory"); + error.code = "EPERM"; + throw error; + } + return originalRename(source, destination); + }; + try { + await assert.rejects(() => manager.update("lumi_ai_weather"), /locked tool directory/); + } finally { + fs.renameSync = originalRename; + } + assert.equal(validateToolDirectory(installedWeather).version, "1.1.0"); + assert.equal(fs.readFileSync(path.join(installedWeather, "data", "local.json"), "utf8"), "preserve"); + + await manager.enable("lumi_ai_weather"); + createTool( + path.join(remoteDir, "lumi_ai_weather"), + metadata("lumi_ai_weather", "1.2.0"), + 'module.exports.register = () => { throw new Error("simulated activation failure"); };\n' + ); + await assert.rejects(() => manager.update("lumi_ai_weather"), /simulated activation failure/); + assert.equal(validateToolDirectory(installedWeather).version, "1.1.0"); + assert.equal(loader.isEnabled("lumi_ai_weather"), true); + assert.equal(registry.tools.has("lumi_ai_weather.lookup"), true); + repoClient.fail = true; await assert.rejects(() => manager.update("lumi_ai_weather"), /required|missing/i); assert.equal(validateToolDirectory(path.join(pluginsDir, "lumi_ai_weather")).version, "1.1.0"); diff --git a/plugins/lumi_ai/tests/verify.js b/plugins/lumi_ai/tests/verify.js index 6f94706..5cbafd6 100644 --- a/plugins/lumi_ai/tests/verify.js +++ b/plugins/lumi_ai/tests/verify.js @@ -7,7 +7,7 @@ const { ToolRegistry } = require("../backend/tool_router"); const { RequestQueue } = require("../backend/queue_manager"); const { RuntimeManager, combinedResourceEstimate, runCaptured, buildRuntimeArgs } = require("../backend/runtime_manager"); const { DEFAULT_CONFIG, getRuntimeState } = require("../backend/config_manager"); -const { AiProvider, normalizeHistory, normalizeInferenceDiagnostics, resolveOutputBudget } = require("../backend/ai_provider"); +const { AiProvider, normalizeHistory, normalizeInferenceDiagnostics, resolveOutputBudget, buildFeedbackContextSnapshot } = require("../backend/ai_provider"); const { GateProvider, similarity, stripForcePrefix, isSensitiveRequest, isSimpleKnowledgeLookup, classifyRequestType, withTimeout } = require("../backend/gate_provider"); const { buildControllerDecision, @@ -30,7 +30,7 @@ const modelManifest = require("../models_manifest.json"); const runtimeManifest = require("../runtime_manifest.json"); const storage = require("../backend/storage"); const { formatBytes, bytesFromMb, sanityCheckSize } = require("../backend/size_utils"); -const { record: recordMetric, paginateRows, summarizeMetrics, isValidTiming, workHistoryPage } = require("../backend/metrics"); +const { record: recordMetric, paginateRows, summarizeMetrics, isValidTiming, workHistoryPage, workHistoryExport, groupWorkRows } = require("../backend/metrics"); const { AiAccessControl } = require("../backend/access_control"); const { AiRateLimiter, mergeLimits } = require("../backend/rate_limits"); const { PLATFORM_DEFAULTS, buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("../backend/commands"); @@ -127,7 +127,7 @@ async function run() { user_id: "history-user", role: "admin", origin: "webui", - prompt: "Tell me about doing a GET request to /setup/twitch", + prompt: "Tell history-user about GET /setup/twitch using C:\\private\\notes.txt password=hunter2", prompt_tokens: 12 }); recordMetric({ @@ -172,6 +172,16 @@ async function run() { assert.equal(workPage.entries[0].final_tokens, 40); assert.equal(workPage.entries[0].delivered_tokens, 35); assert(workPage.entries[0].events.some((event) => event.type === "prompt")); + const workExport = workHistoryExport(workRequestId); + const workExportText = JSON.stringify(workExport); + assert.equal(workExport.schema, "lumi.ai.work.codex.v1"); + assert.equal(workExportText.includes("history-user"), false); + assert.equal(workExportText.includes("hunter2"), false); + assert.equal(workExportText.includes("C:\\\\private"), false); + assert(workExport.privacy.removed.includes("raw diagnostics")); + const legacyGroups = groupWorkRows([{ timestamp: "2025-01-01T00:00:00.000Z", kind: "request", status: "failed", message: "legacy failure" }]); + assert.equal(legacyGroups.length, 1); + assert(legacyGroups[0].work_id.startsWith("legacy-")); const panelTemplate = require("path").join(PLUGIN_ROOT, "views", "assistant-panel.ejs"); const panelDiagnostic = new AssistantPanelDiagnostics(panelTemplate); @@ -211,14 +221,20 @@ async function run() { assert(settingsTemplate.includes("Improvement Center")); assert(settingsTemplate.includes("work.has_truncation")); assert(settingsTemplate.includes("work.processed_tokens")); + assert(settingsTemplate.includes("Export for Codex")); + assert(settingsTemplate.includes("raw diagnostics, and tool payloads removed")); const improvementTemplate = fs.readFileSync(path.join(PLUGIN_ROOT, "views", "improvement-center.ejs"), "utf8"); for (const control of ["Review queue", "Save Corrections", "Run all evals", "Export instruction JSONL", "Export DPO JSONL"]) { assert(improvementTemplate.includes(control)); } assert(improvementTemplate.includes('data-confirm-title="Delete eval case"')); + assert(improvementTemplate.includes("Sources used for this answer")); + assert(improvementTemplate.includes("Searchable OKF title")); const assistantFeedbackScript = fs.readFileSync(path.join(PLUGIN_ROOT, "public", "assistant.js"), "utf8"); for (const tag of FEEDBACK_TAGS) assert(assistantFeedbackScript.includes(`"${tag}"`)); assert(assistantFeedbackScript.includes("/assistant/feedback")); + assert(assistantFeedbackScript.includes('helpful.textContent = "Helpful"')); + assert(assistantFeedbackScript.includes('needsWork.textContent = "Needs work"')); const settingsFrontendScript = fs.readFileSync(path.join(PLUGIN_ROOT, "public", "settings.js"), "utf8"); for (const marker of ["preparing_install", "installing", "Stopping runtimes", "Downloading dependency"]) { assert(settingsFrontendScript.includes(marker)); @@ -252,13 +268,28 @@ async function run() { origin: "webui", platform: "webui", model: "test-model", + rating: "down", feedback_tag: "wrong_link", - optional_correction: "Use /admin/settings." + optional_correction: "Use /admin/settings.", + context_snapshot: { + retrieval: { query: "settings", candidate_count: 5, selected_count: 1, depth: "light" }, + okf: ["Verified settings context"], + repository: ["GET /admin/settings"], + tools: ["settings_lookup"] + } }, { id: "reporter" }); - assert.deepEqual( - Object.keys(review).filter((key) => /prompt|context|reasoning/i.test(key)), - [] - ); + assert.equal(review.rating, "down"); + assert.equal(review.context_snapshot.retrieval.candidate_count, 5); + assert.deepEqual(review.context_snapshot.tools, ["settings_lookup"]); + assert.equal(feedbackStore.list({ viewerRole: "mod" }).entries.length, 0); + assert.equal(feedbackStore.list({ viewerRole: "admin" }).entries.length, 1); + const safeSnapshot = buildFeedbackContextSnapshot({ + contextBlocks: [`Open ${process.cwd()}\\private.md; password=secret`], + contextDiagnostics: [{ provider: "okf", query: "settings", candidate_count: 3 }], + controllerDecision: { okf_retrieval: "light" } + }); + assert.equal(safeSnapshot.okf[0].includes(process.cwd()), false); + assert(safeSnapshot.okf[0].includes("[redacted]")); feedbackStore.verify(review.id, { id: "trusted-mod" }); assert.equal(feedbackStore.get(review.id).status, "verified"); feedbackStore.setStatus(review.id, "approved", { id: "admin" }); @@ -284,6 +315,10 @@ async function run() { explicitly_safe: true, enabled: true }, { id: "admin" }); + correctionStore.linkOkf(correction.id, "knowledge/corrections/lumi-ai-review.md"); + feedbackStore.linkOkfCorrection(review.id, "knowledge/corrections/lumi-ai-review.md", { id: "admin" }); + assert.equal(correctionStore.get(correction.id).linked_okf_path, "knowledge/corrections/lumi-ai-review.md"); + assert.equal(feedbackStore.get(review.id).linked_okf_correction, "knowledge/corrections/lumi-ai-review.md"); assert.equal(correction.active, false); assert.equal(correctionStore.match({ message: review.user_message, diff --git a/plugins/lumi_ai/views/improvement-center.ejs b/plugins/lumi_ai/views/improvement-center.ejs index c2bc278..210073c 100644 --- a/plugins/lumi_ai/views/improvement-center.ejs +++ b/plugins/lumi_ai/views/improvement-center.ejs @@ -32,18 +32,18 @@
    -

    Review queue

    Feedback records contain the user message, assistant answer, delivery metadata, tag, and optional correction only.

    +

    Review queue

    Review ratings, the answer, and a privacy-limited snapshot of the knowledge sources that informed it. Only admins can turn approved feedback into searchable OKF corrections.

    - <% ["", "pending", "flagged", "verified", "approved", "rejected"].forEach((status) => { %> + <% ["", "pending", "flagged", "verified", "approved", "reviewed", "rejected", "archived"].forEach((status) => { %> <%= status || "All" %> <% }) %>
    <% reviews.entries.forEach((review) => { %> -
    +
    -
    <%= review.feedback_tag %> <%= review.feedback_kind || "strict_correction" %> <%= review.status %>
    +
    <%= review.rating === "up" ? "Helpful" : "Needs work" %> <%= review.feedback_tag.replaceAll("_", " ") %> <%= review.feedback_kind || "strict_correction" %> <%= review.status %>
    <%= formatDate(review.timestamp) %> · <%= review.role %> · <%= review.platform %> · <%= review.route_used || "unknown route" %>
    @@ -51,6 +51,18 @@
    Assistant answer
    <%= review.assistant_answer %>
    <% if (review.optional_correction) { %>
    <%= review.feedback_kind === "instruction_based" ? "Instruction guidance" : "Suggested correction" %>
    <%= review.optional_correction %>
    <% } %> + <% const snapshot = review.context_snapshot || {}; const retrieval = snapshot.retrieval || {}; const snapshotRows = [...(snapshot.okf || []), ...(snapshot.corrections || []), ...(snapshot.repository || [])]; %> + <% if (snapshotRows.length || retrieval.query || (snapshot.tools || []).length) { %> +
    + Sources used for this answer<%= retrieval.selected_count || snapshotRows.length %> selected · <%= retrieval.candidate_count || 0 %> considered +
    + <% if (retrieval.query) { %>

    Lookup: <%= retrieval.query %> · depth <%= retrieval.depth || "unknown" %>

    <% } %> + <% snapshotRows.forEach((source) => { %>
    <%= source %>
    <% }) %> + <% if ((snapshot.tools || []).length) { %>

    Available/selected tools: <%= snapshot.tools.join(", ") %>

    <% } %> +
    +
    + <% } %> + <% if (review.linked_okf_correction) { %>

    Searchable correction: <%= review.linked_okf_correction %>

    <% } %> <% if (review.review_notes) { %>

    Review notes: <%= review.review_notes %>

    <% } %>
    <% if (access.can_flag) { %> @@ -66,6 +78,10 @@ <% } %> <% if (access.can_approve) { %> +
    + + +
    @@ -80,10 +96,12 @@
    -
    - +
    +
    + +
    <% } %>
    @@ -107,6 +125,7 @@
    +
    When the target is Correction, Lumi saves an editable, searchable OKF file after approval.
    " />
    " />
    @@ -146,7 +165,8 @@ <%= entry.active ? "active" : entry.enabled ? "staged" : "disabled" %> <% if (access.can_verify) { %>
    <% } %> - <% if (access.can_edit) { %>
    " />
    <% } %> + <% if (entry.linked_okf_path) { %>Open OKF<% } %> + <% if (access.can_edit) { %>
    " />
    <% } %> <% if (access.can_edit) { %> diff --git a/plugins/lumi_ai/views/settings.ejs b/plugins/lumi_ai/views/settings.ejs index 8e8f59d..415b646 100644 --- a/plugins/lumi_ai/views/settings.ejs +++ b/plugins/lumi_ai/views/settings.ejs @@ -103,7 +103,7 @@ ] }) %> -
    +
    @@ -258,7 +258,7 @@
    <%= category.replace("_", " ") %><%= formatBytes(bytes) %>
    <% }) %>
    -
    + @@ -727,6 +727,8 @@
    Original prompt
    <%= work.prompt || "Prompt was not recorded for this legacy entry." %>
    + Export for Codex + Downloads a task-focused JSON file with identities, secrets, local paths, raw diagnostics, and tool payloads removed.
    @@ -773,7 +775,7 @@ <% }) %> diff --git a/plugins/moderation/index.js b/plugins/moderation/index.js index d818f14..85f5a99 100644 --- a/plugins/moderation/index.js +++ b/plugins/moderation/index.js @@ -2,6 +2,11 @@ const path = require("path"); const fs = require("fs"); const crypto = require("crypto"); const multer = require("multer"); +const { + cleanupUploadedFiles, + safeDownloadFilename, + validateUploadedFiles +} = require("../../src/services/upload-security"); const PLUGIN_ID = "moderation"; const EVIDENCE_DIR = path.join(__dirname, "..", "..", "data", "moderation", "evidence"); @@ -34,7 +39,35 @@ module.exports = { const ext = path.extname(file.originalname || ".png").slice(0, 10); cb(null, `${crypto.randomUUID()}${ext}`); } - }) + }), + limits: { fileSize: 8 * 1024 * 1024, files: 4, fields: 40, parts: 45 }, + fileFilter: (_req, file, cb) => { + if (["image/png", "image/jpeg", "image/webp"].includes(file.mimetype)) return cb(null, true); + cb(new Error("Evidence must be a PNG, JPEG, or WebP image.")); + } + }); + const evidenceUpload = (req, res, next) => upload.array("evidence_files", 4)(req, res, (error) => { + if (error) { + cleanupUploadedFiles(req.files); + req.files = []; + req.evidenceUploadError = error.code === "LIMIT_FILE_SIZE" + ? "Each evidence image must be 8 MB or smaller." + : error.message; + return next(); + } + try { + validateUploadedFiles(req.files, { + allowedTypes: ["png", "jpeg", "webp"], + maxBytes: 8 * 1024 * 1024, + requireExtension: true, + label: "evidence image" + }); + } catch (validationError) { + cleanupUploadedFiles(req.files); + req.files = []; + req.evidenceUploadError = validationError.message; + } + next(); }); installGlobalGate(app, (req, res, next) => { @@ -145,21 +178,37 @@ module.exports = { message: "Evidence file not found." }); } - res.download(row.file_path, row.file_name || path.basename(row.file_path)); + const evidencePath = path.resolve(row.file_path); + const evidenceRoot = `${path.resolve(EVIDENCE_DIR)}${path.sep}`; + if (!evidencePath.startsWith(evidenceRoot) || !fs.existsSync(evidencePath)) { + return res.status(404).render("error", { + title: "Not found", + message: "Evidence file not found." + }); + } + res.download(evidencePath, safeDownloadFilename(row.file_name, "evidence")); }); - router.post("/actions", upload.array("evidence_files", 4), async (req, res) => { + router.post("/actions", (req, res, next) => { if (!req.session.user) { return res.redirect("/"); } - const isAdmin = Boolean(req.session.user?.isAdmin); const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); if (!isMod) { return deny(res); } + next(); + }, evidenceUpload, async (req, res) => { + const isAdmin = Boolean(req.session.user?.isAdmin); + const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); + if (req.evidenceUploadError) { + req.session.flash = { type: "error", message: req.evidenceUploadError }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } const actionType = (req.body.action_type || "").toLowerCase(); if (actionType === "kick") { + cleanupUploadedFiles(req.files); req.session.flash = { type: "info", message: "Kick actions are coming soon." @@ -169,6 +218,7 @@ module.exports = { const target = resolveTarget(db, req.body); if (!target) { + cleanupUploadedFiles(req.files); req.session.flash = { type: "error", message: "Target not found." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } @@ -176,6 +226,7 @@ module.exports = { const reasonShort = (req.body.reason_short || "").trim(); const reasonDetail = (req.body.reason_detail || "").trim(); if (!reasonShort || !reasonDetail) { + cleanupUploadedFiles(req.files); req.session.flash = { type: "error", message: "Both summary and detailed reasons are required." diff --git a/plugins/moderation/views/moderation.ejs b/plugins/moderation/views/moderation.ejs index b670cf1..7aab9cd 100644 --- a/plugins/moderation/views/moderation.ejs +++ b/plugins/moderation/views/moderation.ejs @@ -217,7 +217,8 @@
    - + + Up to 4 PNG, JPEG, or WebP images, 8 MB each.
    @@ -309,7 +310,7 @@ <% if (isAdmin) { %> diff --git a/plugins/moderation/views/tos-bans.ejs b/plugins/moderation/views/tos-bans.ejs index a1d67c1..1236885 100644 --- a/plugins/moderation/views/tos-bans.ejs +++ b/plugins/moderation/views/tos-bans.ejs @@ -118,10 +118,10 @@ Permanent - + <% } %> - + diff --git a/plugins/okf/backend/file_knowledge.js b/plugins/okf/backend/file_knowledge.js index 77d86f5..88751b1 100644 --- a/plugins/okf/backend/file_knowledge.js +++ b/plugins/okf/backend/file_knowledge.js @@ -1,5 +1,6 @@ const fs = require("fs"); const path = require("path"); +const { writeTextAtomicSync } = require("../../../src/services/safe-files"); const KNOWLEDGE_SCOPES = Object.freeze(["corrections", "community", "plugins", "core"]); const SCOPE_PRIORITY = Object.freeze({ @@ -26,8 +27,9 @@ const PLACEHOLDER_SUGGEST_RESERVED_KEYS = new Set([ "updated_at" ]); const SEARCH_STOPWORDS = new Set([ - "a", "an", "and", "are", "about", "describe", "for", "identify", "is", "me", - "of", "please", "tell", "the", "to", "what", "who", "was", "were" + "a", "an", "and", "are", "about", "can", "could", "describe", "do", "does", "for", + "help", "how", "i", "identify", "is", "me", "my", "of", "please", "tell", "the", "to", + "use", "using", "what", "where", "who", "was", "were", "you", "your" ]); const knowledgeIndexCache = new Map(); @@ -102,6 +104,7 @@ function searchFileKnowledge({ query = "", user, limit = 5, rootDir = process.cw const access = accessForUser(user); if (!access.authenticated) return []; const tokens = tokenSet(query); + if (String(query || "").trim() && !tokens.size) return []; const entries = resolveVisibleKnowledgePlaceholders(loadKnowledgeEntries(rootDir) .filter((entry) => canSeeKnowledgeEntry(entry, access)) ); @@ -207,6 +210,7 @@ function listCommunityKnowledgeFiles(rootDir = process.cwd()) { status: entry.status, visibility: entry.visibility, priority: entry.priority, + category: entry.category, tags: entry.tags, editable: entry.editable, generated: entry.generated, @@ -216,6 +220,18 @@ function listCommunityKnowledgeFiles(rootDir = process.cwd()) { .sort((a, b) => a.title.localeCompare(b.title)); } +function listCorrectionKnowledgeFiles(rootDir = process.cwd()) { + return loadKnowledgeEntries(rootDir, { includeHidden: true }) + .filter((entry) => entry.scope === "corrections") + .map((entry) => ({ + ...entry, + slug: entry.file_slug, + entry_slug: entry.slug, + path: `knowledge/${entry.path}` + })) + .sort((a, b) => b.priority - a.priority || a.title.localeCompare(b.title)); +} + function getCommunityKnowledgeFile(rootDir = process.cwd(), slug) { const entry = loadKnowledgeEntries(rootDir, { includeHidden: true }) .find((item) => item.scope === "community" && (item.file_slug === slug || item.slug === slug || item.id === slug)); @@ -241,7 +257,7 @@ function saveCommunityKnowledgeFile(rootDir = process.cwd(), values = {}) { } const metadata = normalizeCommunityFileValues(values, slug); const markdown = serializeKnowledgeFile(metadata, values.body || ""); - fs.writeFileSync(filePath, markdown); + writeTextAtomicSync(filePath, markdown); knowledgeIndexCache.clear(); parseKnowledgeFile(filePath, root, { includeHidden: true }); return getCommunityKnowledgeFile(rootDir, slug); @@ -253,7 +269,7 @@ function saveCorrectionKnowledgeFile(rootDir = process.cwd(), values = {}) { const filePath = path.join(root, "corrections", `${slug}.md`); const metadata = normalizeCorrectionFileValues(values, slug); const markdown = serializeKnowledgeFile(metadata, values.body || ""); - fs.writeFileSync(filePath, markdown); + writeTextAtomicSync(filePath, markdown); knowledgeIndexCache.clear(); const entry = parseKnowledgeFile(filePath, root, { includeHidden: true }); return entry ? { @@ -518,7 +534,16 @@ function scoreKnowledgeChunk(entry, chunk, queryTokens) { const text = [entry.title, entry.category, entry.tags.join(" "), chunk.heading, chunk.text].join(" "); const textTokens = tokenSet(text); const overlap = queryTokens.size ? intersectionSize(queryTokens, textTokens) : 1; - const score = (overlap * 100) + SCOPE_PRIORITY[entry.scope] + Number(entry.priority || 0); + const titleOverlap = queryTokens.size ? intersectionSize(queryTokens, tokenSet(`${entry.title} ${entry.tags.join(" ")}`)) : 0; + const headingOverlap = queryTokens.size ? intersectionSize(queryTokens, tokenSet(chunk.heading)) : 0; + const coverage = queryTokens.size ? overlap / queryTokens.size : 1; + const specificRouteBonus = /^(GET|POST|PUT|PATCH|DELETE|MOUNT)\s+\//i.test(chunk.heading) && headingOverlap + ? 140 + : 0; + const genericRoutePenalty = ["Routes", "Web Routes", "Route Reference"].includes(chunk.heading) ? 80 : 0; + const score = (overlap * 100) + (titleOverlap * 45) + (headingOverlap * 80) + + Math.round(coverage * 100) + specificRouteBonus - genericRoutePenalty + + SCOPE_PRIORITY[entry.scope] + Number(entry.priority || 0); const excerpt = excerptForChunk(chunk.text, queryTokens); return { id: entry.id, @@ -584,9 +609,14 @@ function tokenSet(value) { const cleaned = cleanText(value, 4000); const expanded = cleaned.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); const tokens = new Set(); - for (const token of `${cleaned} ${expanded}`.toLowerCase().split(/[^a-z0-9_]+/)) { - if (token.length < 2 || SEARCH_STOPWORDS.has(token)) continue; - tokens.add(token); + for (const variant of [cleaned, expanded]) { + const words = variant.toLowerCase().split(/[^a-z0-9]+/) + .filter((token) => token.length >= 2 && !SEARCH_STOPWORDS.has(token)); + words.forEach((token) => tokens.add(token)); + for (let index = 0; index < words.length - 1; index += 1) { + const compact = `${words[index]}${words[index + 1]}`; + if (compact.length <= 80) tokens.add(compact); + } } return tokens; } @@ -663,6 +693,7 @@ module.exports = { knowledgeRoot, listKnowledgeFiles, listCommunityKnowledgeFiles, + listCorrectionKnowledgeFiles, listKnowledgePlaceholders, loadKnowledgeEntries, migrateSingleBracePlaceholders, diff --git a/plugins/okf/backend/generate_knowledge.js b/plugins/okf/backend/generate_knowledge.js index 4f86d24..7bccbe7 100644 --- a/plugins/okf/backend/generate_knowledge.js +++ b/plugins/okf/backend/generate_knowledge.js @@ -2,19 +2,19 @@ const fs = require("fs"); const path = require("path"); const { ensureKnowledgeDirs, knowledgeRoot } = require("./file_knowledge"); +const { writeTextAtomicSync } = require("../../../src/services/safe-files"); function generateKnowledgeFiles(rootDir = process.cwd()) { const root = ensureKnowledgeDirs(rootDir); - const generatedAt = new Date().toISOString(); const written = []; const coreFile = path.join(root, "core", "lumi-core.md"); - const coreMarkdown = buildCoreKnowledge(rootDir, generatedAt); + const coreMarkdown = buildCoreKnowledge(rootDir); if (writeGeneratedFile(coreFile, coreMarkdown)) { written.push(path.relative(rootDir, coreFile)); } for (const plugin of discoverPlugins(rootDir)) { const pluginFile = path.join(root, "plugins", `${slugify(plugin.id)}.md`); - const pluginMarkdown = buildPluginKnowledge(plugin, generatedAt); + const pluginMarkdown = buildPluginKnowledge(plugin); if (writeGeneratedFile(pluginFile, pluginMarkdown)) { written.push(path.relative(rootDir, pluginFile)); } @@ -22,7 +22,7 @@ function generateKnowledgeFiles(rootDir = process.cwd()) { return written; } -function buildCoreKnowledge(rootDir, generatedAt) { +function buildCoreKnowledge(rootDir) { const packageJson = readJson(path.join(rootDir, "package.json")); const readme = readText(path.join(rootDir, "README.md")); const routes = discoverCoreRoutes(rootDir); @@ -37,8 +37,7 @@ function buildCoreKnowledge(rootDir, generatedAt) { category: "Core", tags: "core, routes, commands, settings", generated: true, - editable: false, - updated_at: generatedAt + editable: false }) + [ `# ${packageJson.name || "Lumi Core"}`, "", @@ -64,7 +63,7 @@ function buildCoreKnowledge(rootDir, generatedAt) { ].filter((line) => line !== "").join("\n"); } -function buildPluginKnowledge(plugin, generatedAt) { +function buildPluginKnowledge(plugin) { const routeLines = plugin.routes.length ? plugin.routes.map((route) => `- ${route.method ? `${route.method.toUpperCase()} ` : ""}${route.path}`).join("\n") : "- No plugin routes detected."; @@ -82,8 +81,7 @@ function buildPluginKnowledge(plugin, generatedAt) { category: "Plugin", tags: ["plugin", plugin.id, ...(plugin.keywords || [])].join(", "), generated: true, - editable: false, - updated_at: generatedAt + editable: false }) + [ `# ${plugin.name || plugin.id}`, "", @@ -221,8 +219,9 @@ function discoverCommandTriggers(source) { function writeGeneratedFile(filePath, content) { if (!canOverwriteGeneratedFile(filePath)) return false; - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${content.trim()}\n`); + const output = `${content.trim()}\n`; + if (fs.existsSync(filePath) && fs.readFileSync(filePath, "utf8") === output) return false; + writeTextAtomicSync(filePath, output); return true; } diff --git a/plugins/okf/backend/okf_store.js b/plugins/okf/backend/okf_store.js index 49f4445..0954429 100644 --- a/plugins/okf/backend/okf_store.js +++ b/plugins/okf/backend/okf_store.js @@ -4,6 +4,7 @@ const PERMISSION_LEVELS = Object.freeze(["edit", "edit_review", "edit_review_imp const STATUS_VALUES = Object.freeze(["draft", "published", "archived"]); const VISIBILITY_VALUES = Object.freeze(["user", "mod", "admin"]); const REVIEW_STATES = Object.freeze(["draft", "review_pending", "approved", "rejected"]); +const VISIBILITY_RANK = Object.freeze({ user: 0, mod: 1, admin: 2 }); function ensureTables(db) { db.exec(` @@ -80,29 +81,75 @@ function accessForUser(db, user) { const okfPermission = okfPermissionForUser(db, user?.id); const isAdmin = Boolean(user?.isAdmin); const isMod = Boolean(user?.isAdmin || user?.isMod); + const capabilities = Object.freeze({ + read_user_fields: Boolean(user), + read_mod_fields: isMod, + read_admin_fields: isAdmin, + edit_content: isAdmin || ["edit", "edit_review", "edit_review_implement"].includes(okfPermission), + review: isAdmin || ["edit_review", "edit_review_implement"].includes(okfPermission), + implement: isAdmin || okfPermission === "edit_review_implement", + manage_permissions: isAdmin, + management_view: isAdmin || ["edit", "edit_review", "edit_review_implement"].includes(okfPermission) + }); return { authenticated: Boolean(user), isAdmin, isMod, okfPermission, - canEdit: isAdmin || ["edit", "edit_review", "edit_review_implement"].includes(okfPermission), - canReview: isAdmin || ["edit_review", "edit_review_implement"].includes(okfPermission), - canImplement: isAdmin || okfPermission === "edit_review_implement", - canManagePermissions: isAdmin, - maxVisibility: isAdmin ? "admin" : isMod ? "mod" : "user" + capabilities, + canEdit: capabilities.edit_content, + canReview: capabilities.review, + canImplement: capabilities.implement, + canManagePermissions: capabilities.manage_permissions, + canViewManagement: capabilities.management_view, + maxVisibility: capabilities.read_admin_fields ? "admin" : capabilities.read_mod_fields ? "mod" : "user" }; } function canSeeEntry(access, entry, { management = false } = {}) { if (!access.authenticated || !entry || entry.deleted_at) return false; - if (access.isAdmin) return true; - if (management && access.canEdit) return true; + if (access.capabilities.read_admin_fields) return true; + if (!canReadVisibility(access, entry.visibility)) return false; + if (management && access.capabilities.management_view) return true; if (entry.status !== "published") return false; - if (entry.visibility === "admin") return false; - if (entry.visibility === "mod" && !access.isMod) return false; return true; } +function canReadVisibility(access, visibility) { + const required = VISIBILITY_RANK[visibility] ?? VISIBILITY_RANK.user; + const maximum = VISIBILITY_RANK[access.maxVisibility] ?? VISIBILITY_RANK.user; + return required <= maximum; +} + +function allowedVisibility(value, access, fallback = "user") { + const requested = normalizeChoice(value, VISIBILITY_VALUES, fallback); + if (canReadVisibility(access, requested)) return requested; + return canReadVisibility(access, fallback) ? fallback : access.maxVisibility; +} + +function visibleSnapshot(snapshot, access) { + if (!snapshot || typeof snapshot !== "object") return {}; + const visible = { + slug: snapshot.slug, + title: snapshot.title, + category: snapshot.category, + tags: Array.isArray(snapshot.tags) ? snapshot.tags : [], + aliases: Array.isArray(snapshot.aliases) ? snapshot.aliases : [], + summary: snapshot.summary, + user_markdown: snapshot.user_markdown, + visibility: snapshot.visibility, + status: snapshot.status, + review_state: snapshot.review_state + }; + if (access.capabilities.read_mod_fields) visible.moderator_markdown = snapshot.moderator_markdown; + if (access.capabilities.read_admin_fields) { + visible.admin_markdown = snapshot.admin_markdown; + visible.ai_facts_markdown = snapshot.ai_facts_markdown; + visible.source_links = Array.isArray(snapshot.source_links) ? snapshot.source_links : []; + } + return visible; +} + function publicEntry(entry, access) { const base = { id: entry.id, @@ -119,10 +166,10 @@ function publicEntry(entry, access) { updated_at: entry.updated_at, published_at: entry.published_at }; - if (access.isMod || access.isAdmin) { + if (access.capabilities.read_mod_fields) { base.moderator_markdown = entry.moderator_markdown; } - if (access.isAdmin || access.canEdit) { + if (access.capabilities.read_admin_fields) { base.admin_markdown = entry.admin_markdown; base.ai_facts_markdown = entry.ai_facts_markdown; base.source_links = parseJsonArray(entry.source_links_json); @@ -153,9 +200,10 @@ function getEntryBySlug(db, slug, user, options = {}) { return publicEntry(entry, access); } -function getEditableEntry(db, slug) { +function getEditableEntry(db, slug, user) { const entry = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug)); - return entry ? normalizeEntry(entry) : null; + const access = accessForUser(db, user); + return canSeeEntry(access, entry, { management: true }) ? publicEntry(entry, access) : null; } function createEntry(db, values, actor) { @@ -165,7 +213,7 @@ function createEntry(db, values, actor) { const entry = normalizeValues(values, { actor, existing: null, - canImplement: access.canImplement, + access, now }); entry.id = crypto.randomUUID(); @@ -179,19 +227,19 @@ function createEntry(db, values, actor) { "VALUES (@id, @slug, @title, @category, @tags_json, @aliases_json, @summary, @user_markdown, @moderator_markdown, @admin_markdown, @ai_facts_markdown, @source_links_json, @visibility, @status, @review_state, @created_by, @updated_by, @reviewed_by, @published_by, @created_at, @updated_at, @reviewed_at, @published_at, @archived_at, @deleted_at)" ).run(entry); addVersion(db, entry.id, "create", null, entry, actor, values.change_note || "Entry created."); - return normalizeEntry(entry); + return publicEntry(entry, access); } function updateEntry(db, slug, values, actor) { const access = accessForUser(db, actor); if (!access.canEdit) throw new Error("OKF edit permission is required."); const current = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug)); - if (!current) throw new Error("OKF entry was not found."); + if (!canSeeEntry(access, current, { management: true })) throw new Error("OKF entry was not found."); const now = Date.now(); const next = normalizeValues(values, { actor, existing: current, - canImplement: access.canImplement, + access, now }); next.id = current.id; @@ -210,13 +258,13 @@ function updateEntry(db, slug, values, actor) { "UPDATE okf_entries SET slug = @slug, title = @title, category = @category, tags_json = @tags_json, aliases_json = @aliases_json, summary = @summary, user_markdown = @user_markdown, moderator_markdown = @moderator_markdown, admin_markdown = @admin_markdown, ai_facts_markdown = @ai_facts_markdown, source_links_json = @source_links_json, visibility = @visibility, status = @status, review_state = @review_state, updated_by = @updated_by, updated_at = @updated_at, reviewed_by = @reviewed_by, published_by = @published_by, reviewed_at = @reviewed_at, published_at = @published_at, archived_at = @archived_at, deleted_at = @deleted_at WHERE id = @id" ).run(next); addVersion(db, next.id, "update", current, next, actor, values.change_note || "Entry updated."); - return normalizeEntry(next); + return publicEntry(next, access); } function setEntryWorkflow(db, slug, action, actor, note = "") { const access = accessForUser(db, actor); const entry = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug)); - if (!entry) throw new Error("OKF entry was not found."); + if (!canSeeEntry(access, entry, { management: true })) throw new Error("OKF entry was not found."); const next = { ...entry }; const now = Date.now(); if (action === "review") { @@ -251,25 +299,32 @@ function setEntryWorkflow(db, slug, action, actor, note = "") { "UPDATE okf_entries SET status = @status, review_state = @review_state, reviewed_by = @reviewed_by, published_by = @published_by, updated_by = @updated_by, updated_at = @updated_at, reviewed_at = @reviewed_at, published_at = @published_at, archived_at = @archived_at, deleted_at = @deleted_at WHERE id = @id" ).run(next); addVersion(db, next.id, action, entry, next, actor, note || `Workflow action: ${action}.`); - return normalizeEntry(next); + return publicEntry(next, access); } -function listVersions(db, entryId) { +function listVersions(db, entryId, user) { + const access = accessForUser(db, user); + const entry = db.prepare("SELECT * FROM okf_entries WHERE id = ? AND deleted_at IS NULL").get(entryId); + if (!canSeeEntry(access, entry, { management: true })) return []; return db .prepare("SELECT * FROM okf_versions WHERE entry_id = ? ORDER BY version_number DESC") .all(entryId) - .map((row) => ({ - ...row, - previous: parseJsonObject(row.previous_json), - next: parseJsonObject(row.next_json) - })); + .map((row) => { + const { previous_json, next_json, changed_by, ...metadata } = row; + return { + ...metadata, + ...(access.capabilities.read_admin_fields ? { changed_by } : {}), + previous: visibleSnapshot(parseJsonObject(previous_json), access), + next: visibleSnapshot(parseJsonObject(next_json), access) + }; + }); } function restoreVersion(db, entryId, versionNumber, actor, note = "") { const access = accessForUser(db, actor); if (!access.canImplement) throw new Error("OKF restore permission is required."); const current = db.prepare("SELECT * FROM okf_entries WHERE id = ? AND deleted_at IS NULL").get(entryId); - if (!current) throw new Error("OKF entry was not found."); + if (!canSeeEntry(access, current, { management: true })) throw new Error("OKF entry was not found."); const version = db .prepare("SELECT * FROM okf_versions WHERE entry_id = ? AND version_number = ?") .get(entryId, Number(versionNumber)); @@ -287,11 +342,11 @@ function restoreVersion(db, entryId, versionNumber, actor, note = "") { aliases_json: JSON.stringify(splitLines(snapshot.aliases || [])), summary: cleanText(snapshot.summary, 800), user_markdown: cleanText(snapshot.user_markdown, 24000), - moderator_markdown: cleanText(snapshot.moderator_markdown, 24000), - admin_markdown: cleanText(snapshot.admin_markdown, 24000), - ai_facts_markdown: cleanText(snapshot.ai_facts_markdown, 24000), - source_links_json: JSON.stringify(cleanLinkList(snapshot.source_links || [])), - visibility: normalizeChoice(snapshot.visibility, VISIBILITY_VALUES, "user"), + moderator_markdown: access.capabilities.read_mod_fields ? cleanText(snapshot.moderator_markdown, 24000) : current.moderator_markdown, + admin_markdown: access.capabilities.read_admin_fields ? cleanText(snapshot.admin_markdown, 24000) : current.admin_markdown, + ai_facts_markdown: access.capabilities.read_admin_fields ? cleanText(snapshot.ai_facts_markdown, 24000) : current.ai_facts_markdown, + source_links_json: access.capabilities.read_admin_fields ? JSON.stringify(cleanLinkList(snapshot.source_links || [])) : current.source_links_json, + visibility: allowedVisibility(snapshot.visibility, access, current.visibility), status: normalizeChoice(snapshot.status, STATUS_VALUES, "draft"), review_state: normalizeChoice(snapshot.review_state, REVIEW_STATES, "draft"), updated_by: String(actor.id), @@ -315,7 +370,7 @@ function restoreVersion(db, entryId, versionNumber, actor, note = "") { actor, note || `Restored version ${Number(versionNumber)}.` ); - return normalizeEntry(restored); + return publicEntry(restored, access); } function grantPermission(db, values, actor) { @@ -336,7 +391,8 @@ function revokePermission(db, id, actor) { db.prepare("UPDATE okf_permissions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(Date.now(), Number(id)); } -function listPermissions(db) { +function listPermissions(db, user) { + if (!accessForUser(db, user).capabilities.manage_permissions) return []; return db .prepare( "SELECT okf_permissions.*, user_profiles.internal_username AS user_name, grantor.internal_username AS granted_by_name " + @@ -350,7 +406,7 @@ function listPermissions(db) { function searchForAi(db, query, user, limit = 5) { const access = accessForUser(db, user); - if (!access.authenticated) return []; + if (!access.capabilities.read_user_fields) return []; return listEntries(db, { q: query }, user) .filter((entry) => entry.status === "published" && entry.review_state === "approved") .slice(0, limit) @@ -361,7 +417,12 @@ function searchForAi(db, query, user, limit = 5) { category: entry.category, visibility: entry.visibility, summary: entry.summary, - facts: [entry.user_markdown, entry.moderator_markdown, entry.admin_markdown, entry.ai_facts_markdown] + facts: [ + entry.user_markdown, + access.capabilities.read_mod_fields ? entry.moderator_markdown : "", + access.capabilities.read_admin_fields ? entry.admin_markdown : "", + access.capabilities.read_admin_fields ? entry.ai_facts_markdown : "" + ] .filter(Boolean) .join("\n\n") .slice(0, 4000), @@ -383,16 +444,16 @@ function matchesFilters(entry, filters, access, options) { entry.tags_json, entry.aliases_json ]; - if (access.isMod || access.isAdmin || options.management) visibleText.push(entry.moderator_markdown); - if (access.isAdmin || options.management) visibleText.push(entry.admin_markdown, entry.ai_facts_markdown, entry.source_links_json); + if (access.capabilities.read_mod_fields) visibleText.push(entry.moderator_markdown); + if (access.capabilities.read_admin_fields) visibleText.push(entry.admin_markdown, entry.ai_facts_markdown, entry.source_links_json); return visibleText.join(" ").toLowerCase().includes(q); } -function normalizeValues(values, { actor, existing, canImplement, now }) { - const status = canImplement +function normalizeValues(values, { actor, existing, access, now }) { + const status = access.capabilities.implement ? normalizeChoice(values.status, STATUS_VALUES, existing?.status || "draft") : existing?.status || "draft"; - const reviewState = canImplement + const reviewState = access.capabilities.implement ? normalizeChoice(values.review_state, REVIEW_STATES, existing?.review_state || "draft") : "review_pending"; const publishedAt = status === "published" ? (existing?.published_at || now) : existing?.published_at || null; @@ -404,11 +465,11 @@ function normalizeValues(values, { actor, existing, canImplement, now }) { aliases_json: JSON.stringify(splitLines(values.aliases ?? parseJsonArray(existing?.aliases_json))), summary: cleanText(values.summary ?? existing?.summary, 800), user_markdown: cleanText(values.user_markdown ?? existing?.user_markdown, 24000), - moderator_markdown: cleanText(values.moderator_markdown ?? existing?.moderator_markdown, 24000), - admin_markdown: cleanText(values.admin_markdown ?? existing?.admin_markdown, 24000), - ai_facts_markdown: cleanText(values.ai_facts_markdown ?? existing?.ai_facts_markdown, 24000), - source_links_json: JSON.stringify(cleanLinkList(values.source_links ?? parseJsonArray(existing?.source_links_json))), - visibility: normalizeChoice(values.visibility, VISIBILITY_VALUES, existing?.visibility || "user"), + moderator_markdown: access.capabilities.read_mod_fields ? cleanText(values.moderator_markdown ?? existing?.moderator_markdown, 24000) : existing?.moderator_markdown || "", + admin_markdown: access.capabilities.read_admin_fields ? cleanText(values.admin_markdown ?? existing?.admin_markdown, 24000) : existing?.admin_markdown || "", + ai_facts_markdown: access.capabilities.read_admin_fields ? cleanText(values.ai_facts_markdown ?? existing?.ai_facts_markdown, 24000) : existing?.ai_facts_markdown || "", + source_links_json: access.capabilities.read_admin_fields ? JSON.stringify(cleanLinkList(values.source_links ?? parseJsonArray(existing?.source_links_json))) : existing?.source_links_json || "[]", + visibility: allowedVisibility(values.visibility, access, existing?.visibility || "user"), status, review_state: reviewState, reviewed_by: existing?.reviewed_by || null, diff --git a/plugins/okf/index.js b/plugins/okf/index.js index 1eb862d..ed62b5e 100644 --- a/plugins/okf/index.js +++ b/plugins/okf/index.js @@ -24,10 +24,12 @@ const { ensureKnowledgeDirs, getCommunityKnowledgeFile, listCommunityKnowledgeFiles, + listCorrectionKnowledgeFiles, loadKnowledgeEntries, migrateSingleBracePlaceholders, registerKnowledgePlaceholderDefinitions, saveCommunityKnowledgeFile, + saveCorrectionKnowledgeFile, searchFileKnowledge } = require("./backend/file_knowledge"); const { generateKnowledgeFiles } = require("./backend/generate_knowledge"); @@ -39,6 +41,10 @@ const PLUGIN_ID = "okf"; module.exports = { id: PLUGIN_ID, init({ web, db, placeholders = placeholderService }) { + if (typeof web.auth?.requireAuth !== "function") { + throw new Error("OKF requires Lumi's shared WebUI authentication helpers."); + } + const requireLogin = web.auth.requireAuth; ensureTables(db); ensureKnowledgeDirs(process.cwd()); if (!getSetting("okf_single_brace_placeholder_migration_v1", false)) { @@ -88,7 +94,11 @@ module.exports = { global.lumiFrameworks.okf = { search: searchOkfForAi, context: okfContextProvider, - accessForUser: (user) => accessForUser(db, user) + accessForUser: (user) => accessForUser(db, user), + saveCorrection(values, actor) { + if (!actor?.isAdmin) throw new Error("Only admins can create correction knowledge files."); + return saveCorrectionKnowledgeFile(process.cwd(), values); + } }; global.lumiFrameworks.ai?.registerContext?.(PLUGIN_ID, okfContextProvider); @@ -119,12 +129,18 @@ module.exports = { renderAdmin(req, res, db); }); + router.post("/admin/preview", requireOkfManagement(db), (req, res) => { + const markdown = String(req.body?.markdown || ""); + if (markdown.length > 24000) return res.status(400).json({ error: "Markdown preview is limited to 24,000 characters." }); + return res.json({ html: renderMarkdown(markdown) }); + }); + router.post("/admin/community", requireOkfEdit(db), (req, res) => { try { validateOkfPlaceholderFields({ body: req.body.body }, req.session.user, placeholders); - const entry = saveCommunityKnowledgeFile(process.cwd(), req.body); + const entry = saveCommunityKnowledgeFile(process.cwd(), communityFileValuesForUser(db, req.session.user, req.body)); registerKnowledgePlaceholderDefinitions(placeholders, { rootDir: process.cwd() }); req.session.flash = { type: "success", message: "Community OKF file saved." }; res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community&community=${encodeURIComponent(entry.slug)}#okf-community-files`); @@ -139,10 +155,11 @@ module.exports = { validateOkfPlaceholderFields({ body: req.body.body }, req.session.user, placeholders); - const entry = saveCommunityKnowledgeFile(process.cwd(), { + const existing = getCommunityKnowledgeFile(process.cwd(), req.params.slug); + const entry = saveCommunityKnowledgeFile(process.cwd(), communityFileValuesForUser(db, req.session.user, { ...req.body, existing_slug: req.params.slug - }); + }, existing)); registerKnowledgePlaceholderDefinitions(placeholders, { rootDir: process.cwd() }); req.session.flash = { type: "success", message: "Community OKF file updated." }; res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community&community=${encodeURIComponent(entry.slug)}#okf-community-files`); @@ -189,7 +206,7 @@ module.exports = { router.post("/admin/entries/:slug/versions/:version/restore", requireOkfManagement(db), (req, res) => { try { - const selected = getEditableEntry(db, req.params.slug); + const selected = getEditableEntry(db, req.params.slug, req.session.user); if (!selected) throw new Error("OKF entry was not found."); const entry = restoreVersion( db, @@ -258,7 +275,15 @@ module.exports = { role: "public", authRequired: true, section: "admin", - canAccess: (user) => accessForUser(db, user).canEdit + canAccess: (user) => user?.isAdmin === true + }); + web.addNavItem({ + label: "Contribute to OKF", + path: `/plugins/${PLUGIN_ID}/admin`, + role: "public", + authRequired: true, + section: "community", + canAccess: (user) => user?.isAdmin !== true && accessForUser(db, user).canEdit }); return () => { global.lumiFrameworks?.ai?.unregisterContext?.(PLUGIN_ID); @@ -392,7 +417,8 @@ async function resolveOkfEntryPlaceholders(entry, user, placeholders) { } function renderAdmin(req, res, db) { - const activeTab = resolveAdminTab(req.query); + const access = accessForUser(db, req.session.user); + const activeTab = resolveAdminTab(req.query, access.canManagePermissions); const filters = { q: req.query.q || "", status: req.query.status || "", @@ -401,13 +427,18 @@ function renderAdmin(req, res, db) { }; const entries = listEntries(db, filters, req.session.user, { management: true }); const allEntries = listEntries(db, {}, req.session.user, { management: true }); - const selected = req.query.edit ? getEditableEntry(db, req.query.edit) : null; - const communityFiles = listCommunityKnowledgeFiles(process.cwd()); - const selectedCommunity = req.query.community ? getCommunityKnowledgeFile(process.cwd(), req.query.community) : null; - const systemFiles = listSystemKnowledgeFiles(process.cwd()); + const selected = req.query.edit ? getEditableEntry(db, req.query.edit, req.session.user) : null; + const communityFiles = listCommunityKnowledgeFiles(process.cwd()).filter((entry) => canReadOkfVisibility(access, entry.visibility)); + const requestedCommunity = req.query.community ? getCommunityKnowledgeFile(process.cwd(), req.query.community) : null; + const selectedCommunity = requestedCommunity && canReadOkfVisibility(access, requestedCommunity.visibility) ? requestedCommunity : null; + const systemFiles = listSystemKnowledgeFiles(process.cwd()).filter((entry) => canReadOkfVisibility(access, entry.visibility)); const selectedSystemFile = req.query.system ? findSystemKnowledgeFile(systemFiles, req.query.system) : null; + const correctionFiles = access.canManagePermissions + ? listCorrectionKnowledgeFiles(process.cwd()).filter((entry) => canReadOkfVisibility(access, entry.visibility)) + : []; + const selectedCorrection = req.query.correction ? findSystemKnowledgeFile(correctionFiles, req.query.correction) : null; const suggestionEntries = [...allEntries, ...communityFiles]; - const versions = selected ? listVersions(db, selected.id) : []; + const versions = selected ? listVersions(db, selected.id, req.session.user) : []; res.render(view("admin"), { title: "OKF Management", activeTab, @@ -418,11 +449,13 @@ function renderAdmin(req, res, db) { selectedCommunity, systemFiles, selectedSystemFile, + correctionFiles, + selectedCorrection, versions, - permissions: listPermissions(db), + permissions: listPermissions(db, req.session.user), levels: PERMISSION_LEVELS, statuses: STATUS_VALUES, - visibilityValues: VISIBILITY_VALUES, + visibilityValues: VISIBILITY_VALUES.filter((value) => canReadOkfVisibility(access, value)), reviewStates: REVIEW_STATES, categories: categoriesFor(suggestionEntries), tags: tagsFor(suggestionEntries), @@ -430,11 +463,13 @@ function renderAdmin(req, res, db) { }); } -function resolveAdminTab(query = {}) { +function resolveAdminTab(query = {}, allowCorrections = false) { if (query.edit) return "general"; if (query.community) return "community"; if (query.system) return "system"; - return ["general", "community", "system"].includes(query.tab) ? query.tab : "general"; + if (allowCorrections && query.correction) return "corrections"; + const allowed = allowCorrections ? ["general", "community", "system", "corrections"] : ["general", "community", "system"]; + return allowed.includes(query.tab) ? query.tab : "general"; } function listSystemKnowledgeFiles(rootDir = process.cwd()) { @@ -449,6 +484,24 @@ function listSystemKnowledgeFiles(rootDir = process.cwd()) { .sort((a, b) => a.scope.localeCompare(b.scope) || a.title.localeCompare(b.title)); } +function canReadOkfVisibility(access, visibility) { + if (visibility === "admin") return access.capabilities.read_admin_fields; + if (visibility === "mod") return access.capabilities.read_mod_fields; + return access.capabilities.read_user_fields; +} + +function communityFileValuesForUser(db, user, values, existing = null) { + const access = accessForUser(db, user); + if (existing && !canReadOkfVisibility(access, existing.visibility)) { + throw new Error("That community knowledge file is not available to your account."); + } + const requestedVisibility = VISIBILITY_VALUES.includes(values.visibility) ? values.visibility : existing?.visibility || "user"; + if (!canReadOkfVisibility(access, requestedVisibility)) { + throw new Error(`You cannot create or edit ${requestedVisibility}-only knowledge.`); + } + return { ...values, visibility: requestedVisibility }; +} + function findSystemKnowledgeFile(files, key) { const value = String(key || ""); return files.find((file) => @@ -463,11 +516,6 @@ function view(name) { return path.join(__dirname, "views", `${name}.ejs`); } -function requireLogin(req, res, next) { - if (req.session.user) return next(); - return res.redirect("/login"); -} - function requireAdmin(req, res, next) { if (req.session.user?.isAdmin) return next(); return denied(res); diff --git a/plugins/okf/tests/verify.js b/plugins/okf/tests/verify.js index b0bfe5e..e5a8edc 100644 --- a/plugins/okf/tests/verify.js +++ b/plugins/okf/tests/verify.js @@ -8,6 +8,7 @@ const { accessForUser, createEntry, ensureTables, + getEditableEntry, getEntryBySlug, grantPermission, listEntries, @@ -23,6 +24,7 @@ const { ensureKnowledgeDirs, getCommunityKnowledgeFile, listCommunityKnowledgeFiles, + listCorrectionKnowledgeFiles, loadKnowledgeEntries, listKnowledgePlaceholders, saveCorrectionKnowledgeFile, @@ -49,11 +51,15 @@ db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, update db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("mod-1", "Mod", now, now); db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("user-1", "User", now, now); db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("editor-1", "Editor", now, now); +db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("reviewer-1", "Reviewer", now, now); +db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("implementer-1", "Implementer", now, now); const admin = { id: "admin-1", isAdmin: true, isMod: true }; const mod = { id: "mod-1", isMod: true }; const user = { id: "user-1" }; const editor = { id: "editor-1" }; +const reviewer = { id: "reviewer-1" }; +const implementer = { id: "implementer-1" }; assert(PRESERVED_PATHS.includes("knowledge/community")); assert(PRESERVED_PATHS.includes("knowledge/corrections")); @@ -69,16 +75,42 @@ assert(adminTemplate.includes("System-generated OKF") && !adminTemplate.includes assert(adminTemplate.includes("data-okf-file-list")); assert(adminTemplate.includes("data-okf-file-search")); assert(adminTemplate.includes('data-okf-file-filter="category"')); +assert(adminTemplate.includes('data-okf-file-filter="tags"')); +assert(adminTemplate.includes("data-okf-markdown-preview-panel")); +assert(adminTemplate.includes("/plugins/okf/admin/preview")); assert(adminTemplate.includes("No community OKF files match these filters.")); assert(adminTemplate.includes("No system-generated OKF files match these filters.")); +assert(adminTemplate.includes("Feedback corrections")); +assert(adminTemplate.includes("selectedCorrection")); const okfPluginSource = fs.readFileSync(path.join(__dirname, "..", "index.js"), "utf8"); assert(okfPluginSource.includes("preferSpecificKnowledgeChunks")); assert(okfPluginSource.includes("trimForContext")); assert(okfPluginSource.includes("remaining = 4200")); +assert(okfPluginSource.includes("communityFileValuesForUser")); +assert(okfPluginSource.includes("canReadOkfVisibility(access, requestedCommunity.visibility)")); +assert(okfPluginSource.includes("VISIBILITY_VALUES.filter((value) => canReadOkfVisibility(access, value))")); +assert(okfPluginSource.includes("saveCorrection(values, actor)")); +assert(okfPluginSource.includes("Only admins can create correction knowledge files")); +assert(okfPluginSource.includes('router.post("/admin/preview", requireOkfManagement(db)')); +assert(okfPluginSource.includes('label: "Contribute to OKF"')); +const fileKnowledgeSource = fs.readFileSync(path.join(__dirname, "..", "backend", "file_knowledge.js"), "utf8"); +assert(fileKnowledgeSource.includes("writeTextAtomicSync(filePath, markdown)")); grantPermission(db, { user_id: "editor-1", level: "edit", notes: "Trusted writer" }, admin); +grantPermission(db, { user_id: "reviewer-1", level: "edit_review", notes: "Trusted reviewer" }, admin); +grantPermission(db, { user_id: "implementer-1", level: "edit_review_implement", notes: "Trusted implementer" }, admin); assert.equal(accessForUser(db, editor).canEdit, true); -assert.equal(listPermissions(db).filter((grant) => !grant.revoked_at).length, 1); +assert.equal(accessForUser(db, editor).capabilities.read_admin_fields, false); +assert.equal(accessForUser(db, reviewer).capabilities.review, true); +assert.equal(accessForUser(db, reviewer).capabilities.implement, false); +assert.equal(accessForUser(db, implementer).capabilities.implement, true); +assert.equal(accessForUser(db, mod).capabilities.read_mod_fields, true); +assert.equal(accessForUser(db, user).capabilities.read_mod_fields, false); +assert.equal(accessForUser(db, admin).capabilities.manage_permissions, true); +assert.equal(accessForUser(db, null).capabilities.read_user_fields, false); +assert.equal(accessForUser(db, mod).capabilities.management_view, false); +assert.equal(listPermissions(db, editor).length, 0); +assert.equal(listPermissions(db, admin).filter((grant) => !grant.revoked_at).length, 3); const entry = createEntry(db, { title: "Currency basics", @@ -104,6 +136,7 @@ assert.equal(listEntries(db, {}, mod).length, 1); assert.equal(getEntryBySlug(db, "currency-basics", user), null); assert.equal(getEntryBySlug(db, "currency-basics", mod).moderator_markdown.includes("Moderators"), true); assert.equal(getEntryBySlug(db, "currency-basics", admin).admin_markdown.includes("Admin-only"), true); +assert.equal(listEntries(db, {}, editor, { management: true }).some((item) => item.slug === "currency-basics"), false); assert(searchForAi(db, "currency", mod).some((item) => item.facts.includes("Moderators"))); assert.equal(searchForAi(db, "currency", user).length, 0); @@ -125,6 +158,21 @@ assert(publicUserContext.some((item) => item.id === publicEntry.id)); assert.equal(publicUserContext.some((item) => item.facts.includes("Admin-only")), false); assert(publicAdminContext.some((item) => item.facts.includes("Admin-only implementation notes."))); assert(publicAdminContext.some((item) => item.facts.includes("Admin AI fact."))); +assert.equal(getEntryBySlug(db, "public-help", editor).admin_markdown, undefined); +assert.equal(getEditableEntry(db, "public-help", editor).ai_facts_markdown, undefined); +assert.equal(getEditableEntry(db, "public-help", reviewer).admin_markdown, undefined); +assert.equal(getEditableEntry(db, "public-help", implementer).ai_facts_markdown, undefined); +assert.equal(searchForAi(db, "Admin-only implementation", editor).length, 0); +updateEntry(db, "public-help", { + title: "Public help", + slug: "public-help", + summary: "Contributor-safe update.", + user_markdown: "Everyone can still read this.", + admin_markdown: "Attempted overwrite.", + ai_facts_markdown: "Attempted AI overwrite." +}, editor); +assert.equal(getEntryBySlug(db, "public-help", admin).admin_markdown, "Admin-only implementation notes."); +assert.equal(getEntryBySlug(db, "public-help", admin).ai_facts_markdown, "Admin AI fact."); const proposed = createEntry(db, { title: "Draft from editor", @@ -144,12 +192,16 @@ updateEntry(db, "draft-from-editor", { user_markdown: "Updated draft content.", change_note: "Editor update." }, editor); -assert.equal(listVersions(db, proposed.id).length, 2); +assert.equal(listVersions(db, proposed.id, admin).length, 2); +const contributorVersions = listVersions(db, proposed.id, editor); +assert.equal(contributorVersions[0].next.admin_markdown, undefined); +assert.equal(contributorVersions[0].next_json, undefined); +assert.equal(contributorVersions[0].changed_by, undefined); const restored = restoreVersion(db, proposed.id, 1, admin, "Test restore."); assert.equal(restored.summary, "Editor draft"); assert.equal(restored.user_markdown, "Draft content."); -assert.equal(listVersions(db, proposed.id).length, 3); +assert.equal(listVersions(db, proposed.id, admin).length, 3); assert.equal(searchForAi(db, "draft", admin).length, 0); setEntryWorkflow(db, "draft-from-editor", "review", admin); @@ -255,6 +307,8 @@ assert(Number.isFinite(currencyResults[0].source_metadata.score)); const identityResults = searchFileKnowledge({ query: "Who is OokamiKunTV?", user, rootDir: tempRoot, limit: 5 }); assert.equal(identityResults[0].id, "community.people"); assert(identityResults[0].facts.includes("OokamiKunTV is a known community contact")); +assert.equal(searchFileKnowledge({ query: "communitypeople", user, rootDir: tempRoot, limit: 5 })[0].id, "community.people"); +assert.equal(searchFileKnowledge({ query: "Can you help me?", user, rootDir: tempRoot, limit: 5 }).length, 0); const commandUserResult = searchFileKnowledge({ query: "commands", user, rootDir: tempRoot, limit: 5 })[0]; assert(commandUserResult.facts.includes("The public currency is coins.")); assert(commandUserResult.facts.includes("[missing OKF reference]")); @@ -317,6 +371,7 @@ const savedCorrection = saveCorrectionKnowledgeFile(tempRoot, { assert.equal(savedCorrection.id, "correction.feedback-correction"); assert.equal(savedCorrection.path, "knowledge/corrections/feedback-correction.md"); assert.equal(savedCorrection.frontmatter.source_feedback_id, "feedback-1"); +assert.equal(listCorrectionKnowledgeFiles(tempRoot)[0].id, "correction.feedback-correction"); assert.equal(searchFileKnowledge({ query: "corrected behavior", user, rootDir: tempRoot, limit: 5 })[0].id, "correction.feedback-correction"); fs.writeFileSync(currencyKnowledgePath, [ "---", @@ -393,6 +448,12 @@ assert(generatedPlugin.includes("Test Plugin")); assert(generatedPlugin.includes("### GET /plugins/test-plugin/settings")); assert(generatedPlugin.includes("Inputs:")); assert(generatedPlugin.includes("test")); +assert.equal(generatedCore.includes("updated_at:"), false); +assert.deepEqual(generateKnowledgeFiles(generatedRoot), []); +assert.equal( + searchFileKnowledge({ query: "testplugin settings", user, rootDir: generatedRoot, limit: 5 })[0].source_metadata.heading, + "GET /plugins/test-plugin/settings" +); fs.writeFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), [ "---", "id: plugin.test-plugin", @@ -406,7 +467,7 @@ generateKnowledgeFiles(generatedRoot); assert(fs.readFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), "utf8").includes("Manual Test Plugin")); fs.rmSync(generatedRoot, { recursive: true, force: true }); -const grant = listPermissions(db).find((row) => row.user_id === "editor-1" && !row.revoked_at); +const grant = listPermissions(db, admin).find((row) => row.user_id === "editor-1" && !row.revoked_at); revokePermission(db, grant.id, admin); assert.equal(accessForUser(db, editor).canEdit, false); diff --git a/plugins/okf/views/admin.ejs b/plugins/okf/views/admin.ejs index 97ce620..5427930 100644 --- a/plugins/okf/views/admin.ejs +++ b/plugins/okf/views/admin.ejs @@ -1,14 +1,17 @@ <%- include("../../../src/web/views/partials/layout-top", { title }) %>
    <%- include("../../../src/web/views/partials/page-header", { - eyebrow: "Administration", - pageTitle: "OKF Management", - description: "Manage role-gated knowledge entries, review state, version history, and OKF-specific editing permissions." + eyebrow: okfAccess.canManagePermissions ? "Administration" : "Community contribution", + pageTitle: okfAccess.canManagePermissions ? "OKF Management" : "Contribute to OKF", + description: okfAccess.canManagePermissions + ? "Manage knowledge entries, reviews, publishing, history, and contributor access." + : `Your ${okfAccess.level || "editor"} access lets you contribute here without granting access to other admin areas.` }) %> <% if (activeTab === "general") { %>
    @@ -49,8 +52,10 @@ <% } else if (activeTab === "community") { %>

    Community files are locally maintained OKF Markdown under knowledge/community.

    - <% } else { %> + <% } else if (activeTab === "system") { %>

    System-generated files are read-only OKF Markdown from core and plugin metadata.

    + <% } else { %> +

    Feedback corrections are reviewed, searchable knowledge fixes linked back to the core feedback that created them.

    <% } %> <% categories.forEach((category) => { %> @@ -124,6 +129,7 @@ +

    <%= communityFiles.length %> file<%= communityFiles.length === 1 ? "" : "s" %> shown.

    @@ -135,7 +141,7 @@
    <% communityFiles.forEach((file) => { %> - + @@ -185,6 +191,7 @@ <% }) %> + Controls the minimum role allowed to retrieve or view this file.
    @@ -197,8 +204,13 @@
    - + You can reference visible frontmatter values with placeholders such as {{community.currency.primary_name}}. +
    <% if (selectedCommunity && (selectedCommunity.generated || !selectedCommunity.editable)) { %>
    @@ -237,6 +249,7 @@ +

    <%= systemFiles.length %> file<%= systemFiles.length === 1 ? "" : "s" %> shown. Generated files are rebuilt from repository metadata and are read-only here.

    @@ -248,7 +261,7 @@
    <% systemFiles.forEach((file) => { %> - + @@ -277,6 +290,46 @@ <% } %> +<% if (activeTab === "corrections" && okfAccess.canManagePermissions) { %> +
    +
    +
    +

    Feedback corrections

    +

    These files have retrieval priority over generated and community knowledge so reviewed fixes win when topics overlap.

    +
    + Open feedback review +
    + <% if (!correctionFiles.length) { %> +
    No feedback correction files have been created yet.
    + <% } else { %> +
    +
    View Download - +
    <%= new Date(note.created_at).toLocaleString() %> -
    +
    FileCategoryStatusVisibilityUpdated
    <%= file.title %>

    <%= file.id %> · <%= file.slug %>.md

    <%= file.category || "Community" %> <%= file.status %>
    FileScopeCategoryStatusVisibility
    <%= file.title %>

    <%= file.id %> · <%= file.path %>

    <%= file.scope %> <%= file.category || "General" %>
    + + + <% correctionFiles.forEach((file) => { %> + + + + + + + + <% }) %> + +
    CorrectionCategoryVisibilityPrioritySource
    <%= file.title %>

    <%= file.path %>

    <%= file.category || "Correction" %><%= file.visibility %><%= file.priority %><% if (String(file.frontmatter.source_feedback_url || '').startsWith('/admin/feedback')) { %>View feedback<% } else { %>Not linked<% } %>
    +
    + <% } %> + <% if (selectedCorrection) { %> + + <% } %> +
    +<% } %> + <% if (selected) { %> - <% if (isAdmin) { %>
    + <% if (isAdmin) { %>
    - <% } else { %> - - - <% } %> + <% } else { %><% } %>
    +
    + +
    + <% (command.random.replies.length ? command.random.replies : [{ text: "", weight: 1, condition: "" }]).forEach((reply) => { %> +
    + + + + +
    + <% }) %> +
    + +

    Every matching reply can be selected. Higher weights are chosen more often; use whole numbers from 1 to 999.

    + +
    +

    Optional conditions use the rolled number, for example <20, >=10, or =5.

    +
    <% if (isAdmin) { %>
    +

    Preview can use safe built-ins such as Math, dates, JSON, URLs, arrays, and text helpers. It cannot open files, run programs, load Node packages, or send external network requests.

    <% } %> - + <% if (!isAdmin && command.mode === 'advanced') { %>

    Dynamic commands can only be edited by admins.

    @@ -211,16 +242,63 @@ const response = form.querySelector(".js-field-response")?.closest(".field"); const code = form.querySelector(".js-field-code"); const language = form.querySelector(".js-field-language"); + const random = form.querySelector(".js-field-random"); const isAdvanced = mode === "advanced"; - if (response) response.style.display = isAdvanced ? "none" : ""; + if (response) response.style.display = mode === "plain" ? "" : "none"; if (code) code.style.display = isAdvanced ? "" : "none"; if (language) language.style.display = isAdvanced ? "" : "none"; + if (random) random.style.display = mode === "random" ? "" : "none"; + }; + const toggleRngFields = (form) => { + const enabled = form.querySelector(".js-rng-enabled")?.checked === true; + const range = form.querySelector("[data-rng-range]"); + if (range) range.hidden = !enabled; + }; + const addRandomReply = (form) => { + const list = form.querySelector("[data-random-replies]"); + if (!list) return; + const row = document.createElement("div"); + row.className = "command-random-row"; + row.dataset.randomReplyRow = ""; + const text = document.createElement("input"); + text.name = "random_text"; + text.placeholder = "Message to send"; + text.dataset.placeholderField = "core.custom_commands.static_response"; + text.dataset.placeholderOutputAudience = "user"; + const weight = document.createElement("input"); + weight.name = "random_weight"; + weight.type = "number"; + weight.min = "1"; + weight.max = "999"; + weight.step = "1"; + weight.value = "1"; + weight.setAttribute("aria-label", "Selection weight"); + const condition = document.createElement("input"); + condition.name = "random_condition"; + condition.placeholder = "Optional: <20"; + condition.setAttribute("aria-label", "RNG condition"); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "button subtle"; + remove.dataset.randomRemove = ""; + remove.textContent = "Remove"; + row.append(text, weight, condition, remove); + list.append(row); + text.focus(); }; forms.forEach((form) => { toggleFields(form); + toggleRngFields(form); form.querySelector(".js-command-mode")?.addEventListener("change", () => { toggleFields(form); }); + form.querySelector(".js-rng-enabled")?.addEventListener("change", () => toggleRngFields(form)); + form.querySelector("[data-random-add]")?.addEventListener("click", () => addRandomReply(form)); + form.addEventListener("click", (event) => { + const remove = event.target.closest("[data-random-remove]"); + if (!remove || !form.contains(remove)) return; + remove.closest("[data-random-reply-row]")?.remove(); + }); }); <%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-dashboard.ejs b/src/web/views/admin-dashboard.ejs index 2769f1b..0f2e6a0 100644 --- a/src/web/views/admin-dashboard.ejs +++ b/src/web/views/admin-dashboard.ejs @@ -31,6 +31,11 @@

    Create public, moderator, or admin pages.

    Manage pages +
    +

    OBS overlays

    +

    Build live Browser Source overlays, scenes, and OBS mappings.

    + Manage overlays +

    Plugins

    Install, enable, and update modules.

    @@ -79,7 +84,7 @@

    Maintenance

    -
    + <%- include("partials/state-button", { type: "submit", classes: "subtle", @@ -90,7 +95,7 @@ ] }) %>
    -
    + <%- include("partials/state-button", { type: "submit", states: [ @@ -112,6 +117,7 @@ }) %>
    +

    <%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-feedback.ejs b/src/web/views/admin-feedback.ejs index 400dcab..697ce4a 100644 --- a/src/web/views/admin-feedback.ejs +++ b/src/web/views/admin-feedback.ejs @@ -46,6 +46,9 @@ Plugin/area + + +
    + <% if (item.merged_into) { %> +
    Merged duplicate

    Merged into <%= item.merged_into.summary %>. <%= item.merged_into.reason %>

    +
    +
    + <% } %> + <% if (item.merged_sources && item.merged_sources.length) { %> +
    + Merged reports (<%= item.merged_sources.length %>) + <% item.merged_sources.forEach((source) => { %> + + <% }) %> +
    + <% } %> +
    Description

    <%= item.description %>

    @@ -228,9 +248,24 @@ + <% if (!item.merged_into) { %> + + <% } %> +

    Repository update checks use the configured target and selected channel.

    +

    @@ -130,9 +131,31 @@
    <% } %> +
    +
    +
    +

    Reusable text values

    +

    Create values such as {{custom.stream.title}} once, then insert them into supported command and content fields.

    +
    + +
    +
    + <% (settings.custom_placeholders || []).forEach((entry) => { %> +
    + + + + + +
    + <% }) %> +
    +

    Names use lowercase letters, numbers, underscores, and dots. Access rules apply to suggestions, previews, and final rendering; values marked for staff cannot be sent to ordinary users.

    +
    +

    Homepage content

    -

    Configure public homepage link buttons and the priority-based dynamic hero without editing raw JSON.

    +

    Choose the links and featured content shown on the public homepage.

    @@ -148,16 +171,16 @@
    -

    Homepage hero entries

    -

    The first available enabled hero by priority is shown on the homepage.

    +

    Featured homepage content

    +

    Lumi checks enabled items from lowest priority number to highest and shows the first one that is available.

    - +
    Advanced JSON -

    The builder writes JSON into hidden fields before save. Edit only if you need a field the builder does not expose.

    +

    Expert option: edit the saved configuration directly only when the form does not offer the setting you need.

    @@ -192,7 +215,7 @@
    -
    +
    @@ -202,4 +225,5 @@
    + <%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-theme.ejs b/src/web/views/admin-theme.ejs index e70877e..8723c9f 100644 --- a/src/web/views/admin-theme.ejs +++ b/src/web/views/admin-theme.ejs @@ -130,6 +130,7 @@
    Reload metadata

    Stable checks read repo metadata from main. Experimental branches are considered only when selected here.

    +
    + Your local data stays in place +

    Updates preserve settings, databases, feedback and attachments, plugin data, AI models and runtimes, exports, community knowledge, corrections, and locally owned knowledge files. Lumi creates a backup first and automatically tries to restore it if an update fails.

    +
    @@ -265,7 +269,7 @@ Advanced
    -

    Repository updates are the recommended path. ZIP uploads still create backups and recovery markers, but they can bypass repository metadata and compatibility checks unless the ZIP includes valid manifest data.

    +

    Repository updates are the recommended path. ZIP uploads still create backups and automatically restore them if file replacement fails, but they can bypass repository metadata and compatibility checks unless the ZIP includes valid manifest data.

    Core ZIP

    diff --git a/src/web/views/admin-users.ejs b/src/web/views/admin-users.ejs index e635052..b22b1e9 100644 --- a/src/web/views/admin-users.ejs +++ b/src/web/views/admin-users.ejs @@ -3,7 +3,7 @@ <%- include("partials/page-header", { eyebrow: "Community", pageTitle: "Users", - description: "Review linked identities, notes, and internal usernames." + description: "Review linked accounts, staff notes, and Lumi usernames." }) %> <% if (!users.length) { %>
    No users yet.
    @@ -20,11 +20,11 @@ - - + + <% if (isAdmin) { %> - + <% } %> @@ -68,7 +68,7 @@ <% } %> diff --git a/src/web/views/feedback.ejs b/src/web/views/feedback.ejs index c771736..80b9922 100644 --- a/src/web/views/feedback.ejs +++ b/src/web/views/feedback.ejs @@ -55,6 +55,25 @@
    Scope<%= selectedFeedback.scope_label_display %>
    Submitted<%= new Date(selectedFeedback.created_at).toLocaleString() %>
    + <% if (selectedFeedback.merged_into) { %> +
    + This report was merged +

    <%= selectedFeedback.merged_into.reason || "It describes the same issue as another report." %>

    + Open the main report: <%= selectedFeedback.merged_into.summary %> +
    + <% } %> + <% if (selectedFeedback.merged_sources && selectedFeedback.merged_sources.length) { %> + + <% } %> <% if (!selectedFeedback.is_mine) { %>
    Public feedback

    You can mark that this affects you too or add public context without seeing private diagnostics or attachments.

    <% } %> @@ -104,14 +123,14 @@ <% }) %> <% } %> - <% if (!selectedFeedback.is_mine && !selectedFeedback.supported_by_me) { %> + <% if (!selectedFeedback.merged_into && !selectedFeedback.is_mine && !selectedFeedback.supported_by_me) { %> <% } else if (selectedFeedback.supported_by_me) { %> Marked as affecting you too. <% } %> - + <% if (!selectedFeedback.merged_into) { %>
    @@ -120,7 +139,7 @@
    - + <% } %> <% } %> @@ -154,7 +173,7 @@
    Internal usernameIdentitiesLumi usernameLinked accounts NotesUpdate usernameChange username
    - +
    <%= new Date(item.updated_at).toLocaleString() %> - <% if (!item.is_mine && !item.supported_by_me) { %> + <% if (item.status !== "duplicate" && !item.is_mine && !item.supported_by_me) { %>
    diff --git a/src/web/views/home.ejs b/src/web/views/home.ejs index bf695e9..667dab9 100644 --- a/src/web/views/home.ejs +++ b/src/web/views/home.ejs @@ -54,7 +54,12 @@ <% } else if (homepageHero.embed_url) { %> <% const heroFrameStyle = `${homepageHero.aspect_ratio ? `aspect-ratio:${homepageHero.aspect_ratio};` : ""}${homepageHero.embed_height ? `min-height:${homepageHero.embed_height}px;` : ""}`; %> - +
    "> + + <% if (homepageHero.secondary_embed_url) { %> + + <% } %> +
    <% } else if (homepageHero.fallback_image_url || homepageHero.fallback_text || homepageHero.fallback_url) { %>
    <% if (homepageHero.fallback_image_url) { %><% } %> @@ -64,7 +69,7 @@
    <% } else if (homepageHero.render_error) { %>
    - Hero unavailable + Featured content unavailable <%= homepageHero.render_error %>
    <% } %> diff --git a/src/web/views/overlay-render.ejs b/src/web/views/overlay-render.ejs new file mode 100644 index 0000000..b5e8eff --- /dev/null +++ b/src/web/views/overlay-render.ejs @@ -0,0 +1,18 @@ + + + + + + + + Lumi Overlay + + + +
    + + + + + + diff --git a/src/web/views/partials/page-header.ejs b/src/web/views/partials/page-header.ejs index 6b02f3f..299c794 100644 --- a/src/web/views/partials/page-header.ejs +++ b/src/web/views/partials/page-header.ejs @@ -8,4 +8,7 @@

    <%= description %>

    <% } %> + <% if (typeof actions !== "undefined" && actions) { %> +
    <%- actions %>
    + <% } %> diff --git a/src/web/views/profile.ejs b/src/web/views/profile.ejs index 4c3f0dd..86ee0a8 100644 --- a/src/web/views/profile.ejs +++ b/src/web/views/profile.ejs @@ -61,8 +61,8 @@ <%= account.provider %> <%= account.display_name || account.provider_user_id %> <% if (account.provider !== "discord") { %> -
    - + +
    <% } %>