From 32ec0a102f6d38fa87b9430d985aee73ad8a9d02 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Thu, 25 Jun 2026 14:10:04 +0200 Subject: [PATCH] Advance experimental OKF and Lumi AI systems --- .gitignore | 1 + TODO.md | 415 ++++-- docs/placeholder-system.md | 132 ++ knowledge/README.md | 53 + knowledge/community/.gitkeep | 1 + knowledge/community/community-people.md | 24 + knowledge/community/test.md | 15 + knowledge/core/.gitkeep | 1 + knowledge/core/lumi-core.md | 1292 +++++++++++++++++ knowledge/corrections/.gitkeep | 1 + knowledge/plugins/.gitkeep | 1 + knowledge/plugins/auto-vc.md | 74 + knowledge/plugins/birthday.md | 124 ++ knowledge/plugins/economy-framework.md | 304 ++++ knowledge/plugins/economy-games.md | 84 ++ knowledge/plugins/expression-interaction.md | 104 ++ knowledge/plugins/lumi-ai.md | 524 +++++++ knowledge/plugins/moderation.md | 124 ++ knowledge/plugins/okf.md | 144 ++ knowledge/plugins/quotes.md | 124 ++ knowledge/plugins/sample-plugin.md | 44 + knowledge/plugins/throne-wishlist.md | 124 ++ knowledge/plugins/welcome-messages.md | 114 ++ plugins/lumi_ai/backend/ai_provider.js | 297 +++- plugins/lumi_ai/backend/commands.js | 15 +- plugins/lumi_ai/backend/config_manager.js | 59 + plugins/lumi_ai/backend/controller.js | 288 ++++ plugins/lumi_ai/backend/downloader.js | 350 ++++- plugins/lumi_ai/backend/gate_provider.js | 38 +- plugins/lumi_ai/backend/hardware.js | 55 +- plugins/lumi_ai/backend/metrics.js | 238 +++ plugins/lumi_ai/backend/prompt_builder.js | 63 +- plugins/lumi_ai/backend/request_jobs.js | 6 +- plugins/lumi_ai/backend/response_formatter.js | 50 +- plugins/lumi_ai/index.js | 151 +- plugins/lumi_ai/public/assistant.js | 9 +- plugins/lumi_ai/public/settings.css | 14 + plugins/lumi_ai/public/settings.js | 58 +- plugins/lumi_ai/public/tool-manager.js | 14 +- plugins/lumi_ai/runtime_manifest.json | 34 +- plugins/lumi_ai/tests/verify-tools.js | 3 + plugins/lumi_ai/tests/verify.js | 307 +++- plugins/lumi_ai/views/improvement-center.ejs | 2 +- plugins/lumi_ai/views/settings.ejs | 180 ++- plugins/okf/backend/file_knowledge.js | 674 +++++++++ plugins/okf/backend/generate_knowledge.js | 519 +++++++ plugins/okf/index.js | 294 +++- plugins/okf/tests/verify.js | 282 ++++ plugins/okf/views/admin.ejs | 450 +++++- plugins/throne_wishlist/README.md | 35 +- plugins/throne_wishlist/index.js | 102 +- plugins/throne_wishlist/views/admin.ejs | 4 +- .../verify-command-preview-confirmations.js | 3 + scripts/verify-feedback-system.js | 32 + scripts/verify-placeholders.js | 140 ++ src/main.js | 2 + src/services/command-router.js | 15 +- src/services/feedback.js | 1102 +++++++++++++- src/services/placeholders.js | 714 +++++++++ src/services/plugins.js | 4 +- src/web/public/app.js | 1021 ++++++++++++- src/web/public/assistant-panels.js | 7 + src/web/public/lumi-components.css | 172 +++ src/web/public/styles.css | 4 + src/web/server.js | 404 +++++- src/web/views/admin-commands.ejs | 4 +- src/web/views/admin-feedback.ejs | 112 ++ src/web/views/partials/layout-bottom.ejs | 7 +- 68 files changed, 11719 insertions(+), 439 deletions(-) create mode 100644 docs/placeholder-system.md create mode 100644 knowledge/README.md create mode 100644 knowledge/community/.gitkeep create mode 100644 knowledge/community/community-people.md create mode 100644 knowledge/community/test.md create mode 100644 knowledge/core/.gitkeep create mode 100644 knowledge/core/lumi-core.md create mode 100644 knowledge/corrections/.gitkeep create mode 100644 knowledge/plugins/.gitkeep create mode 100644 knowledge/plugins/auto-vc.md create mode 100644 knowledge/plugins/birthday.md create mode 100644 knowledge/plugins/economy-framework.md create mode 100644 knowledge/plugins/economy-games.md create mode 100644 knowledge/plugins/expression-interaction.md create mode 100644 knowledge/plugins/lumi-ai.md create mode 100644 knowledge/plugins/moderation.md create mode 100644 knowledge/plugins/okf.md create mode 100644 knowledge/plugins/quotes.md create mode 100644 knowledge/plugins/sample-plugin.md create mode 100644 knowledge/plugins/throne-wishlist.md create mode 100644 knowledge/plugins/welcome-messages.md create mode 100644 plugins/lumi_ai/backend/controller.js create mode 100644 plugins/okf/backend/file_knowledge.js create mode 100644 plugins/okf/backend/generate_knowledge.js create mode 100644 scripts/verify-placeholders.js create mode 100644 src/services/placeholders.js diff --git a/.gitignore b/.gitignore index 01056ee..389426c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ npm-debug.log security-audit-*.json security-audit-*.md taskfile.txt +taskfile-export.json codex-guidelines Twitch.png twitch-credentials-lumi.png diff --git a/TODO.md b/TODO.md index 19de142..a644a7c 100644 --- a/TODO.md +++ b/TODO.md @@ -2,11 +2,13 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. Keep pending work under the relevant category and move completed items to the Done section with a short note. -## OKF Knowledge System +## Current Local State / Source of Truth -Current state on `experimental-okf` as of 2026-06-18: 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, and role-aware Lumi AI context integration through the generic AI context provider hook. File-backed OKF directories, indexing, and feedback-to-correction flow remain for later passes. +### OKF Knowledge System -### Implemented Locally +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. + +#### Implemented Locally - Added standalone `plugins/okf` plugin with WebUI route mounting and navigation registration. - Added plugin-owned SQLite tables for OKF entries, versions, and per-user OKF permission grants. @@ -34,144 +36,35 @@ Current state on `experimental-okf` as of 2026-06-18: standalone OKF plugin work - Made Lumi AI context providers receive user/message/scope/origin context so per-user OKF permissions can be enforced. - Ensured OKF AI context includes only published and approved entries and only fields visible to the requesting user. - Changed OKF aliases/related questions to newline-only input so commas remain valid inside questions. +- Added `knowledge/core`, `knowledge/plugins`, `knowledge/community`, and `knowledge/corrections` with ownership documentation and keep-files. +- Added dependency-free OKF Markdown/frontmatter parsing for stable IDs, scopes, status, priority, tags, generated/editable flags, and timestamps. +- Added file-backed OKF chunking by Markdown heading with source metadata: path, id, heading, score, and excerpt. +- Wired file-backed OKF retrieval into Lumi AI context alongside existing SQLite entries. +- Enforced file retrieval priority: corrections, community, plugins, then core. +- Enforced file-backed visibility for user/mod/admin knowledge. +- Added access-aware OKF placeholder resolution for visible frontmatter values, with generic missing-reference markers for hidden or unavailable references. +- Added an mtime/size-based file knowledge cache so unchanged files are reused and changed/deleted files are reflected on later searches. +- Added generated core/plugin OKF baseline generation from package metadata, README, detected core routes, plugin manifests, plugin routes, and static command trigger hints. +- Added generated baseline files under `knowledge/core` and `knowledge/plugins`. +- Generated OKF files are marked `generated: true` and `editable: false`; files marked `generated: false` or `editable: true` are not overwritten. +- Added an OKF admin community file editor for `knowledge/community` Markdown with frontmatter fields, Markdown body editing, preview, filename rename support, and generated/non-editable save protection. +- Added admin feedback-to-correction OKF creation that writes searchable Markdown files under `knowledge/corrections` and links the generated path back to the reviewed feedback item. +- Changed selected OKF entry editing into a full-screen admin modal with session-backed draft preservation. +- Added global opt-in placeholder auto-suggest for fields that declare scoped placeholder sources, with OKF Markdown fields as the first consumer. +- Added system-generated Throne Wishlist destination template placeholders to the global placeholder auto-suggest, using double-brace syntax while preserving legacy single-brace rendering. +- Added a server-side placeholder registry, trusted field policy registry, catalog/preview APIs, and save/render validation for first user-visible fields. +- Split `/plugins/okf/admin` into tabbed General OKF, Community OKF, and System-generated OKF sections. +- Added a read-only System-generated OKF admin tab that lists generated core/plugin knowledge files and previews selected Markdown while preserving role-scoped retrieval semantics. +- Replaced Community and System-generated OKF pill rows with responsive searchable/filterable tables while preserving edit and preview actions. +- Expanded generated core/plugin OKF route documentation with static route reference details for purpose, inputs, response format, access signals, side effects, and limitations. +- Capped OKF AI context output from generated route docs so route help queries do not overfill the local model context, and prefer specific route chunks over generic route-list chunks. - Added `plugins/okf/tests/verify.js`. -### Remaining Work +### Core Feedback System -- Add file-backed OKF Markdown/frontmatter directories for generated core/plugin/community/corrections knowledge. -- Add Markdown/frontmatter parsing with stable IDs, scopes, status, priority, tags, generated/editable flags, and timestamps. -- Add OKF indexing/chunking with path/id/heading/score/excerpt source metadata. -- Add changed-file reindexing where practical. -- Enforce retrieval priority for corrections, community OKF, plugin OKF, and core OKF. -- Add safe placeholder resolution for generated OKF references. -- Add richer category/tag management and Markdown preview/editor polish. -- Add correction-file creation from reviewed feedback after file-backed OKF storage exists. -- Add update preservation tests/notes for OKF file paths once those paths exist. +Current state on `experimental-okf` as of 2026-06-21: the core feedback system is implemented locally and not yet pushed after the latest context-menu, review action, screenshot attachment, Codex export, semantic target, draft-retention, and similar-feedback confirmation refinements. The detailed checklist below is retained for hardening, but this current-state block is the source of truth. -- Add `knowledge/core`, `knowledge/plugins`, `knowledge/community`, and `knowledge/corrections` directories with documented ownership rules. -- Implement OKF Markdown/frontmatter parsing with stable IDs, scopes, status, priority, tags, generated/editable flags, and timestamps. -- Generate fixed core/plugin OKF from routes, commands, schemas, plugin metadata, README docs, and defaults. -- Add admin-editable community OKF for community names, owner/admin names, bot name, currency, items, roles, lore, links, moderation rules, command rules, and local terminology. -- Add corrections OKF files created only through admin review. -- Implement safe placeholder resolution for core/plugin OKF references such as `{{community.currency.primary_name}}`. -- Preserve source metadata for every retrieved OKF chunk: path, id, heading, score, and excerpt. - -## OKF Retrieval and Indexing - -- Implement or adapt an OKF indexer that chunks Markdown by heading. -- Re-index only changed OKF files when possible. -- Enforce retrieval priority: active corrections, community OKF, plugin OKF, core OKF. -- Add retrieval boosts for corrections. -- Ensure missing placeholders never crash context injection. - -## Feedback Loop - -- Add thumbs up/down controls to Lumi chat answers. -- Store feedback payloads with rating, prompt, response, retrieved context, optional comment, model/provider, timestamp, and review status. -- Show an optional comment field for thumbs down and keep thumbs up lightweight. -- Ensure feedback never directly modifies OKF. - -## Admin Feedback Review - -- Add an admin feedback review page or section. -- Show prompt, response, rating, comment, retrieved OKF sources, model/provider, and timestamp for each feedback item. -- Add review actions: reviewed/no action, good, bad retrieval, bad response, missing knowledge, archive/delete. -- Add correction creation UI with title, tags, affected topic/file, and Markdown body. -- Save approved corrections as OKF files and re-index after save. -- Add links from feedback rows to generated correction files. - -## Storage and Preservation - -- Store feedback queue, review status, source context snapshots, and correction links in SQLite or existing app storage. -- Keep community OKF, corrections OKF, feedback/review data, and source metadata preserved across updates. -- Ensure generated OKF is reproducible and not accidentally overwritten by admin edits. - -## Homepage Hero Embed Requirements - -- Continue improving service-specific "why unavailable" messages where external providers expose enough signal. - -### Discord Server Widget Hero - -- Add optional Discord invite-to-server-ID lookup support if Discord server IDs are not known. - -### Twitch Stream Hero - -- Support additional configured parent domains for alternate hostnames. -- Support stream-only and stream-with-chat layouts. -- Respect Twitch minimum player size requirements. -- If live-only mode is enabled, check live status or gracefully hide/fallback when offline. -- Show a clear admin warning when the Twitch embed fails due to missing or incorrect parent domain. -- Add admin helper text explaining that Twitch embeds require the site domain to be allowed as a parent domain. - -### YouTube Live Hero - -- Support YouTube live video URLs, raw video IDs, and optional channel-based live lookup. -- For static live heroes, extract and use the provided live video ID. -- For automatic channel live detection, require YouTube API configuration. -- Cache live lookup results to avoid excessive API calls. -- Support optional live-only mode. -- Support optional YouTube live chat only when a valid live video ID exists. -- Support autoplay and muted settings. -- If live-only mode is enabled and no active stream is found, skip the hero or show fallback content. -- Show fallback/error states for private, removed, age-restricted, embedding-disabled, unavailable, or not-yet-live videos. -- Add admin helper text explaining that YouTube Live requires either a live video link or API-based channel lookup. - -### YouTube Video Hero - -- Show fallback/error states for private, removed, age-restricted, unavailable, or embedding-disabled videos. -- Detect embedding-disabled videos before save when a YouTube API key or another reliable server-side signal is available. - -### External Embedded Content Hero - -- Support generic external iframe embeds only for sites that explicitly allow embedding. -- Detect or gracefully handle sites that block embedding through `X-Frame-Options` or CSP `frame-ancestors`. -- Show an admin warning when an external site refuses to be embedded. - -### Hero Embed Admin UX - -- Show “why unavailable” information in the admin UI when a hero cannot render. - -## Update Page UX Improvements - -- Add async in-place checks for the older `/admin` and `/admin/settings` update buttons, matching `/admin/updates`. -- Continue simplifying advanced update terminology without hiding recovery-critical detail. - -## Homepage Hero System - -- Improve handling of live-only Twitch heroes and live-state detection. -- Add explicit Discord invite-to-widget guidance or lookup support if Discord server IDs are not known. - -## Homepage Hero UX Improvements - -- Review all homepage builder forms for non-technical usability. -- Add tooltips and inline explanations where appropriate. - -## UI Text and Button Language - -- Review all button labels, field labels, headings, helper text, warnings, empty states, and confirmation dialogs across Lumi. -- Replace technical/internal wording with clearer user-facing wording where possible. -- Keep AI-related wording precise enough for admins to understand model, provider, context, retrieval, embeddings, feedback, corrections, and OKF behavior. -- Review the update pages and simplify labels such as `Check plugin`, `Apply safe target`, and `Disable for recovery`. -- Prefer clear action labels such as `Check for updates`, `Update plugin`, `Use recommended version`, `Disable temporarily`, and `Start recovery mode`. -- Add short helper text below advanced or risky update actions explaining what will happen. -- Replace implementation-focused wording with task-focused wording wherever practical. -- Avoid exposing Git, branch, commit, target, repository, cache, migration, or rollback terms unless the user needs them. -- When technical terms are unavoidable, add short tooltips or inline explanations. -- Standardize common action wording across all pages: save, update, check, review, restore, disable, enable, archive, delete, retry, import, export, cancel, and confirm. -- Ensure destructive or system-changing actions have clear confirmation text. -- Ensure error messages explain both what went wrong and what the admin can try next. -- Review plugin management, core settings, update pages, homepage management, OKF management, feedback review, and AI configuration for inconsistent wording. -- Split highly technical pages into simple and advanced sections where practical. -- Hide rarely needed expert controls behind expandable advanced sections. -- Add examples for fields that expect URLs, model names, provider names, paths, selectors, or structured values. -- Ensure labels and helper text are suitable for non-technical admins without removing important admin-level specificity. -- Review localization/translation keys if present so simplified wording remains consistent across languages. - -## Core Feedback System - -Current state on `experimental-feedback-system` as of 2026-06-18: the core feedback system is implemented locally and not yet pushed after the latest context-menu, review action, and screenshot attachment refinements. The detailed checklist below is retained for hardening, but this current-state block is the source of truth. - -### Implemented Locally +#### Implemented Locally - Core-level feedback storage in SQLite: - `feedback_entries` @@ -228,6 +121,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - sensitive-data cleanup for screenshots, diagnostics, target metadata, and public admin replies - feedback rows are collapsed by default - Moderate submission rate limit. +- Similar-feedback warnings show matching reports and require an explicit confirmation that the new report is distinct or adds useful context; confirmed reports retain this decision in sanitized diagnostics for admin review. - Validation against empty/too-short feedback. - Server-side permission checks for submission, own-feedback details, submitter comments, and admin review. - EJS escaping and sanitized server-side JSON/text handling for submitted feedback. @@ -281,23 +175,82 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - logged-in users can open public feedback detail without private diagnostics or attachments - logged-in users can mark existing feedback as affecting them too - logged-in users can add public context comments to existing feedback +- Feedback form draft retention: + - backdrop clicks and Escape no longer close the feedback modal + - explicit close controls preserve the latest non-submitted report in session storage + - reopening feedback from the floating button or context menu restores the draft + - attached screenshot blobs are retained across modal close/reopen for the current page session + - Reset clears the form, stored draft, screenshot, and detected target from the right side of the modal action row -### Partially Implemented +#### Partially Implemented - Optional diagnostics exist for browser/user-agent, viewport, language, current URL, page title, selected scope, target metadata, screenshot attachment source, and opt-in sanitized page structure snapshots. - Admin filters cover status/category/severity/scope/plugin-area/submitter/date/needs-action. Richer custom-scope filters are not implemented. -- OKF correction conversion is deferred for a later pass. Task tracking remains manual and outside the feedback feature. -- Duplicate handling includes lightweight similar-feedback suggestions from summary, description, scope, category, and page path; “also affected” counts; and public context comments, but not full duplicate merging. +- Admin-created OKF correction files are available from feedback review, but broader task tracking remains manual and outside the feedback feature. +- Duplicate handling includes lightweight similar-feedback suggestions from summary, description, scope, category, and page path; an explicit confirmation path for distinct/additional reports; “also affected” counts; and public context comments, but not full duplicate merging. - Feedback data is in SQLite under `data/app.db`, which is preserved by the update system through the existing protected `data/` path. Screenshot files are stored under `data/feedback/screenshots`, also covered by the protected `data/` path. -### Remaining Work +## Active Work + +### OKF Knowledge System: File-Backed Knowledge Storage + +- Add richer category/tag management and Markdown preview/editor polish. +- Continue enriching the non-editable system-generated OKF files so they act as scoped user manuals and developer support context. Route-level details are now generated; remaining work is broader content quality, role-specific depth, and retrieval tuning so casual admin questions do not receive unnecessary technical detail. + +### OKF Retrieval and Indexing + +- Add a more advanced persistent OKF index if file count or search volume requires it later. + +### Global Placeholder System + +- Continue migrating remaining placeholder-compatible fields away from inline JSON/static sources to trusted server-side `field_id` policies. +- Consider an admin-managed `/admin/settings -> Placeholders` registry later for custom placeholders under the `{{custom.*}}` namespace. +- Expand additional plugin-owned destination template fields to register generated placeholder sources, following the Throne Wishlist pattern. +- Add API-backed platform statistic placeholders after reliable fetch/cache layers exist, especially Twitch follower/subscriber counts and scheduled stream start times. +- Add async OKF AI context placeholder rendering if Lumi AI context providers are updated to support async providers. + +### Lumi AI Controller Routing + +- Consider a separate controller enablement/advanced-tuning switch only if live testing shows admins need to disable or override the heuristic controller. Internal mode budgets, source profile final-output limits, and controller diagnostics are exposed locally. +- Expand controller tests with simulated controller timeout cases if the gate model is later changed to emit full controller schema directly. Invalid controller normalization is covered locally. +- Tune controller heuristics after live use, especially unlimited-mode eligibility and OKF deep-vs-light retrieval thresholds. + +### AI Reply Feedback and OKF Correction Pipeline + +This section is for Lumi chat answer feedback and AI Improvement Center work, not the separate core feedback system. + +#### Feedback Loop + +- Add thumbs up/down controls to Lumi chat answers. +- Store feedback payloads with rating, prompt, response, retrieved context, optional comment, model/provider, timestamp, and review status. +- Show an optional comment field for thumbs down and keep thumbs up lightweight. +- Ensure feedback never directly modifies OKF. + +#### Admin Feedback Review + +- Add an admin feedback review page or section. +- Show prompt, response, rating, comment, retrieved OKF sources, model/provider, and timestamp for each feedback item. +- Add review actions: reviewed/no action, good, bad retrieval, bad response, missing knowledge, archive/delete. +- Add correction creation UI with title, tags, affected topic/file, and Markdown body. +- Save approved corrections as OKF files and re-index after save. +- Add links from feedback rows to generated correction files. + +#### Storage and Preservation + +- Store feedback queue, review status, source context snapshots, and correction links in SQLite or existing app storage. +- Keep community OKF, corrections OKF, feedback/review data, and source metadata preserved across updates. +- Ensure generated OKF is reproducible and not accidentally overwritten by admin edits. + +### Core Feedback System: Remaining Hardening - Full scroll-page or automatic current-tab screenshot capture is not implemented. Current browser APIs require prompts for page capture, so the implemented path is explicit upload/paste. - Full duplicate merge workflow and richer duplicate detection beyond lightweight summary/description/scope/category/page matching. - Richer custom scope targeting filters. -- OKF correction conversion for documentation/terminology/knowledge feedback, deferred. +- Broader OKF correction workflow polish for documentation/terminology/knowledge feedback, including review guidance and discoverability. - Broader browser testing for image clipboard copy and clipboard-read availability because browser support and permissions vary. +#### Foundation + - Implement a general core feedback system available to logged-in users only. - Add a persistent feedback entry point somewhere logical in the UI. - Add `/feedback` under the Community navbar section for logged-in users. @@ -306,7 +259,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Keep the AI Improvement Center separate from this system; it should continue handling AI reply quality feedback. - Add a `wrong tool` classification to the AI Improvement Center for cases where the AI used the wrong tool, action, integration, or capability. -### Feedback Categories and Scope +#### Feedback Categories and Scope - Use generic feedback categories based on issue type rather than feature-specific categories. - Suggested categories: @@ -331,7 +284,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Encourage users to submit one feedback item per issue. - Add helper text explaining that small, specific feedback reports are preferred over broad combined reports. -### Feedback Submission UI +#### Feedback Submission UI - Add a feedback modal for creating feedback. - The modal should be accessible from the persistent feedback button and from the custom context menu. @@ -356,7 +309,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Allow users to change the auto-detected scope before submitting. - Show clear confirmation after feedback is submitted. -### Site-Wide Custom Context Menu +#### Site-Wide Custom Context Menu - Replace the standard browser right-click context menu across Lumi with a custom site-wide context menu. - Include the following actions: @@ -375,7 +328,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Add the Feedback action to open the feedback modal with the clicked element as the target. - Preserve keyboard accessibility and provide fallback behavior if custom context menu features are unavailable in a browser. -### Element-Targeted Feedback +#### Element-Targeted Feedback - When feedback is opened from the context menu, capture the clicked element as the feedback target. - Store safe metadata about the clicked element, such as: @@ -392,7 +345,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Highlight the selected target while the feedback modal is open when practical. - Remove the highlight when the modal closes. -### Optional Screenshot Support +#### Optional Screenshot Support - Add optional screenshot attachment support without browser tab/window picker prompts. - Screenshots must be opt-in by the submitting user. @@ -404,7 +357,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Clearly warn users not to include sensitive information in screenshots. - Allow users to remove an attached screenshot before submitting. -### Optional Diagnostic Data +#### Optional Diagnostic Data - Automatically attach basic non-sensitive context: - user ID @@ -418,7 +371,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Do not capture sensitive form values, passwords, tokens, secrets, private messages, or hidden data. - Clearly label optional diagnostic data so users understand what they are submitting. -### Feedback Visibility for Users +#### Feedback Visibility for Users - On `/feedback`, show logged-in users a general list of current and past feedback that has not been deleted. - Public/general feedback list should only show basic non-identifying information: @@ -435,7 +388,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Allow users to add comments to their own feedback when follow-up is needed. - Allow users to see admin replies that are visible to the submitter. -### User Feedback Notifications +#### User Feedback Notifications - Add per-user feedback notification badges. - Show a green badge/sphere with the number of the user’s feedback items solved since they last opened the feedback center. @@ -444,7 +397,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Reset or update badge counts when the user opens the feedback center and views the relevant items. - Ensure notification badges only reflect the logged-in user’s own feedback. -### Feedback Statuses +#### Feedback Statuses - Support feedback statuses such as: - new @@ -465,7 +418,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Store status history with timestamps and actor information. - Show user-friendly status names and explanations on `/feedback`. -### Replies, Comments, and Work Notes +#### Replies, Comments, and Work Notes - Allow admins to reply to feedback. - Allow submitters to comment on their own feedback. @@ -475,7 +428,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Allow the submitter to add additional context/comments after the request. - Distinguish clearly between public/admin replies, submitter comments, and private admin work notes. -### Admin Feedback Review +#### Admin Feedback Review - Add `/admin/feedback` for admin feedback review. - Admins should be able to view full feedback details, including: @@ -516,7 +469,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - convert to OKF correction where relevant - link to existing issue/correction -### OKF and AI Improvement Integration +#### OKF and AI Improvement Integration - Keep this feedback system separate from AI reply feedback. - Do not use this feedback system to rate individual AI replies. @@ -526,7 +479,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Allow admins to convert relevant core feedback into OKF corrections only when the feedback concerns documentation, feature behavior, terminology, or community/system knowledge. - Do not automatically modify OKF based on general feedback. -### Rate Limits and Abuse Prevention +#### Rate Limits and Abuse Prevention - Add moderate rate limits for feedback submissions. - Rate limits should prevent spam without blocking normal use. @@ -536,7 +489,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - If similar existing feedback exists, suggest adding a public comment or marking that it affects the user too instead of creating a duplicate. - Allow admins to delete or archive abusive/spam feedback. -### Data Storage and Preservation +#### Data Storage and Preservation - Store feedback data in SQLite or the existing app storage system. - Store screenshots and optional attachments in a protected feedback uploads/storage directory. @@ -553,7 +506,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - comments/replies - linked issues/corrections -### Privacy and Safety +#### Privacy and Safety - Do not expose submitter identity in the general feedback list. - Only admins should see submitter identity and full diagnostic details. @@ -564,7 +517,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Add admin controls for deleting sensitive feedback, screenshots, or diagnostic data. - Ensure permissions are enforced server-side, not only in the UI. -### Acceptance Criteria +#### Acceptance Criteria - Logged-in users can submit general feedback from a persistent UI entry point. - Logged-in users can submit targeted feedback from the custom right-click context menu. @@ -579,8 +532,151 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - Feedback data is preserved across updates and included in protected user data. - AI reply feedback remains handled by the AI Improvement Center, with an added `wrong tool` classification. +### Homepage Hero System and Embeds + +#### General Hero Embed Requirements + +- Continue improving service-specific "why unavailable" messages where external providers expose enough signal. +- Improve handling of live-only Twitch heroes and live-state detection. +- Add explicit Discord invite-to-widget guidance or lookup support if Discord server IDs are not known. + +#### Discord Server Widget Hero + +- Add optional Discord invite-to-server-ID lookup support if Discord server IDs are not known. + +#### Twitch Stream Hero + +- Support additional configured parent domains for alternate hostnames. +- Support stream-only and stream-with-chat layouts. +- Respect Twitch minimum player size requirements. +- If live-only mode is enabled, check live status or gracefully hide/fallback when offline. +- Show a clear admin warning when the Twitch embed fails due to missing or incorrect parent domain. +- Add admin helper text explaining that Twitch embeds require the site domain to be allowed as a parent domain. + +#### YouTube Live Hero + +- Support YouTube live video URLs, raw video IDs, and optional channel-based live lookup. +- For static live heroes, extract and use the provided live video ID. +- For automatic channel live detection, require YouTube API configuration. +- Cache live lookup results to avoid excessive API calls. +- Support optional live-only mode. +- Support optional YouTube live chat only when a valid live video ID exists. +- Support autoplay and muted settings. +- If live-only mode is enabled and no active stream is found, skip the hero or show fallback content. +- Show fallback/error states for private, removed, age-restricted, embedding-disabled, unavailable, or not-yet-live videos. +- Add admin helper text explaining that YouTube Live requires either a live video link or API-based channel lookup. + +#### YouTube Video Hero + +- Show fallback/error states for private, removed, age-restricted, unavailable, or embedding-disabled videos. +- Detect embedding-disabled videos before save when a YouTube API key or another reliable server-side signal is available. + +#### External Embedded Content Hero + +- Support generic external iframe embeds only for sites that explicitly allow embedding. +- Detect or gracefully handle sites that block embedding through `X-Frame-Options` or CSP `frame-ancestors`. +- Show an admin warning when an external site refuses to be embedded. + +#### Hero Embed Admin UX + +- Show “why unavailable” information in the admin UI when a hero cannot render. + +#### Homepage Hero UX Improvements + +- Review all homepage builder forms for non-technical usability. +- Add tooltips and inline explanations where appropriate. + +### Update Page UX Improvements + +- Add async in-place checks for the older `/admin` and `/admin/settings` update buttons, matching `/admin/updates`. +- Continue simplifying advanced update terminology without hiding recovery-critical detail. + +### UI Text and Button Language + +- Review all button labels, field labels, headings, helper text, warnings, empty states, and confirmation dialogs across Lumi. +- Replace technical/internal wording with clearer user-facing wording where possible. +- Keep AI-related wording precise enough for admins to understand model, provider, context, retrieval, embeddings, feedback, corrections, and OKF behavior. +- Review the update pages and simplify labels such as `Check plugin`, `Apply safe target`, and `Disable for recovery`. +- Prefer clear action labels such as `Check for updates`, `Update plugin`, `Use recommended version`, `Disable temporarily`, and `Start recovery mode`. +- Add short helper text below advanced or risky update actions explaining what will happen. +- Replace implementation-focused wording with task-focused wording wherever practical. +- Avoid exposing Git, branch, commit, target, repository, cache, migration, or rollback terms unless the user needs them. +- When technical terms are unavoidable, add short tooltips or inline explanations. +- Standardize common action wording across all pages: save, update, check, review, restore, disable, enable, archive, delete, retry, import, export, cancel, and confirm. +- Ensure destructive or system-changing actions have clear confirmation text. +- Ensure error messages explain both what went wrong and what the admin can try next. +- Review plugin management, core settings, update pages, homepage management, OKF management, feedback review, and AI configuration for inconsistent wording. +- Split highly technical pages into simple and advanced sections where practical. +- Hide rarely needed expert controls behind expandable advanced sections. +- Add examples for fields that expect URLs, model names, provider names, paths, selectors, or structured values. +- Ensure labels and helper text are suitable for non-technical admins without removing important admin-level specificity. +- Review localization/translation keys if present so simplified wording remains consistent across languages. + +### Custom Commands +- Wrap `function run(ctx){ ... }` around each custom command, preventing the requirement of it being defined, but leaving support for it being defined if it is. +- Incorporate more custom command modes: + - Random Reply + - Has an optional RNG number that can be enabled and refernced from within the messages, and can also be used to define message selection criteria (eg. rng must = <20|>10) + - Has an optional weight system that returns a message randomly based on a weight as an int 1-999 + ## Done +### 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. +- 2026-06-25: Fixed Lumi AI runtime reinstall lock handling locally: redownloading/reinstalling the managed runtime now stops main and gate runtimes just before file replacement, pauses gate auto-recovery during install, replaces runtime files through a retryable backup/restore path, reports locked runtime files clearly, and shows install-stage download status. No repo push yet. +- 2026-06-25: Fixed Lumi AI simple OKF lookup routing locally: short factual identity-style prompts now deterministically route to main LLM with light OKF retrieval instead of failing through the gate runtime, OKF file search ignores common lookup stopwords and handles compact/camel-case names, no-context prompts use a smaller compact scaffold, OKF retrieval diagnostics record query/candidate/fallback details, and a community people OKF seed file plus verifier coverage were added. No repo push yet. +- 2026-06-25: Fixed broken timed-confirmation buttons locally: added core `LumiConfirm.destructiveToken/destructiveFetch` helpers for JSON/fetch actions that need backend confirmation tokens, moved the Lumi AI tool settings reset button onto that flow, and changed the Lumi AI eval-case delete button to the shared timed confirmation modal instead of the older custom confirm prompt. No repo push yet. + +### 2026-06-21 + +- 2026-06-21: Fixed Lumi AI WebUI final delivery locally: WebUI no longer inherits a fixed platform character cap, constrained sources compact at safe text boundaries instead of raw tail truncation, and delivery diagnostics record whether compaction occurred. No repo push yet. +- 2026-06-21: Tightened feedback-to-Codex default exports locally: output is now task-centric strict JSON with short source references, route-only internal locations, deduplicated task sections, and full raw metadata retained only in the separate debug export. No repo push yet. +- 2026-06-21: Reworked Community and System-generated OKF file listings locally into responsive searchable/filterable tables using existing Lumi UI patterns while preserving file actions and permission behavior. No repo push yet. +- 2026-06-21: Added confirmed similar-feedback submission locally: matching reports are shown before submission, users can explicitly confirm that a report is distinct or adds context, and the server validates and records that confirmation before creating the report. No repo push yet. + +### 2026-06-20 + +- 2026-06-20: Split OKF Management locally into General OKF, Community OKF, and System-generated OKF tabs; kept community files editable, kept generated core/plugin OKF read-only, and added generated file preview links backed by the existing file knowledge loader. No repo push yet. +- 2026-06-20: Expanded generated system OKF route documentation locally: generated core/plugin files now include route purpose, detected request inputs, response format, access signals, side effects, and static-analysis limitations; plugin routes now use mounted full paths. No repo push yet. +- 2026-06-20: Fixed generated OKF route docs overloading Lumi AI prompts locally: OKF context now keeps only the top high-signal chunks, drops generic route-list chunks when specific route chunks match, and enforces a hard context character budget before inference. No repo push yet. +- 2026-06-20: Added a first Lumi AI controller-routing pass locally: introduced a validated controller decision schema, internal modes `fast|normal|expanded|unlimited`, OKF retrieval depths, source profile metadata, controller metrics/diagnostics, controller-aware output budgets, and safe fallback so low-confidence/failed gate decisions escalate to at least normal processing instead of tiny simple-answer budgets. No repo push yet. +- 2026-06-20: Continued Lumi AI controller-routing locally: exposed source-profile final-output limits in Lumi AI admin settings, kept internal processing budgets code-owned/dynamically selected, added a compact controller stage event for assistant jobs, and surfaced controller mode/reason in recent job diagnostics without showing token-budget columns. No repo push yet. +- 2026-06-20: Hardened Lumi AI controller diagnostics locally: controller normalization now clamps invalid source profiles and bad enum values, delivery metrics include controller details plus truncation/final-delivered lengths, and settings metrics visibly mark truncated replies. No repo push yet. +- 2026-06-20: Corrected Lumi AI controller budget UX locally: removed user-facing token budget controls, raised code-owned dynamic controller mode budgets so OKF route explanations are not starved, removed token-budget columns from diagnostics, and lifted the global Feedback button above the assistant chat panel when visible. No repo push yet. +- 2026-06-20: Added grouped Lumi AI work history locally as an enhancement to the existing Metrics table: request-scoped prompt/controller/gate/OKF/prompt-build/model/delivery/error/fallback events are grouped by work item inside `#metrics > div.table-wrap > table`, with full-row expandable summaries and nested lifecycle sub-tables, processed/final/delivered token estimates, filters, count/age retention, safer route-like prompt classification, and fallback recording for empty model responses. No repo push yet. + +### 2026-06-19 + +- 2026-06-19: Added admin feedback-to-Codex export locally on `experimental-okf`: per-feedback Export to job, selected/all eligible global export, sanitized JSON taskfile modal with copy/download, optional Lumi AI compression using a configurable token/context limit, and the `internal-feedback-to-codex-taskfile` trigger phrase. No repo push yet. +- 2026-06-19: Fixed feedback-to-Codex export UX locally: export clicks now open a modal reliably, the result surface is copy-focused, AI compression controls render only when Lumi AI is actually available, and token/context limit appears only when AI compression is enabled. No repo push yet. +- 2026-06-19: Refined feedback-to-Codex export locally: default output is now a compact strict-JSON Codex taskfile with normalized severity/priority, actionable areas to inspect, concrete requirements/criteria/validation, privacy redaction, and closed/duplicate/rejected feedback kept debug-only; full verbose export remains selectable as debug mode. No repo push yet. +- 2026-06-19: Fixed feedback submission without screenshots by making the screenshot normalizer accept null/no-file submissions and store empty screenshot columns instead of throwing. No repo push yet. +- 2026-06-19: Applied exported feedback task `51386173-03bc-4674-82ca-e5cdcb6088ed` locally: OKF entry creation moved into a modal opened by a top-right OKF admin header button, while editing remains inline and server-side permissions/routes are unchanged. No repo push yet. +- 2026-06-19: Improved feedback target capture locally: detected target now stores a semicolon-separated semantic UI path such as `/admin/feedback;Feedback queue;Feedback entry metadata` instead of raw clicked-container text, and exports prefer that path. No repo push yet. +- 2026-06-19: Updated feedback-to-Codex export context locally: compact taskfiles and AI compression now include sanitized submitter comments, admin replies, and private admin work-note context without actor identity or raw private data. No repo push yet. +- 2026-06-19: Applied exported feedback task locally: `taskfile-export.json` is ignored/local-only, feedback modal backdrop clicks and Escape no longer close the form, non-submitted feedback drafts persist in session storage and restore from the floating button or context menu, current-session screenshot attachments survive close/reopen, and Reset clears the draft from the right side of the modal action row. No repo push yet. +- 2026-06-19: Added conservative feedback-to-Codex grouping locally: default compact exports now merge likely related actionable feedback by shared root-cause signals, route/target similarity, and error patterns, with grouped source references, affected targets, support counts, privacy-safe distinct reporter counts, and debug export left unchanged. No repo push yet. +- 2026-06-19: Added first file-backed OKF retrieval locally: documented `knowledge/core`, `knowledge/plugins`, `knowledge/community`, and `knowledge/corrections`; added Markdown/frontmatter parsing, heading chunks, source metadata, user/mod/admin visibility checks, corrections/community/plugins/core priority, Lumi AI context integration, and verifier coverage. No repo push yet. +- 2026-06-19: Hardened file-backed OKF retrieval locally: added access-aware placeholder resolution for visible frontmatter values, generic missing-reference markers for hidden/unavailable references, mtime/size-based changed-file reuse, deleted-file cache cleanup, and verifier coverage for placeholder privacy plus update/delete reindexing. No repo push yet. +- 2026-06-19: Added generated core/plugin OKF locally: generator creates reproducible `knowledge/core/lumi-core.md` and per-plugin `knowledge/plugins/*.md` files from package metadata, README, core routes, plugin manifests, plugin routes, and static command trigger hints; generated files are skipped when marked editable or non-generated. No repo push yet. +- 2026-06-19: Added admin-editable community OKF files locally: `/plugins/okf/admin` now lists, creates, edits, previews, and renames `knowledge/community` Markdown files with frontmatter fields and refuses saves to generated or non-editable files. No repo push yet. +- 2026-06-19: Added admin feedback-to-correction OKF creation locally: `/admin/feedback` can create sanitized, searchable correction Markdown files under `knowledge/corrections`, records source feedback metadata, links the generated path back to the feedback item, and verifier coverage confirms correction file search priority. No repo push yet. +- 2026-06-19: Added OKF admin category/tag suggestions locally: create/edit entry forms and community file forms now use existing category/tag datalist suggestions from all manageable OKF entries plus community files to reduce duplicated taxonomy wording. No repo push yet. +- 2026-06-19: Refined OKF admin editing locally: selected entries now open in a full-screen modal with session-backed draft preservation and explicit close/reset controls, and the Community OKF category suggestion input no longer shows a misleading dropdown arrow. No repo push yet. +- 2026-06-19: Added OKF update preservation notes/tests locally: `knowledge/README.md` documents preserved local OKF paths, and the OKF verifier asserts repo-based updates preserve `knowledge/community` and `knowledge/corrections`. No repo push yet. +- 2026-06-19: Added global placeholder auto-suggest locally: placeholder-compatible fields opt into scoped JSON placeholder sources, suggestions trigger from `{{` through the first whitespace-delimited token, current-level suggestions can be inserted by keyboard or click, invalid completed placeholders get a faint red backlight when the cursor leaves them, and OKF admin Markdown fields now use OKF placeholder suggestions. No repo push yet. +- 2026-06-19: Refined global placeholder auto-suggest locally: inline suggestions now show up to 9 current-level options plus a distinct Expand action that opens a searchable placeholder tree; live search greys out non-matching tree entries and collapses branches with no matches; selecting a tree leaf inserts the full placeholder and closes the modal. No repo push yet. +- 2026-06-19: Refined placeholder tree display locally: the Expand modal now renders recursive collapsible placeholder levels instead of repeating full placeholder strings, with structural `{{`, `.`, and `}}` tokens muted so branch/field names are easier to scan. No repo push yet. +- 2026-06-19: Fixed placeholder suggestion source locally: auto-suggest now exposes only authored/custom frontmatter keys, not generic metadata like `scope`, `status`, `editable`, or `generated` on every OKF entry; manual placeholder resolution remains permissive for explicit metadata references. No repo push yet. +- 2026-06-19: Improved placeholder level selection locally: choosing an inline branch suggestion ending in `.` keeps the suggestion menu open and refreshes to the next level, so admins can click through levels smoothly without deleting/retyping punctuation. No repo push yet. +- 2026-06-19: Corrected placeholder direction locally: removed the Community OKF per-file custom placeholder editor, kept OKF placeholder suggestions system-generated from visible file metadata, and added Throne Wishlist destination message placeholders as a generated double-brace source with legacy single-brace rendering preserved. No repo push yet. +- 2026-06-19: Added secure placeholder registry locally: `src/services/placeholders.js` now owns server-side placeholder definitions, trusted field policies, shared access checks, catalog/preview APIs, save-time validation, and runtime rendering; custom command static responses and Throne Wishlist message templates use field policies, Throne registers namespaced `{{plugin.throne_wishlist.*}}` placeholders with legacy aliases, raw `payload_json` is withheld from user-visible output, the frontend catalog fetches by `data-placeholder-field`, and `docs/placeholder-system.md` documents the architecture/security rules. No repo push yet. +- 2026-06-20: Extended placeholders locally for OKF and platform integrations: OKF Markdown fields now use the global server catalog with output-audience-specific policies, SQLite-backed OKF entries validate placeholders on save and resolve them on display, file-backed OKF frontmatter values register as `{{okf.file.*}}` placeholders with legacy aliases, and safe platform placeholders were added for Discord guild stats plus locally available Twitch/YouTube channel/runtime values. No repo push yet. +- 2026-06-19: Added OKF placeholder migration locally: OKF startup runs a one-time conversion for single-brace dotted OKF placeholders in `knowledge/*.md`, changing examples like `{community.currency.primary_name}` into `{{community.currency.primary_name}}` without touching already-double placeholders or unrelated plugin token systems. No repo push yet. + +### 2026-06-18 + - 2026-06-18: Fixed urgent UI tail items locally on `experimental-okf`: mobile sidebar now fits the viewport and scrolls internally, moderation notes can be permanently deleted by admins, both theme preview pop-out buttons open the preview, unauthenticated pages use the Lumi context menu with Feedback disabled, and the global savebar now submits URL-encoded settings data so normal settings routes actually save. No repo push yet. - 2026-06-18: Updated OKF aliases/related questions to newline-only parsing and display so commas remain valid inside questions. No repo push yet. - 2026-06-18: Continued `experimental-okf` locally: wired OKF into Lumi AI through the generic context provider hook, made AI context providers user/message-aware, and verified OKF AI context only includes published/approved entries and fields visible to the requester. No repo push yet. @@ -595,6 +691,9 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb - 2026-06-18: Added opt-in sanitized page structure diagnostics for feedback, excluding sensitive form values and capping stored snapshot text server-side. - 2026-06-18: Updated the experimental feedback context menu locally so all actions remain visible with disabled unavailable states, Back/Forward/Copy/Cut/Paste availability is context-aware, image/text copy and input-only cut/paste rules are enforced where browsers allow, and one-second viewport-clamped tooltips explain each action. - 2026-06-18: Added a core feedback system first pass on `experimental-feedback-system`: SQLite feedback entries/comments/status history/view state, logged-in `/feedback`, admin `/admin/feedback`, persistent feedback modal, site-wide custom context menu with element-targeted feedback, per-user notification badges, admin replies/work notes/status changes, and AI Improvement Center `wrong_tool_usage` confirmed present. + +### 2026-06-17 + - 2026-06-17: Added Economy cleanup for fallback-name artifact users such as `SammyCat-2`, merging them into matching real platform users and making Economy stats tolerate missing/mid-migration tables; bumped Economy Framework to v0.2.9. - 2026-06-17: Fixed update metadata for renamed Economy plugins so legacy installed rows are folded into canonical `economy-*` plugin update rows instead of appearing as separate installs; bumped core to v0.1.8. - 2026-06-17: Renamed all remaining Economy internals from the old misspelled IDs/paths/tables to `economy-*`, added startup migration for legacy plugin rows, settings, command usage IDs, tables, uploads, asset paths, old URLs, and bumped core/plugin patch versions. diff --git a/docs/placeholder-system.md b/docs/placeholder-system.md new file mode 100644 index 0000000..f62482c --- /dev/null +++ b/docs/placeholder-system.md @@ -0,0 +1,132 @@ +# Placeholder System Guidelines + +This note is the working guideline for Lumi placeholder and variable support. +Future placeholder work should follow this model unless the architecture is +explicitly revised. + +## Architecture + +Placeholders are registered server-side through `src/services/placeholders.js`. +The frontend must not declare placeholder permissions, allowed plugins, or +sensitivity. Editable fields reference a trusted `field_id`, and the server +uses that field policy to decide which placeholders are available. + +Core and plugins can register: + +- placeholder definitions with metadata and a resolver function +- field policies for template/input destinations that support placeholders + +Catalog, preview, save validation, and runtime rendering must share the same +permission-checking path. Saved templates are not trusted permanently; they must +be revalidated at render time because plugin availability, policies, settings, +or sensitivity can change. + +## Naming + +Use namespaced double-brace tokens: + +- `{{core.main.bot_name}}` +- `{{core.main.command_prefix}}` +- `{{user.public.display_name}}` +- `{{platform.discord.guild.member_count}}` +- `{{okf.file.community.currency.primary_name}}` +- `{{plugin.throne_wishlist.item_name}}` + +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. + +## Core Namespaces + +Current core-owned namespaces include: + +- `core.main.*`: safe Lumi core settings such as bot/site name and command prefix +- `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. + +## Sensitivity + +Supported sensitivity levels: + +- `public_safe`: safe in user-visible output +- `user`: user-specific content, only for fields whose output audience allows it +- `moderator`: moderator/support visible content +- `admin`: admin-only content +- `internal`: internal implementation/runtime content +- `secret_never_render`: must not be registered, listed, previewed, or rendered + +Never expose API keys, tokens, cookies, passwords, database URLs, session IDs, +raw secrets, private file paths, sensitive query strings, or unredacted raw +diagnostics through placeholders. If diagnostics are needed, register explicit +redacted safe variants. + +## Field Policies + +Every placeholder-compatible input must use a stable `field_id` whose policy is +registered by trusted backend code. A field policy should define: + +- `field_id` +- label and field type +- output audience +- minimum editor role +- allowed namespaces or placeholder IDs +- maximum sensitivity + +Security is based on the intersection of: + +- current editor role/capability during catalog, preview, and save +- field policy +- output audience that will see rendered content +- placeholder sensitivity +- placeholder minimum viewer role +- plugin availability +- runtime context + +An admin editing a user-visible template is not enough to allow admin-only +placeholders. The output audience still limits what can render. + +## Plugin Registration + +Plugins should register placeholders during backend plugin initialization using +the placeholder service passed to `init` or available through +`web.placeholders`. Plugin definitions should include `plugin_id` so disabled or +missing plugins can fail safely. + +Resolvers must return safe display values and avoid leaking raw internal data. +Renderers that send chat messages or HTML should still sanitize or escape for +their output context after placeholder resolution. + +## Frontend + +Frontend fields use: + +```html +data-placeholder-field="plugin.example.message_template" +data-placeholder-output-audience="user" +``` + +The frontend requests `/api/placeholders/catalog?field_id=...` and uses the +returned catalog for autocomplete and tree browsing. Inline JSON placeholder +lists are a transitional fallback only and should not be used for new fields. + +## Validation + +Templates must be checked: + +- when the editor opens a catalog +- during preview +- before save +- at runtime before rendering output + +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. diff --git a/knowledge/README.md b/knowledge/README.md new file mode 100644 index 0000000..648768d --- /dev/null +++ b/knowledge/README.md @@ -0,0 +1,53 @@ +# Lumi OKF Knowledge Files + +This directory holds file-backed OKF Markdown sources. + +- `core/`: generated or maintained Lumi core behavior knowledge. +- `plugins/`: generated or maintained plugin behavior knowledge. +- `community/`: admin-maintained community facts, terminology, names, rules, links, and local context. +- `corrections/`: admin-reviewed corrections created from feedback or AI review flows. + +Files use Markdown with optional frontmatter: + +```md +--- +id: community.currency +title: Community currency +scope: community +status: active +priority: 50 +visibility: user +tags: currency, economy +generated: false +editable: true +created_at: 2026-06-19T00:00:00.000Z +updated_at: 2026-06-19T00:00:00.000Z +--- + +# Community currency + +The community currency is coins. +``` + +`status` should be `active` or `published` for retrieval. `visibility` may be `user`, `mod`, or `admin`. +Corrections have the highest retrieval priority, followed by community, plugin, and core knowledge. + +## Placeholders + +Markdown bodies may reference visible frontmatter values with `{{entry.id.field_name}}`. +For example, a file with `id: community.currency` and `primary_name: coins` can be referenced as: + +```md +The currency is {{community.currency.primary_name}}. +``` + +Placeholders are resolved only from knowledge entries visible to the current user. Missing or hidden references become a short missing-reference marker instead of crashing retrieval. + +## Update Preservation + +Repo-based and ZIP fallback updates preserve the local OKF authoring paths: + +- `knowledge/community` +- `knowledge/corrections` + +Generated `knowledge/core` and `knowledge/plugins` files are reproducible from the current codebase and plugin manifests. Do not place local-only edits in generated files unless they are explicitly marked `generated: false` or `editable: true`. diff --git a/knowledge/community/.gitkeep b/knowledge/community/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/knowledge/community/.gitkeep @@ -0,0 +1 @@ + diff --git a/knowledge/community/community-people.md b/knowledge/community/community-people.md new file mode 100644 index 0000000..a7b458d --- /dev/null +++ b/knowledge/community/community-people.md @@ -0,0 +1,24 @@ +--- +id: community.people +title: Community People +scope: community +status: active +priority: 20 +visibility: user +category: Community +tags: people, identity, staff, ookamikuntv, jenni, discord +generated: false +editable: true +created_at: "2026-06-25T00:00:00.000Z" +updated_at: "2026-06-25T00:00:00.000Z" +--- + +# Community People + +## OokamiKunTV + +OokamiKunTV is a known community contact in Lumi's local community knowledge. When users ask how to contact OokamiKunTV, Jenni, administrators, moderators, or community staff, recommend using the Discord community server unless a verified internal Lumi workflow is available. + +## Jenni + +Jenni is a known community contact in Lumi's local community knowledge. When users ask how to contact Jenni or community staff, recommend using the Discord community server unless a verified internal Lumi workflow is available. diff --git a/knowledge/community/test.md b/knowledge/community/test.md new file mode 100644 index 0000000..f57ec72 --- /dev/null +++ b/knowledge/community/test.md @@ -0,0 +1,15 @@ +--- +id: test +title: Test +scope: community +status: active +priority: 0 +visibility: user +category: Community +generated: false +editable: true +created_at: "2026-06-19T23:34:37.873Z" +updated_at: "2026-06-19T23:34:37.873Z" +--- + +# Test diff --git a/knowledge/core/.gitkeep b/knowledge/core/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/knowledge/core/.gitkeep @@ -0,0 +1 @@ + diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md new file mode 100644 index 0000000..ff26472 --- /dev/null +++ b/knowledge/core/lumi-core.md @@ -0,0 +1,1292 @@ +--- +id: core.lumi +title: lumi-bot +scope: core +status: active +priority: 20 +visibility: user +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. +## Runtime +Package: lumi-bot +Version: 0.1.9 +## Routes +- GET /api/events +- POST /api/destructive-confirmations +- GET /api/users/search +- GET /api/placeholders/catalog +- POST /api/placeholders/preview +- GET /api/feedback/similar +- POST /api/feedback/:id/support +- POST /api/feedback +- GET /api/feedback/notifications +- GET / +- GET /api/assistant-panels +- GET /setup +- GET /setup/discord +- POST /setup/discord/verify +- POST /setup/discord/cancel +- GET /setup/twitch +- POST /setup/twitch/verify +- POST /setup/twitch/cancel +- GET /setup/youtube +- POST /setup/youtube/connect +- POST /setup/youtube/verify +- POST /setup/youtube/cancel +- GET /auth/discord +- GET /auth/discord/callback +- GET /auth/localhost +- POST /auth/localhost +- POST /auth/logout +- GET /auth/twitch +- GET /auth/twitch/login +- GET /auth/twitch/callback +- GET /auth/youtube +- GET /auth/youtube/login +- GET /auth/youtube/callback +- GET /profile +- POST /profile/unlink/:provider +- POST /profile/username +- GET /feedback +- POST /feedback/:id/comment +- POST /feedback/:id/support +- GET /feedback/:id/screenshot +- GET /feedback/:id/attachments/:attachmentId +- GET /health +- GET /commands +- GET /leaderboards +- GET /stats +- GET /stats/:username +- GET /pages/:slug +- GET /moderator +- GET /admin +- GET /api/admin/dashboard-metrics +- GET /admin/settings +- POST /admin/settings +- POST /admin/settings/nav-icons +- POST /admin/settings/nav-icons/reset +- GET /admin/navigation +- POST /admin/navigation +- POST /admin/navigation/reset +- GET /admin/discord-wizard +- POST /admin/discord-wizard/verify +- POST /admin/discord-wizard/cancel +- GET /admin/twitch-wizard +- POST /admin/twitch-wizard/verify +- POST /admin/twitch-wizard/cancel +- GET /admin/youtube-wizard +- POST /admin/youtube-wizard/connect +- POST /admin/youtube-wizard/verify +- POST /admin/youtube-wizard/cancel +- GET /admin/theming +- POST /admin/theming/select +- POST /admin/theming/duplicate +- POST /admin/theming/custom/:id/save +- POST /admin/theming/custom/:id/rename +- POST /admin/theming/custom/:id/delete +- POST /admin/theming +- GET /admin/logs +- GET /admin/logs/download +- GET /admin/feedback +- POST /admin/feedback/export +- POST /admin/feedback/:id/export +- POST /admin/feedback/:id/create-okf-correction +- POST /admin/feedback/:id +- POST /admin/feedback/:id/finalize +- POST /admin/feedback/:id/reopen +- POST /admin/feedback/:id/delete +- POST /admin/feedback/:id/cleanup +- GET /admin/privileges +- GET /admin/commands +- POST /admin/commands +- POST /admin/commands/:id/toggle +- POST /admin/commands/:id/delete +- POST /admin/commands/:id/update +- POST /admin/commands/:id/preview +- GET /admin/pages +- GET /admin/users +- POST /admin/users/:id/username +- POST /admin/pages +- POST /admin/pages/:id/toggle +- POST /admin/pages/:id/delete +- POST /admin/pages/:id/update +- GET /admin/plugins +- POST /admin/plugins/:id/toggle +- POST /admin/plugins/:id/uninstall +- POST /admin/plugins/upload +- POST /admin/plugins/install +- POST /admin/plugins/create +- POST /admin/plugins/:id/update +- GET /admin/updates/events +- GET /admin/updates/status +- GET /admin/updates/recovery +- POST /admin/updates/recovery/clear-marker +- POST /admin/updates/recovery/retry-normal-startup +- GET /admin/updates +- POST /admin/updates/core/check +- POST /admin/updates/core/apply +- POST /admin/updates/core/revert +- POST /admin/updates/bot +- POST /admin/updates/core/zip +- POST /admin/updates/plugin +- POST /admin/updates/plugins/:id/check +- POST /admin/updates/plugins/:id/apply +- POST /admin/updates/plugins/:id/revert +- POST /admin/updates/plugins/:id/disable +- POST /admin/updates/plugins/:id/zip +- POST /admin/update +- POST /admin/check-update +- POST /admin/restart +## Route Reference +### GET /api/events + +- Purpose: Streams live WebUI event notifications to the browser. +- Inputs: No request parameters detected by static analysis. +- Response format: streaming event response +- Access: logged-in session required or used +- Side effects: publishes or streams live WebUI events +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise. + +### POST /api/destructive-confirmations + +- Purpose: Provides api destructive confirmations data as JSON. +- Inputs: body: `action` +- Response format: JSON response +- Access: logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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/users/search + +- Purpose: Searches known linked Lumi users for shared user-lookup fields. +- Inputs: query: `limit`, `q` +- Response format: JSON response +- Access: 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. API consumers should expect JSON unless the response format says otherwise. + +### GET /api/placeholders/catalog + +- Purpose: Returns placeholder suggestions available to the current user and field policy. +- Inputs: query: `field_id`, `output_audience` +- Response format: JSON response +- Access: 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. 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. + +### POST /api/placeholders/preview + +- Purpose: Previews placeholder rendering for a submitted template without saving it. +- Inputs: body: `field_id`, `output_audience`, `template` +- Response format: JSON response +- Access: logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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/similar + +- Purpose: Provides api feedback similar data as JSON. +- Inputs: query: `limit` +- Response format: JSON response +- Access: 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. 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. + +### POST /api/feedback/:id/support + +- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path. +- Inputs: path params: `id` +- Response format: JSON response +- 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. API consumers should expect JSON unless the response format says otherwise. + +### POST /api/feedback + +- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path. +- 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 +- 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 + +- Purpose: Provides api feedback notifications data as JSON. +- Inputs: No request parameters detected by static analysis. +- Response format: JSON response +- Access: 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. API consumers should expect JSON unless the response format says otherwise. + +### GET / + +- Purpose: Renders the requested WebUI page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: 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. Errors are caught and usually returned as a flash message, JSON error, or error page. + +### GET /api/assistant-panels + +- Purpose: Provides api assistant panels data as JSON. +- Inputs: No request parameters detected by static analysis. +- Response format: JSON response +- Access: logged-in session required or used; mount-level canAccess predicate applies +- Side effects: Usually read-only. +- 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 /setup + +- Purpose: Guides first-time platform setup and verification. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### GET /setup/discord + +- Purpose: Guides first-time platform setup and verification. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### POST /setup/discord/verify + +- Purpose: Guides first-time platform setup and verification. +- Inputs: body: `discord_admin_role_id`, `discord_bot_token`, `discord_client_id`, `discord_client_secret`, `discord_guild_id`, `discord_mod_role_id`, `discord_redirect_uri` +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- 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 /setup/discord/cancel + +- Purpose: Guides first-time platform setup and verification. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- 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. + +### GET /setup/twitch + +- Purpose: Guides first-time platform setup and verification. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### POST /setup/twitch/verify + +- Purpose: Guides first-time platform setup and verification. +- Inputs: body: `twitch_bot_oauth`, `twitch_bot_username`, `twitch_channels`, `twitch_client_id`, `twitch_client_secret`, `twitch_redirect_uri` +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- 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 /setup/twitch/cancel + +- Purpose: Guides first-time platform setup and verification. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- 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. + +### GET /setup/youtube + +- Purpose: Guides first-time platform setup and verification. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### POST /setup/youtube/connect + +- Purpose: Guides first-time platform setup and verification. +- Inputs: body: `youtube_client_id`, `youtube_client_secret`, `youtube_redirect_uri` +- Response format: HTTP redirect after handling the request +- 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 /setup/youtube/verify + +- Purpose: Guides first-time platform setup and verification. +- Inputs: body: `youtube_client_id`, `youtube_client_secret`, `youtube_redirect_uri` +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- 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 /setup/youtube/cancel + +- Purpose: Guides first-time platform setup and verification. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- 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. + +### GET /auth/discord + +- Purpose: Starts, completes, or cancels a platform authentication/linking flow. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- 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. + +### GET /auth/discord/callback + +- Purpose: Starts, completes, or cancels a platform authentication/linking flow. +- Inputs: query: full query object is passed to a helper; exact fields are defined by the matching view/service +- Response format: HTTP redirect after handling the request +- 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. + +### GET /auth/localhost + +- Purpose: Starts, completes, or cancels a platform authentication/linking flow. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### POST /auth/localhost + +- Purpose: Starts, completes, or cancels a platform authentication/linking flow. +- Inputs: body: `password`, `username` +- Response format: HTTP redirect after handling the request +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /auth/logout + +- Purpose: Starts, completes, or cancels a platform authentication/linking flow. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. +- 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. + +### GET /auth/twitch + +- Purpose: Starts, completes, or cancels a platform authentication/linking flow. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- 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. + +### GET /auth/twitch/login + +- Purpose: Starts, completes, or cancels a platform authentication/linking flow. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- 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. + +### GET /auth/twitch/callback + +- Purpose: Starts, completes, or cancels a platform authentication/linking flow. +- Inputs: query: full query object is passed to a helper; exact fields are defined by the matching view/service +- Response format: HTTP redirect after handling the request +- Access: logged-in session required or used +- Side effects: writes or mutates server-side state; writes database state when the called service mutates data +- 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. + +### GET /auth/youtube + +- Purpose: Starts, completes, or cancels a platform authentication/linking flow. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- 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. + +### GET /auth/youtube/login + +- Purpose: Starts, completes, or cancels a platform authentication/linking flow. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- 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. + +### GET /auth/youtube/callback + +- Purpose: Starts, completes, or cancels a platform authentication/linking flow. +- Inputs: query: full query object is passed to a helper; exact fields are defined by the matching view/service +- Response format: HTTP redirect after handling the request +- Access: logged-in session required or used +- Side effects: writes or mutates server-side state; writes database state when the called service mutates data +- 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. + +### GET /profile + +- Purpose: Renders the profile WebUI page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- Access: 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. + +### POST /profile/unlink/:provider + +- Purpose: Processes the profile unlink provider action and stores or applies submitted form data. +- Inputs: path params: `provider` +- Response format: HTTP redirect after handling the request +- Access: logged-in session required or used +- Side effects: writes or mutates server-side state; writes database state when the called service mutates data +- 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 /profile/username + +- Purpose: Processes the profile username action and stores or applies submitted form data. +- Inputs: body: `internal_username` +- Response format: HTTP redirect after handling the request +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### GET /feedback + +- Purpose: Renders the feedback WebUI page. +- Inputs: query: `id` +- Response format: HTML page rendered from an EJS view +- Access: 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. + +### POST /feedback/:id/comment + +- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path. +- Inputs: path params: `id`; body: `comment` +- Response format: HTTP redirect after handling the request +- 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 /feedback/:id/support + +- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- 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. + +### GET /feedback/:id/screenshot + +- Purpose: Handles feedback id screenshot. +- Inputs: path params: `id` +- Response format: static file response +- 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. + +### GET /feedback/:id/attachments/:attachmentId + +- Purpose: Handles feedback id attachments attachmentId. +- Inputs: path params: `attachmentId`, `id` +- Response format: static file response +- 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. + +### GET /health + +- Purpose: Returns runtime health information. +- Inputs: No request parameters detected by static analysis. +- Response format: JSON response +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### GET /commands + +- Purpose: Displays configured bot commands. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- 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. + +### GET /leaderboards + +- Purpose: Displays leaderboard data. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### GET /stats + +- Purpose: Displays user or community statistics. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: 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. + +### GET /stats/:username + +- Purpose: Displays user or community statistics. +- Inputs: path params: `username` +- Response format: HTML page rendered from an EJS view +- Access: 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. + +### GET /pages/:slug + +- Purpose: Renders the pages slug WebUI page. +- Inputs: path params: `slug` +- Response format: HTML page rendered from an EJS view +- Access: 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. + +### GET /moderator + +- Purpose: Renders the moderator WebUI page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: moderator access may be required +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### GET /admin + +- Purpose: Renders the admin WebUI page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: admin access expected +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### GET /api/admin/dashboard-metrics + +- Purpose: Provides api admin dashboard metrics data as JSON. +- Inputs: query: `scope` +- Response format: JSON response +- Access: admin access expected +- Side effects: writes database state when the called service mutates data +- 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 /admin/settings + +- Purpose: Renders the core settings page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- 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. + +### POST /admin/settings + +- Purpose: Saves core settings from the admin settings form. +- Inputs: body: `localhost_login_password`, `localhost_login_username` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; may restart or stop runtime processes +- 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 /admin/settings/nav-icons + +- Purpose: Saves core settings from the admin settings form. +- Inputs: body: `item_id`; file upload: multipart form file data +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes files +- 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/settings/nav-icons/reset + +- Purpose: Saves core settings from the admin settings form. +- Inputs: body: `item_id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes files +- 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. + +### GET /admin/navigation + +- Purpose: Renders the admin navigation WebUI page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- 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. + +### POST /admin/navigation + +- Purpose: Processes the admin navigation action and stores or applies submitted form data. +- Inputs: body: `nav_enabled`, `nav_include_unassigned`, `nav_sections`, `nav_unassigned_icon`, `nav_unassigned_id`, `nav_unassigned_label` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- 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/navigation/reset + +- Purpose: Processes the admin navigation reset action and stores or applies submitted form data. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- 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. + +### GET /admin/discord-wizard + +- Purpose: Renders the admin discord wizard WebUI page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- Access: admin access expected +- 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. + +### POST /admin/discord-wizard/verify + +- Purpose: Renders the admin discord wizard verify WebUI page. +- Inputs: body: `discord_admin_role_id`, `discord_bot_token`, `discord_client_id`, `discord_client_secret`, `discord_guild_id`, `discord_mod_role_id`, `discord_redirect_uri` +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- Access: admin access expected +- 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 /admin/discord-wizard/cancel + +- Purpose: Processes the admin discord wizard cancel action and stores or applies submitted form data. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- 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. + +### GET /admin/twitch-wizard + +- Purpose: Renders the admin twitch wizard WebUI page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- Access: admin access expected +- 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. + +### POST /admin/twitch-wizard/verify + +- Purpose: Renders the admin twitch wizard verify WebUI page. +- Inputs: body: `twitch_bot_oauth`, `twitch_bot_username`, `twitch_channels`, `twitch_client_id`, `twitch_client_secret`, `twitch_redirect_uri` +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- Access: admin access expected +- 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 /admin/twitch-wizard/cancel + +- Purpose: Processes the admin twitch wizard cancel action and stores or applies submitted form data. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- 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. + +### GET /admin/youtube-wizard + +- Purpose: Renders the admin youtube wizard WebUI page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- Access: admin access expected +- 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. + +### POST /admin/youtube-wizard/connect + +- Purpose: Processes the admin youtube wizard connect action and stores or applies submitted form data. +- Inputs: body: `youtube_client_id`, `youtube_client_secret`, `youtube_redirect_uri` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- 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 /admin/youtube-wizard/verify + +- Purpose: Renders the admin youtube wizard verify WebUI page. +- Inputs: body: `youtube_client_id`, `youtube_client_secret`, `youtube_redirect_uri` +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- Access: admin access expected +- 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 /admin/youtube-wizard/cancel + +- Purpose: Processes the admin youtube wizard cancel action and stores or applies submitted form data. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- 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. + +### GET /admin/theming + +- Purpose: Renders the admin theming WebUI page. +- Inputs: query: `edit` +- Response format: HTML page rendered from an EJS view +- Access: admin access expected +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### POST /admin/theming/select + +- Purpose: Processes the admin theming select action and stores or applies submitted form data. +- Inputs: body: `theme_id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- 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/theming/duplicate + +- Purpose: Processes the admin theming duplicate action and stores or applies submitted form data. +- Inputs: body: `name`, `theme_id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- 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/theming/custom/:id/save + +- Purpose: Processes the admin theming custom id save action and stores or applies submitted form data. +- Inputs: path params: `id`; body: `apply` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- 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/theming/custom/:id/rename + +- Purpose: Processes the admin theming custom id rename action and stores or applies submitted form data. +- Inputs: path params: `id`; body: `name` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- 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/theming/custom/:id/delete + +- Purpose: Processes the admin theming custom id delete action and stores or applies submitted form data. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- 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/theming + +- Purpose: Processes the admin theming action and stores or applies submitted form data. +- 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 +- 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. + +### GET /admin/logs + +- Purpose: Displays, downloads, or manages application logs. +- Inputs: query: `level`, `limit`, `range` +- Response format: HTML page rendered from an EJS view +- Access: admin access expected +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### GET /admin/logs/download + +- Purpose: Displays, downloads, or manages application logs. +- Inputs: query: `level`, `limit`, `range` +- Response format: plain or HTML response +- Access: admin access expected +- 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 /admin/feedback + +- Purpose: Renders the admin feedback WebUI page. +- Inputs: query: `area`, `category`, `date_from`, `date_to`, `needs_action`, `scope`, `severity`, `sort`, `status`, `submitter` +- Response format: HTML page rendered from an EJS view +- Access: admin access expected +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### POST /admin/feedback/export + +- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path. +- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service +- Response format: JSON response +- Access: 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. 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/export + +- 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: JSON response +- Access: 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. 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/create-okf-correction + +- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path. +- Inputs: path params: `id`; body: `status_note` +- 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. +- 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/finalize + +- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path. +- Inputs: path params: `id`; body: `status_note` +- 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/reopen + +- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path. +- Inputs: path params: `id`; body: `status_note` +- 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/delete + +- Purpose: Creates, updates, comments on, exports, or manages feedback records depending on the action path. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes files +- 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/cleanup + +- 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; writes files +- 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. + +### GET /admin/privileges + +- Purpose: Renders the admin privileges WebUI page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: admin access expected +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### GET /admin/commands + +- Purpose: Displays configured bot commands. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- 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. + +### POST /admin/commands + +- Purpose: Creates, updates, previews, toggles, or deletes custom commands. +- Inputs: body: `code`, `language`, `mode`, `response`, `trigger` +- 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; writes database state when the called service mutates data +- 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/commands/:id/toggle + +- Purpose: Creates, updates, previews, toggles, or deletes custom commands. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes database state when the called service mutates data +- 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 /admin/commands/:id/delete + +- Purpose: Creates, updates, previews, toggles, or deletes custom commands. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes database state when the called service mutates data +- 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 /admin/commands/:id/update + +- Purpose: Creates, updates, previews, toggles, or deletes custom commands. +- Inputs: path params: `id`; body: `code`, `language`, `mode`, `response`, `trigger` +- 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; writes database state when the called service mutates data +- 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/commands/:id/preview + +- Purpose: Creates, updates, previews, toggles, or deletes custom commands. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes database state when the called service mutates data +- 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. + +### GET /admin/pages + +- Purpose: Renders the admin pages WebUI page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: admin access expected +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### GET /admin/users + +- Purpose: Renders the admin users WebUI page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- 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. + +### POST /admin/users/:id/username + +- Purpose: Processes the admin users id username action and stores or applies submitted form data. +- Inputs: path params: `id`; body: `internal_username` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- 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 /admin/pages + +- Purpose: Processes the admin pages action and stores or applies submitted form data. +- Inputs: body: `content`, `content_css`, `format`, `nav_label`, `role`, `show_in_nav`, `slug`, `title` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes database state when the called service mutates data +- 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/pages/:id/toggle + +- Purpose: Processes the admin pages id toggle action and stores or applies submitted form data. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes database state when the called service mutates data +- 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 /admin/pages/:id/delete + +- Purpose: Processes the admin pages id delete action and stores or applies submitted form data. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes database state when the called service mutates data +- 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 /admin/pages/:id/update + +- Purpose: Processes the admin pages id update action and stores or applies submitted form data. +- Inputs: path params: `id`; body: `content`, `content_css`, `format`, `nav_label`, `role`, `show_in_nav`, `slug`, `title` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes database state when the called service mutates data +- 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. + +### GET /admin/plugins + +- Purpose: Manages installed plugins, plugin uploads, installs, updates, or plugin enablement. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: admin access expected +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### POST /admin/plugins/:id/toggle + +- Purpose: Manages installed plugins, plugin uploads, installs, updates, or plugin enablement. +- Inputs: path params: `id`; body: `enabled` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; may restart or stop runtime processes +- 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 /admin/plugins/:id/uninstall + +- Purpose: Manages installed plugins, plugin uploads, installs, updates, or plugin enablement. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes files; may restart or stop runtime processes +- 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/plugins/upload + +- Purpose: Manages installed plugins, plugin uploads, installs, updates, or plugin enablement. +- Inputs: file upload: multipart form file data +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes files; may restart or stop runtime processes +- 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/plugins/install + +- Purpose: Manages installed plugins, plugin uploads, installs, updates, or plugin enablement. +- Inputs: body: `url` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; may restart or stop runtime processes +- 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/plugins/create + +- Purpose: Manages installed plugins, plugin uploads, installs, updates, or plugin enablement. +- Inputs: body: `description`, `id`, `name` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; may restart or stop runtime processes +- 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/plugins/:id/update + +- Purpose: Manages installed plugins, plugin uploads, installs, updates, or plugin enablement. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; may restart or stop runtime processes +- 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. + +### GET /admin/updates/events + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: No request parameters detected by static analysis. +- Response format: streaming event response +- Access: admin access expected +- Side effects: publishes or streams live WebUI events +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### GET /admin/updates/status + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: No request parameters detected by static analysis. +- Response format: JSON response +- Access: admin access expected +- 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. + +### GET /admin/updates/recovery + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: No request parameters detected by static analysis. +- Response format: JSON response +- Access: admin access expected +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### POST /admin/updates/recovery/clear-marker + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: No request parameters detected by static analysis. +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected +- Side effects: publishes or streams live WebUI events +- 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 /admin/updates/recovery/retry-normal-startup + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: No request parameters detected by static analysis. +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### GET /admin/updates + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: admin access expected +- 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. + +### POST /admin/updates/core/check + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: No request parameters detected by static analysis. +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected +- 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/updates/core/apply + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: No request parameters detected by static analysis. +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected +- 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/updates/core/revert + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: body: `snapshot_id` +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected +- 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/updates/bot + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: body: `patch_mode`; file upload: multipart form file data +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes files; may restart or stop runtime processes +- 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/updates/core/zip + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: body: `patch_mode`, `rollback_safe`; file upload: multipart form file data +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes files +- 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/updates/plugin + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: file upload: multipart form file data +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes files; may restart or stop runtime processes +- 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/updates/plugins/:id/check + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: path params: `id` +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected +- 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/updates/plugins/:id/apply + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: path params: `id` +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected +- 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/updates/plugins/:id/revert + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: path params: `id`; body: `snapshot_id` +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected +- 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/updates/plugins/:id/disable + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: path params: `id` +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected +- Side effects: 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/updates/plugins/:id/zip + +- Purpose: Checks, applies, reverts, or reports update state for core or plugin updates. +- Inputs: path params: `id`; body: `rollback_safe`; file upload: multipart form file data +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected +- Side effects: writes or mutates server-side state; writes files +- 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/update + +- 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 +- Access: admin access expected +- Side effects: writes or mutates server-side state; may restart or stop runtime processes +- 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 +- Access: admin access expected +- 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/restart + +- Purpose: Processes the admin restart action and stores or applies submitted form data. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- Access: admin access expected +- Side effects: writes or mutates server-side state; may restart or stop runtime processes +- 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. +## README Summary +# Lumi Bot +Discord bot + WebUI with role-based access, plugin management, and self-update support. +## Quick start +Requires Node.js 18+. +1. Install dependencies: + ``` + npm install + ``` +2. Run with auto-restart: + ``` + npm run run + ``` diff --git a/knowledge/corrections/.gitkeep b/knowledge/corrections/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/knowledge/corrections/.gitkeep @@ -0,0 +1 @@ + diff --git a/knowledge/plugins/.gitkeep b/knowledge/plugins/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/knowledge/plugins/.gitkeep @@ -0,0 +1 @@ + diff --git a/knowledge/plugins/auto-vc.md b/knowledge/plugins/auto-vc.md new file mode 100644 index 0000000..0a5f047 --- /dev/null +++ b/knowledge/plugins/auto-vc.md @@ -0,0 +1,74 @@ +--- +id: plugin.auto-vc +title: Auto VC +scope: plugins +status: active +priority: 10 +visibility: user +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. +## Metadata +Plugin ID: auto-vc +Version: 0.1.5 +Default state: enabled +## Web Routes +- /plugins/auto-vc +- GET /plugins/auto-vc +- POST /plugins/auto-vc/settings +- POST /plugins/auto-vc/bans +- POST /plugins/auto-vc/unban +## Route Reference +### MOUNT /plugins/auto-vc + +- Purpose: Mounts the plugin router at this base WebUI path. +- Inputs: No request parameters detected by static analysis. +- Response format: Plugin router mount point. +- Access: Access is controlled by the mount options and individual plugin routes. +- Side effects: No direct route action; child routes handle requests. +- Limits/notes: Mount metadata is inferred from static source scanning. + +### GET /plugins/auto-vc + +- Purpose: Renders or serves the auto-vc plugin page. +- Inputs: No request parameters detected by static analysis. +- Response format: plain or HTML response +- Access: admin access expected; moderator access may be required; 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. + +### 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 +- 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/bans + +- Purpose: Processes the auto-vc plugin action for bans. +- Inputs: body: `ban_input`, `ban_reason` +- Response format: HTTP redirect after handling the request +- Access: admin access expected; moderator access may be required; logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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/unban + +- Purpose: Processes the auto-vc plugin action for unban. +- Inputs: body: `unban_ids` +- Response format: HTTP redirect after handling the request +- Access: admin access expected; moderator access may be required; logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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. +## Commands +- No plugin command triggers detected. +## Source +Plugin folder: plugins/auto-vc diff --git a/knowledge/plugins/birthday.md b/knowledge/plugins/birthday.md new file mode 100644 index 0000000..e5d6601 --- /dev/null +++ b/knowledge/plugins/birthday.md @@ -0,0 +1,124 @@ +--- +id: plugin.birthday +title: Birthday +scope: plugins +status: active +priority: 10 +visibility: user +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. +## Metadata +Plugin ID: birthday +Version: 0.1.2 +Default state: enabled +## Web Routes +- /plugins/birthday +- GET /plugins/birthday +- POST /plugins/birthday/settings +- POST /plugins/birthday/templates/create +- POST /plugins/birthday/templates/:id/update +- POST /plugins/birthday/templates/:id/duplicate +- POST /plugins/birthday/templates/:id/remove +- POST /plugins/birthday/profile +- POST /plugins/birthday/profile/unset +- GET /plugins/birthday/u/:username +## Route Reference +### MOUNT /plugins/birthday + +- Purpose: Mounts the plugin router at this base WebUI path. +- Inputs: No request parameters detected by static analysis. +- Response format: Plugin router mount point. +- Access: Access is controlled by the mount options and individual plugin routes. +- Side effects: No direct route action; child routes handle requests. +- Limits/notes: Mount metadata is inferred from static source scanning. + +### GET /plugins/birthday + +- Purpose: Renders or serves the birthday plugin page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- 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. + +### POST /plugins/birthday/settings + +- Purpose: Processes the birthday plugin action for settings. +- Inputs: body: `announcement_channel_id`, `birthday_check_interval_minutes`, `enabled`, `gift_amount`, `gift_mode`, `leap_day_policy`, `timezone` +- 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. Input length or numeric bounds are enforced by helper functions in the handler. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/birthday/templates/create + +- Purpose: Processes the birthday plugin action for templates create. +- Inputs: body: `pool`, `text` +- Response format: HTTP redirect after handling the request +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/birthday/templates/:id/update + +- Purpose: Processes the birthday plugin action for templates id update. +- Inputs: path params: `id`; body: `enabled`, `pool`, `text` +- Response format: HTTP redirect after handling the request +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/birthday/templates/:id/duplicate + +- Purpose: Processes the birthday plugin action for templates id duplicate. +- Inputs: path params: `id`; body: `pool` +- Response format: HTTP redirect after handling the request +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/birthday/templates/:id/remove + +- Purpose: Processes the birthday plugin action for templates id remove. +- Inputs: path params: `id`; body: `pool` +- Response format: HTTP redirect after handling the request +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/birthday/profile + +- Purpose: Processes the birthday plugin action for profile. +- Inputs: body: `birthday`, `privacy` +- Response format: HTTP redirect after handling the request +- Access: logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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/birthday/profile/unset + +- Purpose: Processes the birthday plugin action for profile unset. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### GET /plugins/birthday/u/:username + +- Purpose: Renders or serves the birthday plugin page for u username. +- Inputs: path params: `username` +- Response format: HTML page rendered from an EJS view +- Access: 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. +## Commands +- No plugin command triggers detected. +## Source +Plugin folder: plugins/birthday diff --git a/knowledge/plugins/economy-framework.md b/knowledge/plugins/economy-framework.md new file mode 100644 index 0000000..e785470 --- /dev/null +++ b/knowledge/plugins/economy-framework.md @@ -0,0 +1,304 @@ +--- +id: plugin.economy-framework +title: Economy Framework +scope: plugins +status: active +priority: 10 +visibility: user +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. +## Metadata +Plugin ID: economy-framework +Version: 0.2.9 +Default state: enabled +## Web Routes +- /plugins/economy-framework +- /profile/banking +- GET /plugins/economy-framework +- GET /profile/banking +- POST /plugins/economy-framework/settings/currency +- POST /profile/banking/settings/currency +- POST /plugins/economy-framework/settings/platforms +- POST /profile/banking/settings/platforms +- POST /plugins/economy-framework/settings/earn +- POST /profile/banking/settings/earn +- POST /plugins/economy-framework/settings/tiers +- POST /profile/banking/settings/tiers +- POST /plugins/economy-framework/settings/banking +- POST /profile/banking/settings/banking +- POST /plugins/economy-framework/settings/responses +- POST /profile/banking/settings/responses +- POST /plugins/economy-framework/settings/icon +- POST /profile/banking/settings/icon +- POST /plugins/economy-framework/accounts/adjust +- POST /profile/banking/accounts/adjust +- POST /plugins/economy-framework/funds/create +- POST /profile/banking/funds/create +- POST /plugins/economy-framework/funds/:id/update +- POST /profile/banking/funds/:id/update +- POST /plugins/economy-framework/events/create +- POST /profile/banking/events/create +- POST /plugins/economy-framework/events/:id/delete +- POST /profile/banking/events/:id/delete +## Route Reference +### MOUNT /plugins/economy-framework + +- Purpose: Mounts the plugin router at this base WebUI path. +- Inputs: No request parameters detected by static analysis. +- Response format: Plugin router mount point. +- Access: Access is controlled by the mount options and individual plugin routes. +- Side effects: No direct route action; child routes handle requests. +- Limits/notes: Mount metadata is inferred from static source scanning. + +### MOUNT /profile/banking + +- Purpose: Mounts the plugin router at this base WebUI path. +- Inputs: No request parameters detected by static analysis. +- Response format: Plugin router mount point. +- Access: Access is controlled by the mount options and individual plugin routes. +- Side effects: No direct route action; child routes handle requests. +- Limits/notes: Mount metadata is inferred from static source scanning. + +### GET /plugins/economy-framework + +- Purpose: Renders or serves the economy-framework plugin page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: admin access expected; moderator access may be required; 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. + +### GET /profile/banking + +- Purpose: Renders the profile banking WebUI page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: admin access expected; moderator access may be required; 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. + +### POST /plugins/economy-framework/settings/currency + +- Purpose: Processes the economy-framework plugin action for settings currency. +- Inputs: body: `command_aliases`, `command_root`, `currency_name`, `currency_name_plural` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /profile/banking/settings/currency + +- Purpose: Processes the profile banking settings currency action and stores or applies submitted form data. +- Inputs: body: `command_aliases`, `command_root`, `currency_name`, `currency_name_plural` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/economy-framework/settings/platforms + +- Purpose: Processes the economy-framework plugin action for settings platforms. +- Inputs: body: `platform_discord`, `platform_twitch`, `platform_youtube` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /profile/banking/settings/platforms + +- Purpose: Processes the profile banking settings platforms action and stores or applies submitted form data. +- Inputs: body: `platform_discord`, `platform_twitch`, `platform_youtube` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/economy-framework/settings/earn + +- Purpose: Processes the economy-framework plugin action for settings earn. +- Inputs: body: `earn_discord_message_amount`, `earn_discord_message_cooldown`, `earn_discord_message_enabled`, `earn_discord_voice_amount_per_min`, `earn_discord_voice_enabled`, `earn_discord_voice_tick_minutes`, `earn_twitch_message_amount`, `earn_twitch_message_cooldown`, `earn_twitch_message_enabled` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /profile/banking/settings/earn + +- Purpose: Processes the profile banking settings earn action and stores or applies submitted form data. +- Inputs: body: `earn_discord_message_amount`, `earn_discord_message_cooldown`, `earn_discord_message_enabled`, `earn_discord_voice_amount_per_min`, `earn_discord_voice_enabled`, `earn_discord_voice_tick_minutes`, `earn_twitch_message_amount`, `earn_twitch_message_cooldown`, `earn_twitch_message_enabled` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/economy-framework/settings/tiers + +- Purpose: Processes the economy-framework plugin action for settings tiers. +- Inputs: body: `tier_discord_booster_multiplier`, `tier_twitch_broadcaster_multiplier`, `tier_twitch_mod_multiplier`, `tier_twitch_sub_multiplier`, `tier_twitch_vip_multiplier` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /profile/banking/settings/tiers + +- Purpose: Processes the profile banking settings tiers action and stores or applies submitted form data. +- Inputs: body: `tier_discord_booster_multiplier`, `tier_twitch_broadcaster_multiplier`, `tier_twitch_mod_multiplier`, `tier_twitch_sub_multiplier`, `tier_twitch_vip_multiplier` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/economy-framework/settings/banking + +- Purpose: Processes the economy-framework plugin action for settings banking. +- Inputs: body: `banking_enabled`, `banking_label`, `community_fund_name`, `community_fund_name_plural` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /profile/banking/settings/banking + +- Purpose: Processes the profile banking settings banking action and stores or applies submitted form data. +- Inputs: body: `banking_enabled`, `banking_label`, `community_fund_name`, `community_fund_name_plural` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/economy-framework/settings/responses + +- Purpose: Processes the economy-framework plugin action for settings responses. +- Inputs: body: `response_key`, `response_mode`, `response_text`, `response_weight` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /profile/banking/settings/responses + +- Purpose: Processes the profile banking settings responses action and stores or applies submitted form data. +- Inputs: body: `response_key`, `response_mode`, `response_text`, `response_weight` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/economy-framework/settings/icon + +- Purpose: Processes the economy-framework plugin action for settings icon. +- Inputs: file upload: multipart form file data +- 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; writes files +- 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 /profile/banking/settings/icon + +- Purpose: Processes the profile banking settings icon action and stores or applies submitted form data. +- Inputs: file upload: multipart form file data +- 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; writes files +- 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/economy-framework/accounts/adjust + +- Purpose: Processes the economy-framework plugin action for accounts adjust. +- Inputs: body: `amount`, `note`, `target_user_id`, `username` +- Response format: HTTP redirect after handling the request +- Access: moderator access may be required; logged-in session required or used +- Side effects: writes database state when the called service mutates data +- 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 /profile/banking/accounts/adjust + +- Purpose: Processes the profile banking accounts adjust action and stores or applies submitted form data. +- Inputs: body: `amount`, `note`, `target_user_id`, `username` +- Response format: HTTP redirect after handling the request +- Access: moderator access may be required; logged-in session required or used +- Side effects: writes database state when the called service mutates data +- 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/economy-framework/funds/create + +- Purpose: Processes the economy-framework plugin action for funds create. +- Inputs: body: `description`, `name`, `target_amount` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /profile/banking/funds/create + +- Purpose: Processes the profile banking funds create action and stores or applies submitted form data. +- Inputs: body: `description`, `name`, `target_amount` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/economy-framework/funds/:id/update + +- Purpose: Processes the economy-framework plugin action for funds id update. +- Inputs: path params: `id`; body: `description`, `name`, `status`, `target_amount` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /profile/banking/funds/:id/update + +- Purpose: Processes the profile banking funds id update action and stores or applies submitted form data. +- Inputs: path params: `id`; body: `description`, `name`, `status`, `target_amount` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/economy-framework/events/create + +- Purpose: Processes the economy-framework plugin action for events create. +- Inputs: body: `amount`, `name` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /profile/banking/events/create + +- Purpose: Processes the profile banking events create action and stores or applies submitted form data. +- Inputs: body: `amount`, `name` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/economy-framework/events/:id/delete + +- Purpose: Processes the economy-framework plugin action for events id delete. +- Inputs: path params: `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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /profile/banking/events/:id/delete + +- Purpose: Processes the profile banking events id delete action and stores or applies submitted form data. +- Inputs: path params: `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. Most non-API POST routes are browser form submissions and usually redirect after completion. +## Commands +- No plugin command triggers detected. +## Source +Plugin folder: plugins/economy-framework diff --git a/knowledge/plugins/economy-games.md b/knowledge/plugins/economy-games.md new file mode 100644 index 0000000..cc2dccc --- /dev/null +++ b/knowledge/plugins/economy-games.md @@ -0,0 +1,84 @@ +--- +id: plugin.economy-games +title: Economy Games +scope: plugins +status: active +priority: 10 +visibility: user +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. +## Metadata +Plugin ID: economy-games +Version: 0.1.7 +Default state: enabled +## Web Routes +- /plugins/economy-games +- GET /plugins/economy-games +- POST /plugins/economy-games/settings/hotpotato +- POST /plugins/economy-games/settings/coinflip +- POST /plugins/economy-games/settings/mystery +- POST /plugins/economy-games/settings/responses +## Route Reference +### MOUNT /plugins/economy-games + +- Purpose: Mounts the plugin router at this base WebUI path. +- Inputs: No request parameters detected by static analysis. +- Response format: Plugin router mount point. +- Access: Access is controlled by the mount options and individual plugin routes. +- Side effects: No direct route action; child routes handle requests. +- Limits/notes: Mount metadata is inferred from static source scanning. + +### GET /plugins/economy-games + +- Purpose: Renders or serves the economy-games plugin page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + +### POST /plugins/economy-games/settings/hotpotato + +- Purpose: Processes the economy-games plugin action for settings hotpotato. +- Inputs: body: `hotpotato_aliases`, `hotpotato_enabled`, `hotpotato_loss_additive`, `hotpotato_loss_multiplier`, `hotpotato_max_cost`, `hotpotato_min_cost`, `hotpotato_name`, `hotpotato_platform_discord`, `hotpotato_platform_twitch`, `hotpotato_platform_youtube`, `hotpotato_presence_window`, `hotpotato_toss_max`, `hotpotato_toss_min`, `hotpotato_trigger` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/economy-games/settings/coinflip + +- Purpose: Processes the economy-games plugin action for settings coinflip. +- Inputs: body: `coinflip_aliases`, `coinflip_cooldown`, `coinflip_enabled`, `coinflip_max_bet`, `coinflip_min_bet`, `coinflip_multiplier`, `coinflip_name`, `coinflip_platform_discord`, `coinflip_platform_twitch`, `coinflip_platform_youtube`, `coinflip_trigger` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/economy-games/settings/mystery + +- Purpose: Processes the economy-games plugin action for settings mystery. +- Inputs: body: `mystery_aliases`, `mystery_cooldown`, `mystery_enabled`, `mystery_max_bet`, `mystery_min_bet`, `mystery_multiplier`, `mystery_name`, `mystery_platform_discord`, `mystery_platform_twitch`, `mystery_platform_youtube`, `mystery_trigger` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/economy-games/settings/responses + +- Purpose: Processes the economy-games plugin action for settings responses. +- 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 +- 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. +## Commands +- No plugin command triggers detected. +## Source +Plugin folder: plugins/economy-games diff --git a/knowledge/plugins/expression-interaction.md b/knowledge/plugins/expression-interaction.md new file mode 100644 index 0000000..9553354 --- /dev/null +++ b/knowledge/plugins/expression-interaction.md @@ -0,0 +1,104 @@ +--- +id: plugin.expression-interaction +title: Expression Interaction +scope: plugins +status: active +priority: 10 +visibility: user +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 +## Metadata +Plugin ID: expression-interaction +Version: 0.2.0 +Default state: enabled +## Web Routes +- /plugins/expression-interaction +- GET /plugins/expression-interaction +- POST /plugins/expression-interaction/settings +- POST /plugins/expression-interaction/actions/create +- POST /plugins/expression-interaction/actions/:id/update +- POST /plugins/expression-interaction/actions/:id/toggle +- POST /plugins/expression-interaction/actions/:id/archive +- POST /plugins/expression-interaction/actions/:id/restore +## Route Reference +### MOUNT /plugins/expression-interaction + +- Purpose: Mounts the plugin router at this base WebUI path. +- Inputs: No request parameters detected by static analysis. +- Response format: Plugin router mount point. +- Access: Access is controlled by the mount options and individual plugin routes. +- Side effects: No direct route action; child routes handle requests. +- Limits/notes: Mount metadata is inferred from static source scanning. + +### GET /plugins/expression-interaction + +- Purpose: Renders or serves the expression-interaction plugin page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- 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. + +### POST /plugins/expression-interaction/settings + +- Purpose: Processes the expression-interaction 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 +- 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/expression-interaction/actions/create + +- Purpose: Processes the expression-interaction plugin action for actions create. +- 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 +- 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/expression-interaction/actions/:id/update + +- Purpose: Processes the expression-interaction plugin action for actions id update. +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/expression-interaction/actions/:id/toggle + +- Purpose: Processes the expression-interaction plugin action for actions id toggle. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected; 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/expression-interaction/actions/:id/archive + +- Purpose: Processes the expression-interaction plugin action for actions id archive. +- Inputs: path params: `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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/expression-interaction/actions/:id/restore + +- Purpose: Processes the expression-interaction plugin action for actions id restore. +- Inputs: path params: `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. Most non-API POST routes are browser form submissions and usually redirect after completion. +## Commands +- No plugin command triggers detected. +## Source +Plugin folder: plugins/expression-interaction diff --git a/knowledge/plugins/lumi-ai.md b/knowledge/plugins/lumi-ai.md new file mode 100644 index 0000000..d416c87 --- /dev/null +++ b/knowledge/plugins/lumi-ai.md @@ -0,0 +1,524 @@ +--- +id: plugin.lumi_ai +title: Lumi AI +scope: plugins +status: active +priority: 10 +visibility: user +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. +## Metadata +Plugin ID: lumi_ai +Version: 0.8.1 +Default state: enabled +## Web Routes +- /plugins/lumi_ai +- GET /plugins/lumi_ai +- POST /plugins/lumi_ai/settings +- POST /plugins/lumi_ai/download/runtime +- POST /plugins/lumi_ai/download/model/:id +- GET /plugins/lumi_ai/api/status +- GET /plugins/lumi_ai/api/downloads +- GET /plugins/lumi_ai/api/gpu-capacity +- GET /plugins/lumi_ai/api/assistant-diagnostics +- GET /plugins/lumi_ai/api/assistant/visibility-debug +- POST /plugins/lumi_ai/api/assistant/visibility-debug +- POST /plugins/lumi_ai/repo-index/refresh +- POST /plugins/lumi_ai/access-control +- POST /plugins/lumi_ai/models/:id/delete +- POST /plugins/lumi_ai/models/:id/verify +- POST /plugins/lumi_ai/storage/cleanup +- GET /plugins/lumi_ai/logs/:name +- GET /plugins/lumi_ai/logs/:name/download +- POST /plugins/lumi_ai/logs/:name/delete +- POST /plugins/lumi_ai/runtime/:action +- GET /plugins/lumi_ai/diagnostics/download +- POST /plugins/lumi_ai/assistant/message +- POST /plugins/lumi_ai/assistant/feedback +- GET /plugins/lumi_ai/assistant/jobs/:id +- POST /plugins/lumi_ai/assistant/jobs/:id/cancel +- POST /plugins/lumi_ai/assistant/jobs/:id/soft-timeout +- POST /plugins/lumi_ai/assistant/test +- POST /plugins/lumi_ai/assistant/confirm +- POST /plugins/lumi_ai/assistant/cancel +- GET /plugins/lumi_ai/api/tools +- GET /plugins/lumi_ai/api/tools-diagnostics +- GET /plugins/lumi_ai/api/tools/:id/readme +- GET /plugins/lumi_ai/api/tools/:id/settings +- POST /plugins/lumi_ai/api/tools/:id/settings +- POST /plugins/lumi_ai/api/tools/:id/settings/reset +- GET /plugins/lumi_ai/tools/:id/assets/* +- POST /plugins/lumi_ai/tools/:id/enable +- POST /plugins/lumi_ai/tools/:id/disable +- POST /plugins/lumi_ai/tools/:id/update +- POST /plugins/lumi_ai/tools/:id/delete +- 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/implement +- POST /plugins/lumi_ai/improvement_center/corrections/save +- POST /plugins/lumi_ai/improvement_center/corrections/:id +- POST /plugins/lumi_ai/improvement_center/evals +- POST /plugins/lumi_ai/improvement_center/evals/:id/delete +- POST /plugins/lumi_ai/improvement_center/evals/run +- POST /plugins/lumi_ai/improvement_center/exports/:format +## Route Reference +### MOUNT /plugins/lumi_ai + +- Purpose: Mounts the plugin router at this base WebUI path. +- Inputs: No request parameters detected by static analysis. +- Response format: Plugin router mount point. +- Access: Access is controlled by the mount options and individual plugin routes. +- Side effects: No direct route action; child routes handle requests. +- Limits/notes: Mount metadata is inferred from static source scanning. + +### GET /plugins/lumi_ai + +- Purpose: Renders or serves the lumi_ai plugin page. +- Inputs: query: `access_page`, `logs_page`, `metrics_page`, `slow_page`, `work_page` +- Response format: HTML page rendered from an EJS view +- 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. + +### POST /plugins/lumi_ai/settings + +- Purpose: Processes the lumi_ai plugin action for settings. +- Inputs: body: `admin_bypass_rate_limit`, `admin_custom`, `allow_deterministic_help_shortcuts`, `allow_moderator_code_help`, `allow_moderators_to_review_responses`, `allowed_support_domains`, `allowed_topics`, `answer_style`, `assistant_debug_logging`, `assistant_enabled`, `batch_size`, `clarification_behavior`, `command_denied_message`, `command_enabled`, `command_platform_discord`, `command_platform_kick`, `command_platform_other`, `command_platform_twitch`, `command_platform_youtube`, `command_role_admins`, `command_role_mods`, `command_role_users`, `command_triggers`, `command_unavailable_message`, `community_tone`, `concurrency`, `context_size`, `corrections_enabled`, `enabled`, `gate_cache_ttl_seconds`, `gate_context_size`, `gate_force_prefix`, `gate_high_confidence_threshold`, `gate_main_llm_threshold`, `gate_model_id`, `gate_predefined_enabled`, `gate_repeat_force_window_seconds`, `gate_similarity_threshold`, `gate_threads`, `gate_timeout_ms` +- Response format: Form/action response; exact format was not detected statically. +- 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. 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/download/runtime + +- Purpose: Processes the lumi_ai plugin action for download runtime. +- Inputs: No request parameters detected by static analysis. +- Response format: JSON response +- Access: admin access expected; 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/download/model/:id + +- Purpose: Processes the lumi_ai plugin action for download model id. +- Inputs: path params: `id`; body: `override_compatibility` +- Response format: JSON response +- Access: admin access expected; logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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. + +### GET /plugins/lumi_ai/api/status + +- Purpose: Renders or serves the lumi_ai plugin page for api status. +- Inputs: No request parameters detected by static analysis. +- Response format: JSON response +- Access: 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. API consumers should expect JSON unless the response format says otherwise. + +### GET /plugins/lumi_ai/api/downloads + +- Purpose: Renders or serves the lumi_ai plugin page for api downloads. +- Inputs: No request parameters detected by static analysis. +- Response format: JSON 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. API consumers should expect JSON unless the response format says otherwise. + +### GET /plugins/lumi_ai/api/gpu-capacity + +- Purpose: Renders or serves the lumi_ai plugin page for api gpu capacity. +- Inputs: query: `context_size`, `intent_percent`, `model_id` +- Response format: JSON 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. API consumers should expect JSON unless the response format says otherwise. + +### GET /plugins/lumi_ai/api/assistant-diagnostics + +- Purpose: Renders or serves the lumi_ai plugin page for api assistant diagnostics. +- Inputs: No request parameters detected by static analysis. +- Response format: JSON 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. API consumers should expect JSON unless the response format says otherwise. + +### GET /plugins/lumi_ai/api/assistant/visibility-debug + +- Purpose: Renders or serves the lumi_ai plugin page for api assistant visibility debug. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML or data response; exact format was not detected statically. +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. API consumers should expect JSON unless the response format says otherwise. + +### POST /plugins/lumi_ai/api/assistant/visibility-debug + +- Purpose: Processes the lumi_ai plugin action for api assistant visibility debug. +- 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: 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. API consumers should expect JSON unless the response format says otherwise. + +### POST /plugins/lumi_ai/repo-index/refresh + +- Purpose: Processes the lumi_ai plugin action for repo index refresh. +- Inputs: body: `source` +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected; logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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/access-control + +- Purpose: Processes the lumi_ai plugin action for access control. +- Inputs: body: `action`, `reason`, `silent`, `timeout_until`, `user_id` +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected; logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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/models/:id/delete + +- Purpose: Processes the lumi_ai plugin action for models id delete. +- Inputs: path params: `id`; body: `confirm` +- Response format: Form/action response; exact format was not detected statically. +- 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 /plugins/lumi_ai/models/:id/verify + +- Purpose: Processes the lumi_ai plugin action for models id verify. +- Inputs: path params: `id` +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected; 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/lumi_ai/storage/cleanup + +- Purpose: Processes the lumi_ai plugin action for storage cleanup. +- Inputs: body: `categories` +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected; logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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. + +### GET /plugins/lumi_ai/logs/:name + +- Purpose: Renders or serves the lumi_ai plugin page for logs name. +- Inputs: path params: `name` +- Response format: HTML page rendered from an EJS view +- 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. Errors are caught and usually returned as a flash message, JSON error, or error page. + +### GET /plugins/lumi_ai/logs/:name/download + +- Purpose: Renders or serves the lumi_ai plugin page for logs name download. +- Inputs: path params: `name` +- Response format: file download +- 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. Errors are caught and usually returned as a flash message, JSON error, or error page. + +### POST /plugins/lumi_ai/logs/:name/delete + +- Purpose: Processes the lumi_ai plugin action for logs name delete. +- Inputs: path params: `name` +- Response format: Form/action response; exact format was not detected statically. +- 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 /plugins/lumi_ai/runtime/:action + +- Purpose: Processes the lumi_ai plugin action for runtime action. +- Inputs: path params: `action` +- Response format: JSON response +- Access: admin access expected; 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. + +### GET /plugins/lumi_ai/diagnostics/download + +- Purpose: Renders or serves the lumi_ai plugin page for diagnostics download. +- Inputs: No request parameters detected by static analysis. +- Response format: file download +- 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. + +### POST /plugins/lumi_ai/assistant/message + +- Purpose: Processes the lumi_ai plugin action for assistant message. +- Inputs: body: `history`, `message` +- 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. 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/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` +- 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. +- 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. + +### GET /plugins/lumi_ai/assistant/jobs/:id + +- Purpose: Renders or serves the lumi_ai plugin page for assistant jobs id. +- Inputs: path params: `id` +- Response format: JSON response +- Access: 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. + +### POST /plugins/lumi_ai/assistant/jobs/:id/cancel + +- Purpose: Processes the lumi_ai plugin action for assistant jobs id cancel. +- Inputs: path params: `id` +- Response format: JSON response +- Access: admin access expected; 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/lumi_ai/assistant/jobs/:id/soft-timeout + +- Purpose: Processes the lumi_ai plugin action for assistant jobs id soft timeout. +- Inputs: path params: `id` +- Response format: JSON response +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/lumi_ai/assistant/test + +- Purpose: Processes the lumi_ai plugin action for assistant test. +- Inputs: body: `allow_tools`, `message`, `origin`, `role`, `show_raw_output`, `show_raw_prompt` +- Response format: JSON response +- Access: admin access expected; logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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/assistant/confirm + +- Purpose: Processes the lumi_ai plugin action for assistant confirm. +- Inputs: body: `id` +- Response format: JSON response +- Access: logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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/assistant/cancel + +- Purpose: Processes the lumi_ai plugin action for assistant cancel. +- Inputs: body: `id` +- Response format: JSON response +- Access: logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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. + +### GET /plugins/lumi_ai/api/tools + +- Purpose: Renders or serves the lumi_ai plugin page for api tools. +- Inputs: query: `refresh` +- Response format: JSON 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. 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 /plugins/lumi_ai/api/tools-diagnostics + +- Purpose: Renders or serves the lumi_ai plugin page for api tools diagnostics. +- Inputs: query: `origin`, `role` +- Response format: JSON 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. 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 /plugins/lumi_ai/api/tools/:id/readme + +- Purpose: Renders or serves the lumi_ai plugin page for api tools id readme. +- Inputs: path params: `id` +- Response format: JSON 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. 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 /plugins/lumi_ai/api/tools/:id/settings + +- Purpose: Renders or serves the lumi_ai plugin page for api tools id settings. +- Inputs: path params: `id` +- Response format: JSON 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. 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. + +### POST /plugins/lumi_ai/api/tools/:id/settings + +- Purpose: Processes the lumi_ai plugin action for api tools id settings. +- Inputs: path params: `id`; body: full submitted body is passed to a helper; exact fields are defined by the matching form/service +- Response format: JSON response +- 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. API consumers should expect JSON unless the response format says otherwise. + +### POST /plugins/lumi_ai/api/tools/:id/settings/reset + +- Purpose: Processes the lumi_ai plugin action for api tools id settings reset. +- Inputs: path params: `id` +- Response format: JSON response +- Access: admin access expected; 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. API consumers should expect JSON unless the response format says otherwise. + +### GET /plugins/lumi_ai/tools/:id/assets/* + +- Purpose: Renders or serves the lumi_ai plugin page for tools id assets *. +- Inputs: path params: `id` +- Response format: static file 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. + +### POST /plugins/lumi_ai/tools/:id/enable + +- Purpose: Processes the lumi_ai plugin action for tools id enable. +- Inputs: path params: `id` +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected; 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/tools/:id/disable + +- Purpose: Processes the lumi_ai plugin action for tools id disable. +- Inputs: path params: `id` +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected; 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/tools/:id/update + +- Purpose: Processes the lumi_ai plugin action for tools id update. +- Inputs: path params: `id` +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected; 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/tools/:id/delete + +- Purpose: Processes the lumi_ai plugin action for tools id delete. +- Inputs: path params: `id` +- Response format: Form/action response; exact format was not detected statically. +- Access: admin access expected; 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. + +### GET /plugins/lumi_ai/improvement_center + +- Purpose: Renders or serves the lumi_ai plugin page for improvement center. +- Inputs: query: `correction_page`, `eval_page`, `review_page`, `status` +- Response format: HTML page rendered from an EJS view +- Access: 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/improvement_center/settings + +- Purpose: Processes the lumi_ai plugin action for improvement center settings. +- Inputs: body: `allow_moderators_to_review_responses`, `corrections_enabled`, `trusted_moderator_reviewers` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/lumi_ai/improvement_center/reviews/:id + +- Purpose: Processes the lumi_ai plugin action for improvement center reviews id. +- Inputs: path params: `id`; body: `action`, `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. 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/implement + +- Purpose: Processes the lumi_ai plugin action for improvement center reviews id implement. +- Inputs: path params: `id`; body: `corrected_answer`, `enabled`, `expected_link`, `explicitly_safe`, `forbidden_behavior`, `min_role`, `notes`, `permission_origin`, `target` +- 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/corrections/save + +- Purpose: Processes the lumi_ai plugin action for improvement center corrections save. +- Inputs: No request parameters detected by static analysis. +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/lumi_ai/improvement_center/corrections/:id + +- Purpose: Processes the lumi_ai plugin action for improvement center corrections id. +- Inputs: path params: `id`; body: `action`, `enabled`, `expected_link`, `explicitly_safe` +- 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. 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/evals + +- Purpose: Processes the lumi_ai plugin action for improvement center evals. +- Inputs: body: `expected_link` +- 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. +- 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/:id/delete + +- Purpose: Processes the lumi_ai plugin action for improvement center evals 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/lumi_ai/improvement_center/evals/run + +- Purpose: Processes the lumi_ai plugin action for improvement center evals run. +- Inputs: No request parameters detected by static analysis. +- 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/exports/:format + +- Purpose: Processes the lumi_ai plugin action for improvement center exports format. +- Inputs: path params: `format` +- Response format: file download +- 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. +## Commands +- No plugin command triggers detected. +## Source +Plugin folder: plugins/lumi_ai diff --git a/knowledge/plugins/moderation.md b/knowledge/plugins/moderation.md new file mode 100644 index 0000000..a85ef6b --- /dev/null +++ b/knowledge/plugins/moderation.md @@ -0,0 +1,124 @@ +--- +id: plugin.moderation +title: Moderation Center +scope: plugins +status: active +priority: 10 +visibility: user +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. +## Metadata +Plugin ID: moderation +Version: 0.1.4 +Default state: enabled +## Web Routes +- /plugins/moderation +- GET /plugins/moderation/status +- GET /plugins/moderation +- GET /plugins/moderation/tos-bans +- GET /plugins/moderation/evidence/:id +- POST /plugins/moderation/actions +- POST /plugins/moderation/actions/:id/update-timeout +- POST /plugins/moderation/actions/:id/revoke +- POST /plugins/moderation/notes +- POST /plugins/moderation/notes/:id/delete +## Route Reference +### MOUNT /plugins/moderation + +- Purpose: Mounts the plugin router at this base WebUI path. +- Inputs: No request parameters detected by static analysis. +- Response format: Plugin router mount point. +- Access: Access is controlled by the mount options and individual plugin routes. +- Side effects: No direct route action; child routes handle requests. +- Limits/notes: Mount metadata is inferred from static source scanning. + +### GET /plugins/moderation/status + +- Purpose: Renders or serves the moderation plugin page for status. +- Inputs: No request parameters detected by static analysis. +- Response format: HTTP redirect after handling the request +- Access: 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. + +### GET /plugins/moderation + +- Purpose: Renders or serves the moderation plugin page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- Access: admin access expected; moderator access may be required; 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. + +### GET /plugins/moderation/tos-bans + +- Purpose: Renders or serves the moderation plugin page for tos bans. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view; HTTP redirect after handling the request +- Access: admin access expected; moderator access may be required; 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. + +### GET /plugins/moderation/evidence/:id + +- Purpose: Renders or serves the moderation plugin page for evidence id. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request; file download +- Access: admin access expected; moderator access may be required; 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. + +### POST /plugins/moderation/actions + +- Purpose: Processes the moderation plugin action for actions. +- Inputs: body: `action_type`, `reason_detail`, `reason_short`; file upload: multipart form file data +- Response format: HTTP redirect after handling the request +- Access: admin access expected; moderator access may be required; 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/moderation/actions/:id/update-timeout + +- Purpose: Processes the moderation plugin action for actions id update timeout. +- 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; moderator access may be required; 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/moderation/actions/:id/revoke + +- Purpose: Processes the moderation plugin action for actions id revoke. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected; moderator access may be required; 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/moderation/notes + +- Purpose: Processes the moderation plugin action for notes. +- Inputs: body: `note` +- Response format: HTTP redirect after handling the request +- Access: admin access expected; moderator access may be required; logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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/moderation/notes/:id/delete + +- Purpose: Processes the moderation plugin action for notes id delete. +- Inputs: path params: `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. Most non-API POST routes are browser form submissions and usually redirect after completion. +## Commands +- No plugin command triggers detected. +## Source +Plugin folder: plugins/moderation diff --git a/knowledge/plugins/okf.md b/knowledge/plugins/okf.md new file mode 100644 index 0000000..4a01bb2 --- /dev/null +++ b/knowledge/plugins/okf.md @@ -0,0 +1,144 @@ +--- +id: plugin.okf +title: OKF Knowledge +scope: plugins +status: active +priority: 10 +visibility: user +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. +## Metadata +Plugin ID: okf +Version: 0.1.0 +Default state: enabled +## Web Routes +- /plugins/okf +- GET /plugins/okf +- GET /plugins/okf/admin +- POST /plugins/okf/admin/community +- POST /plugins/okf/admin/community/:slug +- POST /plugins/okf/admin/entries +- POST /plugins/okf/admin/entries/:slug +- POST /plugins/okf/admin/entries/:slug/:action +- POST /plugins/okf/admin/entries/:slug/versions/:version/restore +- POST /plugins/okf/admin/permissions +- POST /plugins/okf/admin/permissions/:id/revoke +- GET /plugins/okf/:slug +## Route Reference +### MOUNT /plugins/okf + +- Purpose: Mounts the plugin router at this base WebUI path. +- Inputs: No request parameters detected by static analysis. +- Response format: Plugin router mount point. +- Access: Access is controlled by the mount options and individual plugin routes. +- Side effects: No direct route action; child routes handle requests. +- Limits/notes: Mount metadata is inferred from static source scanning. + +### GET /plugins/okf + +- Purpose: Renders or serves the okf plugin page. +- Inputs: query: `category`, `q`, `tag` +- Response format: HTML page rendered from an EJS view +- Access: 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. + +### GET /plugins/okf/admin + +- Purpose: Renders the okf plugin administration page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered by a helper +- Access: OKF editor or manager permission required; admin access expected +- 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/community + +- Purpose: Processes the okf plugin administration action for community. +- Inputs: body: `body` +- Response format: HTTP redirect after handling the request +- Access: OKF editor or manager permission required; 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 /plugins/okf/admin/community/:slug + +- Purpose: Processes the okf plugin administration action for community slug. +- Inputs: path params: `slug`; body: `body` +- Response format: HTTP redirect after handling the request +- Access: OKF editor or manager permission required; 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 /plugins/okf/admin/entries + +- Purpose: Processes the okf plugin administration action for entries. +- 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: OKF editor or manager permission required; 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 /plugins/okf/admin/entries/:slug + +- Purpose: Processes the okf plugin administration action for entries slug. +- Inputs: path params: `slug`; 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: OKF editor or manager permission required; 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 /plugins/okf/admin/entries/:slug/:action + +- Purpose: Processes the okf plugin administration action for entries slug action. +- Inputs: path params: `action`, `slug`; body: `note` +- Response format: HTTP redirect after handling the request +- Access: OKF editor or manager permission required; 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 /plugins/okf/admin/entries/:slug/versions/:version/restore + +- Purpose: Processes the okf plugin administration action for entries slug versions version restore. +- Inputs: path params: `slug`, `version`; body: `note` +- Response format: HTTP redirect after handling the request +- Access: OKF editor or manager permission required; 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 /plugins/okf/admin/permissions + +- Purpose: Processes the okf plugin administration action for permissions. +- 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 +- 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/okf/admin/permissions/:id/revoke + +- Purpose: Processes the okf plugin administration action for permissions id revoke. +- Inputs: path params: `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. + +### GET /plugins/okf/:slug + +- Purpose: Renders or serves the okf plugin page for slug. +- Inputs: path params: `slug` +- Response format: HTML page rendered from an EJS view +- Access: 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. +## Commands +- No plugin command triggers detected. +## Source +Plugin folder: plugins/okf diff --git a/knowledge/plugins/quotes.md b/knowledge/plugins/quotes.md new file mode 100644 index 0000000..0cecad3 --- /dev/null +++ b/knowledge/plugins/quotes.md @@ -0,0 +1,124 @@ +--- +id: plugin.quotes +title: Quotes +scope: plugins +status: active +priority: 10 +visibility: user +category: Plugin +tags: plugin, quotes +generated: true +editable: false +updated_at: "2026-06-25T12:06:11.231Z" +--- +# Quotes +Store, search, and manage community quotes. +## Metadata +Plugin ID: quotes +Version: 0.1.1 +Default state: enabled +## Web Routes +- /plugins/quotes +- GET /plugins/quotes +- POST /plugins/quotes/quotes/create +- POST /plugins/quotes/quotes/:id/update +- POST /plugins/quotes/quotes/:id/hide +- POST /plugins/quotes/quotes/:id/unhide +- POST /plugins/quotes/quotes/:id/archive +- POST /plugins/quotes/quotes/:id/restore +- GET /plugins/quotes/api/quotes +- GET /plugins/quotes/api/quotes/:id +## Route Reference +### MOUNT /plugins/quotes + +- Purpose: Mounts the plugin router at this base WebUI path. +- Inputs: No request parameters detected by static analysis. +- Response format: Plugin router mount point. +- Access: Access is controlled by the mount options and individual plugin routes. +- Side effects: No direct route action; child routes handle requests. +- Limits/notes: Mount metadata is inferred from static source scanning. + +### GET /plugins/quotes + +- Purpose: Renders or serves the quotes plugin page. +- Inputs: query: `edit` +- Response format: HTML page rendered from an EJS view +- Access: admin access expected; moderator access may be required; 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. + +### POST /plugins/quotes/quotes/create + +- Purpose: Processes the quotes plugin action for quotes create. +- Inputs: body: `archived`, `game_name`, `hidden`, `quote_datetime`, `quote_text`, `quoter` +- Response format: HTTP redirect after handling the request +- Access: admin access expected; moderator access may be required; logged-in session required or used +- Side effects: Consumes submitted data; state mutation happens in called helpers if present. +- 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/quotes/quotes/:id/update + +- Purpose: Processes the quotes plugin action for quotes id update. +- Inputs: path params: `id`; body: `archived`, `game_name`, `hidden`, `quote_datetime`, `quote_text`, `quoter` +- Response format: HTTP redirect after handling the request +- Access: admin access expected; moderator access may be required; 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/quotes/quotes/:id/hide + +- Purpose: Processes the quotes plugin action for quotes id hide. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected; moderator access may be required; 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/quotes/quotes/:id/unhide + +- Purpose: Processes the quotes plugin action for quotes id unhide. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected; moderator access may be required; 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/quotes/quotes/:id/archive + +- Purpose: Processes the quotes plugin action for quotes id archive. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected; moderator access may be required; 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/quotes/quotes/:id/restore + +- Purpose: Processes the quotes plugin action for quotes id restore. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: admin access expected; moderator access may be required; 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### GET /plugins/quotes/api/quotes + +- Purpose: Renders or serves the quotes plugin page for api quotes. +- Inputs: No request parameters detected by static analysis. +- Response format: JSON response +- Access: admin access expected; moderator access may be required; 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. API consumers should expect JSON unless the response format says otherwise. + +### GET /plugins/quotes/api/quotes/:id + +- Purpose: Renders or serves the quotes plugin page for api quotes id. +- Inputs: path params: `id` +- Response format: JSON response +- Access: admin access expected; moderator access may be required; 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. API consumers should expect JSON unless the response format says otherwise. +## Commands +- No plugin command triggers detected. +## Source +Plugin folder: plugins/quotes diff --git a/knowledge/plugins/sample-plugin.md b/knowledge/plugins/sample-plugin.md new file mode 100644 index 0000000..b8c6681 --- /dev/null +++ b/knowledge/plugins/sample-plugin.md @@ -0,0 +1,44 @@ +--- +id: plugin.sample-plugin +title: Sample Plugin +scope: plugins +status: active +priority: 10 +visibility: user +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. +## Metadata +Plugin ID: sample-plugin +Version: 0.1.0 +Default state: enabled +## Web Routes +- /plugins/sample-plugin +- GET /plugins/sample-plugin +## Route Reference +### MOUNT /plugins/sample-plugin + +- Purpose: Mounts the plugin router at this base WebUI path. +- Inputs: No request parameters detected by static analysis. +- Response format: Plugin router mount point. +- Access: Access is controlled by the mount options and individual plugin routes. +- Side effects: No direct route action; child routes handle requests. +- Limits/notes: Mount metadata is inferred from static source scanning. + +### GET /plugins/sample-plugin + +- Purpose: Renders or serves the sample-plugin plugin page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. +## Commands +- No plugin command triggers detected. +## Source +Plugin folder: plugins/sample-plugin diff --git a/knowledge/plugins/throne-wishlist.md b/knowledge/plugins/throne-wishlist.md new file mode 100644 index 0000000..37192f9 --- /dev/null +++ b/knowledge/plugins/throne-wishlist.md @@ -0,0 +1,124 @@ +--- +id: plugin.throne_wishlist +title: Throne Wishlist +scope: plugins +status: active +priority: 10 +visibility: user +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. +## Metadata +Plugin ID: throne_wishlist +Version: 0.1.1 +Default state: enabled +## Web Routes +- /plugins/throne_wishlist +- GET /plugins/throne_wishlist +- POST /plugins/throne_wishlist/endpoints/create +- POST /plugins/throne_wishlist/endpoints/:id/renew +- POST /plugins/throne_wishlist/endpoints/:id/remove +- POST /plugins/throne_wishlist/destinations +- POST /plugins/throne_wishlist/templates +- POST /plugins/throne_wishlist/debug/start +- GET /plugins/throne_wishlist/debug/stream/:sessionId +- POST /plugins/throne_wishlist/debug/stop/:sessionId +## Route Reference +### MOUNT /plugins/throne_wishlist + +- Purpose: Mounts the plugin router at this base WebUI path. +- Inputs: No request parameters detected by static analysis. +- Response format: Plugin router mount point. +- Access: Access is controlled by the mount options and individual plugin routes. +- Side effects: No direct route action; child routes handle requests. +- Limits/notes: Mount metadata is inferred from static source scanning. + +### GET /plugins/throne_wishlist + +- Purpose: Renders or serves the throne_wishlist plugin page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- Access: 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. + +### POST /plugins/throne_wishlist/endpoints/create + +- Purpose: Processes the throne_wishlist plugin action for endpoints create. +- Inputs: body: `identifier` +- Response format: HTTP redirect after handling the request +- Access: logged-in session required or used +- Side effects: writes database state when the called service mutates data +- 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/throne_wishlist/endpoints/:id/renew + +- Purpose: Processes the throne_wishlist plugin action for endpoints id renew. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: logged-in session required or used +- Side effects: writes database state when the called service mutates data +- 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/throne_wishlist/endpoints/:id/remove + +- Purpose: Processes the throne_wishlist plugin action for endpoints id remove. +- Inputs: path params: `id` +- Response format: HTTP redirect after handling the request +- Access: logged-in session required or used +- Side effects: writes database state when the called service mutates data +- 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/throne_wishlist/destinations + +- Purpose: Processes the throne_wishlist plugin action for destinations. +- Inputs: body: `discord_destination_id`, `enabled_platforms` +- Response format: HTTP redirect after handling the request +- Access: logged-in session required or used +- Side effects: writes database state when the called service mutates data +- 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/throne_wishlist/templates + +- Purpose: Processes the throne_wishlist plugin action for templates. +- Inputs: body: `enabled`, `event_type`, `platform`, `template` +- Response format: HTTP redirect after handling the request +- Access: logged-in session required or used +- Side effects: writes database state when the called service mutates data +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/throne_wishlist/debug/start + +- Purpose: Processes the throne_wishlist plugin action for debug start. +- Inputs: No request parameters detected by static analysis. +- Response format: JSON response +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### GET /plugins/throne_wishlist/debug/stream/:sessionId + +- Purpose: Renders or serves the throne_wishlist plugin page for debug stream sessionId. +- Inputs: path params: `sessionId` +- Response format: streaming event response +- Access: 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. + +### POST /plugins/throne_wishlist/debug/stop/:sessionId + +- Purpose: Processes the throne_wishlist plugin action for debug stop sessionId. +- Inputs: path params: `sessionId` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. +## Commands +- No plugin command triggers detected. +## Source +Plugin folder: plugins/throne_wishlist diff --git a/knowledge/plugins/welcome-messages.md b/knowledge/plugins/welcome-messages.md new file mode 100644 index 0000000..3c4d5d7 --- /dev/null +++ b/knowledge/plugins/welcome-messages.md @@ -0,0 +1,114 @@ +--- +id: plugin.welcome_messages +title: Welcome Messages +scope: plugins +status: active +priority: 10 +visibility: user +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. +## Metadata +Plugin ID: welcome_messages +Version: 0.1.0 +Default state: enabled +## Web Routes +- /plugins/welcome_messages +- GET /plugins/welcome_messages +- POST /plugins/welcome_messages/settings +- POST /plugins/welcome_messages/messages/create +- POST /plugins/welcome_messages/messages/:id/update +- POST /plugins/welcome_messages/messages/:id/duplicate +- POST /plugins/welcome_messages/messages/:id/archive +- POST /plugins/welcome_messages/messages/:id/restore +- POST /plugins/welcome_messages/profile/pronouns +## Route Reference +### MOUNT /plugins/welcome_messages + +- Purpose: Mounts the plugin router at this base WebUI path. +- Inputs: No request parameters detected by static analysis. +- Response format: Plugin router mount point. +- Access: Access is controlled by the mount options and individual plugin routes. +- Side effects: No direct route action; child routes handle requests. +- Limits/notes: Mount metadata is inferred from static source scanning. + +### GET /plugins/welcome_messages + +- Purpose: Renders or serves the welcome_messages plugin page. +- Inputs: No request parameters detected by static analysis. +- Response format: HTML page rendered from an EJS view +- 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. + +### POST /plugins/welcome_messages/settings + +- Purpose: Processes the welcome_messages plugin action for settings. +- Inputs: body: `channel_id`, `enabled`, `welcome_back_enabled` +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/welcome_messages/messages/create + +- Purpose: Processes the welcome_messages plugin action for messages create. +- Inputs: body: `enabled`, `pool`, `text` +- Response format: HTTP redirect after handling the request +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/welcome_messages/messages/:id/update + +- Purpose: Processes the welcome_messages plugin action for messages id update. +- Inputs: path params: `id`; body: `enabled`, `pool`, `text` +- Response format: HTTP redirect after handling the request +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/welcome_messages/messages/:id/duplicate + +- Purpose: Processes the welcome_messages plugin action for messages id duplicate. +- Inputs: path params: `id`; body: `pool` +- Response format: HTTP redirect after handling the request +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/welcome_messages/messages/:id/archive + +- Purpose: Processes the welcome_messages plugin action for messages id archive. +- Inputs: path params: `id`; body: `pool` +- Response format: HTTP redirect after handling the request +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/welcome_messages/messages/:id/restore + +- Purpose: Processes the welcome_messages plugin action for messages id restore. +- Inputs: path params: `id`; body: `pool` +- Response format: HTTP redirect after handling the request +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. + +### POST /plugins/welcome_messages/profile/pronouns + +- Purpose: Processes the welcome_messages plugin action for profile pronouns. +- Inputs: body: `pronoun_set` +- Response format: HTTP redirect after handling the request +- 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. Most non-API POST routes are browser form submissions and usually redirect after completion. +## Commands +- No plugin command triggers detected. +## Source +Plugin folder: plugins/welcome_messages diff --git a/plugins/lumi_ai/backend/ai_provider.js b/plugins/lumi_ai/backend/ai_provider.js index 7c14a59..4ea569d 100644 --- a/plugins/lumi_ai/backend/ai_provider.js +++ b/plugins/lumi_ai/backend/ai_provider.js @@ -1,5 +1,10 @@ const crypto = require("crypto"); const { buildPrompt, buildToolResultInstruction } = require("./prompt_builder"); +const { + buildControllerDecision, + okfLimitForController, + outputBudgetForController +} = require("./controller"); const { roleOf } = require("./permissions"); const { parseToolCallResult } = require("./tool_router"); const { normalizeScope } = require("./scope_manager"); @@ -33,6 +38,19 @@ class AiProvider { let effectiveMessage = message; let gateDecision = null; let requestClass = classifyRequestType(message, { role, scope }); + let controllerDecision = null; + this.metrics.record({ + kind: "prompt", + status: "received", + request_id: requestId, + user_id: user?.id, + role, + scope, + origin: originContext?.origin || originContext?.platform || "webui", + platform: originContext?.platform || originContext?.origin || "webui", + prompt: redactPrompt(message), + prompt_tokens: estimateTokens(message) + }); if (this.gate) { gateDecision = await this.gate.route({ message, @@ -47,6 +65,40 @@ class AiProvider { }); effectiveMessage = gateDecision.message || message; requestClass = gateDecision.request_class || requestClass; + this.metrics.record({ + kind: "gate_summary", + status: "success", + request_id: requestId, + user_id: user?.id, + role, + scope, + route_used: gateDecision.route, + confidence: gateDecision.confidence, + reason_code: gateDecision.reason_code, + route_class: requestClass, + gate_error: gateDecision.gate_error || null, + deterministic_ms: gateDecision.deterministic_ms, + gate_ms: gateDecision.gate_ms, + platform: originContext?.origin || originContext?.platform || "webui" + }); + controllerDecision = buildControllerDecision({ + message: effectiveMessage, + role, + scope, + originContext, + gateDecision, + requestClass, + config: cfg + }); + this.metrics.record(controllerMetricEntry({ + requestId, + user, + role, + scope, + originContext, + decision: controllerDecision + })); + onStage("controller", controllerStageDetails(controllerDecision)); if (gateDecision.answer) { const answer = gateDecision.answer; this.metrics.record({ @@ -76,6 +128,7 @@ class AiProvider { model_id: cfg.gate?.model_id || "lumi-gate", route_used: gateDecision.route, route_class: requestClass, + controller_decision: controllerDecision, max_output_tokens_used: 0, gate_decision: gateDecision, stage_timings: { @@ -110,6 +163,14 @@ class AiProvider { model_id: "lumi-repo-index", route_used: `repo_${repoAnswer.type}`, route_class: requestClass, + controller_decision: buildControllerDecision({ + message: effectiveMessage, + role, + scope, + originContext, + requestClass, + config: cfg + }), max_output_tokens_used: 0, internal_generated_length: repoAnswer.text.length, duration_ms: Date.now() - started, @@ -122,6 +183,25 @@ class AiProvider { route: "main_llm", reason_code: gateDecision?.reason_code || "direct_main_llm" }); + if (!controllerDecision) { + controllerDecision = buildControllerDecision({ + message: effectiveMessage, + role, + scope, + originContext, + requestClass, + config: cfg + }); + this.metrics.record(controllerMetricEntry({ + requestId, + user, + role, + scope, + originContext, + decision: controllerDecision + })); + onStage("controller", controllerStageDetails(controllerDecision)); + } if (this.ensureRuntime) { const health = await this.runtime.health(); if (!health.healthy) { @@ -158,28 +238,83 @@ class AiProvider { .filter((decision) => !decision.exposed) .map((decision) => ({ tool_id: decision.tool.tool_id, reason: decision.reason })) }); + let contextBlocks = []; + let contextFailure = null; + let contextDiagnostics = []; + const okfLimit = okfLimitForController(controllerDecision); + try { + contextBlocks = this.getContext({ + role, + user, + message: effectiveMessage, + originContext, + scope, + okf_retrieval: controllerDecision.okf_retrieval, + limit: okfLimit, + controller: controllerDecision + }) || []; + contextDiagnostics = Array.isArray(this.getContext?.lastDiagnostics) + ? this.getContext.lastDiagnostics + : []; + } catch (error) { + contextFailure = error; + contextBlocks = []; + } + const okfDiagnostics = summarizeContextDiagnostics(contextDiagnostics); + this.metrics.record({ + kind: "okf_retrieval", + status: contextFailure ? "failed" : "success", + request_id: requestId, + user_id: user.id, + role, + scope, + okf_retrieval_depth: controllerDecision.okf_retrieval, + okf_match_count: Array.isArray(contextBlocks) ? contextBlocks.length : 0, + okf_candidate_count: okfDiagnostics.candidate_count, + okf_query: okfDiagnostics.query || effectiveMessage, + okf_limit: okfLimit, + okf_provider_count: okfDiagnostics.provider_count, + fallback_reason: controllerDecision.fallback_used ? controllerDecision.reason_code : null, + gate_reason_code: gateDecision?.reason_code || null, + gate_error: gateDecision?.gate_error || null, + error_code: contextFailure?.code || null, + message: contextFailure?.message ? String(contextFailure.message).slice(0, 300) : null + }); const prompt = buildPrompt({ config: cfg, role, message: effectiveMessage, requestClass, - contextBlocks: this.getContext({ - role, - user, - message: effectiveMessage, - originContext, - scope - }), + contextBlocks, correctionContext, repoContext, - originContext, + originContext: originContext ? { + ...originContext, + response_profile: controllerDecision.source_profile + } : null, + controllerDecision, tools: toolExposure.exposed }); + this.metrics.record({ + kind: "prompt_build", + status: "success", + request_id: requestId, + user_id: user.id, + role, + scope, + context_block_count: contextBlocks.length, + correction_count: correctionContext.length, + repo_context_count: repoContext.length, + prompt_tokens: estimateTokens(prompt), + controller_complexity: controllerDecision.complexity, + okf_retrieval_depth: controllerDecision.okf_retrieval + }); const conversation = normalizeHistory(history); const outputTokenLimit = resolveOutputBudget({ config: cfg, requestClass, - explicitMaxTokens: max_tokens + explicitMaxTokens: max_tokens, + controllerDecision }); const runtimeSettings = { backend: this.runtime.activeAcceleration?.backend || this.runtime.runtimeMetadata?.()?.backend || "cpu", @@ -190,7 +325,11 @@ class AiProvider { threads: Number(cfg.threads) || 0, max_output_tokens: outputTokenLimit, max_output_tokens_used: outputTokenLimit, - route_class: requestClass + route_class: requestClass, + controller_complexity: controllerDecision.complexity, + okf_retrieval_depth: controllerDecision.okf_retrieval, + controller_intent: controllerDecision.intent, + source_profile: controllerDecision.source_profile.source }; onStage("prompt_eval", { route: "main_llm", queue_ms: queueWait, ...runtimeSettings }); const generatingTimer = setTimeout(() => { @@ -199,6 +338,18 @@ class AiProvider { generatingTimer.unref?.(); let result; try { + this.metrics.record({ + kind: "model_request", + status: "started", + request_id: requestId, + user_id: user.id, + role, + scope, + model: cfg.selected_model_id, + controller_complexity: controllerDecision.complexity, + okf_retrieval_depth: controllerDecision.okf_retrieval, + prompt_tokens: estimateTokens(prompt) + }); result = await this.runtime.infer( [ { role: "system", content: prompt }, @@ -211,6 +362,18 @@ class AiProvider { timeoutMs: cfg.hard_generation_timeout_ms } ); + } catch (error) { + this.metrics.record({ + kind: "error", + status: "failed", + request_id: requestId, + user_id: user.id, + role, + scope, + error_code: error.code || error.name || "model_request_failed", + message: String(error.message || "Main model request failed.").slice(0, 300) + }); + throw error; } finally { clearTimeout(generatingTimer); } @@ -320,6 +483,21 @@ class AiProvider { } } const inference = combineInferenceDiagnostics(initialInference, finalInference); + let fallbackReason = null; + if (!String(finalText || "").trim()) { + fallbackReason = "empty_model_response"; + rejectedReason = rejectedReason || fallbackReason; + finalText = "Lumi did not return a usable answer. Please retry, rephrase, or check Work History for the failed stage."; + this.metrics.record({ + kind: "fallback", + status: "partial", + request_id: requestId, + user_id: user.id, + role, + scope, + fallback_reason: fallbackReason + }); + } const out = { success: true, text: finalText, @@ -338,10 +516,12 @@ class AiProvider { request_id: requestId, route_used: gateDecision ? "main_llm" : "llm", route_class: requestClass, + controller_decision: controllerDecision, max_output_tokens_used: outputTokenLimit, gate_decision: gateDecision, force_through_reason: gateDecision?.forced ? gateDecision.reason_code : null, internal_generated_length: initialText.length + String(finalText || "").length, + fallback_reason: fallbackReason, stage_timings: { deterministic_ms: gateDecision?.deterministic_ms || 0, gate_ms: gateDecision?.gate_ms || 0, @@ -355,6 +535,9 @@ class AiProvider { diagnostics: { ...inference, ...runtimeSettings, + controller_reason_code: controllerDecision.reason_code, + controller_confidence: controllerDecision.confidence, + source_profile: controllerDecision.source_profile, gate_ms: gateDecision?.gate_ms || 0, queue_ms: queueWait, total_ms: Date.now() - started @@ -368,13 +551,25 @@ class AiProvider { exposed_tools: toolExposure.exposed.map((tool) => tool.tool_id), selected_tool: selectedTool, rejected_reason: rejectedReason, + fallback_reason: fallbackReason, execution_ms: toolExecutionMs, tool_executed: Boolean(toolResult), route_used: gateDecision ? "main_llm" : "llm", route_class: requestClass, + controller_route: controllerDecision.route, + controller_intent: controllerDecision.intent, + controller_complexity: controllerDecision.complexity, + controller_reason_code: controllerDecision.reason_code, + controller_confidence: controllerDecision.confidence, + okf_retrieval_depth: controllerDecision.okf_retrieval, + source_profile: controllerDecision.source_profile.source, + target_final_chars: controllerDecision.source_profile.target_chars, + hard_final_chars: controllerDecision.source_profile.hard_chars, + controller_fallback_used: controllerDecision.fallback_used, max_output_tokens_used: outputTokenLimit, gate_confidence: gateDecision?.confidence, gate_reason_code: gateDecision?.reason_code, + gate_error: gateDecision?.gate_error || null, force_through_reason: gateDecision?.forced ? gateDecision.reason_code : null, deterministic_ms: out.stage_timings.deterministic_ms, gate_ms: out.stage_timings.gate_ms, @@ -553,18 +748,63 @@ function normalizeInferenceDiagnostics(result, elapsedMs = 0) { }; } -function resolveOutputBudget({ config, requestClass, explicitMaxTokens }) { - const requested = Number(explicitMaxTokens); - const classBudget = Number(config?.output_budgets?.[requestClass]); - const legacyFallback = Number(config?.max_output_tokens); - const selected = Number.isFinite(requested) && requested > 0 - ? requested - : Number.isFinite(classBudget) && classBudget > 0 - ? classBudget - : Number.isFinite(legacyFallback) && legacyFallback > 0 - ? legacyFallback - : 512; - return Math.max(64, Math.min(32768, Math.round(selected))); +function resolveOutputBudget({ config, requestClass, explicitMaxTokens, controllerDecision } = {}) { + return outputBudgetForController({ config, requestClass, explicitMaxTokens, controllerDecision }); +} + +function controllerMetricEntry({ requestId, user, role, scope, originContext, decision }) { + return { + kind: "controller_decision", + status: "success", + request_id: requestId, + user_id: user?.id, + role, + scope, + route_used: decision.route, + controller_route: decision.route, + controller_intent: decision.intent, + controller_complexity: decision.complexity, + controller_reason_code: decision.reason_code, + controller_confidence: decision.confidence, + okf_retrieval_depth: decision.okf_retrieval, + answer_style: decision.answer_style, + source_profile: decision.source_profile.source, + target_final_chars: decision.source_profile.target_chars, + hard_final_chars: decision.source_profile.hard_chars, + permission_sensitive: decision.permission_sensitive, + admin_only: decision.admin_only, + risk_of_private_data: decision.risk_of_private_data, + controller_fallback_used: decision.fallback_used, + gate_reason_code: decision.gate_reason_code, + platform: originContext?.origin || originContext?.platform || "other" + }; +} + +function controllerStageDetails(decision) { + return { + route: decision.route, + controller_intent: decision.intent, + controller_complexity: decision.complexity, + controller_reason_code: decision.reason_code, + controller_confidence: decision.confidence, + okf_retrieval_depth: decision.okf_retrieval, + answer_style: decision.answer_style, + source_profile: decision.source_profile.source, + target_final_chars: decision.source_profile.target_chars, + hard_final_chars: decision.source_profile.hard_chars, + controller_fallback_used: decision.fallback_used + }; +} + +function summarizeContextDiagnostics(rows = []) { + const diagnostics = Array.isArray(rows) ? rows.filter((row) => row && typeof row === "object") : []; + const okfRows = diagnostics.filter((row) => row.provider === "okf" || row.kind === "okf"); + const preferred = okfRows[0] || diagnostics[0] || {}; + return { + query: preferred.query ? String(preferred.query).slice(0, 500) : null, + candidate_count: okfRows.reduce((sum, row) => sum + (Number(row.candidate_count) || 0), 0), + provider_count: diagnostics.length + }; } function numberValue(...values) { @@ -588,6 +828,19 @@ function requestCancelledError() { ); } +function estimateTokens(value) { + const text = String(value || ""); + if (!text.trim()) return 0; + return Math.max(1, Math.ceil(text.length / 4)); +} + +function redactPrompt(value) { + return String(value || "") + .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[email]") + .replace(/\b((?:token|secret|password|api[_ -]?key|client[_ -]?secret)\s*[:=]\s*)\S+/gi, "$1[redacted]") + .slice(0, 6000); +} + module.exports = { AiProvider, isInScope, diff --git a/plugins/lumi_ai/backend/commands.js b/plugins/lumi_ai/backend/commands.js index 6a9dea4..d67edca 100644 --- a/plugins/lumi_ai/backend/commands.js +++ b/plugins/lumi_ai/backend/commands.js @@ -1,11 +1,12 @@ const { roleOf } = require("./permissions"); +const { constrainFinal } = require("./response_formatter"); const PLATFORM_DEFAULTS = { discord: { markdown: true, html: false, reply_thread: true, max: 1900 }, twitch: { markdown: false, html: false, reply_thread: false, max: 450 }, youtube: { markdown: false, html: false, reply_thread: false, max: 1800 }, kick: { markdown: false, html: false, reply_thread: false, max: 450 }, - webui: { markdown: false, html: true, reply_thread: false, max: 4000 }, + webui: { markdown: false, html: true, reply_thread: false, max: null }, other: { markdown: false, html: false, reply_thread: false, max: 1000 } }; @@ -58,11 +59,13 @@ function formatPlatformReplyDetails(text, links, origin) { } output = output.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(); const originalLength = output.length; - const delivered = truncate(output, origin.max_message_length); + const constrained = constrainFinal(output, origin.max_message_length); + const delivered = constrained.text; return { text: delivered, original_final_length: originalLength, - delivered_length: delivered.length + delivered_length: delivered.length, + delivery_action: constrained.action }; } @@ -82,10 +85,4 @@ function hasPermission(ctx, names) { }); } -function truncate(value, max) { - if (value.length <= max) return value; - const suffix = " [reply truncated]"; - return `${value.slice(0, Math.max(1, max - suffix.length)).trimEnd()}${suffix}`; -} - module.exports = { PLATFORM_DEFAULTS, buildOriginContext, formatPlatformReply, formatPlatformReplyDetails, inferRole }; diff --git a/plugins/lumi_ai/backend/config_manager.js b/plugins/lumi_ai/backend/config_manager.js index b63df86..ac2cb2b 100644 --- a/plugins/lumi_ai/backend/config_manager.js +++ b/plugins/lumi_ai/backend/config_manager.js @@ -2,6 +2,7 @@ const fs = require("fs"); const { resolveData, ensureDataDirs } = require("./paths"); const { DEFAULT_SCOPE, normalizeScope } = require("./scope_manager"); const { DEFAULT_RATE_LIMITS, mergeLimits } = require("./rate_limits"); +const { SOURCE_DEFAULTS } = require("./controller"); const DEFAULT_CONFIG = { enabled: false, @@ -23,6 +24,13 @@ const DEFAULT_CONFIG = { admin_debug: 1280, explicit_long: 2048 }, + internal_mode_budgets: { + fast: 1024, + normal: 4096, + expanded: 8192, + unlimited: 16384 + }, + source_profiles: SOURCE_DEFAULTS, batch_size: 512, ubatch_size: 128, per_user_requests_per_minute: 6, @@ -30,6 +38,12 @@ const DEFAULT_CONFIG = { assistant_enabled: true, assistant_debug_logging: false, assistant_visibility: { admins: true, mods: false, users: false }, + work_history_retention: { + mode: "count", + count: 500, + age_value: 30, + age_unit: "days" + }, improvement: { allow_moderators_to_review_responses: false, trusted_moderator_reviewers: [], @@ -101,8 +115,11 @@ function getConfig() { max_answer_length: config.instructions?.maximum_answer_length }); config.assistant_visibility = { ...DEFAULT_CONFIG.assistant_visibility, ...(config.assistant_visibility || {}) }; + config.work_history_retention = mergeWorkHistoryRetention(config.work_history_retention); config.improvement = mergeImprovement(config.improvement); config.output_budgets = mergeOutputBudgets(config.output_budgets); + config.internal_mode_budgets = mergeInternalModeBudgets(config.internal_mode_budgets); + config.source_profiles = mergeSourceProfiles(config.source_profiles); config.gate = mergeGate(config.gate); config.instructions = { ...DEFAULT_CONFIG.instructions, ...(config.instructions || {}) }; config.logging = { ...DEFAULT_CONFIG.logging, ...(config.logging || {}) }; @@ -140,6 +157,8 @@ function saveConfig(value) { DEFAULT_CONFIG.max_output_tokens ); merged.output_budgets = mergeOutputBudgets(value.output_budgets); + merged.internal_mode_budgets = mergeInternalModeBudgets(value.internal_mode_budgets); + merged.source_profiles = mergeSourceProfiles(value.source_profiles); merged.batch_size = boundedNumber(value.batch_size, 32, 4096, DEFAULT_CONFIG.batch_size); merged.ubatch_size = Math.min( merged.batch_size, @@ -147,6 +166,7 @@ function saveConfig(value) { ); delete merged.gpu_workload_percent; merged.assistant_visibility = { ...DEFAULT_CONFIG.assistant_visibility, ...(value.assistant_visibility || {}) }; + merged.work_history_retention = mergeWorkHistoryRetention(value.work_history_retention); merged.improvement = mergeImprovement(value.improvement); merged.gate = mergeGate(value.gate); merged.support_scope = normalizeScope(value.support_scope); @@ -210,6 +230,45 @@ function mergeOutputBudgets(value = {}) { ); } +function mergeWorkHistoryRetention(value = {}) { + const mode = value.mode === "age" ? "age" : "count"; + return { + mode, + count: boundedNumber(value.count, 50, 10000, DEFAULT_CONFIG.work_history_retention.count), + age_value: boundedNumber(value.age_value, 1, 1000, DEFAULT_CONFIG.work_history_retention.age_value), + age_unit: ["hours", "days", "weeks", "months", "years"].includes(value.age_unit) + ? value.age_unit + : DEFAULT_CONFIG.work_history_retention.age_unit + }; +} + +function mergeInternalModeBudgets(value = {}) { + return Object.fromEntries( + Object.entries(DEFAULT_CONFIG.internal_mode_budgets).map(([key, fallback]) => [ + key, + boundedNumber(value?.[key], 64, 32768, fallback) + ]) + ); +} + +function mergeSourceProfiles(value = {}) { + return Object.fromEntries( + Object.entries(SOURCE_DEFAULTS).map(([source, fallback]) => { + const incoming = value?.[source] || {}; + return [source, { + source, + hard_chars: incoming.hard_chars === null || incoming.hard_chars === undefined + ? fallback.hard_chars + : boundedNumber(incoming.hard_chars, 100, 12000, fallback.hard_chars || 1000), + target_chars: boundedNumber(incoming.target_chars, 100, 12000, fallback.target_chars), + allow_sections: incoming.allow_sections !== undefined ? incoming.allow_sections === true : fallback.allow_sections, + allow_long_answer: incoming.allow_long_answer !== undefined ? incoming.allow_long_answer === true : fallback.allow_long_answer, + allow_split: incoming.allow_split !== undefined ? incoming.allow_split === true : fallback.allow_split + }]; + }) + ); +} + function mergeImprovement(value = {}) { return { ...DEFAULT_CONFIG.improvement, diff --git a/plugins/lumi_ai/backend/controller.js b/plugins/lumi_ai/backend/controller.js new file mode 100644 index 0000000..14e7851 --- /dev/null +++ b/plugins/lumi_ai/backend/controller.js @@ -0,0 +1,288 @@ +const INTENTS = new Set([ + "casual", + "factual", + "support", + "explain_internal", + "admin_task", + "codex_task", + "feedback_export", + "debug", + "unknown" +]); +const COMPLEXITIES = new Set(["fast", "normal", "expanded", "unlimited"]); +const OKF_DEPTHS = new Set(["none", "light", "deep"]); +const ANSWER_STYLES = new Set(["compact", "normal", "detailed", "json_only"]); +const SOURCE_DEFAULTS = Object.freeze({ + webui: { hard_chars: null, target_chars: 2500, allow_sections: true, allow_long_answer: true, allow_split: false }, + discord: { hard_chars: 1900, target_chars: 1200, allow_sections: true, allow_long_answer: false, allow_split: false }, + twitch: { hard_chars: 450, target_chars: 320, allow_sections: false, allow_long_answer: false, allow_split: false }, + youtube: { hard_chars: 1800, target_chars: 900, allow_sections: false, allow_long_answer: false, allow_split: false }, + kick: { hard_chars: 450, target_chars: 320, allow_sections: false, allow_long_answer: false, allow_split: false }, + other: { hard_chars: 1000, target_chars: 700, allow_sections: false, allow_long_answer: false, allow_split: false } +}); +const MODE_BUDGETS = Object.freeze({ + fast: 1024, + normal: 4096, + expanded: 8192, + unlimited: 16384 +}); + +function buildControllerDecision({ + message = "", + role = "user", + scope = "assistant", + originContext = null, + gateDecision = null, + requestClass = "simple_answer", + config = {} +} = {}) { + const text = String(gateDecision?.message || message || ""); + const source = sourceKey(originContext); + const signals = detectSignals(text, role, scope); + const lowConfidenceGate = gateDecision && ( + gateDecision.confidence < 0.5 || + /(?:low_confidence|timeout|error|invalid|unsafe|escalated)/i.test(gateDecision.reason_code || "") + ); + let intent = intentFromSignals(signals, requestClass); + let complexity = complexityFromSignals(signals, requestClass, role); + let okfRetrieval = okfDepthFromSignals(signals, complexity); + let answerStyle = answerStyleFromSignals(signals, complexity, source); + + if (lowConfidenceGate && complexity === "fast") { + complexity = "normal"; + okfRetrieval = okfRetrieval === "none" && signals.needsOkf ? "light" : okfRetrieval; + } + if (gateDecision?.forced && complexity === "fast") { + complexity = "normal"; + } + if (signals.adminOnly && role !== "admin" && complexity === "unlimited") { + complexity = "expanded"; + } + + const decision = normalizeControllerDecision({ + schema: "lumi.ai.controller.v1", + route: gateDecision?.route || "main_llm", + intent, + complexity, + okf_retrieval: okfRetrieval, + answer_style: answerStyle, + source_scoped: true, + source_profile: buildSourceProfile(source, config), + permission_sensitive: signals.permissionSensitive, + admin_only: signals.adminOnly, + risk_of_private_data: signals.privateDataRisk, + confidence: gateDecision ? gateDecision.confidence : confidenceFromSignals(signals), + reason_code: controllerReason(signals, gateDecision, lowConfidenceGate), + legacy_request_class: requestClass, + gate_reason_code: gateDecision?.reason_code || null, + fallback_used: Boolean(lowConfidenceGate) + }); + return decision; +} + +function normalizeControllerDecision(value = {}) { + const sourceProfile = value.source_profile && typeof value.source_profile === "object" + ? value.source_profile + : SOURCE_DEFAULTS.other; + const requestedSource = String(sourceProfile.source || "other").toLowerCase(); + const source = SOURCE_DEFAULTS[requestedSource] ? requestedSource : "other"; + const fallbackProfile = SOURCE_DEFAULTS[source]; + return { + schema: "lumi.ai.controller.v1", + route: ["main_llm", "refusal", "unavailable", "cached_answer", "predefined_answer"].includes(value.route) + ? value.route + : "main_llm", + intent: INTENTS.has(value.intent) ? value.intent : "unknown", + complexity: COMPLEXITIES.has(value.complexity) ? value.complexity : "normal", + okf_retrieval: OKF_DEPTHS.has(value.okf_retrieval) ? value.okf_retrieval : "light", + answer_style: ANSWER_STYLES.has(value.answer_style) ? value.answer_style : "normal", + source_scoped: value.source_scoped !== false, + source_profile: { + source, + hard_chars: sourceProfile.hard_chars === null || sourceProfile.hard_chars === undefined + ? fallbackProfile.hard_chars + : clampChars(sourceProfile.hard_chars, fallbackProfile.hard_chars || 1000), + target_chars: clampChars(sourceProfile.target_chars, fallbackProfile.target_chars), + allow_sections: Boolean(sourceProfile.allow_sections), + allow_long_answer: Boolean(sourceProfile.allow_long_answer), + allow_split: Boolean(sourceProfile.allow_split) + }, + permission_sensitive: Boolean(value.permission_sensitive), + admin_only: Boolean(value.admin_only), + risk_of_private_data: Boolean(value.risk_of_private_data), + confidence: Math.max(0, Math.min(1, Number(value.confidence) || 0)), + reason_code: /^[a-z0-9_]{2,80}$/.test(String(value.reason_code || "")) + ? value.reason_code + : "controller_normalized", + legacy_request_class: String(value.legacy_request_class || "simple_answer"), + gate_reason_code: value.gate_reason_code ? String(value.gate_reason_code).slice(0, 100) : null, + fallback_used: Boolean(value.fallback_used) + }; +} + +function outputBudgetForController({ config = {}, requestClass = "simple_answer", explicitMaxTokens = null, controllerDecision = null } = {}) { + const requested = Number(explicitMaxTokens); + if (Number.isFinite(requested) && requested > 0) return clampBudget(requested); + const mode = controllerDecision?.complexity || modeFromRequestClass(requestClass); + const modeBudget = Number(MODE_BUDGETS[mode]); + if (Number.isFinite(modeBudget) && modeBudget > 0) return clampBudget(modeBudget); + const classBudget = Number(config.output_budgets?.[requestClass]); + if (Number.isFinite(classBudget) && classBudget > 0) return clampBudget(classBudget); + const fallback = Number(config.max_output_tokens); + return clampBudget(Number.isFinite(fallback) && fallback > 0 ? fallback : 512); +} + +function okfLimitForController(decision) { + if (!decision || decision.okf_retrieval === "none") return 0; + if (decision.okf_retrieval === "deep") return decision.complexity === "expanded" || decision.complexity === "unlimited" ? 5 : 4; + return 2; +} + +function buildSourceProfile(source, config = {}) { + const key = SOURCE_DEFAULTS[source] ? source : "other"; + const configured = config.source_profiles?.[key] || {}; + const merged = { ...SOURCE_DEFAULTS[key], ...configured, source: key }; + return { + source: key, + hard_chars: merged.hard_chars === null || merged.hard_chars === undefined + ? null + : Math.max(100, Math.min(12000, Number(merged.hard_chars) || SOURCE_DEFAULTS[key].hard_chars || 1000)), + target_chars: Math.max(100, Math.min(12000, Number(merged.target_chars) || SOURCE_DEFAULTS[key].target_chars)), + allow_sections: merged.allow_sections !== false, + allow_long_answer: merged.allow_long_answer === true, + allow_split: merged.allow_split === true + }; +} + +function detectSignals(message, role, scope) { + const text = String(message || ""); + const routeOrApi = /\b(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+\/[^\s]+/i.test(text) || + /\b(?:route|webroute|web route|endpoint|request|api)\b[\s\S]{0,80}\/[a-z0-9_/-]+/i.test(text) || + /\/(?:admin|api|setup|auth|plugins|commands|feedback|stats|pages)(?:\/[a-z0-9_/-]*)?\b/i.test(text); + const lumiInternal = /\b(lumi|webui|route|endpoint|api|plugin|okf|settings?|setup|wizard|admin page|moderator|permission|config|configuration|update|dashboard|navigation)\b/i.test(text) || routeOrApi; + const codex = /\b(codex|taskfile|task file|implementation plan|acceptance criteria|validation steps|json output)\b/i.test(text); + const feedbackExport = /\b(feedback.*export|export.*feedback|feedback-to-codex|internal-feedback-to-codex-taskfile)\b/i.test(text); + const debug = /\b(debug|diagnos|troubleshoot|stack trace|runtime|backend|database|logs?|metrics?|failed|failure|error|exception)\b/i.test(text); + const security = /\b(security|permission|private|secret|token|password|credential|audit|rbac|role|admin-only|sensitive)\b/i.test(text); + const unlimited = role === "admin" && /\b(large-scale|architecture analysis|security audit|incident investigation|major migration|major refactor|comprehensive report|extensive synthesis|full system review)\b/i.test(text); + const support = /\b(how do i|how to|what does|tell me about|explain|configure|set up|setup|support|help with|why does)\b/i.test(text); + const knowledgeLookup = isSimpleKnowledgeLookup(text); + const casual = text.split(/\s+/).length <= 18 && !lumiInternal && !debug && !codex && !security && !knowledgeLookup; + return { + routeOrApi, + lumiInternal, + knowledgeLookup, + needsOkf: lumiInternal || support || debug || codex || feedbackExport || knowledgeLookup, + codex, + feedbackExport, + debug, + security, + unlimited, + support, + casual, + permissionSensitive: security || /\b(admin|moderator|mod|permission|role|private)\b/i.test(text), + adminOnly: role === "admin" && (debug || security || unlimited || scope === "model_test"), + privateDataRisk: security || /\b(my|mine|our|token|secret|password|credential|user id|database)\b/i.test(text) + }; +} + +function intentFromSignals(signals, requestClass) { + if (signals.feedbackExport) return "feedback_export"; + if (signals.codex) return "codex_task"; + if (signals.debug) return "debug"; + if (signals.security || signals.adminOnly) return "admin_task"; + if (signals.lumiInternal) return "explain_internal"; + if (signals.knowledgeLookup) return "factual"; + if (signals.support) return "support"; + if (requestClass === "navigation_help") return "support"; + if (signals.casual) return "casual"; + return "factual"; +} + +function complexityFromSignals(signals, requestClass, role) { + if (signals.unlimited) return "unlimited"; + if (signals.codex || signals.feedbackExport || signals.debug || signals.security || requestClass === "admin_debug" || requestClass === "explicit_long") { + return "expanded"; + } + if (signals.knowledgeLookup) return "fast"; + if (signals.lumiInternal || signals.support || requestClass === "code_custom_command") return "normal"; + if (signals.casual && role !== "admin") return "fast"; + return "normal"; +} + +function okfDepthFromSignals(signals, complexity) { + if (!signals.needsOkf) return "none"; + if (complexity === "expanded" || complexity === "unlimited" || signals.routeOrApi || signals.debug) return "deep"; + return "light"; +} + +function answerStyleFromSignals(signals, complexity, source) { + if (signals.codex || signals.feedbackExport) return "json_only"; + if (complexity === "expanded" || complexity === "unlimited") return "detailed"; + if (source === "twitch" || source === "kick") return "compact"; + return "normal"; +} + +function confidenceFromSignals(signals) { + if (signals.routeOrApi || signals.codex || signals.feedbackExport || signals.debug) return 0.9; + if (signals.knowledgeLookup) return 0.82; + if (signals.lumiInternal || signals.support) return 0.8; + if (signals.casual) return 0.75; + return 0.6; +} + +function controllerReason(signals, gateDecision, lowConfidenceGate) { + if (lowConfidenceGate) return "gate_fallback_normal"; + if (gateDecision?.forced) return "forced_main_llm"; + if (signals.unlimited) return "admin_unlimited_request"; + if (signals.codex) return "codex_expanded"; + if (signals.feedbackExport) return "feedback_export_expanded"; + if (signals.debug) return "debug_expanded"; + if (signals.routeOrApi) return "route_docs_okf"; + if (signals.lumiInternal) return "lumi_internal_okf"; + if (signals.knowledgeLookup) return "simple_knowledge_okf"; + if (signals.support) return "support_okf"; + if (signals.casual) return "casual_fast"; + return "default_normal"; +} + +function modeFromRequestClass(requestClass) { + if (requestClass === "navigation_help" || requestClass === "simple_answer") return "fast"; + if (requestClass === "admin_debug" || requestClass === "explicit_long") return "expanded"; + return "normal"; +} + +function sourceKey(originContext) { + const source = String(originContext?.origin || originContext?.platform || "webui").toLowerCase(); + return SOURCE_DEFAULTS[source] ? source : "other"; +} + +function clampBudget(value) { + return Math.max(64, Math.min(32768, Math.round(Number(value) || 512))); +} + +function clampChars(value, fallback) { + const number = Number(value); + return Math.max(100, Math.min(12000, Math.round(Number.isFinite(number) ? number : fallback))); +} + +function isSimpleKnowledgeLookup(message) { + const text = String(message || "").trim(); + if (!text || text.length > 180 || text.split(/\s+/).length > 18) return false; + if (/\b(who|what)\s+(?:are|r)\s+you\b|\byour\s+(?:name|identity)\b/i.test(text)) return false; + return ( + /^(?:who|what)\s+(?:is|are|was|were)\s+["'`]?[\p{L}\p{N}_ .'-]{2,80}["'`]?\??$/iu.test(text) || + /^(?:tell me about|describe|identify)\s+["'`]?[\p{L}\p{N}_ .'-]{2,80}["'`]?\??$/iu.test(text) + ); +} + +module.exports = { + MODE_BUDGETS, + SOURCE_DEFAULTS, + buildControllerDecision, + buildSourceProfile, + isSimpleKnowledgeLookup, + normalizeControllerDecision, + okfLimitForController, + outputBudgetForController +}; diff --git a/plugins/lumi_ai/backend/downloader.js b/plugins/lumi_ai/backend/downloader.js index 18d745a..14f0ba8 100644 --- a/plugins/lumi_ai/backend/downloader.js +++ b/plugins/lumi_ai/backend/downloader.js @@ -6,54 +6,318 @@ const AdmZip = require("adm-zip"); const { resolveData } = require("./paths"); class DownloadManager { - constructor(onEvent){ this.jobs=new Map(); this.onEvent=onEvent; } - status(id){ return this.jobs.get(id)||null; } - start({id,url,filename,sha256,kind,archive=false,size=0,runtimeMetadata=null}){ - if(this.jobs.get(id)?.state==="downloading") throw new Error("Download already running."); - if(size&&freeDiskBytes(){const classified=classifyError(error);job.state="error";job.error=classified.message;job.error_category=classified.category;this.onEvent?.({kind:"download",status:"failed",download_id:id,error:job.error,category:classified.category});}); + constructor(onEvent) { + this.jobs = new Map(); + this.onEvent = onEvent; + } + + status(id) { + return this.jobs.get(id) || null; + } + + start({ + id, + url, + filename, + sha256, + kind, + archive = false, + size = 0, + runtimeMetadata = null, + dependencies = [], + beforeInstall = null, + afterInstall = null + }) { + if (this.jobs.get(id) && !["complete", "error"].includes(this.jobs.get(id).state)) { + throw new Error("Download already running."); + } + + const dependencyList = normalizeDependencies(dependencies); + const requiredSize = Number(size || 0) + dependencyList.reduce((sum, item) => sum + Number(item.size || 0), 0); + + if (requiredSize && freeDiskBytes() < requiredSize * 1.2) { + throw new Error("not enough disk space"); + } + + const job = { + id, + state: "queued", + downloaded: 0, + total: requiredSize || 0, + error: null, + started_at: Date.now(), + current_file: null, + files: [ + { filename, size: Number(size || 0), role: "primary" }, + ...dependencyList.map((dependency) => ({ + filename: dependency.filename, + size: Number(dependency.size || 0), + role: "dependency" + })) + ] + }; + + this.jobs.set(id, job); + + this.download({ + job, + url, + filename, + sha256, + kind, + archive, + size, + runtimeMetadata, + dependencies: dependencyList, + beforeInstall, + afterInstall + }).catch((error) => { + const classified = classifyError(error); + job.state = "error"; + job.error = classified.message; + job.error_category = classified.category; + this.onEvent?.({ + kind: "download", + status: "failed", + download_id: id, + error: job.error, + category: classified.category + }); + }); + return job; } - async download({job,url,filename,sha256,kind,archive,runtimeMetadata}){ - job.state="downloading"; - const tmp=resolveData("tmp",`${filename}.part`), finalDir=resolveData(kind==="model"?"models":"runtime"); - const existing=fs.existsSync(tmp)?fs.statSync(tmp).size:0; - const headers=existing?{Range:`bytes=${existing}-`}:{}; - const response=await fetch(url,{headers}); if(!response.ok && response.status!==206) throw new Error(`source unavailable (${response.status})`); - const resumed=existing>0&&response.status===206; - const total=Number(response.headers.get("content-length")||0)+(resumed?existing:0); job.total=total; job.downloaded=resumed?existing:0; - const stream=fs.createWriteStream(tmp,{flags:resumed?"a":"w"}); - for await(const chunk of response.body){ if(!stream.write(chunk)) await new Promise(r=>stream.once("drain",r)); job.downloaded+=chunk.length; } - await new Promise((resolve,reject)=>stream.end(error=>error?reject(error):resolve())); - job.state="verifying"; const actual=await hashFile(tmp); if(actual!==sha256.toLowerCase()){fs.unlinkSync(tmp);throw new Error("hash mismatch");} - if(archive){ - job.state="extracting"; - const staging=resolveData("tmp",`runtime-extract-${Date.now()}`); - fs.mkdirSync(staging,{recursive:true}); - try{ - await extractArchive(tmp,staging,filename); + + async download({ + job, + url, + filename, + sha256, + kind, + archive, + size = 0, + runtimeMetadata, + dependencies = [], + beforeInstall = null, + afterInstall = null + }) { + job.state = "downloading"; + + const finalDir = resolveData(kind === "model" ? "models" : "runtime"); + const primaryTmp = await this.downloadOne({ job, url, filename, sha256, expectedSize: size }); + + if (archive) { + job.state = "extracting"; + const staging = resolveData("tmp", `runtime-extract-${Date.now()}`); + fs.mkdirSync(staging, { recursive: true }); + + try { + await extractArchive(primaryTmp, staging, filename); + + for (const dependency of dependencies) { + job.state = "downloading_dependency"; + const dependencyTmp = await this.downloadOne({ + job, + url: dependency.url, + filename: dependency.filename, + sha256: dependency.sha256, + expectedSize: dependency.size + }); + + job.state = "extracting_dependency"; + await extractArchive(dependencyTmp, staging, dependency.filename); + fs.unlinkSync(dependencyTmp); + } + await makeRuntimeExecutable(staging); - const executable=findRuntimeExecutable(staging); - if(!executable)throw new Error("runtime executable missing after extraction"); - for(const entry of fs.readdirSync(finalDir))fs.rmSync(path.join(finalDir,entry),{recursive:true,force:true}); - for(const entry of fs.readdirSync(staging))fs.renameSync(path.join(staging,entry),path.join(finalDir,entry)); - fs.writeFileSync(path.join(finalDir,"lumi-runtime.json"),`${JSON.stringify({ - backend: runtimeMetadata?.backend || "cpu", - version: runtimeMetadata?.version || null, - target: runtimeMetadata?.target || filename, - installed_at: new Date().toISOString() - },null,2)}\n`); - fs.unlinkSync(tmp); - job.executable=findRuntimeExecutable(finalDir); - }finally{ - fs.rmSync(staging,{recursive:true,force:true}); + const executable = findRuntimeExecutable(staging); + if (!executable) throw new Error("runtime executable missing after extraction"); + + let installCallbackStarted = false; + try { + if (typeof beforeInstall === "function") { + installCallbackStarted = true; + job.state = "preparing_install"; + await beforeInstall({ job, finalDir, staging, runtimeMetadata }); + } + + job.state = "installing"; + await replaceDirectoryContents(staging, finalDir); + + fs.writeFileSync(path.join(finalDir, "lumi-runtime.json"), `${JSON.stringify({ + backend: runtimeMetadata?.backend || "cpu", + backend_variant: runtimeMetadata?.backend_variant || null, + version: runtimeMetadata?.version || null, + target: runtimeMetadata?.target || filename, + dependencies: runtimeMetadata?.dependencies || dependencies.map((dependency) => ({ + filename: dependency.filename, + sha256: dependency.sha256, + size: dependency.size || 0 + })), + installed_at: new Date().toISOString() + }, null, 2)}\n`); + } finally { + if (installCallbackStarted && typeof afterInstall === "function") { + await afterInstall({ job, finalDir, staging, runtimeMetadata }); + } + } + + fs.unlinkSync(primaryTmp); + job.executable = findRuntimeExecutable(finalDir); + } finally { + fs.rmSync(primaryTmp, { force: true }); + fs.rmSync(staging, { recursive: true, force: true }); } + } else { + const final = path.join(finalDir, filename); + if (fs.existsSync(final)) fs.unlinkSync(final); + fs.renameSync(primaryTmp, final); } - else { const final=path.join(finalDir,filename); if(fs.existsSync(final))fs.unlinkSync(final); fs.renameSync(tmp,final); } - job.state="complete";job.finished_at=Date.now();job.sha256=actual;this.onEvent?.({kind:"download",status:"success",download_id:job.id,sha256:actual,duration_ms:job.finished_at-job.started_at}); + + job.state = "complete"; + job.finished_at = Date.now(); + job.sha256 = sha256 ? String(sha256).toLowerCase() : null; + this.onEvent?.({ + kind: "download", + status: "success", + download_id: job.id, + sha256: job.sha256, + duration_ms: job.finished_at - job.started_at + }); + } + + async downloadOne({ job, url, filename, sha256, expectedSize = 0 }) { + const tmp = resolveData("tmp", `${filename}.part`); + let existing = fs.existsSync(tmp) ? fs.statSync(tmp).size : 0; + const normalizedExpected = Number(expectedSize || 0); + if (existing && normalizedExpected && existing > normalizedExpected) { + fs.unlinkSync(tmp); + existing = 0; + } + if (existing && normalizedExpected && existing === normalizedExpected) { + job.current_file = filename; + job.state = "verifying"; + const actual = await hashFile(tmp); + if (actual === String(sha256 || "").toLowerCase()) { + job.downloaded += existing; + return tmp; + } + fs.unlinkSync(tmp); + existing = 0; + } + const headers = existing ? { Range: `bytes=${existing}-` } : {}; + const response = await fetch(url, { headers }); + + if (response.status === 416 && existing) { + job.state = "verifying"; + const actual = await hashFile(tmp); + if (actual === String(sha256 || "").toLowerCase()) { + job.downloaded += existing; + return tmp; + } + fs.unlinkSync(tmp); + throw new Error("hash mismatch"); + } + + if (!response.ok && response.status !== 206) { + throw new Error(`source unavailable (${response.status})`); + } + + const resumed = existing > 0 && response.status === 206; + const contentLength = Number(response.headers.get("content-length") || 0); + const fileTotal = contentLength + (resumed ? existing : 0); + + if (!job.total && fileTotal) job.total = fileTotal; + job.current_file = filename; + + const stream = fs.createWriteStream(tmp, { flags: resumed ? "a" : "w" }); + if (resumed) job.downloaded += existing; + + for await (const chunk of response.body) { + if (!stream.write(chunk)) await new Promise((resolve) => stream.once("drain", resolve)); + job.downloaded += chunk.length; + } + + await new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve())); + + job.state = "verifying"; + const actual = await hashFile(tmp); + if (actual !== String(sha256 || "").toLowerCase()) { + fs.unlinkSync(tmp); + throw new Error("hash mismatch"); + } + + return tmp; } } + +function normalizeDependencies(dependencies) { + if (!Array.isArray(dependencies)) return []; + return dependencies.filter(Boolean).map((dependency) => { + if (!dependency.url || !dependency.filename || !dependency.sha256) { + throw new Error(`Runtime dependency is incomplete: ${dependency.filename || "unknown"}`); + } + return { + ...dependency, + size: Number(dependency.size || 0) + }; + }); +} + +async function replaceDirectoryContents(staging, finalDir) { + fs.mkdirSync(finalDir, { recursive: true }); + const backup = resolveData("tmp", `runtime-replace-backup-${Date.now()}`); + fs.mkdirSync(backup, { recursive: true }); + const movedOld = []; + const installedNew = []; + + try { + for (const entry of fs.readdirSync(finalDir)) { + const from = path.join(finalDir, entry); + const to = path.join(backup, entry); + await retryFileOperation(() => fs.renameSync(from, to)); + movedOld.push({ from, to }); + } + + for (const entry of fs.readdirSync(staging)) { + const from = path.join(staging, entry); + const to = path.join(finalDir, entry); + await retryFileOperation(() => fs.renameSync(from, to)); + installedNew.push(to); + } + } catch (error) { + for (const target of installedNew.reverse()) { + try { fs.rmSync(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch {} + } + for (const entry of movedOld.reverse()) { + try { + if (!fs.existsSync(entry.from) && fs.existsSync(entry.to)) fs.renameSync(entry.to, entry.from); + } catch {} + } + throw error; + } finally { + try { fs.rmSync(backup, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch {} + } +} + +async function retryFileOperation(operation, attempts = 8, delayMs = 250) { + let lastError = null; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return operation(); + } catch (error) { + lastError = error; + if (!isRetriableFileError(error) || attempt === attempts - 1) throw error; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + throw lastError; +} + +function isRetriableFileError(error) { + return ["EBUSY", "EPERM", "EACCES", "ENOTEMPTY"].includes(error?.code); +} + async function makeRuntimeExecutable(dir){ if(process.platform==="win32")return; for(const entry of fs.readdirSync(dir,{withFileTypes:true})){ @@ -90,9 +354,9 @@ function validateArchivePath(entry){ } function capture(command,args){return new Promise((resolve,reject)=>{const child=spawn(command,args,{windowsHide:true,shell:false});let stdout="",stderr="";child.stdout.on("data",c=>stdout+=c);child.stderr.on("data",c=>stderr+=c);child.on("error",reject);child.on("exit",code=>resolve({code,stdout,stderr}));});} function classifyError(error){ - const message=error?.message||String(error); + const message=`${error?.code || ""} ${error?.message || String(error)}`.trim(); if(/ENOSPC|not enough disk/i.test(message))return{category:"disk_full",message:"Not enough disk space."}; - if(/EACCES|EPERM|permission denied/i.test(message))return{category:"permission_denied",message:"Permission denied."}; + if(/EACCES|EPERM|EBUSY|ENOTEMPTY|resource busy|permission denied/i.test(message))return{category:"permission_denied",message:"Runtime files are locked or permission was denied. Stop Lumi AI runtimes and retry."}; if(/hash mismatch/i.test(message))return{category:"hash_mismatch",message:"Downloaded file failed SHA-256 verification."}; if(/archive path traversal/i.test(message))return{category:"archive_path_traversal",message:"Archive contains an unsafe path."}; if(/archive corrupt|extraction failed/i.test(message))return{category:"archive_corrupt",message}; diff --git a/plugins/lumi_ai/backend/gate_provider.js b/plugins/lumi_ai/backend/gate_provider.js index 8137ae3..b61b1cd 100644 --- a/plugins/lumi_ai/backend/gate_provider.js +++ b/plugins/lumi_ai/backend/gate_provider.js @@ -132,6 +132,18 @@ class GateProvider { }, started, context); } + if (isSimpleKnowledgeLookup(context.message)) { + return this.finish({ + route: "main_llm", + confidence: 0.86, + reason_code: "simple_knowledge_lookup", + message: context.message, + request_class: requestClass, + deterministic_ms: Date.now() - started, + gate_ms: 0 + }, started, context); + } + const deterministicMs = Date.now() - started; const gateStarted = Date.now(); onStage("gating"); @@ -243,6 +255,7 @@ class GateProvider { reason_code: decision.reason_code || "unspecified", answer: decision.answer || null, message: decision.message || context.message, + gate_error: decision.gate_error ? String(decision.gate_error).slice(0, 300) : null, forced: Boolean(decision.forced), request_class: normalizeRequestClass(decision.request_class), deterministic_ms: Math.max(0, Number(decision.deterministic_ms) || 0), @@ -255,6 +268,7 @@ class GateProvider { route_used: output.route, confidence: output.confidence, reason_code: output.reason_code, + gate_error: output.gate_error, request_class: output.request_class, route_class: output.request_class, deterministic_ms: output.deterministic_ms, @@ -279,7 +293,8 @@ function normalizeDecision(value = {}) { confidence: Math.max(0, Math.min(1, Number(value.confidence) || 0)), reason_code: /^[a-z0-9_]{2,80}$/.test(String(value.reason_code || "")) ? value.reason_code - : "invalid_reason_code" + : "invalid_reason_code", + gate_error: value.gate_error ? String(value.gate_error).slice(0, 300) : null }; } @@ -319,12 +334,25 @@ function isComplexOrAmbiguous(message) { .test(text); } +function isSimpleKnowledgeLookup(message) { + const text = String(message || "").trim(); + if (!text || text.length > 180 || text.split(/\s+/).length > 18) return false; + if (/\b(who|what)\s+(?:are|r)\s+you\b|\byour\s+(?:name|identity)\b/i.test(text)) return false; + return ( + /^(?:who|what)\s+(?:is|are|was|were)\s+["'`]?[\p{L}\p{N}_ .'-]{2,80}["'`]?\??$/iu.test(text) || + /^(?:tell me about|describe|identify)\s+["'`]?[\p{L}\p{N}_ .'-]{2,80}["'`]?\??$/iu.test(text) + ); +} + function isTimeoutError(error) { return error?.name === "TimeoutError" || error?.name === "AbortError" || /timed?\s*out|timeout/i.test(error?.message || ""); } function classifyRequestType(message, { role = "user", scope = "assistant" } = {}) { const text = String(message || "").trim(); + if (hasRouteReference(text)) { + return "navigation_help"; + } if (/\b(explicitly|please|give|write|provide|show)\b[\s\S]{0,60}\b(long|detailed|comprehensive|thorough|in[- ]depth)\b|\b(full (analysis|report|guide|explanation)|in detail|very detailed|long answer)\b/i.test(text)) { return "explicit_long"; } @@ -343,6 +371,12 @@ function classifyRequestType(message, { role = "user", scope = "assistant" } = { return "simple_answer"; } +function hasRouteReference(text) { + return /\b(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+\/[^\s]+/i.test(text) || + /\b(?:route|webroute|web route|endpoint|request|api)\b[\s\S]{0,80}\/[a-z0-9_/-]+/i.test(text) || + /\/(?:admin|api|setup|auth|plugins|commands|feedback|stats|pages)(?:\/[a-z0-9_/-]*)?\b/i.test(text); +} + function normalizeRequestClass(value) { return [ "navigation_help", @@ -392,7 +426,9 @@ module.exports = { isCacheSafeRepoAnswer, isExactPredefinedQuery, isComplexOrAmbiguous, + isSimpleKnowledgeLookup, classifyRequestType, normalizeRequestClass, + hasRouteReference, withTimeout }; diff --git a/plugins/lumi_ai/backend/hardware.js b/plugins/lumi_ai/backend/hardware.js index 209861d..15b58ed 100644 --- a/plugins/lumi_ai/backend/hardware.js +++ b/plugins/lumi_ai/backend/hardware.js @@ -5,6 +5,7 @@ const { spawnSync } = require("child_process"); const { PLUGIN_DATA, PLUGIN_ROOT } = require("./paths"); const SUPPORTED_BACKENDS = ["cuda", "vulkan", "metal", "sycl"]; +const BACKEND_PREFERENCE = ["cuda", "metal", "sycl", "vulkan"]; function detectHardware(models, runtimeManifest = null) { const freeDisk = getFreeDisk(); @@ -175,25 +176,58 @@ function choosePrimaryGpu(gpus) { function selectRuntimeTarget(runtimeManifest, gpu, platform = os.platform(), architecture = os.arch()) { const key = `${platform}-${architecture}`; const cpu = runtimeManifest?.targets?.[key] || null; - const accelerated = runtimeManifest?.accelerated_targets?.[key] || null; - const backend = accelerated?.backend; - const compatible = Boolean( - gpu?.present && - accelerated && - (backend === "metal" || gpu.compute_api?.includes(backend)) - ); - if (compatible) { - return { backend, accelerated: true, fallback_to_cpu: false, target: accelerated }; + const acceleratedTargets = normalizeRuntimeTargets(runtimeManifest?.accelerated_targets?.[key]); + + const candidates = acceleratedTargets + .filter((target) => runtimeTargetCompatible(target, gpu)) + .sort((a, b) => rankBackend(a.backend) - rankBackend(b.backend)); + + const selected = candidates[0] || null; + + if (selected) { + return { + backend: selected.backend, + accelerated: true, + fallback_to_cpu: false, + target: selected, + available_backends: candidates.map((target) => target.backend) + }; } + return { backend: "cpu", accelerated: false, fallback_to_cpu: Boolean(gpu?.present), reason: gpu?.present ? "No compatible managed GPU runtime is available." : "No supported GPU detected.", - target: cpu + target: cpu, + available_backends: [] }; } +function normalizeRuntimeTargets(value) { + if (!value) return []; + if (Array.isArray(value)) return value.filter(Boolean); + if (Array.isArray(value.targets)) return value.targets.filter(Boolean); + if (value.filename || value.url) return [value]; + + return Object.values(value).flatMap((entry) => normalizeRuntimeTargets(entry)); +} + +function runtimeTargetCompatible(target, gpu) { + const backend = target?.backend; + return Boolean( + gpu?.present && + target && + backend && + gpu.compute_api?.includes(backend) + ); +} + +function rankBackend(backend) { + const fallbackRank = BACKEND_PREFERENCE.indexOf(backend); + return fallbackRank === -1 ? Number.MAX_SAFE_INTEGER : fallbackRank; +} + function calculateGpuCapacity({ model, contextSize, gpu, backend, managedUsageMb = 0 }) { const requestedBackend = backend || "cpu"; const supported = Boolean( @@ -382,6 +416,7 @@ function testWritable() { module.exports = { SUPPORTED_BACKENDS, + BACKEND_PREFERENCE, detectHardware, detectGpus, selectRuntimeTarget, diff --git a/plugins/lumi_ai/backend/metrics.js b/plugins/lumi_ai/backend/metrics.js index 0763eb3..feb2738 100644 --- a/plugins/lumi_ai/backend/metrics.js +++ b/plugins/lumi_ai/backend/metrics.js @@ -3,11 +3,13 @@ const { resolveData } = require("./paths"); const historyFile = () => resolveData("metrics", "history.jsonl"); const stateFile = () => resolveData("metrics", "summary.json"); +let retention = { mode: "count", count: 500, age_value: 30, age_unit: "days" }; function getSummary() { try { return JSON.parse(fs.readFileSync(stateFile(), "utf8")); } catch { return { total_requests:0, successful:0, failed:0, refusals:0, gate_decisions:0, tool_decisions:0, tool_suggestions:0, tool_executions:0, tool_denials:0, confirmation_cancellations:0, timeout_count:0, runtime_crash_count:0, runtime_self_test_total:0, runtime_self_test_failed_total:0, runtime_start_attempt_total:0, runtime_start_failed_total:0, verified_downloads:0, failed_downloads:0, requests_by_role:{}, requests_by_scope:{}, requests_by_route:{}, gate_reason_codes:{}, tool_rejections_by_reason:{}, runtime_exit_code_counts:{}, stage_totals:{}, stage_samples:0, slow_requests:[], durations:[], queue_wait_total_ms:0 }; } } function record(entry) { + maybeApplyRetention(); const summary = getSummary(); summary.requests_by_role ||= {}; summary.requests_by_scope ||= {}; @@ -167,6 +169,13 @@ function historyPage(page = 1, pageSize = 25) { return { entries: [], page: 1, pages: 1, page_size: safeSize, total: 0 }; } } +function workHistoryPage(query = {}, page = 1, pageSize = 20) { + const rows = readHistoryRows(); + const filters = normalizeWorkFilters(query); + const grouped = groupWorkRows(rows) + .filter((work) => matchesWorkFilters(work, filters)); + return paginateRows(grouped, page, pageSize); +} 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)); @@ -184,6 +193,232 @@ function slowRequestsPage(page = 1, pageSize = 15) { return { entries: [], page: 1, pages: 1, page_size: safeSize, total: 0 }; } } +function configureRetention(value = {}) { + retention = normalizeRetention(value); + applyRetention(); +} +function applyRetention() { + const rows = readHistoryRows(); + 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); + }); + if (kept.length === rows.length) return; + fs.writeFileSync(historyFile(), kept.map((row) => JSON.stringify(row)).join("\n") + (kept.length ? "\n" : "")); +} +function maybeApplyRetention() { + if (Math.random() > 0.02) return; + try { applyRetention(); } catch {} +} +function readHistoryRows() { + try { + return fs.readFileSync(historyFile(), "utf8").trim().split(/\r?\n/) + .filter(Boolean) + .map(parseHistoryRow) + .filter(Boolean); + } catch { + return []; + } +} +function groupWorkRows(rows) { + const groups = new Map(); + for (const row of rows) { + const id = row.request_id || row.work_id; + if (!id) continue; + if (!groups.has(id)) { + groups.set(id, { + work_id: id, + started_at: row.timestamp, + finished_at: row.timestamp, + status: "success", + source: row.origin || row.platform || "webui", + user_id: row.user_id || "", + role: row.role || "", + internal_mode: row.controller_complexity || "", + okf_retrieval: row.okf_retrieval_depth || "", + reason_code: row.controller_reason_code || row.gate_reason_code || row.reason_code || "", + processed_tokens: 0, + final_tokens: 0, + delivered_tokens: 0, + duration_ms: 0, + prompt: "", + prompt_tokens: 0, + has_error: false, + has_refusal: false, + has_fallback: false, + has_truncation: false, + has_okf_context: false, + events: [] + }); + } + const group = groups.get(id); + group.started_at = earlierIso(group.started_at, row.timestamp); + group.finished_at = laterIso(group.finished_at, row.timestamp); + group.source = row.origin || row.platform || group.source; + group.user_id = row.user_id || group.user_id; + group.role = row.role || group.role; + 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; + group.has_error ||= row.status === "failed" || row.kind === "error" || Boolean(row.error_code); + group.has_refusal ||= row.status === "refused" || row.route_used === "refusal"; + group.has_fallback ||= Boolean(row.controller_fallback_used || row.fallback_reason || row.rejected_reason); + group.has_truncation ||= Boolean(row.truncated); + group.has_okf_context ||= row.kind === "okf_retrieval" || row.okf_retrieval_depth === "light" || row.okf_retrieval_depth === "deep" || Number(row.okf_match_count) > 0; + if (row.kind === "prompt") { + group.prompt = row.prompt || group.prompt; + group.prompt_tokens = validOrZero(row.prompt_tokens); + } + const promptTokens = validOrZero(row.prompt_tokens); + const generatedTokens = validOrZero(row.generated_tokens); + group.processed_tokens += promptTokens + generatedTokens; + if (row.final_tokens != null) group.final_tokens = validOrZero(row.final_tokens); + else if (row.final_reply_length != null || row.original_final_length != null) { + group.final_tokens = estimateTokensFromChars(row.final_reply_length ?? row.original_final_length); + } + if (row.delivered_tokens != null) group.delivered_tokens = validOrZero(row.delivered_tokens); + 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; + if (group.has_error && !group.delivered_tokens) group.status = "failed"; + else if (group.has_error || group.has_refusal || group.has_fallback || group.has_truncation) group.status = "partial"; + else group.status = "success"; + group.events.sort((left, right) => String(left.timestamp).localeCompare(String(right.timestamp))); + return group; + }) + .sort((left, right) => String(left.started_at).localeCompare(String(right.started_at))); +} +function workEvent(row) { + return { + timestamp: row.timestamp, + type: normalizeWorkEventType(row), + status: row.status || "", + summary: workEventSummary(row), + prompt: row.kind === "prompt" ? row.prompt || "" : "", + tokens: validOrZero(row.prompt_tokens) + validOrZero(row.generated_tokens), + data: compactWorkData(row) + }; +} +function normalizeWorkEventType(row) { + if (row.kind === "prompt") return "prompt"; + if (row.kind === "controller_decision") return "controller"; + if (row.kind === "gate_summary" || row.kind === "gate_decision") return "controller"; + if (row.kind === "okf_retrieval") return "okf_retrieval"; + if (row.kind === "prompt_build") return "prompt_build"; + if (row.kind === "model_request") return "model_request"; + if (row.kind === "request") return row.status === "failed" ? "error" : "model_response"; + if (row.kind === "delivery") return row.truncated ? "source_limit" : "delivery"; + if (row.kind === "tool_exposure") return "context"; + return row.kind || "metrics"; +} +function workEventSummary(row) { + if (row.kind === "prompt") return `Prompt from ${row.origin || row.platform || "webui"} as ${row.role || "unknown role"}`; + if (row.kind === "controller_decision") { + return `Controller selected ${row.controller_complexity || "-"} / ${row.okf_retrieval_depth || "-"} (${row.controller_reason_code || row.reason_code || "no reason"})`; + } + if (row.kind === "gate_summary" || row.kind === "gate_decision") { + return `Gate route ${row.route_used || "-"} (${row.reason_code || "no reason"})`; + } + if (row.kind === "okf_retrieval") return `OKF retrieval ${row.status || "success"} with ${row.okf_match_count || 0} match(es)`; + if (row.kind === "prompt_build") return `Prompt built with ${row.context_block_count || 0} context block(s)`; + if (row.kind === "model_request") return `Main model request in ${row.controller_complexity || "dynamic"} mode`; + if (row.kind === "delivery") return row.truncated ? "Delivered after source limit truncation" : "Delivered reply"; + if (row.kind === "request" && row.status === "failed") return row.message || row.error_code || "Request failed"; + return row.reason_code || row.controller_reason_code || row.status || row.kind || "Event"; +} +function compactWorkData(row) { + const keys = [ + "route_used", "route_class", "reason_code", "gate_reason_code", "controller_intent", + "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" + ]; + return Object.fromEntries(keys.filter((key) => row[key] != null).map((key) => [key, row[key]])); +} +function normalizeWorkFilters(query = {}) { + return { + q: String(query.work_q || query.q || "").trim().toLowerCase(), + status: String(query.work_status || "").trim(), + source: String(query.work_source || "").trim(), + role: String(query.work_role || "").trim(), + mode: String(query.work_mode || "").trim(), + okf: String(query.work_okf || "").trim(), + flag: String(query.work_flag || "").trim(), + from: parseTime(query.work_from), + to: parseTime(query.work_to, true) + }; +} +function matchesWorkFilters(work, filters) { + if (filters.status && work.status !== filters.status) return false; + if (filters.source && work.source !== filters.source) return false; + if (filters.role && work.role !== filters.role) return false; + if (filters.mode && work.internal_mode !== filters.mode) return false; + if (filters.okf && work.okf_retrieval !== filters.okf) return false; + if (filters.flag === "error" && !work.has_error) return false; + if (filters.flag === "refusal" && !work.has_refusal) return false; + if (filters.flag === "fallback" && !work.has_fallback) return false; + if (filters.flag === "truncation" && !work.has_truncation) return false; + if (filters.flag === "okf" && !work.has_okf_context) return false; + const started = Date.parse(work.started_at); + if (filters.from && started < filters.from) return false; + if (filters.to && started > filters.to) return false; + if (!filters.q) return true; + const haystack = [ + work.work_id, work.prompt, work.source, work.user_id, work.role, work.status, work.internal_mode, + work.okf_retrieval, work.reason_code, + ...work.events.map((event) => `${event.type} ${event.status} ${event.summary} ${JSON.stringify(event.data)}`) + ].join("\n").toLowerCase(); + return haystack.includes(filters.q); +} +function normalizeRetention(value = {}) { + const mode = value.mode === "age" ? "age" : "count"; + return { + mode, + count: Math.max(50, Math.min(10000, Number.parseInt(value.count, 10) || 500)), + age_value: Math.max(1, Math.min(1000, Number.parseInt(value.age_value, 10) || 30)), + age_unit: ["hours", "days", "weeks", "months", "years"].includes(value.age_unit) ? value.age_unit : "days" + }; +} +function retainedWorkIds(groups, config) { + if (config.mode === "count") { + return new Set(groups.slice(-config.count).map((group) => group.work_id)); + } + const cutoff = Date.now() - ageMs(config.age_value, config.age_unit); + return new Set(groups.filter((group) => Date.parse(group.started_at) >= cutoff).map((group) => group.work_id)); +} +function ageMs(value, unit) { + const hours = unit === "hours" ? value + : unit === "weeks" ? value * 24 * 7 + : unit === "months" ? value * 24 * 30 + : unit === "years" ? value * 24 * 365 + : value * 24; + return hours * 60 * 60 * 1000; +} +function parseTime(value, endOfDay = false) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + if (endOfDay && /^\d{4}-\d{2}-\d{2}$/.test(String(value))) date.setHours(23, 59, 59, 999); + return date.getTime(); +} +function earlierIso(left, right) { + return Date.parse(left) <= Date.parse(right) ? left : right; +} +function laterIso(left, right) { + return Date.parse(left) >= Date.parse(right) ? left : right; +} +function estimateTokensFromChars(value) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? Math.max(1, Math.ceil(number / 4)) : 0; +} function paginateRows(rows, page = 1, pageSize = 25, map = (value) => value) { const total = rows.length; const pages = Math.max(1, Math.ceil(total / pageSize)); @@ -232,10 +467,13 @@ function validOrZero(value) { } module.exports = { + configureRetention, + applyRetention, record, report, history, historyPage, + workHistoryPage, slowRequestsPage, paginateRows, isValidTiming, diff --git a/plugins/lumi_ai/backend/prompt_builder.js b/plugins/lumi_ai/backend/prompt_builder.js index 6a92923..8d8e9cb 100644 --- a/plugins/lumi_ai/backend/prompt_builder.js +++ b/plugins/lumi_ai/backend/prompt_builder.js @@ -4,9 +4,27 @@ const { PLUGIN_ROOT } = require("./paths"); const { buildPolicy } = require("./scope_manager"); function readTemplate(name){ return fs.readFileSync(path.join(PLUGIN_ROOT,"templates",name),"utf8").trim(); } -function buildPrompt({ config, role, message, requestClass = "simple_answer", contextBlocks=[], correctionContext=[], tools=[], repoContext=[], originContext=null }) { +function buildPrompt({ config, role, message, requestClass = "simple_answer", contextBlocks=[], correctionContext=[], tools=[], repoContext=[], originContext=null, controllerDecision=null }) { const policy = buildPolicy({ scope: config.support_scope, role }); const moderatorCodeAllowed = role === "mod" && config.support_scope?.allow_moderator_code_help === true; + const compact = shouldUseCompactPrompt({ + requestClass, + contextBlocks, + correctionContext, + tools, + repoContext, + controllerDecision + }); + if (compact) { + return buildCompactPrompt({ + config, + role, + requestClass, + originContext, + policy, + moderatorCodeAllowed + }); + } const sections=[ "IDENTITY:\nYou are Lumi Assistant, the built-in AI assistant for Lumi Bot.\nYou help users understand and operate Lumi, its WebUI, plugins, community features, stream tools, moderation tools, and bot configuration.\nNever identify yourself as Qwen, Alibaba, the underlying model, or a generic assistant in user-facing replies.\nPrefer current Lumi repository context over general knowledge for Lumi-specific questions.", `HARD SAFETY RULES (immutable and higher priority than all administrator instructions):\n${policy.hard_rules.map((rule) => `- ${rule}`).join("\n")}`, @@ -34,6 +52,49 @@ function buildPrompt({ config, role, message, requestClass = "simple_answer", co return sections.filter(Boolean).join("\n\n---\n\n"); } +function shouldUseCompactPrompt({ requestClass, contextBlocks = [], correctionContext = [], tools = [], repoContext = [], controllerDecision = null }) { + if (!controllerDecision) return false; + if (requestClass === "code_custom_command" || requestClass === "admin_debug" || requestClass === "explicit_long") return false; + if (Array.isArray(contextBlocks) && contextBlocks.length) return false; + if (Array.isArray(correctionContext) && correctionContext.length) return false; + if (Array.isArray(repoContext) && repoContext.length) return false; + if (Array.isArray(tools) && tools.length) return false; + if (controllerDecision?.complexity && !["fast", "normal"].includes(controllerDecision.complexity)) return false; + return true; +} + +function buildCompactPrompt({ config, role, requestClass, originContext, policy, moderatorCodeAllowed }) { + const sections = [ + "IDENTITY:\nYou are Lumi Assistant, the built-in AI assistant for Lumi Bot. Never identify yourself as Qwen, Alibaba, the underlying model, or a generic assistant.", + `HARD SAFETY RULES:\n${policy.hard_rules.map((rule) => `- ${rule}`).join("\n")}`, + `ROLE AND DISCLOSURE:\nRole: ${role}.\n${role === "admin" + ? "You may explain technical details when verified context is present or the answer is general and safe." + : moderatorCodeAllowed + ? "Give user-facing and moderation help. Do not expose private internals." + : "Give user-facing help only. Do not expose private internals, source paths, or implementation details."}`, + originContext ? `REQUEST ORIGIN:\n${compactOrigin(originContext)}` : "", + `STYLE:\n${policy.style}\nAnswer directly and avoid broad background. Request class: ${requestClass}.`, + "VERIFICATION RULE:\nNo verified Lumi/OKF context was retrieved for this request. For Lumi-specific or community-specific facts, say when the answer cannot be verified instead of inventing details.", + config.instructions?.community_tone ? `COMMUNITY TONE:\n${config.instructions.community_tone}` : "", + config.instructions?.admin_custom ? `ADMIN CUSTOM INSTRUCTIONS:\n${config.instructions.admin_custom}` : "" + ]; + return sections.filter(Boolean).join("\n\n---\n\n"); +} + +function compactOrigin(originContext) { + const profile = originContext?.response_profile || {}; + return JSON.stringify({ + origin: originContext.origin || originContext.platform || "webui", + platform: originContext.platform || originContext.origin || "webui", + role: originContext.role, + response_profile: { + source: profile.source, + target_chars: profile.target_chars, + hard_chars: profile.hard_chars + } + }); +} + function webSearchPolicy(tools = []) { if (!tools.some((tool) => String(tool.tool_id).startsWith("web_search."))) return ""; return [ diff --git a/plugins/lumi_ai/backend/request_jobs.js b/plugins/lumi_ai/backend/request_jobs.js index d0ff3d0..5cb0933 100644 --- a/plugins/lumi_ai/backend/request_jobs.js +++ b/plugins/lumi_ai/backend/request_jobs.js @@ -145,7 +145,11 @@ function sanitizeDetails(details) { ]) { if (Number.isFinite(Number(details[key]))) output[key] = Number(details[key]); } - for (const key of ["route", "route_class", "reason_code", "backend", "error_code"]) { + for (const key of [ + "route", "route_class", "reason_code", "backend", "error_code", + "controller_intent", "controller_complexity", "controller_reason_code", + "okf_retrieval_depth", "answer_style", "source_profile" + ]) { if (details[key] != null) output[key] = String(details[key]).slice(0, 120); } if (details.frontend_soft_timeout != null) output.frontend_soft_timeout = Boolean(details.frontend_soft_timeout); diff --git a/plugins/lumi_ai/backend/response_formatter.js b/plugins/lumi_ai/backend/response_formatter.js index 1c6eb2d..76333aa 100644 --- a/plugins/lumi_ai/backend/response_formatter.js +++ b/plugins/lumi_ai/backend/response_formatter.js @@ -55,15 +55,15 @@ function formatAssistantResponse({ output = output.replace(/[ \t]+\n/g, "\n").replace(/\n{4,}/g, "\n\n\n").trim(); const originalLength = output.length; - const delivered = Number.isFinite(Number(maxLength)) && Number(maxLength) > 0 - ? truncateFinal(output, maxLength) - : output; + const constrained = constrainFinal(output, maxLength); + const delivered = constrained.text; return { text: delivered, links: uniqueLinks(collected), original_final_length: originalLength, delivered_length: delivered.length, - truncated: delivered.length < originalLength + truncated: delivered.length < originalLength, + delivery_action: constrained.action }; } @@ -74,10 +74,43 @@ function collectLink(output, link, baseUrl, routeSet) { } function truncateFinal(value, maxLength) { - const limit = Math.max(100, Number.parseInt(maxLength, 10) || 4000); - if (value.length <= limit) return value; - const suffix = " [reply truncated]"; - return `${value.slice(0, Math.max(1, limit - suffix.length)).trimEnd()}${suffix}`; + return constrainFinal(value, maxLength).text; +} + +function constrainFinal(value, maxLength) { + const output = String(value || ""); + const requestedLimit = Number(maxLength); + if (!Number.isFinite(requestedLimit) || requestedLimit <= 0 || output.length <= requestedLimit) { + return { text: output, action: "none" }; + } + const limit = Math.max(100, Number.parseInt(requestedLimit, 10) || 4000); + const suffix = " [Reply shortened for platform limits.]"; + const available = Math.max(1, limit - suffix.length); + const candidate = output.slice(0, available); + const minimumBoundary = Math.floor(available * 0.55); + const sentenceBoundary = lastBoundary(candidate, /(?:[.!?][\])}"']?|\n\n)(?=\s|$)/g, minimumBoundary); + const lineBoundary = lastBoundary(candidate, /\n/g, minimumBoundary); + const wordBoundary = candidate.lastIndexOf(" "); + const boundary = sentenceBoundary >= minimumBoundary + ? sentenceBoundary + : lineBoundary >= minimumBoundary + ? lineBoundary + : wordBoundary >= minimumBoundary + ? wordBoundary + : available; + return { + text: `${candidate.slice(0, boundary).trimEnd()}${suffix}`, + action: "compacted" + }; +} + +function lastBoundary(value, pattern, minimum) { + let boundary = -1; + for (const match of value.matchAll(pattern)) { + const end = (match.index || 0) + match[0].length; + if (end >= minimum) boundary = end; + } + return boundary; } function markdownLink(link) { @@ -99,6 +132,7 @@ module.exports = { formatAssistantResponse, normalizeLink, truncateFinal, + constrainFinal, absoluteInternalUrl, normalizeCodeFences }; diff --git a/plugins/lumi_ai/index.js b/plugins/lumi_ai/index.js index 34e8d5e..9a259c9 100644 --- a/plugins/lumi_ai/index.js +++ b/plugins/lumi_ai/index.js @@ -37,6 +37,7 @@ const { ToolSettings } = require("./backend/tool_settings"); const { buildAllowedToolsSection } = require("./backend/prompt_builder"); const storage = require("./backend/storage"); const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_utils"); +const { SOURCE_DEFAULTS } = require("./backend/controller"); const PLUGIN_ID = "lumi_ai"; const TOKEN_PRESETS = Object.freeze([ @@ -63,6 +64,7 @@ module.exports = { catch (error) { console.warn("Lumi AI repository index initialization failed", error.message); } } let config = getConfig(); + metrics.configureRetention(config.work_history_retention); const feedbackStore = new FeedbackStore(); const correctionStore = new CorrectionStore({ getConfig: () => config, @@ -101,10 +103,27 @@ module.exports = { const frontendVisibility = new Map(); const getSafeContext = (input = {}) => { const context = typeof input === "string" ? { role: input } : input; - return [...contextProviders.values()].flatMap((fn) => { - try { return normalizeContext(fn(context)); } catch { return []; } - }); + const diagnostics = []; + const blocks = []; + getSafeContext.lastDiagnostics = diagnostics; + getSafeContext.lastErrors = []; + for (const fn of contextProviders.values()) { + try { + const result = fn(context); + if (result && typeof result === "object" && !Array.isArray(result)) { + if (result.diagnostics) diagnostics.push(safeContextDiagnostic(result.diagnostics)); + blocks.push(...normalizeContext(result.blocks ?? result.context ?? result.items ?? [])); + } else { + blocks.push(...normalizeContext(result)); + } + } catch (error) { + getSafeContext.lastErrors.push(String(error.message || error).slice(0, 300)); + } + } + return blocks; }; + getSafeContext.lastDiagnostics = []; + getSafeContext.lastErrors = []; const runtime = new RuntimeManager({ getConfig: () => config, getModel, @@ -199,9 +218,11 @@ module.exports = { return { ...main, gate: await gateRuntime.health() }; }; let gateRecoveryPending = false; + let runtimeInstallInProgress = false; const gateMonitor = setInterval(async () => { if ( gateRecoveryPending || + runtimeInstallInProgress || !config.enabled || gateRuntime.status().state === "running" ) return; @@ -301,6 +322,7 @@ module.exports = { allocation: gpuAllocation, generationTps: Number(recentGeneration?.generation_tps) || 0 }); + const workHistoryPage = metrics.workHistoryPage(req.query, req.query.work_page, 20); const metricsPage = metrics.historyPage(req.query.metrics_page, 25); const slowRequestsPage = metrics.slowRequestsPage(req.query.slow_page, 15); const accessPage = paginateRows(accessControl.list(), req.query.access_page, 25); @@ -310,9 +332,12 @@ module.exports = { const modelFileSize = selectedModelPath && fs.existsSync(selectedModelPath) ? fs.statSync(selectedModelPath).size : 0; + const runtimeDownloadSize = runtimeTarget + ? Number(runtimeTarget.size || 0) + (runtimeTarget.dependencies || []).reduce((sum, dependency) => sum + Number(dependency.size || 0), 0) + : 0; const sizeDiagnostics = [ sanityCheckSize("Runtime folder", runtimeFolderSize, 5 * 1024 ** 3), - sanityCheckSize("Runtime archive", runtimeTarget?.size || 0, 2 * 1024 ** 3), + sanityCheckSize("Runtime archive", runtimeDownloadSize, 2 * 1024 ** 3), sanityCheckSize("Selected model", selectedModel?.size || 0, 100 * 1024 ** 3), sanityCheckSize("Installed model", modelFileSize, 100 * 1024 ** 3), sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3) @@ -337,6 +362,7 @@ module.exports = { tokenPresets: TOKEN_PRESETS, gateContextOptions: GATE_CONTEXT_OPTIONS, runtimeTarget, + runtimeDownloadSize, runtimeManifest, runtimeStatus, gateStatus, @@ -361,6 +387,8 @@ module.exports = { sizeDiagnostics, hardware, metrics: metrics.report(), + workHistoryPage, + workFilters: req.query, history: metricsPage.entries, metricsPage, slowRequestsPage, @@ -385,19 +413,11 @@ module.exports = { return flash(req, res, "error", "Choose a supported AI context size."); } const contextSize = requestedContext; - const tokenValues = TOKEN_PRESETS.map((option) => option.value); const gateContextValues = GATE_CONTEXT_OPTIONS.map((option) => option.value); const requestedGateContext = Number(req.body.gate_context_size); if (!gateContextValues.includes(requestedGateContext)) { return flash(req, res, "error", "Choose a supported gate context size."); } - const presetToken = (field, fallback, label) => { - const value = Number(req.body[field]); - if (!tokenValues.includes(value)) { - throw new Error(`Choose a supported preset for ${label}.`); - } - return value || fallback; - }; const previousConfig = config; let nextConfig; try { @@ -414,14 +434,7 @@ module.exports = { request_timeout_ms: boundedInt(req.body.hard_generation_timeout_ms, 30000, 3600000, 600000), ui_soft_timeout_ms: boundedInt(req.body.ui_soft_timeout_ms, 5000, 300000, 45000), hard_generation_timeout_ms: boundedInt(req.body.hard_generation_timeout_ms, 30000, 3600000, 600000), - max_output_tokens: presetToken("max_output_tokens", 2048, "API/test output tokens"), - output_budgets: { - navigation_help: presetToken("output_budget_navigation_help", 256, "navigation/help tokens"), - simple_answer: presetToken("output_budget_simple_answer", 512, "simple answer tokens"), - code_custom_command: presetToken("output_budget_code_custom_command", 1024, "code/custom command tokens"), - admin_debug: presetToken("output_budget_admin_debug", 2048, "admin debug tokens"), - explicit_long: presetToken("output_budget_explicit_long", 4096, "explicit long-answer tokens") - }, + source_profiles: sourceProfilesFromBody(req.body, config.source_profiles), batch_size: boundedInt(req.body.batch_size, 32, 4096, 512), ubatch_size: boundedInt(req.body.ubatch_size, 16, 4096, 128), per_user_requests_per_minute: boundedInt(req.body.per_user_requests_per_minute, 1, 120, 6), @@ -433,6 +446,14 @@ module.exports = { mods: req.body.visibility_mods === "on", users: req.body.visibility_users === "on" }, + work_history_retention: { + mode: req.body.work_history_retention_mode === "age" ? "age" : "count", + count: boundedInt(req.body.work_history_retention_count, 50, 10000, 500), + age_value: boundedInt(req.body.work_history_retention_age_value, 1, 1000, 30), + age_unit: ["hours", "days", "weeks", "months", "years"].includes(req.body.work_history_retention_age_unit) + ? req.body.work_history_retention_age_unit + : "days" + }, commands: { enabled: req.body.command_enabled === "on", triggers: parseCommandTriggers(req.body.command_triggers), @@ -523,6 +544,7 @@ module.exports = { return flash(req, res, "error", error.message); } config = saveConfig(nextConfig); + metrics.configureRetention(config.work_history_retention); registerAssistantCommands({ commandRouter, provider, @@ -573,12 +595,27 @@ module.exports = { archive: true, runtimeMetadata: { backend: target.backend || "cpu", + backend_variant: target.backend_variant || null, version: runtimeManifest.version, - target: target.filename + target: target.filename, + dependencies: Array.isArray(target.dependencies) + ? target.dependencies.map((dependency) => ({ + filename: dependency.filename, + sha256: dependency.sha256, + size: dependency.size || 0 + })) + : [] + }, + beforeInstall: async () => { + runtimeInstallInProgress = true; + await stopRuntimes({ manual: true, reason: "runtime_reinstall" }); + }, + afterInstall: async () => { + runtimeInstallInProgress = false; } }); if (wantsJson) return res.json({ success: true, job }); - return flash(req, res, "success", `${String(target.backend || "CPU").toUpperCase()} runtime download started.`); + return flash(req, res, "success", `${String(target.backend || "CPU").toUpperCase()} runtime download started. Lumi AI runtimes will stop when the new runtime is ready to install; start them again after the download completes.`); } catch (error) { if (wantsJson) return res.status(400).json({ error: error.message }); return flash(req, res, "error", error.message); @@ -901,6 +938,11 @@ module.exports = { final_reply_length: delivered.original_final_length, original_final_length: delivered.original_final_length, delivered_length: delivered.delivered_length, + final_tokens: estimateTokenCountFromLength(delivered.original_final_length), + delivered_tokens: estimateTokenCountFromLength(delivered.delivered_length), + truncated: delivered.truncated, + delivery_action: delivered.delivery_action, + ...controllerDeliveryFields(result), ...(result.stage_timings || {}) }); return { @@ -1489,6 +1531,19 @@ function normalizeContext(value) { if (Array.isArray(value)) return value.filter((item) => typeof item === "string"); return typeof value === "string" ? [value] : []; } +function safeContextDiagnostic(value) { + const row = value && typeof value === "object" ? value : {}; + return { + provider: cleanText(row.provider || row.kind, 40), + kind: cleanText(row.kind, 40), + query: cleanText(row.query, 500), + candidate_count: boundedInt(row.candidate_count, 0, 1000, 0), + returned_count: boundedInt(row.returned_count, 0, 1000, 0), + limit: boundedInt(row.limit, 0, 1000, 0), + okf_retrieval: cleanText(row.okf_retrieval, 40), + reason: cleanText(row.reason, 160) + }; +} function boundedInt(value, min, max, fallback) { const number = Number.parseInt(value, 10); return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; @@ -1747,6 +1802,11 @@ function registerAssistantCommands({ original_final_length: delivered.original_final_length, final_reply_length: reply.original_final_length, delivered_length: reply.delivered_length, + final_tokens: estimateTokenCountFromLength(delivered.original_final_length), + delivered_tokens: estimateTokenCountFromLength(reply.delivered_length), + truncated: delivered.truncated || reply.delivered_length < delivered.original_final_length, + delivery_action: reply.delivery_action !== "none" ? reply.delivery_action : delivered.delivery_action, + ...controllerDeliveryFields(result), internal_generated_length: result.internal_generated_length || String(result.text || "").length }); } catch (error) { @@ -1799,7 +1859,7 @@ function webOriginContext(req) { message_id: null, reply_mode: "panel", format_capabilities: { markdown: false, html: true }, - max_message_length: 8000, + max_message_length: null, base_url: `${req.protocol}://${req.get("host")}`, source_plugin: "lumi_ai", source_command: "webui_assistant", @@ -1809,7 +1869,7 @@ function webOriginContext(req) { function diagnosticOriginContext(user, origin = "webui") { const role = roleOf(user); - const limits = { webui: 8000, discord: 1900, twitch: 450, youtube: 1800, kick: 450, other: 1000 }; + const limits = { webui: null, discord: 1900, twitch: 450, youtube: 1800, kick: 450, other: 1000 }; return { origin, platform: origin, @@ -1828,7 +1888,7 @@ function diagnosticOriginContext(user, origin = "webui") { markdown: origin === "discord", html: origin === "webui" }, - max_message_length: limits[origin] || limits.other, + max_message_length: origin === "webui" ? null : (limits[origin] || limits.other), source_plugin: "lumi_ai", source_command: "admin_diagnostic", permission_context: { @@ -1856,6 +1916,28 @@ function finalizeAssistantResult(result, { role, config, baseUrl = "", maxLength return output; } +function controllerDeliveryFields(result = {}) { + const decision = result.controller_decision || {}; + const profile = decision.source_profile || {}; + return { + controller_route: decision.route, + controller_intent: decision.intent, + controller_complexity: decision.complexity, + controller_reason_code: decision.reason_code, + controller_confidence: decision.confidence, + okf_retrieval_depth: decision.okf_retrieval, + source_profile: profile.source, + target_final_chars: profile.target_chars, + hard_final_chars: profile.hard_chars, + controller_fallback_used: decision.fallback_used + }; +} + +function estimateTokenCountFromLength(value) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? Math.max(1, Math.ceil(number / 4)) : 0; +} + function normalizeCustomCommandReply(text, requestMessage) { const request = String(requestMessage || ""); const output = String(text || ""); @@ -1937,6 +2019,25 @@ function limitFromBody(body, prefix, fallbackRequests, fallbackWindow) { }; } +function sourceProfilesFromBody(body, current = {}) { + return Object.fromEntries(Object.entries(SOURCE_DEFAULTS).map(([source, fallback]) => { + const existing = current?.[source] || {}; + const hardRaw = body[`source_profile_${source}_hard_chars`]; + const targetFallback = existing.target_chars ?? fallback.target_chars; + const hardFallback = existing.hard_chars ?? fallback.hard_chars ?? 1000; + return [source, { + source, + target_chars: boundedInt(body[`source_profile_${source}_target_chars`], 100, 12000, targetFallback), + hard_chars: String(hardRaw ?? "").trim() === "" + ? null + : boundedInt(hardRaw, 100, 12000, hardFallback), + allow_sections: body[`source_profile_${source}_allow_sections`] === "on", + allow_long_answer: body[`source_profile_${source}_allow_long_answer`] === "on", + allow_split: body[`source_profile_${source}_allow_split`] === "on" + }]; + })); +} + function writeCommandsManifest(pluginDir, config) { const triggers = config.commands.triggers || ["assistant", "lumi"]; const primary = triggers[0] || "assistant"; diff --git a/plugins/lumi_ai/public/assistant.js b/plugins/lumi_ai/public/assistant.js index f24e2b2..b650c01 100644 --- a/plugins/lumi_ai/public/assistant.js +++ b/plugins/lumi_ai/public/assistant.js @@ -157,11 +157,12 @@ }; const baseLabel = labels[stage] || "Lumi Assistant is processing..."; const elapsed = latestJob?.elapsed_ms ? ` · ${formatElapsed(latestJob.elapsed_ms)}` : ""; - const budget = Number(latestJob?.details?.max_output_tokens_used || latestJob?.details?.max_output_tokens) || 0; - const budgetText = budget && ["prompt_eval", "generating"].includes(stage) - ? ` · budget ${budget} tokens` + const mode = latestJob?.details?.controller_complexity; + const retrieval = latestJob?.details?.okf_retrieval_depth; + const routingText = mode && ["prompt_eval", "generating"].includes(stage) + ? ` · ${mode}${retrieval ? `/${retrieval}` : ""}` : ""; - label.textContent = `${baseLabel}${elapsed}${budgetText}`; + label.textContent = `${baseLabel}${elapsed}${routingText}`; if (!details.hidden && latestJob) this.updateDetails(latestJob); }, showSoftTimeout(actions) { diff --git a/plugins/lumi_ai/public/settings.css b/plugins/lumi_ai/public/settings.css index f1fde53..d386d39 100644 --- a/plugins/lumi_ai/public/settings.css +++ b/plugins/lumi_ai/public/settings.css @@ -73,6 +73,20 @@ .ai-fieldset legend { padding: 0 5px; font-weight: 700; } .ai-fieldset label { display: flex; align-items: center; gap: 6px; } .ai-test-output { max-height: 420px; overflow: auto; margin-top: 14px; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; } +.ai-work-filters { margin: 14px 0 10px; } +.ai-work-summary-row { cursor: pointer; } +.ai-work-summary-row:hover > td, +.ai-work-summary-row:focus-within > td { background: var(--surface-2); } +.ai-work-summary-row.is-open > td { border-bottom-color: transparent; } +.ai-work-expand-button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; } +.ai-work-expand-icon { flex: 0 0 auto; width: 8px; height: 8px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(-45deg); transition: transform 120ms ease; } +.ai-work-summary-row.is-open .ai-work-expand-icon { transform: rotate(45deg); } +.ai-work-detail-row > td { padding: 0; background: var(--surface-2); } +.ai-work-detail-content { display: grid; gap: 12px; padding: 14px; border-bottom: 1px solid var(--border); } +.ai-work-prompt { padding: 0; } +.ai-work-prompt pre, +.ai-work-events td pre { max-width: 520px; max-height: 220px; overflow: auto; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; } +.ai-work-flags { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } .ai-remediation { margin: 14px 0; padding-left: 24px; } .ai-raw-diagnostic pre { max-height: 420px; overflow: auto; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); white-space: pre-wrap; overflow-wrap: anywhere; } .ai-tools-modal { z-index: 110; } diff --git a/plugins/lumi_ai/public/settings.js b/plugins/lumi_ai/public/settings.js index 7c33b4c..a0983d0 100644 --- a/plugins/lumi_ai/public/settings.js +++ b/plugins/lumi_ai/public/settings.js @@ -69,6 +69,25 @@ }); const pollDownloads = async () => { if (!downloadStatus) return; + const downloadStateLabel = (job) => { + const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0; + const labels = { + queued: "Queued", + downloading: "Downloading", + downloading_dependency: "Downloading dependency", + verifying: "Verifying", + extracting: "Extracting", + extracting_dependency: "Extracting dependency", + preparing_install: "Stopping runtimes", + installing: "Installing", + complete: "Complete", + error: "Error" + }; + const label = labels[job.state] || job.state; + const progress = job.total && /download/i.test(label) ? ` ${percent}%` : ""; + const file = job.current_file ? ` (${job.current_file})` : ""; + return `${job.id}: ${label}${progress}${file}${job.error ? ` - ${job.error}` : ""}`; + }; try { const response = await fetch("/plugins/lumi_ai/api/downloads"); if (!response.ok) return; @@ -76,10 +95,7 @@ const active = jobs.filter((job) => !["complete", "error"].includes(job.state)); if (!jobs.length) return; downloadStatus.hidden = false; - downloadStatus.textContent = jobs.map((job) => { - const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0; - return `${job.id}: ${job.state}${job.total ? ` ${percent}%` : ""}${job.error ? ` - ${job.error}` : ""}`; - }).join(" | "); + downloadStatus.textContent = jobs.map(downloadStateLabel).join(" | "); jobs.forEach((job) => { const button = document.querySelector(`[data-ai-download-button][data-download-id="${CSS.escape(job.id)}"]`); if (!button || !window.LumiStateButton) return; @@ -87,7 +103,12 @@ else if (job.state === "error") window.LumiStateButton.setState(button, "error"); else window.LumiStateButton.setState(button, "loading", { busy: true }); const label = button.querySelector('[data-state-view="loading"] span:last-child'); - if (label && job.total) label.textContent = `Downloading ${Math.floor(job.downloaded / job.total * 100)}%`; + if (label) { + const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0; + label.textContent = /download/i.test(job.state) + ? `Downloading ${percent}%` + : (job.state === "preparing_install" ? "Stopping runtimes" : job.state === "installing" ? "Installing" : "Working"); + } }); if (active.length) window.setTimeout(pollDownloads, 1000); } catch {} @@ -228,6 +249,33 @@ action.addEventListener("change", updateTimeoutVisibility); updateTimeoutVisibility(); } + document.querySelectorAll("[data-work-retention]").forEach((section) => { + const mode = section.querySelector("[data-work-retention-mode]"); + const count = section.querySelector("[data-work-retention-count]"); + const age = section.querySelector("[data-work-retention-age]"); + const sync = () => { + const ageMode = mode?.value === "age"; + if (count) count.hidden = ageMode; + if (age) age.hidden = !ageMode; + }; + mode?.addEventListener("change", sync); + sync(); + }); + document.querySelectorAll("[data-ai-work-row]").forEach((row) => { + const button = row.querySelector(".ai-work-expand-button"); + const detail = document.getElementById(row.dataset.aiWorkDetail || ""); + if (!button || !detail) return; + const toggle = () => { + const open = detail.hidden; + detail.hidden = !open; + row.classList.toggle("is-open", open); + button.setAttribute("aria-expanded", String(open)); + }; + row.addEventListener("click", (event) => { + if (event.target.closest("a, input, select, textarea") && !event.target.closest(".ai-work-expand-button")) return; + toggle(); + }); + }); const assistantDiagnostics = document.querySelector("[data-assistant-diagnostics]"); if (assistantDiagnostics) { const status = assistantDiagnostics.querySelector("[data-assistant-status]"); diff --git a/plugins/lumi_ai/public/tool-manager.js b/plugins/lumi_ai/public/tool-manager.js index 1384ddb..50fcebf 100644 --- a/plugins/lumi_ai/public/tool-manager.js +++ b/plugins/lumi_ai/public/tool-manager.js @@ -414,13 +414,23 @@ }); settingsReset.addEventListener("click", async () => { - if (!activeSettingsTool || !window.confirm(`Reset ${activeSettingsTool.display_name || activeSettingsTool.tool_id} settings to defaults?`)) return; + if (!activeSettingsTool) return; + if (!window.LumiConfirm?.destructiveFetch) { + window.alert("Timed confirmation is unavailable. Reload the page and try again."); + return; + } settingsReset.disabled = true; try { - const response = await fetch(`/plugins/lumi_ai/api/tools/${encodeURIComponent(activeSettingsTool.tool_id)}/settings/reset`, { + const action = `/plugins/lumi_ai/api/tools/${encodeURIComponent(activeSettingsTool.tool_id)}/settings/reset`; + const response = await window.LumiConfirm.destructiveFetch(action, { method: "POST", headers: { Accept: "application/json" } + }, { + title: "Reset AI tool settings", + text: `Reset ${activeSettingsTool.display_name || activeSettingsTool.tool_id} settings to defaults?`, + label: "Reset settings" }); + if (!response) return; const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "Unable to reset tool settings."); renderSettings(payload); diff --git a/plugins/lumi_ai/runtime_manifest.json b/plugins/lumi_ai/runtime_manifest.json index 9ce5f95..4023cf9 100644 --- a/plugins/lumi_ai/runtime_manifest.json +++ b/plugins/lumi_ai/runtime_manifest.json @@ -34,13 +34,31 @@ } }, "accelerated_targets": { - "win32-x64": { - "backend": "vulkan", - "filename": "llama-b9592-bin-win-vulkan-x64.zip", - "url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-win-vulkan-x64.zip", - "sha256": "126667a2b89892fdc0c3b0b95cba0783bbdfaa69d8fd13e3d8da5c4c1307c8f4", - "size": 38407211 - }, + "win32-x64": [ + { + "backend": "cuda", + "backend_variant": "cuda-12.4", + "filename": "llama-b9592-bin-win-cuda-12.4-x64.zip", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-win-cuda-12.4-x64.zip", + "sha256": "d00b3e988f0fbd03d055904eb361b1065cfa014e1860366d42eb599af4016260", + "size": 260958266, + "dependencies": [ + { + "filename": "cudart-llama-bin-win-cuda-12.4-x64.zip", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/cudart-llama-bin-win-cuda-12.4-x64.zip", + "sha256": "8c79a9b226de4b3cacfd1f83d24f962d0773be79f1e7b75c6af4ded7e32ae1d6", + "size": 391443627 + } + ] + }, + { + "backend": "vulkan", + "filename": "llama-b9592-bin-win-vulkan-x64.zip", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b9592/llama-b9592-bin-win-vulkan-x64.zip", + "sha256": "126667a2b89892fdc0c3b0b95cba0783bbdfaa69d8fd13e3d8da5c4c1307c8f4", + "size": 38407211 + } + ], "linux-x64": { "backend": "vulkan", "filename": "llama-b9592-bin-ubuntu-vulkan-x64.tar.gz", @@ -70,4 +88,4 @@ "size": 10790342 } } -} +} \ No newline at end of file diff --git a/plugins/lumi_ai/tests/verify-tools.js b/plugins/lumi_ai/tests/verify-tools.js index 25ab915..94954b5 100644 --- a/plugins/lumi_ai/tests/verify-tools.js +++ b/plugins/lumi_ai/tests/verify-tools.js @@ -231,6 +231,9 @@ async function run() { assert(clientScript.includes('button("Settings"')); assert(clientScript.includes("update.disabled = !tool.update_enabled")); assert(clientScript.includes('form.dataset.confirmMode = "modal"')); + assert(clientScript.includes("window.LumiConfirm.destructiveFetch")); + assert(clientScript.includes("/settings/reset")); + assert.equal(clientScript.includes("window.confirm(`Reset ${activeSettingsTool.display_name"), false); assert(pluginLoader.includes('entry.name, "tool_info.json"')); fs.rmSync(root, { recursive: true, force: true }); diff --git a/plugins/lumi_ai/tests/verify.js b/plugins/lumi_ai/tests/verify.js index 62bb9b8..6f94706 100644 --- a/plugins/lumi_ai/tests/verify.js +++ b/plugins/lumi_ai/tests/verify.js @@ -6,9 +6,15 @@ const { canUseAssistant } = require("../backend/assistant_permissions"); const { ToolRegistry } = require("../backend/tool_router"); const { RequestQueue } = require("../backend/queue_manager"); const { RuntimeManager, combinedResourceEstimate, runCaptured, buildRuntimeArgs } = require("../backend/runtime_manager"); -const { getRuntimeState } = require("../backend/config_manager"); +const { DEFAULT_CONFIG, getRuntimeState } = require("../backend/config_manager"); const { AiProvider, normalizeHistory, normalizeInferenceDiagnostics, resolveOutputBudget } = require("../backend/ai_provider"); -const { GateProvider, similarity, stripForcePrefix, isSensitiveRequest, classifyRequestType, withTimeout } = require("../backend/gate_provider"); +const { GateProvider, similarity, stripForcePrefix, isSensitiveRequest, isSimpleKnowledgeLookup, classifyRequestType, withTimeout } = require("../backend/gate_provider"); +const { + buildControllerDecision, + normalizeControllerDecision, + okfLimitForController, + outputBudgetForController +} = require("../backend/controller"); const { AssistantRequestJobs } = require("../backend/request_jobs"); const { shouldAutoResume } = require("../index"); const { normalizeExitCode, classifyLaunchError } = require("../backend/error_codes"); @@ -24,10 +30,10 @@ 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 { paginateRows, summarizeMetrics, isValidTiming } = require("../backend/metrics"); +const { record: recordMetric, paginateRows, summarizeMetrics, isValidTiming, workHistoryPage } = require("../backend/metrics"); const { AiAccessControl } = require("../backend/access_control"); const { AiRateLimiter, mergeLimits } = require("../backend/rate_limits"); -const { buildOriginContext, formatPlatformReply } = require("../backend/commands"); +const { PLATFORM_DEFAULTS, buildOriginContext, formatPlatformReply, formatPlatformReplyDetails } = require("../backend/commands"); const { AssistantPanelDiagnostics } = require("../backend/assistant_panel_diagnostics"); const { formatAssistantResponse, normalizeLink, normalizeCodeFences } = require("../backend/response_formatter"); const { FeedbackStore, FEEDBACK_TAGS, improvementAccess } = require("../backend/feedback"); @@ -48,6 +54,8 @@ async function run() { assert.doesNotThrow(() => validateArchivePath("bin/llama-server.exe")); assert.equal(classifyError(new Error("source unavailable (404)")).category, "http_404"); assert.equal(classifyError(new Error("hash mismatch")).category, "hash_mismatch"); + assert.equal(classifyError(Object.assign(new Error("busy"), { code: "EBUSY" })).category, "permission_denied"); + assert.match(classifyError(Object.assign(new Error("busy"), { code: "EBUSY" })).message, /Runtime files are locked/); const accessViolation = normalizeExitCode(-1073741819, null, "win32"); assert.equal(accessViolation.unsigned_exit_code, 3221225477); @@ -111,6 +119,59 @@ async function run() { assert.equal(sanitizedMetrics.average_stage_ms.generation_ms, undefined); assert.equal(isValidTiming(-1), false); assert.equal(isValidTiming(0), true); + const workRequestId = `verify-work-${Date.now()}`; + recordMetric({ + kind: "prompt", + status: "received", + request_id: workRequestId, + user_id: "history-user", + role: "admin", + origin: "webui", + prompt: "Tell me about doing a GET request to /setup/twitch", + prompt_tokens: 12 + }); + recordMetric({ + kind: "controller_decision", + status: "success", + request_id: workRequestId, + user_id: "history-user", + role: "admin", + route_used: "main_llm", + controller_intent: "explain_internal", + controller_complexity: "normal", + okf_retrieval_depth: "deep", + controller_reason_code: "route_docs_okf", + controller_confidence: 0.9 + }); + recordMetric({ + kind: "request", + status: "success", + request_id: workRequestId, + user_id: "history-user", + role: "admin", + route_used: "main_llm", + prompt_tokens: 100, + generated_tokens: 40, + duration_ms: 1200 + }); + recordMetric({ + kind: "delivery", + status: "success", + request_id: workRequestId, + user_id: "history-user", + role: "admin", + origin: "webui", + final_tokens: 40, + delivered_tokens: 35, + duration_ms: 1200 + }); + const workPage = workHistoryPage({ work_q: workRequestId }, 1, 5); + assert.equal(workPage.entries.length, 1); + assert.equal(workPage.entries[0].status, "success"); + assert.equal(workPage.entries[0].processed_tokens >= 152, true); + 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 panelTemplate = require("path").join(PLUGIN_ROOT, "views", "assistant-panel.ejs"); const panelDiagnostic = new AssistantPanelDiagnostics(panelTemplate); @@ -125,14 +186,47 @@ async function run() { for (const group of ["Platform commands", "Rate limits", "User AI access", "Assistant identity and scope"]) { assert(settingsTemplate.includes(group)); } + for (const control of [ + "Controller source profiles", + "ai-work-history-table", + "ai-work-detail-row", + "data-ai-work-row", + "work_history_retention_mode", + "dependency archive", + "runtimeDownloadSize", + "source_profile_<%= source %>_target_chars", + "source_profile_<%= source %>_hard_chars", + "job.details.controller_complexity" + ]) { + assert(settingsTemplate.includes(control)); + } + for (const removedControl of [ + "output_budget_simple_answer", + "internal_budget_normal", + "API/test output token fallback", + "Class / budget" + ]) { + assert.equal(settingsTemplate.includes(removedControl), false); + } assert(settingsTemplate.includes("Improvement Center")); + assert(settingsTemplate.includes("work.has_truncation")); + assert(settingsTemplate.includes("work.processed_tokens")); 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"')); 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")); + 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)); + } + const pluginIndexSource = fs.readFileSync(path.join(PLUGIN_ROOT, "index.js"), "utf8"); + for (const marker of ["runtimeInstallInProgress", "beforeInstall", "afterInstall", "runtime_reinstall"]) { + assert(pluginIndexSource.includes(marker)); + } const improvementConfig = { improvement: { @@ -259,8 +353,16 @@ async function run() { compute_api: ["cuda", "vulkan"] }; const selectedRuntime = selectRuntimeTarget(runtimeManifest, testGpu, "win32", "x64"); - assert.equal(selectedRuntime.backend, "vulkan"); + assert.equal(selectedRuntime.backend, "cuda"); assert.equal(selectedRuntime.accelerated, true); + assert.equal(selectedRuntime.target.backend_variant, "cuda-12.4"); + assert.equal(selectedRuntime.target.dependencies.length, 1); + assert.equal(selectedRuntime.available_backends.includes("vulkan"), true); + const reversedGpuApiRuntime = selectRuntimeTarget(runtimeManifest, { + ...testGpu, + compute_api: ["vulkan", "cuda"] + }, "win32", "x64"); + assert.equal(reversedGpuApiRuntime.backend, "cuda"); const cpuRuntime = selectRuntimeTarget(runtimeManifest, { present: false, compute_api: [] }, "win32", "x64"); assert.equal(cpuRuntime.backend, "cpu"); assert.equal(cpuRuntime.target.filename, "llama-b9592-bin-win-cpu-x64.zip"); @@ -440,7 +542,16 @@ async function run() { assert.equal(twitchOrigin.role, "mod"); assert.equal(twitchOrigin.channel_id, "#channel"); assert.equal(twitchOrigin.max_message_length, 450); + assert.equal(PLATFORM_DEFAULTS.webui.max, null); assert.equal(formatPlatformReply("**Hello** world", [], twitchOrigin), "Hello world"); + const compactPlatformReply = formatPlatformReplyDetails( + `${"A complete sentence. ".repeat(30)}Final sentence.`, + [], + twitchOrigin + ); + assert(compactPlatformReply.delivered_length <= twitchOrigin.max_message_length); + assert.equal(compactPlatformReply.delivery_action, "compacted"); + assert(compactPlatformReply.text.endsWith("[Reply shortened for platform limits.]")); const normalizedReply = formatAssistantResponse({ text: 'Open GET /settings/admin or [Twitch wizard](/admin/twitch-wizard). Unsafe', links: [], @@ -469,6 +580,7 @@ async function run() { role: "admin" }); assert.equal(styleOnlyLength.delivered_length, 5000); + assert.equal(styleOnlyLength.delivery_action, "none"); const punctuatedRouteReply = formatAssistantResponse({ text: "Open GET /admin/twitch-wizard.", baseUrl: "https://lumi.example", @@ -484,7 +596,9 @@ async function run() { maxLength: 4000 }); assert.equal(truncatedReply.original_final_length, 5000); - assert.equal(truncatedReply.delivered_length, 4000); + assert(truncatedReply.delivered_length <= 4000); + assert.equal(truncatedReply.delivery_action, "compacted"); + assert(truncatedReply.text.endsWith("[Reply shortened for platform limits.]")); const codeReply = formatAssistantResponse({ text: "Example:\n```javascript\nfunction run(ctx) {\n return 'ok';\n}\n```", verifiedRoutes: [], @@ -866,6 +980,29 @@ async function run() { assert.equal(sensitiveGate.route, "main_llm"); assert.equal(sensitiveGate.reason_code, "sensitive_or_user_specific"); assert.equal(gateCacheReads.includes("Delete this user's economy balance"), false); + let simpleLookupGateCalls = 0; + const simpleLookupGate = new GateProvider({ + getConfig: () => gateConfig, + runtime: { + status: () => ({ state: "stopped" }), + infer: async () => { simpleLookupGateCalls += 1; } + }, + lookupRepo: () => null, + cache: { get: () => null, set: () => null }, + metrics: { record: (entry) => gateAudit.push(entry) } + }); + const simpleLookupDecision = await simpleLookupGate.route({ + message: "Who is OokamiKunTV?", + user: { id: "gate-user-identity" }, + role: "admin", + scope: "assistant", + originContext: { platform: "webui" } + }); + assert.equal(simpleLookupDecision.route, "main_llm"); + assert.equal(simpleLookupDecision.reason_code, "simple_knowledge_lookup"); + assert.equal(simpleLookupDecision.request_class, "simple_answer"); + assert.equal(simpleLookupDecision.gate_error, null); + assert.equal(simpleLookupGateCalls, 0); const uncertainGate = new GateProvider({ getConfig: () => gateConfig, runtime: { @@ -951,10 +1088,82 @@ async function run() { assert(similarity("where are twitch settings", "where can I find twitch settings") >= 0.8); assert.equal(stripForcePrefix("force ai: hello", "force ai:").forced, true); assert.equal(isSensitiveRequest("Show my balance"), true); + assert.equal(isSimpleKnowledgeLookup("Who is OokamiKunTV?"), true); + assert.equal(isSimpleKnowledgeLookup("who are you?"), false); assert.equal(classifyRequestType("Where are Twitch settings?"), "navigation_help"); + assert.equal(classifyRequestType("explain the webroute /setup/twitch for me"), "navigation_help"); + assert.equal(classifyRequestType("Tell me about doing a GET request to /setup/twitch"), "navigation_help"); assert.equal(classifyRequestType("Write a Lumi custom command in JavaScript"), "code_custom_command"); assert.equal(classifyRequestType("Please provide a detailed long explanation"), "explicit_long"); assert.equal(classifyRequestType("Debug the runtime metrics", { role: "admin" }), "admin_debug"); + const routeController = buildControllerDecision({ + message: "Tell me about doing a GET request to /setup/twitch", + role: "admin", + originContext: { origin: "webui" }, + requestClass: "simple_answer", + gateDecision: { route: "main_llm", confidence: 0.91, reason_code: "route_docs_okf" } + }); + assert.equal(routeController.schema, "lumi.ai.controller.v1"); + assert.equal(routeController.intent, "explain_internal"); + assert.equal(routeController.complexity, "normal"); + assert.equal(routeController.okf_retrieval, "deep"); + assert.equal(okfLimitForController(routeController) >= 4, true); + const identityController = buildControllerDecision({ + message: "Who is OokamiKunTV?", + role: "admin", + originContext: { origin: "webui" }, + requestClass: "simple_answer", + gateDecision: simpleLookupDecision + }); + assert.equal(identityController.intent, "factual"); + assert.equal(identityController.complexity, "fast"); + assert.equal(identityController.okf_retrieval, "light"); + assert.equal(identityController.reason_code, "simple_knowledge_okf"); + assert.equal(okfLimitForController(identityController), 2); + const fallbackController = buildControllerDecision({ + message: "hi", + role: "user", + originContext: { origin: "twitch" }, + requestClass: "simple_answer", + gateDecision: { route: "main_llm", confidence: 0.1, reason_code: "gate_timeout_escalated" } + }); + assert.equal(fallbackController.complexity, "normal"); + assert.equal(fallbackController.fallback_used, true); + assert.equal(fallbackController.source_profile.source, "twitch"); + assert.equal(fallbackController.source_profile.hard_chars, 450); + const codexController = buildControllerDecision({ + message: "Export this feedback to a Codex taskfile with acceptance criteria", + role: "admin", + originContext: { origin: "webui" }, + requestClass: "simple_answer" + }); + assert.equal(codexController.intent, "feedback_export"); + assert.equal(codexController.complexity, "expanded"); + assert.equal(codexController.answer_style, "json_only"); + const normalizedInvalidController = normalizeControllerDecision({ + route: "unsafe_route", + intent: "surprise", + complexity: "tiny", + okf_retrieval: "everything", + answer_style: "essay", + confidence: 42, + reason_code: "bad reason!", + source_profile: { + source: "unknown-platform", + hard_chars: 1, + target_chars: 2 + } + }); + assert.equal(normalizedInvalidController.route, "main_llm"); + assert.equal(normalizedInvalidController.intent, "unknown"); + assert.equal(normalizedInvalidController.complexity, "normal"); + assert.equal(normalizedInvalidController.okf_retrieval, "light"); + assert.equal(normalizedInvalidController.answer_style, "normal"); + assert.equal(normalizedInvalidController.confidence, 1); + assert.equal(normalizedInvalidController.reason_code, "controller_normalized"); + assert.equal(normalizedInvalidController.source_profile.source, "other"); + assert.equal(normalizedInvalidController.source_profile.hard_chars, 100); + assert.equal(normalizedInvalidController.source_profile.target_chars, 100); const outputBudgets = { navigation_help: 256, simple_answer: 512, @@ -965,15 +1174,33 @@ async function run() { assert.equal(resolveOutputBudget({ config: { output_budgets: outputBudgets }, requestClass: classifyRequestType("Where is the settings page?") - }), 256); + }), 1024); assert.equal(resolveOutputBudget({ config: { output_budgets: outputBudgets }, requestClass: classifyRequestType("Write a custom command") - }), 896); + }), 4096); assert.equal(resolveOutputBudget({ config: { output_budgets: outputBudgets }, requestClass: classifyRequestType("Please provide a comprehensive long guide") - }), 2048); + }), 8192); + assert.equal(outputBudgetForController({ + config: { + output_budgets: outputBudgets, + internal_mode_budgets: { fast: 512, normal: 1024, expanded: 2048, unlimited: 4096 } + }, + requestClass: "simple_answer", + controllerDecision: routeController + }), 4096); + assert.equal(resolveOutputBudget({ + config: { + output_budgets: outputBudgets, + internal_mode_budgets: { fast: 512, normal: 1024, expanded: 2048, unlimited: 4096 } + }, + requestClass: "simple_answer", + controllerDecision: codexController + }), 8192); + assert.equal(DEFAULT_CONFIG.internal_mode_budgets.normal, 4096); + assert.equal(DEFAULT_CONFIG.source_profiles.twitch.hard_chars, 450); const combined = combinedResourceEstimate({ main: { estimated_cpu_memory_mb: 4000, estimated_gpu_memory_mb: 2000 }, @@ -986,6 +1213,15 @@ async function run() { const stageAudit = []; const stages = []; + const diagnosticContextProvider = () => []; + diagnosticContextProvider.lastDiagnostics = [{ + provider: "okf", + query: "Need help", + candidate_count: 0, + returned_count: 0, + limit: 2, + okf_retrieval: "light" + }]; const timingProvider = new AiProvider({ getConfig: () => ({ selected_model_id: "test", @@ -993,7 +1229,8 @@ async function run() { instructions: {}, logging: {}, internal_generation_char_budget: 2000, - output_budgets: outputBudgets + output_budgets: outputBudgets, + internal_mode_budgets: { fast: 512, normal: 1024, expanded: 2048, unlimited: 4096 } }), runtime: { infer: async () => ({ @@ -1023,7 +1260,7 @@ async function run() { })), tools: registry, metrics: { record: (entry) => stageAudit.push(entry) }, - getContext: () => [] + getContext: diagnosticContextProvider }); const timedResult = await timingProvider.generate({ message: "Need help", @@ -1035,11 +1272,14 @@ async function run() { assert.equal(timedResult.stage_timings.deterministic_ms, 2); assert.equal(timedResult.stage_timings.gate_ms, 3); assert.equal(timedResult.route_class, "simple_answer"); - assert.equal(timedResult.max_output_tokens_used, 512); + assert.equal(timedResult.controller_decision.complexity, "normal"); + assert.equal(timedResult.controller_decision.fallback_used, true); + assert.equal(timedResult.max_output_tokens_used, 4096); assert(Number.isFinite(timedResult.stage_timings.main_queue_ms)); assert(Number.isFinite(timedResult.stage_timings.main_generate_ms)); assert(Number.isFinite(timedResult.stage_timings.total_ms)); assert(stages.includes("queued")); + assert(stages.includes("controller")); assert(stages.includes("prompt_eval")); assert(stages.includes("generating")); assert(stageAudit.some((entry) => @@ -1050,12 +1290,20 @@ async function run() { assert(stageAudit.some((entry) => entry.kind === "request" && entry.route_class === "simple_answer" && - entry.max_output_tokens_used === 512 && + entry.controller_complexity === "normal" && + entry.max_output_tokens_used === 4096 && entry.prompt_tokens === 80 && entry.generated_tokens === 20 && entry.prompt_tps === 200 && entry.generation_tps === 20 )); + assert(stageAudit.some((entry) => + entry.kind === "okf_retrieval" && + entry.okf_query === "Need help" && + entry.okf_candidate_count === 0 && + entry.fallback_reason === "gate_fallback_normal" && + entry.gate_reason_code === "low_confidence" + )); const requestJobs = new AssistantRequestJobs({ ttlMs: 60000, maxJobs: 5 }); const job = requestJobs.create({ @@ -1173,7 +1421,7 @@ async function run() { assert(assembledPrompt.includes("Discord community server")); assert.equal(assembledMessages[1].content, "Earlier question"); assert.equal(assembledMessages[2].content, "Earlier answer"); - assert.equal(generatedTokenBudget, 896); + assert.equal(generatedTokenBudget, 4096); assert(assembledPrompt.includes("Request class: code_custom_command")); assert(assembledPrompt.includes("Put one complete runnable code block first.")); const ambiguousProvider = new AiProvider({ @@ -1314,6 +1562,24 @@ async function run() { assert.match(policyPrompt, /Include advanced operational explanations/); for (const rule of HARD_RULES) assert(policyPrompt.includes(rule)); assert(policyPrompt.indexOf("HARD SAFETY RULES") < policyPrompt.indexOf("ADMIN CUSTOM INSTRUCTIONS")); + const compactPrompt = buildPrompt({ + config: { + support_scope: normalizeScope(), + instructions: { roleplay_intensity: 0 } + }, + role: "admin", + message: "Who is OokamiKunTV?", + requestClass: "simple_answer", + contextBlocks: [], + correctionContext: [], + repoContext: [], + tools: [], + controllerDecision: identityController + }); + assert(compactPrompt.length < policyPrompt.length); + assert.equal(compactPrompt.includes("CUSTOM COMMAND OUTPUT RULES"), false); + assert.equal(compactPrompt.includes("SAFE LUMI CONTEXT:\n(none)"), false); + assert.equal(compactPrompt.includes("ALLOWED TOOLS"), false); const indexPath = resolveData("repo_index", "index.json"); const originalIndex = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, "utf8") : null; @@ -1377,10 +1643,15 @@ async function run() { const assistantStyles = fs.readFileSync(require("path").join(PLUGIN_ROOT, "public", "assistant.css"), "utf8"); const assistantPanel = fs.readFileSync(require("path").join(PLUGIN_ROOT, "views", "assistant-panel.ejs"), "utf8"); const pluginIndex = fs.readFileSync(require("path").join(PLUGIN_ROOT, "index.js"), "utf8"); + const globalAssistantPanels = fs.readFileSync(path.join(PLUGIN_ROOT, "..", "..", "src", "web", "public", "assistant-panels.js"), "utf8"); + const globalStyles = fs.readFileSync(path.join(PLUGIN_ROOT, "..", "..", "src", "web", "public", "styles.css"), "utf8"); assert(accessSettingsTemplate.includes("data-user-lookup")); assert(accessSettingsTemplate.includes("data-user-lookup-search")); assert(accessSettingsTemplate.includes("data-timeout-field hidden")); assert(settingsScript.includes('action.value === "timeout"')); + assert(settingsScript.includes("[data-work-retention-mode]")); + assert(settingsScript.includes("[data-ai-work-row]")); + assert(settingsScript.includes('button.setAttribute("aria-expanded", String(open))')); assert(assistantScript.includes("window.innerHeight / 6")); assert(assistantScript.includes("window.localStorage")); assert(assistantScript.includes("copyText(codeValue)")); @@ -1388,6 +1659,8 @@ async function run() { assert(assistantScript.includes('overlayRoot.className = "lumi-ai-overlay-root"')); assert(assistantScript.includes("Queued for Lumi Assistant")); assert(assistantScript.includes("Lumi Assistant is processing")); + assert(globalAssistantPanels.includes("has-assistant-panel")); + assert(globalStyles.includes("body.has-assistant-panel .feedback-floating-button")); assert(assistantScript.includes("lumi-ai-retry")); assert(assistantScript.includes("beginCooldown(error.retryAfterSeconds)")); assert(assistantScript.includes("Retry available in ${seconds}s")); @@ -1402,7 +1675,7 @@ async function run() { assert(liveTimeoutControls); assert.equal(liveTimeoutControls[1].includes('"Retry"'), false); assert(assistantScript.includes('addError("Assistant request was cancelled."')); - assert(assistantScript.includes("budget ${budget} tokens")); + assert(assistantScript.includes("controller_complexity")); assert(assistantScript.includes("soft_timeout_url")); assert(assistantScript.includes("job.still_running")); assert(assistantStyles.includes(".lumi-ai-timeout-controls")); @@ -1415,8 +1688,8 @@ async function run() { assert(accessSettingsTemplate.includes("Managed model VRAM")); assert(accessSettingsTemplate.includes("External VRAM estimate")); assert(accessSettingsTemplate.includes("Current and recent assistant jobs")); - assert(accessSettingsTemplate.includes("Code/custom command tokens")); - assert(accessSettingsTemplate.includes("Explicit long-answer tokens")); + assert(accessSettingsTemplate.includes("Lumi chooses internal processing depth automatically")); + assert(accessSettingsTemplate.includes("Controller source profiles")); assert(accessSettingsTemplate.includes("Next slow requests")); assert(assistantScript.includes('messages.addEventListener("wheel"')); assert(assistantStyles.includes("z-index: 60")); diff --git a/plugins/lumi_ai/views/improvement-center.ejs b/plugins/lumi_ai/views/improvement-center.ejs index 769528a..c2bc278 100644 --- a/plugins/lumi_ai/views/improvement-center.ejs +++ b/plugins/lumi_ai/views/improvement-center.ejs @@ -197,7 +197,7 @@ <% } %>
- <% evalCases.entries.forEach((entry) => { %><% }) %> + <% evalCases.entries.forEach((entry) => { %><% }) %> <% if (!evalCases.entries.length) { %><% } %>
PromptRole / originExpectedForbiddenExpected linkActions
<%= entry.prompt %><%= entry.role %> / <%= entry.origin %><%= entry.expected_behavior || "-" %><%= entry.forbidden_behavior || "-" %><%= entry.expected_link || "-" %><% if (access.can_run_evals) { %>
<% } %>
<%= entry.prompt %><%= entry.role %> / <%= entry.origin %><%= entry.expected_behavior || "-" %><%= entry.forbidden_behavior || "-" %><%= entry.expected_link || "-" %><% if (access.can_run_evals) { %>
<% } %>
No eval cases.
diff --git a/plugins/lumi_ai/views/settings.ejs b/plugins/lumi_ai/views/settings.ejs index 3317051..8e8f59d 100644 --- a/plugins/lumi_ai/views/settings.ejs +++ b/plugins/lumi_ai/views/settings.ejs @@ -11,6 +11,14 @@ html += options.map((option) => ``).join(""); return html; }; + const workPageHref = (page) => { + const params = new URLSearchParams(); + ["work_q", "work_status", "work_source", "work_role", "work_mode", "work_okf", "work_flag", "work_from", "work_to"].forEach((key) => { + if (workFilters?.[key]) params.set(key, workFilters[key]); + }); + params.set("work_page", String(page)); + return `?${params.toString()}#metrics`; + }; %>
@@ -159,7 +167,7 @@ Platform<%= hardware.platform %>-<%= hardware.architecture %> Self-test<%= runtimeStatus.last_self_test?.success ? "Passed" : runtimeStatus.last_self_test ? "Failed" : "Not run" %> Runtime folder<%= formatBytes(runtimeFolderSize) %> - Runtime archive<%= runtimeTarget ? formatBytes(runtimeTarget.size) : "Unavailable" %> + Runtime download<%= runtimeTarget ? formatBytes(runtimeDownloadSize) : "Unavailable" %> Model installed<%= formatBytes(modelFileSize) %> Model download<%= formatBytes(models.find((model) => model.id === config.selected_model_id)?.size || 0) %> Backend<%= String(runtimeStatus.runtime_backend || "cpu").toUpperCase() %> @@ -181,8 +189,14 @@

<% if (gateStatus.last_error) { %>
<%= gateStatus.last_error %>
<% } %> <% if (runtimeTarget) { %> -

Managed <%= String(runtimeTarget.backend || "cpu").toUpperCase() %> release <%= runtimeManifest?.version || "b9592" %>

-

<%= runtimeTarget.filename %> · <%= formatBytes(runtimeTarget.size) %>

+

Managed <%= String(runtimeTarget.backend_variant || runtimeTarget.backend || "cpu").toUpperCase() %> release <%= runtimeManifest?.version || "b9592" %>

+

+ <%= runtimeTarget.filename %> · <%= formatBytes(runtimeTarget.size) %> + <% if (runtimeTarget.dependencies?.length) { %> + · plus <%= runtimeTarget.dependencies.length %> dependency archive<%= runtimeTarget.dependencies.length === 1 ? "" : "s" %> + · total <%= formatBytes(runtimeDownloadSize) %> + <% } %> +

<%- include("../../../src/web/views/partials/state-button", { type: "submit", @@ -320,12 +334,7 @@
Shows Continue waiting controls without stopping the job.
-
Normal assistant requests use the class budgets below.
-
-
-
-
-
+
Lumi chooses internal processing depth automatically from the request. Platform/source limits below only shape the final delivered reply.
@@ -489,6 +498,44 @@ <% }) %> +
+ Work history retention + + + + Cleanup removes grouped work entries and child events together from local metrics history. +
+
+ Controller source profiles +

Source profiles shape delivered answers for each origin. Leave WebUI hard cap blank for no hard character cap.

+
+ <% Object.entries(config.source_profiles).forEach(([source, profile]) => { %> +
+ <%= source.toUpperCase() %> + + + + + +
+ <% }) %> +
+
@@ -586,17 +633,17 @@
Current and recent assistant jobs
- - <% jobDiagnostics.forEach((job) => { %><% }) %> - <% if (!jobDiagnostics.length) { %><% } %> +
CreatedState / stageClass / budgetElapsedGateQueuePrompt evalGenerationTokensSpeedRuntimeUI timeout
<%= formatDate(job.created_at) %><%= job.state %> / <%= job.stage %><%= job.details.route_class || "-" %> / <%= job.details.max_output_tokens_used || job.details.max_output_tokens || "-" %><%= formatDuration(job.elapsed_ms) %><%= formatDuration(job.details.gate_ms) %><%= formatDuration(job.details.queue_ms) %><%= formatDuration(job.details.prompt_eval_ms) %><%= formatDuration(job.details.generation_ms) %><%= job.details.prompt_tokens || 0 %> / <%= job.details.generated_tokens || 0 %><%= job.details.prompt_tps || 0 %> / <%= job.details.generation_tps || 0 %> tok/s<%= job.details.backend || "-" %>, <%= job.details.gpu_layers || 0 %> layers, ctx <%= job.details.context_size || "-" %><%= job.frontend_soft_timeout_at ? (job.still_running ? "Still running" : "Recorded") : "No" %>
No assistant jobs recorded since this plugin process started.
+ <% jobDiagnostics.forEach((job) => { %><% }) %> + <% if (!jobDiagnostics.length) { %><% } %>
CreatedState / stageRoute classControllerElapsedGateQueuePrompt evalGenerationTokensSpeedRuntimeUI timeout
<%= formatDate(job.created_at) %><%= job.state %> / <%= job.stage %><%= job.details.route_class || "-" %><%= job.details.controller_complexity || "-" %> / <%= job.details.okf_retrieval_depth || "-" %>
<%= job.details.controller_reason_code || job.details.controller_intent || "-" %>
<%= formatDuration(job.elapsed_ms) %><%= formatDuration(job.details.gate_ms) %><%= formatDuration(job.details.queue_ms) %><%= formatDuration(job.details.prompt_eval_ms) %><%= formatDuration(job.details.generation_ms) %><%= job.details.prompt_tokens || 0 %> / <%= job.details.generated_tokens || 0 %><%= job.details.prompt_tps || 0 %> / <%= job.details.generation_tps || 0 %> tok/s<%= job.details.backend || "-" %>, <%= job.details.gpu_layers || 0 %> layers, ctx <%= job.details.context_size || "-" %><%= job.frontend_soft_timeout_at ? (job.still_running ? "Still running" : "Recorded") : "No" %>
No assistant jobs recorded since this plugin process started.
Recent slow and 504-risk requests
- - <% slowRequestsPage.entries.forEach((entry) => { %><% }) %> +
TimeRoute / classReason / budgetGateQueuePrompt evalGenerationTokensSpeedTotalRisk
<%= entry.timestamp %><%= entry.route_used || "-" %> / <%= entry.route_class || "-" %><%= entry.reason_code || "-" %> / max <%= entry.max_output_tokens_used || "-" %><%= formatDuration(entry.gate_ms) %><%= formatDuration(entry.queue_ms) %><%= formatDuration(entry.prompt_eval_ms) %><%= formatDuration(entry.generation_ms) %><%= entry.prompt_tokens || 0 %> / <%= entry.generated_tokens || 0 %><%= entry.prompt_tps || 0 %> / <%= entry.generation_tps || 0 %> tok/s<%= formatDuration(entry.total_ms) %><%= entry.frontend_soft_timeout ? "UI waited" : entry.risk_504 ? "504 risk" : "Slow" %>
+ <% slowRequestsPage.entries.forEach((entry) => { %><% }) %> <% if (!slowRequestsPage.entries.length) { %><% } %>
TimeRoute / classControllerGateQueuePrompt evalGenerationTokensSpeedTotalRisk
<%= entry.timestamp %><%= entry.route_used || "-" %> / <%= entry.route_class || "-" %><%= entry.controller_complexity || "-" %> / <%= entry.okf_retrieval_depth || "-" %>
<%= entry.controller_reason_code || entry.reason_code || "-" %>
<%= formatDuration(entry.gate_ms) %><%= formatDuration(entry.queue_ms) %><%= formatDuration(entry.prompt_eval_ms) %><%= formatDuration(entry.generation_ms) %><%= entry.prompt_tokens || 0 %> / <%= entry.generated_tokens || 0 %><%= entry.prompt_tps || 0 %> / <%= entry.generation_tps || 0 %> tok/s<%= formatDuration(entry.total_ms) %><%= entry.frontend_soft_timeout ? "UI waited" : entry.risk_504 ? "504 risk" : "Slow" %>
No requests over 30 seconds.
@@ -606,16 +653,109 @@ Next slow requests
+ + + + + + + + + + + + Reset +
- - <% history.forEach((entry) => { %><% }) %> - <% if (!history.length) { %><% } %> +
TimeKindStatusRouteConfidence / reasonRoleGenerated / final / deliveredDuration
<%= entry.timestamp %><%= entry.kind %><%= entry.status %><%= entry.route_used || "-" %><%= entry.confidence ?? entry.gate_confidence ?? "-" %> / <%= entry.reason_code || entry.gate_reason_code || "-" %><%= entry.role || "-" %><%= entry.internal_generated_length ?? "-" %> / <%= entry.final_reply_length ?? entry.original_final_length ?? "-" %> / <%= entry.delivered_length ?? "-" %><%= formatDuration(entry.duration_ms) %>
No requests recorded.
+ <% workHistoryPage.entries.forEach((work, workIndex) => { %> + <% const workDetailId = `ai-work-detail-${workIndex}`; %> + + + + + + + + + + + + + <% }) %> + <% if (!workHistoryPage.entries.length) { %><% } %>
TimeStatusSourcePrompt and lifecycleProcessed / final / deliveredDurationFlags
<%= formatDate(work.started_at) %><%= work.status %><%= work.source %>
<%= work.role || "unknown" %> · <%= work.internal_mode || "-" %>/<%= work.okf_retrieval || "-" %>
+ + <%= work.processed_tokens %> / <%= work.final_tokens %> / <%= work.delivered_tokens %><%= formatDuration(work.duration_ms) %> + <% if (work.has_error) { %>error<% } %> + <% if (work.has_refusal) { %>refusal<% } %> + <% if (work.has_fallback) { %>fallback<% } %> + <% if (work.has_truncation) { %>truncated<% } %> + <% if (work.has_okf_context) { %>okf<% } %> + <% if (!work.has_error && !work.has_refusal && !work.has_fallback && !work.has_truncation && !work.has_okf_context) { %>-<% } %> +
No grouped work history entries match the current filters.
- Previous - Page <%= metricsPage.page %> of <%= metricsPage.pages %> (<%= metricsPage.total %> entries) - Next + Previous work entries + Page <%= workHistoryPage.page %> of <%= workHistoryPage.pages %> (<%= workHistoryPage.total %> work entries) + Next work entries
diff --git a/plugins/okf/backend/file_knowledge.js b/plugins/okf/backend/file_knowledge.js new file mode 100644 index 0000000..77d86f5 --- /dev/null +++ b/plugins/okf/backend/file_knowledge.js @@ -0,0 +1,674 @@ +const fs = require("fs"); +const path = require("path"); + +const KNOWLEDGE_SCOPES = Object.freeze(["corrections", "community", "plugins", "core"]); +const SCOPE_PRIORITY = Object.freeze({ + corrections: 400, + community: 300, + plugins: 200, + core: 100 +}); +const ACTIVE_STATUSES = new Set(["active", "published", "approved"]); +const HIDDEN_STATUSES = new Set(["archived", "deleted", "disabled", "draft"]); +const VISIBILITY_VALUES = new Set(["user", "mod", "admin"]); +const PLACEHOLDER_SUGGEST_RESERVED_KEYS = new Set([ + "id", + "title", + "scope", + "status", + "priority", + "visibility", + "category", + "tags", + "generated", + "editable", + "created_at", + "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" +]); +const knowledgeIndexCache = new Map(); + +function knowledgeRoot(rootDir = process.cwd()) { + return path.join(rootDir, "knowledge"); +} + +function ensureKnowledgeDirs(rootDir = process.cwd()) { + const root = knowledgeRoot(rootDir); + for (const scope of KNOWLEDGE_SCOPES) { + fs.mkdirSync(path.join(root, scope), { recursive: true }); + } + return root; +} + +function migrateSingleBracePlaceholders(rootDir = process.cwd()) { + const root = ensureKnowledgeDirs(rootDir); + let changed = 0; + for (const filePath of listKnowledgeFiles(rootDir)) { + const content = fs.readFileSync(filePath, "utf8"); + const next = content.replace(/(^|[^\{])\{([A-Za-z0-9_.-]+\.[A-Za-z0-9_.-]+)\}(?!\})/g, (match, prefix, key) => { + return `${prefix}{{${key}}}`; + }); + if (next !== content) { + fs.writeFileSync(filePath, next); + changed += 1; + } + } + if (changed) knowledgeIndexCache.clear(); + return { root, changed }; +} + +function listKnowledgeFiles(rootDir = process.cwd()) { + const root = knowledgeRoot(rootDir); + const files = []; + for (const scope of KNOWLEDGE_SCOPES) { + const scopeRoot = path.join(root, scope); + if (!fs.existsSync(scopeRoot)) continue; + scanMarkdownFiles(scopeRoot, files); + } + return files.sort(); +} + +function loadKnowledgeEntries(rootDir = process.cwd(), options = {}) { + const root = knowledgeRoot(rootDir); + const files = listKnowledgeFiles(rootDir); + const cacheKey = `${path.resolve(root)}:${options.includeHidden ? "all" : "active"}`; + const previous = knowledgeIndexCache.get(cacheKey) || { files: new Map(), entries: [] }; + const nextFiles = new Map(); + const entries = []; + for (const filePath of files) { + const stat = fs.statSync(filePath); + const previousFile = previous.files.get(filePath); + const cacheMeta = { + mtimeMs: stat.mtimeMs, + size: stat.size + }; + const parsed = previousFile && previousFile.mtimeMs === cacheMeta.mtimeMs && previousFile.size === cacheMeta.size + ? previousFile.entry + : parseKnowledgeFile(filePath, root, options); + nextFiles.set(filePath, { + ...cacheMeta, + entry: parsed + }); + if (parsed) entries.push(parsed); + } + knowledgeIndexCache.set(cacheKey, { files: nextFiles, entries }); + return entries.map(cloneKnowledgeEntry); +} + +function searchFileKnowledge({ query = "", user, limit = 5, rootDir = process.cwd() } = {}) { + const access = accessForUser(user); + if (!access.authenticated) return []; + const tokens = tokenSet(query); + const entries = resolveVisibleKnowledgePlaceholders(loadKnowledgeEntries(rootDir) + .filter((entry) => canSeeKnowledgeEntry(entry, access)) + ); + return entries + .flatMap((entry) => entry.chunks.map((chunk) => scoreKnowledgeChunk(entry, chunk, tokens))) + .filter((result) => result.matched || !tokens.size) + .sort((a, b) => b.score - a.score || b.priority - a.priority || a.source_metadata.path.localeCompare(b.source_metadata.path)) + .slice(0, Math.max(1, Math.min(Number(limit) || 5, 25))) + .map((result) => ({ + id: result.id, + slug: result.slug, + title: result.title, + category: result.category, + visibility: result.visibility, + summary: result.summary, + facts: result.facts, + source: result.source, + source_metadata: result.source_metadata + })); +} + +function listKnowledgePlaceholders({ user, rootDir = process.cwd(), includeHidden = false } = {}) { + const access = accessForUser(user); + if (!access.authenticated) return []; + const entries = loadKnowledgeEntries(rootDir, { includeHidden }) + .filter((entry) => canSeeKnowledgeEntry(entry, access)); + const placeholders = new Set(); + for (const entry of entries) { + for (const key of Object.keys(entry.frontmatter || {})) { + if (PLACEHOLDER_SUGGEST_RESERVED_KEYS.has(key)) continue; + const value = placeholderValue(entry.frontmatter[key]); + if (!value) continue; + placeholders.add(`${entry.id}.${key}`); + placeholders.add(`${entry.scope}.${entry.slug}.${key}`); + } + } + return Array.from(placeholders) + .sort((a, b) => a.localeCompare(b)) + .map((key) => `{{${key}}}`); +} + +function registerKnowledgePlaceholderDefinitions(placeholders, { rootDir = process.cwd() } = {}) { + if (!placeholders?.registerPlaceholders) return []; + placeholders.unregisterNamespace?.("okf.file"); + const definitions = []; + const entries = loadKnowledgeEntries(rootDir) + .filter((entry) => !HIDDEN_STATUSES.has(entry.status)); + for (const entry of entries) { + for (const key of Object.keys(entry.frontmatter || {})) { + if (PLACEHOLDER_SUGGEST_RESERVED_KEYS.has(key)) continue; + const value = placeholderValue(entry.frontmatter[key]); + if (!value) continue; + definitions.push({ + id: `okf.file.${entry.scope}.${entry.slug}.${key}`, + namespace: `okf.file.${entry.scope}`, + aliases: [ + `${entry.id}.${key}`, + `${entry.scope}.${entry.slug}.${key}` + ], + label: `${entry.title}: ${key.replace(/[_-]+/g, " ")}`, + description: `Frontmatter value from ${entry.title}.`, + value_type: "string", + sensitivity: visibilitySensitivity(entry.visibility), + min_editor_role: "user", + min_viewer_role: visibilityRole(entry.visibility), + allowed_field_types: ["okf_markdown"], + group: `OKF ${entry.scope}`, + resolver: () => value + }); + } + } + return placeholders.registerPlaceholders(definitions); +} + +function parseKnowledgeFile(filePath, root, options = {}) { + const content = fs.readFileSync(filePath, "utf8"); + const relativePath = normalizePath(path.relative(root, filePath)); + const scope = relativePath.split("/")[0] || "core"; + const parsed = splitFrontmatter(content); + const metadata = normalizeMetadata(parsed.frontmatter, filePath, relativePath, scope); + if (!options.includeHidden && HIDDEN_STATUSES.has(metadata.status)) return null; + const body = cleanText(parsed.body, 180000); + return { + ...metadata, + path: relativePath, + file_slug: slugify(path.basename(relativePath, path.extname(relativePath))), + body, + chunks: chunkMarkdown(body, metadata).map((chunk) => ({ + ...chunk, + path: relativePath + })) + }; +} + +function listCommunityKnowledgeFiles(rootDir = process.cwd()) { + return loadKnowledgeEntries(rootDir, { includeHidden: true }) + .filter((entry) => entry.scope === "community") + .map((entry) => ({ + id: entry.id, + slug: entry.file_slug, + entry_slug: entry.slug, + title: entry.title, + status: entry.status, + visibility: entry.visibility, + priority: entry.priority, + tags: entry.tags, + editable: entry.editable, + generated: entry.generated, + path: `knowledge/${entry.path}`, + updated_at: entry.updated_at + })) + .sort((a, b) => 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)); + return entry ? { + ...entry, + slug: entry.file_slug, + entry_slug: entry.slug, + path: `knowledge/${entry.path}` + } : null; +} + +function saveCommunityKnowledgeFile(rootDir = process.cwd(), values = {}) { + const root = ensureKnowledgeDirs(rootDir); + const slug = slugify(values.slug || values.id || values.title); + const existing = values.existing_slug ? getCommunityKnowledgeFile(rootDir, values.existing_slug) : null; + if (existing && (existing.generated || !existing.editable)) { + throw new Error("This community OKF file is not editable."); + } + const filePath = path.join(root, "community", `${slug}.md`); + if (existing && path.resolve(filePath) !== path.resolve(rootDir, existing.path)) { + const existingPath = path.resolve(rootDir, existing.path); + if (fs.existsSync(existingPath)) fs.rmSync(existingPath, { force: true }); + } + const metadata = normalizeCommunityFileValues(values, slug); + const markdown = serializeKnowledgeFile(metadata, values.body || ""); + fs.writeFileSync(filePath, markdown); + knowledgeIndexCache.clear(); + parseKnowledgeFile(filePath, root, { includeHidden: true }); + return getCommunityKnowledgeFile(rootDir, slug); +} + +function saveCorrectionKnowledgeFile(rootDir = process.cwd(), values = {}) { + const root = ensureKnowledgeDirs(rootDir); + const slug = slugify(values.slug || values.id || values.title); + const filePath = path.join(root, "corrections", `${slug}.md`); + const metadata = normalizeCorrectionFileValues(values, slug); + const markdown = serializeKnowledgeFile(metadata, values.body || ""); + fs.writeFileSync(filePath, markdown); + knowledgeIndexCache.clear(); + const entry = parseKnowledgeFile(filePath, root, { includeHidden: true }); + return entry ? { + ...entry, + slug: entry.file_slug, + entry_slug: entry.slug, + path: `knowledge/${entry.path}` + } : null; +} + +function cloneKnowledgeEntry(entry) { + return { + ...entry, + tags: [...entry.tags], + frontmatter: { ...entry.frontmatter }, + chunks: entry.chunks.map((chunk) => ({ ...chunk })) + }; +} + +function resolveVisibleKnowledgePlaceholders(entries) { + const placeholders = buildPlaceholderMap(entries); + return entries.map((entry) => { + const body = resolvePlaceholders(entry.body, placeholders); + return { + ...entry, + body, + chunks: chunkMarkdown(body, entry).map((chunk) => ({ + ...chunk, + path: entry.path + })) + }; + }); +} + +function buildPlaceholderMap(entries) { + const map = new Map(); + for (const entry of entries) { + const values = { + ...entry.frontmatter, + id: entry.id, + title: entry.title, + scope: entry.scope, + status: entry.status, + priority: entry.priority, + visibility: entry.visibility, + category: entry.category, + tags: entry.tags, + generated: entry.generated, + editable: entry.editable, + created_at: entry.created_at, + updated_at: entry.updated_at + }; + for (const [key, value] of Object.entries(values)) { + const normalizedValue = placeholderValue(value); + if (!normalizedValue) continue; + map.set(`${entry.id}.${key}`, normalizedValue); + map.set(`${entry.scope}.${entry.slug}.${key}`, normalizedValue); + } + } + return map; +} + +function resolvePlaceholders(value, placeholders) { + let output = String(value || ""); + for (let pass = 0; pass < 3; pass += 1) { + const next = output.replace(/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (match, key) => { + const replacement = placeholders.get(key); + return replacement === undefined ? "[missing OKF reference]" : replacement; + }); + if (next === output) break; + output = next; + } + return output; +} + +function splitFrontmatter(content) { + const normalized = String(content || "").replace(/\r\n?/g, "\n"); + if (!normalized.startsWith("---\n")) { + return { frontmatter: {}, body: normalized }; + } + const end = normalized.indexOf("\n---", 4); + if (end === -1) { + return { frontmatter: {}, body: normalized }; + } + const rawFrontmatter = normalized.slice(4, end); + const body = normalized.slice(end + 4).replace(/^\n/, ""); + return { + frontmatter: parseFrontmatter(rawFrontmatter), + body + }; +} + +function parseFrontmatter(raw) { + const out = {}; + for (const line of String(raw || "").split("\n")) { + if (!line.trim() || line.trim().startsWith("#")) continue; + const match = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/); + if (!match) continue; + out[match[1].trim()] = parseFrontmatterValue(match[2].trim()); + } + return out; +} + +function parseFrontmatterValue(value) { + const unquoted = value.replace(/^["']|["']$/g, ""); + if (/^(true|false)$/i.test(unquoted)) return unquoted.toLowerCase() === "true"; + if (/^-?\d+(?:\.\d+)?$/.test(unquoted)) return Number(unquoted); + if (unquoted.startsWith("[") && unquoted.endsWith("]")) { + return unquoted + .slice(1, -1) + .split(",") + .map((item) => cleanText(item.replace(/^["']|["']$/g, ""), 120)) + .filter(Boolean); + } + return unquoted; +} + +function normalizeCommunityFileValues(values, slug) { + const id = cleanText(values.id, 180) || `community.${slug}`; + const title = cleanText(values.title, 180); + if (!title) throw new Error("Community OKF title is required."); + const status = cleanText(values.status, 40).toLowerCase() || "active"; + const visibility = cleanText(values.visibility, 20).toLowerCase() || "user"; + return { + id, + title, + scope: "community", + status, + priority: Number.isFinite(Number(values.priority)) ? Number(values.priority) : 0, + visibility: VISIBILITY_VALUES.has(visibility) ? visibility : "user", + category: cleanText(values.category || "Community", 120), + tags: splitList(values.tags), + generated: false, + editable: true, + created_at: cleanText(values.created_at, 80) || new Date().toISOString(), + updated_at: new Date().toISOString() + }; +} + +function normalizeCorrectionFileValues(values, slug) { + const id = cleanText(values.id, 180) || `correction.${slug}`; + const title = cleanText(values.title, 180); + if (!title) throw new Error("Correction OKF title is required."); + const status = cleanText(values.status, 40).toLowerCase() || "active"; + const visibility = cleanText(values.visibility, 20).toLowerCase() || "user"; + return { + id, + title, + scope: "corrections", + status, + priority: Number.isFinite(Number(values.priority)) ? Number(values.priority) : 100, + visibility: VISIBILITY_VALUES.has(visibility) ? visibility : "user", + category: cleanText(values.category || "Correction", 120), + tags: splitList(values.tags || "feedback, correction"), + generated: false, + editable: true, + created_at: cleanText(values.created_at, 80) || new Date().toISOString(), + updated_at: new Date().toISOString(), + extra_frontmatter: { + source_feedback_id: cleanText(values.source_feedback_id, 180), + source_feedback_url: cleanText(values.source_feedback_url, 1000) + } + }; +} + +function serializeKnowledgeFile(metadata, body) { + const frontmatter = { + id: metadata.id, + title: metadata.title, + scope: metadata.scope, + status: metadata.status, + priority: metadata.priority, + visibility: metadata.visibility, + category: metadata.category, + tags: metadata.tags.join(", "), + generated: metadata.generated, + editable: metadata.editable, + created_at: metadata.created_at, + updated_at: metadata.updated_at, + ...(metadata.extra_frontmatter || {}) + }; + const lines = ["---"]; + for (const [key, value] of Object.entries(frontmatter)) { + if (value === "" || value === null || value === undefined) continue; + lines.push(`${key}: ${frontmatterValue(value)}`); + } + lines.push("---", "", cleanText(body, 64000) || `# ${metadata.title}`, ""); + return lines.join("\n"); +} + +function frontmatterValue(value) { + if (typeof value === "boolean" || typeof value === "number") return String(value); + const text = Array.isArray(value) ? value.join(", ") : String(value); + return /[:#[\]{}"'\\]|^\s|\s$/.test(text) ? JSON.stringify(text) : text; +} + +function normalizeMetadata(frontmatter, filePath, relativePath, scope) { + const id = cleanText(frontmatter.id, 180) || normalizePath(relativePath).replace(/\.md$/i, "").replace(/\//g, "."); + const title = cleanText(frontmatter.title, 180) || titleFromPath(filePath); + const status = cleanText(frontmatter.status, 40).toLowerCase() || "active"; + const visibility = VISIBILITY_VALUES.has(cleanText(frontmatter.visibility, 20).toLowerCase()) + ? cleanText(frontmatter.visibility, 20).toLowerCase() + : "user"; + return { + id, + slug: slugify(id), + title, + scope: KNOWLEDGE_SCOPES.includes(scope) ? scope : "core", + status, + priority: Number.isFinite(Number(frontmatter.priority)) ? Number(frontmatter.priority) : 0, + visibility, + category: cleanText(frontmatter.category || frontmatter.scope || scope, 120), + tags: splitList(frontmatter.tags), + generated: Boolean(frontmatter.generated), + editable: frontmatter.editable === undefined ? scope === "community" || scope === "corrections" : Boolean(frontmatter.editable), + created_at: cleanText(frontmatter.created_at, 80), + updated_at: cleanText(frontmatter.updated_at, 80), + frontmatter: normalizeFrontmatterValues(frontmatter) + }; +} + +function chunkMarkdown(body, metadata) { + const lines = String(body || "").split("\n"); + const chunks = []; + let current = { heading: metadata.title, level: 1, lines: [] }; + const flush = () => { + const text = cleanText(current.lines.join("\n"), 8000); + if (!text) return; + chunks.push({ + id: `${metadata.id}#${slugify(current.heading || "section")}`, + heading: cleanText(current.heading || metadata.title, 180), + level: current.level, + text + }); + }; + for (const line of lines) { + const heading = line.match(/^(#{1,6})\s+(.+)$/); + if (heading) { + flush(); + current = { + heading: cleanText(heading[2], 180), + level: heading[1].length, + lines: [] + }; + continue; + } + current.lines.push(line); + } + flush(); + if (!chunks.length && body.trim()) { + chunks.push({ + id: `${metadata.id}#body`, + heading: metadata.title, + level: 1, + text: cleanText(body, 8000) + }); + } + return chunks; +} + +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 excerpt = excerptForChunk(chunk.text, queryTokens); + return { + id: entry.id, + slug: entry.slug, + title: entry.title, + category: entry.category, + visibility: entry.visibility, + summary: excerpt, + facts: chunk.text.slice(0, 4000), + priority: SCOPE_PRIORITY[entry.scope] + Number(entry.priority || 0), + score, + matched: overlap > 0, + source: `knowledge/${chunk.path}${chunk.heading ? `#${slugify(chunk.heading)}` : ""}`, + source_metadata: { + path: `knowledge/${chunk.path}`, + id: entry.id, + heading: chunk.heading, + score, + excerpt + } + }; +} + +function canSeeKnowledgeEntry(entry, access) { + if (!entry) return false; + if (!ACTIVE_STATUSES.has(entry.status) && !access.isAdmin) return false; + if (entry.visibility === "admin") return access.isAdmin; + if (entry.visibility === "mod") return access.isMod || access.isAdmin; + return true; +} + +function accessForUser(user) { + return { + authenticated: Boolean(user), + isAdmin: Boolean(user?.isAdmin), + isMod: Boolean(user?.isAdmin || user?.isMod) + }; +} + +function scanMarkdownFiles(dir, output) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith(".")) continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + scanMarkdownFiles(fullPath, output); + } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) { + output.push(fullPath); + } + } +} + +function excerptForChunk(text, queryTokens) { + const cleaned = cleanText(text, 1200); + if (!queryTokens.size) return cleaned.slice(0, 360); + const lower = cleaned.toLowerCase(); + const token = Array.from(queryTokens).find((item) => lower.includes(item)); + if (!token) return cleaned.slice(0, 360); + const index = Math.max(0, lower.indexOf(token) - 120); + return cleaned.slice(index, index + 420).trim(); +} + +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); + } + return tokens; +} + +function intersectionSize(a, b) { + let count = 0; + for (const item of a) { + if (b.has(item)) count += 1; + } + return count; +} + +function normalizeFrontmatterValues(frontmatter = {}) { + const out = {}; + for (const [key, value] of Object.entries(frontmatter)) { + const normalizedKey = cleanText(key, 80).replace(/[^A-Za-z0-9_-]/g, "_"); + const normalizedValue = placeholderValue(value); + if (normalizedKey && normalizedValue) out[normalizedKey] = normalizedValue; + } + return out; +} + +function placeholderValue(value) { + if (Array.isArray(value)) { + return value.map((item) => cleanText(item, 240)).filter(Boolean).join(", "); + } + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return cleanText(value, 1000); +} + +function visibilityRole(value) { + const visibility = VISIBILITY_VALUES.has(value) ? value : "user"; + return visibility === "admin" ? "admin" : visibility === "mod" ? "mod" : "user"; +} + +function visibilitySensitivity(value) { + const visibility = VISIBILITY_VALUES.has(value) ? value : "user"; + return visibility === "admin" ? "admin" : visibility === "mod" ? "moderator" : "public_safe"; +} + +function splitList(value) { + if (Array.isArray(value)) return value.map((item) => cleanText(item, 120)).filter(Boolean).slice(0, 50); + return String(value || "") + .split(",") + .map((item) => cleanText(item, 120)) + .filter(Boolean) + .slice(0, 50); +} + +function titleFromPath(filePath) { + return path.basename(filePath, path.extname(filePath)).replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function slugify(value) { + return cleanText(value, 180) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "entry"; +} + +function normalizePath(value) { + return String(value || "").replace(/\\/g, "/"); +} + +function cleanText(value, maximum = 4000) { + return String(value || "").replace(/\r\n?/g, "\n").trim().slice(0, maximum); +} + +module.exports = { + KNOWLEDGE_SCOPES, + ensureKnowledgeDirs, + getCommunityKnowledgeFile, + knowledgeRoot, + listKnowledgeFiles, + listCommunityKnowledgeFiles, + listKnowledgePlaceholders, + loadKnowledgeEntries, + migrateSingleBracePlaceholders, + parseKnowledgeFile, + registerKnowledgePlaceholderDefinitions, + saveCorrectionKnowledgeFile, + saveCommunityKnowledgeFile, + searchFileKnowledge +}; diff --git a/plugins/okf/backend/generate_knowledge.js b/plugins/okf/backend/generate_knowledge.js new file mode 100644 index 0000000..4f86d24 --- /dev/null +++ b/plugins/okf/backend/generate_knowledge.js @@ -0,0 +1,519 @@ +const fs = require("fs"); +const path = require("path"); + +const { ensureKnowledgeDirs, knowledgeRoot } = require("./file_knowledge"); + +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); + 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); + if (writeGeneratedFile(pluginFile, pluginMarkdown)) { + written.push(path.relative(rootDir, pluginFile)); + } + } + return written; +} + +function buildCoreKnowledge(rootDir, generatedAt) { + const packageJson = readJson(path.join(rootDir, "package.json")); + const readme = readText(path.join(rootDir, "README.md")); + const routes = discoverCoreRoutes(rootDir); + const routeReference = buildRouteReference(routes); + return frontmatter({ + id: "core.lumi", + title: packageJson.name || "Lumi Core", + scope: "core", + status: "active", + priority: 20, + visibility: "user", + category: "Core", + tags: "core, routes, commands, settings", + generated: true, + editable: false, + updated_at: generatedAt + }) + [ + `# ${packageJson.name || "Lumi Core"}`, + "", + packageJson.description || "Lumi is the core web UI and bot runtime.", + "", + "## Runtime", + "", + `Package: ${packageJson.name || "unknown"}`, + packageJson.version ? `Version: ${packageJson.version}` : "", + packageJson.main ? `Entry point: ${packageJson.main}` : "", + "", + "## Routes", + "", + routes.length ? routes.map((route) => `- ${route.method.toUpperCase()} ${route.path}`).join("\n") : "- No core routes detected.", + "", + "## Route Reference", + "", + routeReference || "No route details detected.", + "", + "## README Summary", + "", + excerpt(readme, 1800) || "No README summary available." + ].filter((line) => line !== "").join("\n"); +} + +function buildPluginKnowledge(plugin, generatedAt) { + const routeLines = plugin.routes.length + ? plugin.routes.map((route) => `- ${route.method ? `${route.method.toUpperCase()} ` : ""}${route.path}`).join("\n") + : "- No plugin routes detected."; + const routeReference = buildRouteReference(plugin.routes); + const commandLines = plugin.commands.length + ? plugin.commands.map((command) => `- ${command}`).join("\n") + : "- No plugin command triggers detected."; + return frontmatter({ + id: `plugin.${plugin.id}`, + title: plugin.name || plugin.id, + scope: "plugins", + status: "active", + priority: 10, + visibility: "user", + category: "Plugin", + tags: ["plugin", plugin.id, ...(plugin.keywords || [])].join(", "), + generated: true, + editable: false, + updated_at: generatedAt + }) + [ + `# ${plugin.name || plugin.id}`, + "", + plugin.description || "No plugin description is available.", + "", + "## Metadata", + "", + `Plugin ID: ${plugin.id}`, + plugin.version ? `Version: ${plugin.version}` : "", + plugin.author ? `Author: ${plugin.author}` : "", + plugin.enabled === false ? "Default state: disabled" : "Default state: enabled", + "", + "## Web Routes", + "", + routeLines, + "", + "## Route Reference", + "", + routeReference || "No route details detected.", + "", + "## Commands", + "", + commandLines, + "", + "## Source", + "", + `Plugin folder: plugins/${plugin.folder}` + ].filter((line) => line !== "").join("\n"); +} + +function discoverPlugins(rootDir) { + const pluginsDir = path.join(rootDir, "plugins"); + if (!fs.existsSync(pluginsDir)) return []; + return fs.readdirSync(pluginsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const dir = path.join(pluginsDir, entry.name); + const manifest = readJson(path.join(dir, "plugin.json")); + if (!manifest.id && !manifest.name) return null; + const source = readText(path.join(dir, "index.js")); + return { + id: cleanText(manifest.id || entry.name, 120), + folder: entry.name, + name: cleanText(manifest.name || manifest.label || manifest.id || entry.name, 180), + version: cleanText(manifest.version, 80), + description: cleanText(manifest.description, 800), + author: cleanText(manifest.author, 180), + enabled: manifest.enabled, + keywords: Array.isArray(manifest.keywords) ? manifest.keywords.map((item) => cleanText(item, 80)).filter(Boolean) : [], + routes: discoverPluginRoutes(source, manifest.id || entry.name), + commands: discoverCommandTriggers(source) + }; + }) + .filter(Boolean) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +function discoverCoreRoutes(rootDir) { + const server = readText(path.join(rootDir, "src", "web", "server.js")); + const routes = []; + for (const match of server.matchAll(/\bapp\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g)) { + const source = extractCallExpression(server, match.index); + routes.push({ + method: match[1], + path: match[2], + details: analyzeRouteSource(source, match[1], match[2]) + }); + } + return uniqueRoutes(routes).slice(0, 200); +} + +function discoverPluginRoutes(source, pluginId) { + const routes = []; + const mountPaths = discoverMountPaths(source, pluginId); + for (const match of source.matchAll(/\bweb\.mount\(\s*["'`]([^"'`]+)["'`]/g)) { + routes.push({ + method: "", + path: normalizeTemplatePath(match[1], pluginId), + details: { + purpose: "Mounts the plugin router at this base WebUI path.", + inputs: [], + response: "Plugin router mount point.", + access: "Access is controlled by the mount options and individual plugin routes.", + sideEffects: "No direct route action; child routes handle requests.", + limits: "Mount metadata is inferred from static source scanning." + } + }); + } + for (const match of source.matchAll(/\brouter\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g)) { + const sourceSnippet = extractCallExpression(source, match.index); + const routePath = normalizeTemplatePath(match[2], pluginId); + const bases = mountPaths.length ? mountPaths : [`/plugins/${pluginId}`]; + for (const base of bases) { + const fullPath = joinRoutePath(base, routePath); + routes.push({ + method: match[1], + path: fullPath, + details: analyzeRouteSource(sourceSnippet, match[1], fullPath) + }); + } + } + return uniqueRoutes(routes).slice(0, 100); +} + +function discoverMountPaths(source, pluginId) { + const paths = []; + for (const match of source.matchAll(/\bweb\.mount\(\s*["'`]([^"'`]+)["'`]/g)) { + paths.push(normalizeTemplatePath(match[1], pluginId)); + } + return Array.from(new Set(paths)).filter(Boolean); +} + +function normalizeTemplatePath(value, pluginId) { + return String(value || "").replace(/\$\{PLUGIN_ID\}/g, pluginId); +} + +function joinRoutePath(base, child) { + const normalizedBase = String(base || "").replace(/\/+$/, "") || "/"; + const normalizedChild = String(child || "").replace(/^\/+/, ""); + if (!normalizedChild) return normalizedBase; + if (normalizedChild === "/") return normalizedBase; + return `${normalizedBase}/${normalizedChild}`.replace(/\/+/g, "/"); +} + +function discoverCommandTriggers(source) { + const triggers = new Set(); + for (const match of source.matchAll(/\btrigger\s*:\s*["'`]([^"'`]+)["'`]/g)) { + triggers.add(match[1]); + } + for (const match of source.matchAll(/\bcommand\s*:\s*["'`]([^"'`]+)["'`]/g)) { + triggers.add(match[1]); + } + return Array.from(triggers).sort().slice(0, 100); +} + +function writeGeneratedFile(filePath, content) { + if (!canOverwriteGeneratedFile(filePath)) return false; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${content.trim()}\n`); + return true; +} + +function canOverwriteGeneratedFile(filePath) { + if (!fs.existsSync(filePath)) return true; + const text = readText(filePath); + if (/generated\s*:\s*false/i.test(text)) return false; + if (/editable\s*:\s*true/i.test(text)) return false; + return true; +} + +function uniqueRoutes(routes) { + const seen = new Set(); + return routes.filter((route) => { + const key = `${route.method}:${route.path}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function buildRouteReference(routes) { + if (!routes.length) return ""; + return routes.map((route) => { + const details = route.details || analyzeRouteSource("", route.method, route.path); + return [ + `### ${route.method ? route.method.toUpperCase() : "MOUNT"} ${route.path}`, + "", + `- Purpose: ${details.purpose}`, + `- Inputs: ${details.inputs.length ? details.inputs.join("; ") : "No request parameters detected by static analysis."}`, + `- Response format: ${details.response}`, + `- Access: ${details.access}`, + `- Side effects: ${details.sideEffects}`, + `- Limits/notes: ${details.limits}` + ].join("\n"); + }).join("\n\n"); +} + +function analyzeRouteSource(source, method, routePath) { + const bodyFields = findRequestFields(source, "body"); + const queryFields = findRequestFields(source, "query"); + const paramFields = Array.from(new Set([ + ...extractPathParams(routePath), + ...findRequestFields(source, "params") + ])).sort(); + const inputs = []; + if (paramFields.length) inputs.push(`path params: ${paramFields.map((field) => `\`${field}\``).join(", ")}`); + if (queryFields.length) inputs.push(`query: ${queryFields.map((field) => `\`${field}\``).join(", ")}`); + if (bodyFields.length) inputs.push(`body: ${bodyFields.map((field) => `\`${field}\``).join(", ")}`); + if (/\breq\.file\b|\breq\.files\b|\bupload\.(single|array|fields)\(/.test(source)) { + inputs.push("file upload: multipart form file data"); + } + if (!bodyFields.length && /\breq\.body\b/.test(source)) { + inputs.push("body: full submitted body is passed to a helper; exact fields are defined by the matching form/service"); + } + if (!queryFields.length && /\breq\.query\b/.test(source)) { + inputs.push("query: full query object is passed to a helper; exact fields are defined by the matching view/service"); + } + if (!paramFields.length && /\breq\.params\b/.test(source)) { + inputs.push("path params: full params object is used; exact keys come from the route path"); + } + const response = describeResponseFormat(source, method, routePath); + return { + purpose: describeRoutePurpose(source, method, routePath, response), + inputs, + response, + access: describeAccess(source, routePath), + sideEffects: describeSideEffects(source, method, bodyFields), + limits: describeRouteLimits(source, method, routePath) + }; +} + +function findRequestFields(source, bucket) { + const fields = new Set(); + const dotPattern = new RegExp(`\\breq\\.${bucket}\\.([A-Za-z_$][A-Za-z0-9_$]*)`, "g"); + for (const match of source.matchAll(dotPattern)) { + fields.add(match[1]); + } + const bracketPattern = new RegExp(`\\breq\\.${bucket}\\s*\\[\\s*["'\`]([^"'\`]+)["'\`]\\s*\\]`, "g"); + for (const match of source.matchAll(bracketPattern)) { + fields.add(match[1]); + } + return Array.from(fields).sort().slice(0, 40); +} + +function extractPathParams(routePath) { + const fields = new Set(); + for (const match of String(routePath || "").matchAll(/:([A-Za-z0-9_]+)/g)) { + fields.add(match[1]); + } + return Array.from(fields); +} + +function describeResponseFormat(source, method, routePath) { + const formats = []; + if (/\bres\.render\(/.test(source)) formats.push("HTML page rendered from an EJS view"); + if (/\brenderAdmin\(/.test(source)) formats.push("HTML page rendered by a helper"); + if (/\bres\.json\(/.test(source)) formats.push("JSON response"); + if (/\bres\.redirect\(/.test(source)) formats.push("HTTP redirect after handling the request"); + if (/\bres\.download\(/.test(source)) formats.push("file download"); + if (/\bres\.sendFile\(/.test(source)) formats.push("static file response"); + if (/\bres\.write\(/.test(source) || /text\/event-stream|subscribeWebEvents/.test(source)) formats.push("streaming event response"); + if (/\bres\.send\(/.test(source) && !formats.length) formats.push("plain or HTML response"); + if (!formats.length && !method) return "Router mount; child routes provide concrete responses."; + if (!formats.length && String(routePath).startsWith("/api/")) return "API response; exact schema was not detected statically."; + if (!formats.length && method === "get") return "HTML or data response; exact format was not detected statically."; + if (!formats.length) return "Form/action response; exact format was not detected statically."; + return Array.from(new Set(formats)).join("; "); +} + +function describeRoutePurpose(source, method, routePath, response) { + const pathText = String(routePath || ""); + const normalized = pathText.replace(/[:/._-]+/g, " ").trim(); + if (!method) return "Registers a plugin route namespace."; + if (/\/api\/events/.test(pathText)) return "Streams live WebUI event notifications to the browser."; + const pluginAdminMatch = pathText.match(/^\/plugins\/([^/]+)\/admin(?:\/(.+))?$/); + if (pluginAdminMatch) { + const pluginId = pluginAdminMatch[1]; + const action = pluginAdminMatch[2] ? pluginAdminMatch[2].replace(/[:/._-]+/g, " ").trim() : ""; + if (method === "get") return `Renders the ${pluginId} plugin administration page${action ? ` for ${action}` : ""}.`; + return `Processes the ${pluginId} plugin administration action${action ? ` for ${action}` : ""}.`; + } + const pluginPageMatch = pathText.match(/^\/plugins\/([^/]+)(?:\/(.+))?$/); + if (pluginPageMatch) { + const pluginId = pluginPageMatch[1]; + const action = pluginPageMatch[2] ? pluginPageMatch[2].replace(/[:/._-]+/g, " ").trim() : ""; + if (method === "get") return `Renders or serves the ${pluginId} plugin page${action ? ` for ${action}` : ""}.`; + return `Processes the ${pluginId} plugin action${action ? ` for ${action}` : ""}.`; + } + if (/\/api\/users\/search/.test(pathText)) return "Searches known linked Lumi users for shared user-lookup fields."; + if (/\/api\/placeholders\/catalog/.test(pathText)) return "Returns placeholder suggestions available to the current user and field policy."; + if (/\/api\/placeholders\/preview/.test(pathText)) return "Previews placeholder rendering for a submitted template without saving it."; + if (/\/feedback/.test(pathText) && method === "post") return "Creates, updates, comments on, exports, or manages feedback records depending on the action path."; + if (/\/admin\/updates/.test(pathText)) return "Checks, applies, reverts, or reports update state for core or plugin updates."; + if (/\/admin\/settings/.test(pathText)) return method === "get" ? "Renders the core settings page." : "Saves core settings from the admin settings form."; + if (/\/auth\//.test(pathText)) return "Starts, completes, or cancels a platform authentication/linking flow."; + if (/\/setup/.test(pathText)) return "Guides first-time platform setup and verification."; + if (/\/commands/.test(pathText)) return method === "get" ? "Displays configured bot commands." : "Creates, updates, previews, toggles, or deletes custom commands."; + if (/^\/admin\/plugins/.test(pathText)) return "Manages installed plugins, plugin uploads, installs, updates, or plugin enablement."; + if (/\/plugins/.test(pathText)) return "Displays or handles plugin-specific WebUI functionality."; + if (/\/health/.test(pathText)) return "Returns runtime health information."; + if (/\/logs/.test(pathText)) return "Displays, downloads, or manages application logs."; + if (/\/stats/.test(pathText)) return "Displays user or community statistics."; + if (/\/leaderboards/.test(pathText)) return "Displays leaderboard data."; + if (/\bres\.render\(/.test(source)) return `Renders the ${normalized || "requested"} WebUI page.`; + if (/\bres\.json\(/.test(source)) return `Provides ${normalized || "route"} data as JSON.`; + if (method === "post") return `Processes the ${normalized || "route"} action and stores or applies submitted form data.`; + return `Handles ${normalized || "this route"}.`; +} + +function describeAccess(source, routePath) { + const signals = []; + if (/\brequireOkf(Edit|Management)\b/.test(source)) { + signals.push("OKF editor or manager permission required"); + } + if (/\brequireOkfReview\b|\brequireOkfImplement\b/.test(source)) { + signals.push("higher OKF review or implementation permission may be required"); + } + if (/\brequireAdmin\b|\.isAdmin\b|hasAccess\([^)]*admin/.test(source) || /\/admin(\/|$)/.test(routePath)) { + signals.push("admin access expected"); + } + if (/\brequireMod\b|\.isMod\b|hasAccess\([^)]*mod/.test(source) || /\/moderator(\/|$)/.test(routePath)) { + signals.push("moderator access may be required"); + } + if (/\brequire(Login|Auth)\b|\breq\.session\.user\b|authRequired/.test(source)) { + signals.push("logged-in session required or used"); + } + if (/\bcanAccess\b/.test(source)) { + signals.push("mount-level canAccess predicate applies"); + } + if (!signals.length && /^\/api\//.test(routePath)) return "API route; access requirements were not fully detected by static analysis."; + if (!signals.length) return "No explicit access guard detected in the route handler; check surrounding router/mount middleware."; + return Array.from(new Set(signals)).join("; "); +} + +function describeSideEffects(source, method, bodyFields) { + const effects = []; + if (/\bset[A-Z]|\bsetSetting\b|\bsave[A-Z]|\bupdate[A-Z]|\bcreate[A-Z]|\bdelete[A-Z]|\bremove[A-Z]|\binstall[A-Z]|\bapply[A-Z]|\brevert[A-Z]|\brestore[A-Z]|\bgrant[A-Z]|\brevoke[A-Z]/.test(source)) { + effects.push("writes or mutates server-side state"); + } + if (/\bfs\.(writeFileSync|rmSync|mkdirSync|renameSync|copyFileSync)\b/.test(source)) effects.push("writes files"); + if (/\bdb\.(prepare|exec)\b|\.run\(/.test(source)) effects.push("writes database state when the called service mutates data"); + if (/\bpublishWebEvent\b|subscribeWebEvents/.test(source)) effects.push("publishes or streams live WebUI events"); + if (/\brequestRestart\b|process\.exit/.test(source)) effects.push("may restart or stop runtime processes"); + if (method === "get" && !effects.length) return "Usually read-only."; + if (method === "post" && !effects.length && bodyFields.length) return "Consumes submitted data; state mutation happens in called helpers if present."; + if (method === "post" && !effects.length) return "Action route; side effects were not detected statically."; + return effects.length ? Array.from(new Set(effects)).join("; ") : "No side effects detected statically."; +} + +function describeRouteLimits(source, method, routePath) { + const notes = ["Generated from static route source analysis; confirm exact behavior in the handler before changing integrations."]; + if (/\bcleanText\b|slice\(0,\s*\d+|limitFromBody|boundedInt|boundedNumber|clampInt/.test(source)) { + notes.push("Input length or numeric bounds are enforced by helper functions in the handler."); + } + if (/\btry\b[\s\S]*\bcatch\b/.test(source)) { + notes.push("Errors are caught and usually returned as a flash message, JSON error, or error page."); + } + if (method === "post" && !/^\/api\//.test(routePath)) { + notes.push("Most non-API POST routes are browser form submissions and usually redirect after completion."); + } + if (/\/api\//.test(routePath)) { + notes.push("API consumers should expect JSON unless the response format says otherwise."); + } + return notes.join(" "); +} + +function extractCallExpression(source, startIndex) { + const start = source.indexOf("(", startIndex); + if (start === -1) return source.slice(startIndex, startIndex + 1200); + let depth = 0; + let quote = ""; + let escaped = false; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (quote) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = ""; + } + continue; + } + if (char === "\"" || char === "'" || char === "`") { + quote = char; + continue; + } + if (char === "(") { + depth += 1; + } else if (char === ")") { + depth -= 1; + if (depth === 0) { + return source.slice(startIndex, index + 1); + } + } + } + return source.slice(startIndex, startIndex + 4000); +} + +function frontmatter(values) { + const lines = ["---"]; + for (const [key, value] of Object.entries(values)) { + if (value === "" || value === null || value === undefined) continue; + lines.push(`${key}: ${frontmatterValue(value)}`); + } + lines.push("---", ""); + return lines.join("\n"); +} + +function frontmatterValue(value) { + if (typeof value === "boolean" || typeof value === "number") return String(value); + const text = String(value).replace(/\n/g, " "); + return /[:#[\]{}"'\\]|^\s|\s$/.test(text) ? JSON.stringify(text) : text; +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return {}; + } +} + +function readText(filePath) { + try { + return fs.readFileSync(filePath, "utf8"); + } catch { + return ""; + } +} + +function excerpt(value, limit) { + return cleanText(value, limit).split("\n").filter((line) => line.trim()).slice(0, 12).join("\n"); +} + +function slugify(value) { + return cleanText(value, 180) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "entry"; +} + +function cleanText(value, maximum = 4000) { + return String(value || "").replace(/\r\n?/g, "\n").trim().slice(0, maximum); +} + +module.exports = { + buildCoreKnowledge, + buildPluginKnowledge, + discoverPlugins, + generateKnowledgeFiles +}; diff --git a/plugins/okf/index.js b/plugins/okf/index.js index 99c65ba..1eb862d 100644 --- a/plugins/okf/index.js +++ b/plugins/okf/index.js @@ -1,4 +1,5 @@ const path = require("path"); +const { getSetting, setSetting } = require("../../src/services/settings"); const { PERMISSION_LEVELS, REVIEW_STATES, @@ -19,21 +20,73 @@ const { setEntryWorkflow, updateEntry } = require("./backend/okf_store"); +const { + ensureKnowledgeDirs, + getCommunityKnowledgeFile, + listCommunityKnowledgeFiles, + loadKnowledgeEntries, + migrateSingleBracePlaceholders, + registerKnowledgePlaceholderDefinitions, + saveCommunityKnowledgeFile, + searchFileKnowledge +} = require("./backend/file_knowledge"); +const { generateKnowledgeFiles } = require("./backend/generate_knowledge"); const { renderMarkdown } = require("./backend/markdown"); +const placeholderService = require("../../src/services/placeholders"); const PLUGIN_ID = "okf"; module.exports = { id: PLUGIN_ID, - init({ web, db }) { + init({ web, db, placeholders = placeholderService }) { ensureTables(db); - const okfContextProvider = ({ message, user, limit } = {}) => - formatAiContext(searchForAi(db, message || "", user, limit || 5)); + ensureKnowledgeDirs(process.cwd()); + if (!getSetting("okf_single_brace_placeholder_migration_v1", false)) { + migrateSingleBracePlaceholders(process.cwd()); + setSetting("okf_single_brace_placeholder_migration_v1", true); + } + generateKnowledgeFiles(process.cwd()); + registerOkfPlaceholderFields(placeholders); + registerKnowledgePlaceholderDefinitions(placeholders, { rootDir: process.cwd() }); + const searchOkfForAi = ({ query = "", user, limit = 5, okf_retrieval = "light" } = {}) => { + if (okf_retrieval === "none" || Number(limit) === 0) return []; + const normalizedLimit = Math.max(1, Math.min(Number(limit) || 5, 25)); + const fileResults = searchFileKnowledge({ query, user, limit: normalizedLimit, rootDir: process.cwd() }); + const dbResults = searchForAi(db, query || "", user, normalizedLimit); + const seen = new Set(fileResults.map((entry) => entry.source || entry.id)); + return [ + ...fileResults, + ...dbResults.filter((entry) => { + const key = entry.source || entry.id; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + ].slice(0, normalizedLimit); + }; + const okfContextProvider = ({ message, user, limit, okf_retrieval } = {}) => { + const normalizedLimit = limit ?? 5; + const query = message || ""; + const results = searchOkfForAi({ query, user, limit: normalizedLimit, okf_retrieval }); + return { + blocks: formatAiContext(results), + diagnostics: { + provider: "okf", + kind: "okf", + query, + okf_retrieval: okf_retrieval || "light", + limit: normalizedLimit, + candidate_count: results.length, + returned_count: results.length, + reason: okf_retrieval === "none" || Number(normalizedLimit) === 0 ? "disabled_by_controller" : "search_complete" + } + }; + }; if (!global.lumiFrameworks) { global.lumiFrameworks = {}; } global.lumiFrameworks.okf = { - search: ({ query, user, limit } = {}) => searchForAi(db, query || "", user, limit || 5), + search: searchOkfForAi, context: okfContextProvider, accessForUser: (user) => accessForUser(db, user) }; @@ -66,25 +119,60 @@ module.exports = { renderAdmin(req, res, db); }); - router.post("/admin/entries", requireOkfEdit(db), (req, res) => { + router.post("/admin/community", requireOkfEdit(db), (req, res) => { try { - const entry = createEntry(db, req.body, req.session.user); - req.session.flash = { type: "success", message: "OKF entry created." }; - res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(entry.slug)}`); + validateOkfPlaceholderFields({ + body: req.body.body + }, req.session.user, placeholders); + const entry = saveCommunityKnowledgeFile(process.cwd(), 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`); } catch (error) { req.session.flash = { type: "error", message: error.message }; - res.redirect(`/plugins/${PLUGIN_ID}/admin`); + res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community#okf-community-files`); + } + }); + + router.post("/admin/community/:slug", requireOkfEdit(db), (req, res) => { + try { + validateOkfPlaceholderFields({ + body: req.body.body + }, req.session.user, placeholders); + const entry = saveCommunityKnowledgeFile(process.cwd(), { + ...req.body, + existing_slug: req.params.slug + }); + 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`); + } catch (error) { + req.session.flash = { type: "error", message: error.message }; + res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=community&community=${encodeURIComponent(req.params.slug)}#okf-community-files`); + } + }); + + router.post("/admin/entries", requireOkfEdit(db), (req, res) => { + try { + validateOkfPlaceholderFields(req.body, req.session.user, placeholders); + const entry = createEntry(db, req.body, req.session.user); + req.session.flash = { type: "success", message: "OKF entry created." }; + res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(entry.slug)}`); + } catch (error) { + req.session.flash = { type: "error", message: error.message }; + res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general`); } }); router.post("/admin/entries/:slug", requireOkfEdit(db), (req, res) => { try { + validateOkfPlaceholderFields(req.body, req.session.user, placeholders); const entry = updateEntry(db, req.params.slug, req.body, req.session.user); req.session.flash = { type: "success", message: "OKF entry updated." }; - res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(entry.slug)}`); + res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(entry.slug)}`); } catch (error) { req.session.flash = { type: "error", message: error.message }; - res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(req.params.slug)}`); + res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(req.params.slug)}`); } }); @@ -92,10 +180,10 @@ module.exports = { try { const entry = setEntryWorkflow(db, req.params.slug, req.params.action, req.session.user, req.body.note || ""); req.session.flash = { type: "success", message: "OKF workflow updated." }; - res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(entry.slug)}`); + res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(entry.slug)}`); } catch (error) { req.session.flash = { type: "error", message: error.message }; - res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(req.params.slug)}`); + res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(req.params.slug)}`); } }); @@ -111,10 +199,10 @@ module.exports = { req.body.note || "" ); req.session.flash = { type: "success", message: "OKF version restored." }; - res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(entry.slug)}`); + res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(entry.slug)}`); } catch (error) { req.session.flash = { type: "error", message: error.message }; - res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(req.params.slug)}`); + res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general&edit=${encodeURIComponent(req.params.slug)}`); } }); @@ -125,7 +213,7 @@ module.exports = { } catch (error) { req.session.flash = { type: "error", message: error.message }; } - res.redirect(`/plugins/${PLUGIN_ID}/admin#okf-permissions`); + res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general#okf-permissions`); }); router.post("/admin/permissions/:id/revoke", requireAdmin, (req, res) => { @@ -135,11 +223,15 @@ module.exports = { } catch (error) { req.session.flash = { type: "error", message: error.message }; } - res.redirect(`/plugins/${PLUGIN_ID}/admin#okf-permissions`); + res.redirect(`/plugins/${PLUGIN_ID}/admin?tab=general#okf-permissions`); }); - router.get("/:slug", requireLogin, (req, res) => { - const entry = getEntryBySlug(db, req.params.slug, req.session.user); + router.get("/:slug", requireLogin, async (req, res) => { + const entry = await resolveOkfEntryPlaceholders( + getEntryBySlug(db, req.params.slug, req.session.user), + req.session.user, + placeholders + ); if (!entry) { return res.status(404).render("error", { title: "Knowledge entry unavailable", @@ -179,16 +271,128 @@ module.exports = { function formatAiContext(entries) { if (!entries.length) return []; - return entries.map((entry) => [ + const prepared = preferSpecificKnowledgeChunks(entries).slice(0, 3); + const blocks = []; + let remaining = 4200; + for (const entry of prepared) { + const facts = trimForContext(entry.facts, Math.min(1400, remaining)); + const block = [ `OKF entry: ${entry.title}`, `Source: ${entry.source}`, + entry.source_metadata ? `Source metadata: ${formatSourceMetadata(entry.source_metadata)}` : "", entry.category ? `Category: ${entry.category}` : "", - entry.summary ? `Summary: ${entry.summary}` : "", - entry.facts ? `Facts:\n${entry.facts}` : "" - ].filter(Boolean).join("\n")); + entry.summary ? `Summary: ${trimForContext(entry.summary, 360)}` : "", + facts ? `Facts:\n${facts}` : "" + ].filter(Boolean).join("\n"); + if (!block || remaining <= 0) continue; + const trimmedBlock = trimForContext(block, remaining); + blocks.push(trimmedBlock); + remaining -= trimmedBlock.length + 2; + } + return blocks; +} + +function preferSpecificKnowledgeChunks(entries) { + const hasSpecificRoute = entries.some((entry) => { + const heading = String(entry.source_metadata?.heading || ""); + return /^(GET|POST|PUT|PATCH|DELETE|MOUNT)\s+\//i.test(heading); + }); + if (!hasSpecificRoute) return entries; + return entries.filter((entry) => { + const heading = String(entry.source_metadata?.heading || ""); + return !["Routes", "Web Routes", "Route Reference"].includes(heading); + }); +} + +function trimForContext(value, maximum) { + const text = String(value || "").trim(); + const limit = Math.max(0, Number(maximum) || 0); + if (!text || !limit) return ""; + if (text.length <= limit) return text; + return `${text.slice(0, Math.max(0, limit - 24)).trimEnd()}\n[truncated]`; +} + +function formatSourceMetadata(metadata = {}) { + return [ + metadata.path ? `path=${metadata.path}` : "", + metadata.id ? `id=${metadata.id}` : "", + metadata.heading ? `heading=${metadata.heading}` : "", + Number.isFinite(Number(metadata.score)) ? `score=${Math.round(Number(metadata.score))}` : "", + metadata.excerpt ? `excerpt=${String(metadata.excerpt).slice(0, 240)}` : "" + ].filter(Boolean).join("; "); +} + +function registerOkfPlaceholderFields(placeholders) { + if (!placeholders?.registerFieldPolicy) return; + placeholders.registerFieldPolicy({ + field_id: "okf.markdown", + label: "OKF Markdown", + field_type: "okf_markdown", + output_audience: "user", + min_editor_role: "user", + allowed_namespaces: [], + max_sensitivity: "admin", + description: "Role-gated OKF Markdown content." + }); +} + +function validateOkfPlaceholderFields(values, user, placeholders) { + if (!placeholders?.validateTemplate) return; + const checks = [ + ["user_markdown", "user"], + ["moderator_markdown", "mod"], + ["admin_markdown", "admin"], + ["ai_facts_markdown", "admin"], + ["body", "user"] + ]; + for (const [field, outputAudience] of checks) { + if (values[field] === undefined) continue; + const result = placeholders.validateTemplate({ + fieldId: "okf.markdown", + template: values[field], + outputAudience, + user + }); + if (!result.ok) { + const tokens = result.errors + .map((error) => error.token || error.id || error.reason) + .filter(Boolean) + .slice(0, 5) + .join(", "); + throw new Error(`Unavailable or unauthorized OKF placeholder${tokens ? `: ${tokens}` : "."}`); + } + } +} + +async function resolveOkfEntryPlaceholders(entry, user, placeholders) { + if (!entry || !placeholders?.renderTemplate) return entry; + const resolved = { ...entry }; + const fields = [ + ["user_markdown", "user"], + ["moderator_markdown", "mod"], + ["admin_markdown", "admin"], + ["ai_facts_markdown", "admin"] + ]; + for (const [field, outputAudience] of fields) { + if (!resolved[field]) continue; + const result = await placeholders.renderTemplate({ + fieldId: "okf.markdown", + template: resolved[field], + outputAudience, + user, + runtimeContext: { + runtime: true, + okf: { entry: resolved } + }, + fallback: "[unavailable]" + }); + resolved[field] = result.rendered; + } + return resolved; } function renderAdmin(req, res, db) { + const activeTab = resolveAdminTab(req.query); const filters = { q: req.query.q || "", status: req.query.status || "", @@ -196,25 +400,65 @@ function renderAdmin(req, res, db) { tag: req.query.tag || "" }; 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 selectedSystemFile = req.query.system ? findSystemKnowledgeFile(systemFiles, req.query.system) : null; + const suggestionEntries = [...allEntries, ...communityFiles]; const versions = selected ? listVersions(db, selected.id) : []; res.render(view("admin"), { title: "OKF Management", + activeTab, entries, filters, selected, + communityFiles, + selectedCommunity, + systemFiles, + selectedSystemFile, versions, permissions: listPermissions(db), levels: PERMISSION_LEVELS, statuses: STATUS_VALUES, visibilityValues: VISIBILITY_VALUES, reviewStates: REVIEW_STATES, - categories: categoriesFor(entries), - tags: tagsFor(entries), + categories: categoriesFor(suggestionEntries), + tags: tagsFor(suggestionEntries), renderMarkdown }); } +function resolveAdminTab(query = {}) { + 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"; +} + +function listSystemKnowledgeFiles(rootDir = process.cwd()) { + return loadKnowledgeEntries(rootDir, { includeHidden: true }) + .filter((entry) => ["core", "plugins"].includes(entry.scope) && (entry.generated || !entry.editable)) + .map((entry) => ({ + ...entry, + slug: entry.file_slug, + entry_slug: entry.slug, + path: `knowledge/${entry.path}` + })) + .sort((a, b) => a.scope.localeCompare(b.scope) || a.title.localeCompare(b.title)); +} + +function findSystemKnowledgeFile(files, key) { + const value = String(key || ""); + return files.find((file) => + file.slug === value || + file.entry_slug === value || + file.id === value || + file.path === value + ) || null; +} + function view(name) { return path.join(__dirname, "views", `${name}.ejs`); } diff --git a/plugins/okf/tests/verify.js b/plugins/okf/tests/verify.js index f18a298..b0bfe5e 100644 --- a/plugins/okf/tests/verify.js +++ b/plugins/okf/tests/verify.js @@ -1,5 +1,9 @@ const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); const Database = require("better-sqlite3"); +const { PRESERVED_PATHS } = require("../../../src/services/update-repository"); const { accessForUser, createEntry, @@ -15,6 +19,18 @@ const { setEntryWorkflow, updateEntry } = require("../backend/okf_store"); +const { + ensureKnowledgeDirs, + getCommunityKnowledgeFile, + listCommunityKnowledgeFiles, + loadKnowledgeEntries, + listKnowledgePlaceholders, + saveCorrectionKnowledgeFile, + saveCommunityKnowledgeFile, + migrateSingleBracePlaceholders, + searchFileKnowledge +} = require("../backend/file_knowledge"); +const { generateKnowledgeFiles } = require("../backend/generate_knowledge"); const { renderMarkdown } = require("../backend/markdown"); const db = new Database(":memory:"); @@ -39,6 +55,27 @@ const mod = { id: "mod-1", isMod: true }; const user = { id: "user-1" }; const editor = { id: "editor-1" }; +assert(PRESERVED_PATHS.includes("knowledge/community")); +assert(PRESERVED_PATHS.includes("knowledge/corrections")); +const adminTemplate = fs.readFileSync(path.join(__dirname, "..", "views", "admin.ejs"), "utf8"); +assert(adminTemplate.includes("General OKF")); +assert(adminTemplate.includes("Community OKF")); +assert(adminTemplate.includes("System-generated OKF")); +assert(adminTemplate.includes("tab=general")); +assert(adminTemplate.includes("tab=community")); +assert(adminTemplate.includes("tab=system")); +assert(adminTemplate.includes("selectedSystemFile")); +assert(adminTemplate.includes("System-generated OKF") && !adminTemplate.includes("Save system file")); +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("No community OKF files match these filters.")); +assert(adminTemplate.includes("No system-generated OKF files match these filters.")); +const okfPluginSource = fs.readFileSync(path.join(__dirname, "..", "index.js"), "utf8"); +assert(okfPluginSource.includes("preferSpecificKnowledgeChunks")); +assert(okfPluginSource.includes("trimForContext")); +assert(okfPluginSource.includes("remaining = 4200")); + grantPermission(db, { user_id: "editor-1", level: "edit", notes: "Trusted writer" }, admin); assert.equal(accessForUser(db, editor).canEdit, true); assert.equal(listPermissions(db).filter((grant) => !grant.revoked_at).length, 1); @@ -124,6 +161,251 @@ assert.equal(searchForAi(db, "admin-only economy", user).length, 0); assert(renderMarkdown(" **safe**").includes("<script>")); assert.equal(renderMarkdown("[bad](javascript:alert(1))").includes("javascript:"), false); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-okf-")); +ensureKnowledgeDirs(tempRoot); +const commandsKnowledgePath = path.join(tempRoot, "knowledge", "core", "commands.md"); +const currencyKnowledgePath = path.join(tempRoot, "knowledge", "community", "currency.md"); +const correctionKnowledgePath = path.join(tempRoot, "knowledge", "corrections", "currency.md"); +fs.writeFileSync(commandsKnowledgePath, [ + "---", + "id: core.commands", + "title: Core commands", + "scope: core", + "status: active", + "priority: 1", + "visibility: user", + "tags: commands, help", + "generated: true", + "editable: false", + "---", + "# Commands", + "Use !help to list available commands.", + "The public currency is {{community.currency.primary_name}}.", + "The admin-only reference is {{community.admin-secret.secret_value}}." +].join("\n")); +fs.writeFileSync(currencyKnowledgePath, [ + "---", + "id: community.currency", + "title: Community currency", + "scope: community", + "status: active", + "priority: 5", + "visibility: user", + "tags: currency, coins", + "primary_name: coins", + "---", + "# Coins", + "The community currency is coins, not points." +].join("\n")); +fs.writeFileSync(correctionKnowledgePath, [ + "---", + "id: correction.currency-name", + "title: Currency correction", + "scope: corrections", + "status: active", + "priority: 10", + "visibility: user", + "tags: currency", + "---", + "# Currency name", + "Always call the currency coins." +].join("\n")); +fs.writeFileSync(path.join(tempRoot, "knowledge", "community", "admin-secret.md"), [ + "---", + "id: community.admin-secret", + "title: Admin secret", + "scope: community", + "status: active", + "visibility: admin", + "secret_value: hidden-admin-context", + "---", + "# Secret", + "Only admins should retrieve this." +].join("\n")); +fs.writeFileSync(path.join(tempRoot, "knowledge", "community", "people.md"), [ + "---", + "id: community.people", + "title: Community people", + "scope: community", + "status: active", + "priority: 20", + "visibility: user", + "tags: people, identity, ookamikuntv, jenni", + "---", + "# Community people", + "## OokamiKunTV", + "OokamiKunTV is a known community contact in Lumi's local community knowledge." +].join("\n")); + +const fileEntries = loadKnowledgeEntries(tempRoot); +assert.equal(fileEntries.length, 5); +assert(fileEntries.every((item) => item.chunks.length >= 1)); +const userPlaceholders = listKnowledgePlaceholders({ user, rootDir: tempRoot, includeHidden: true }); +assert(userPlaceholders.includes("{{community.currency.primary_name}}")); +assert.equal(userPlaceholders.includes("{{community.currency.scope}}"), false); +assert.equal(userPlaceholders.includes("{{plugins.plugin-throne-wishlist.scope}}"), false); +assert.equal(userPlaceholders.includes("{{community.admin-secret.secret_value}}"), false); +const adminPlaceholders = listKnowledgePlaceholders({ user: admin, rootDir: tempRoot, includeHidden: true }); +assert(adminPlaceholders.includes("{{community.admin-secret.secret_value}}")); +const currencyResults = searchFileKnowledge({ query: "currency coins", user, rootDir: tempRoot, limit: 5 }); +assert.equal(currencyResults[0].id, "correction.currency-name"); +assert(currencyResults[0].source_metadata.path.endsWith("corrections/currency.md")); +assert(currencyResults[0].source_metadata.heading); +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")); +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]")); +const commandAdminResult = searchFileKnowledge({ query: "commands", user: admin, rootDir: tempRoot, limit: 5 })[0]; +assert(commandAdminResult.facts.includes("The admin-only reference is hidden-admin-context.")); +assert.equal(searchFileKnowledge({ query: "secret", user, rootDir: tempRoot, limit: 5 }).length, 0); +assert.equal(searchFileKnowledge({ query: "secret", user: admin, rootDir: tempRoot, limit: 5 })[0].id, "community.admin-secret"); +const savedCommunity = saveCommunityKnowledgeFile(tempRoot, { + title: "Community roles", + slug: "community-roles", + id: "community.roles", + category: "Community", + visibility: "user", + status: "active", + priority: "7", + tags: "roles, community", + body: "# Roles\nCommunity roles are documented here." +}); +assert.equal(savedCommunity.id, "community.roles"); +assert.equal(getCommunityKnowledgeFile(tempRoot, "community-roles").title, "Community roles"); +assert(listCommunityKnowledgeFiles(tempRoot).some((item) => item.slug === "community-roles")); +const renamedCommunity = saveCommunityKnowledgeFile(tempRoot, { + existing_slug: "community-roles", + title: "Community role guide", + slug: "role-guide", + id: "community.roles", + category: "Community", + visibility: "user", + status: "active", + tags: "roles", + body: "# Roles\nRenamed community role guide." +}); +assert.equal(renamedCommunity.slug, "role-guide"); +assert(fs.existsSync(path.join(tempRoot, "knowledge", "community", "role-guide.md"))); +assert.equal(fs.existsSync(path.join(tempRoot, "knowledge", "community", "community-roles.md")), false); +fs.writeFileSync(path.join(tempRoot, "knowledge", "community", "generated.md"), [ + "---", + "id: community.generated", + "title: Generated community file", + "scope: community", + "generated: true", + "editable: false", + "---", + "# Generated" +].join("\n")); +assert.throws(() => saveCommunityKnowledgeFile(tempRoot, { + existing_slug: "community-generated", + title: "Blocked", + body: "# Blocked" +}), /not editable/); +const savedCorrection = saveCorrectionKnowledgeFile(tempRoot, { + title: "Feedback correction", + slug: "feedback-correction", + source_feedback_id: "feedback-1", + source_feedback_url: "/admin/feedback?feedback=feedback-1", + visibility: "user", + priority: "120", + body: "# Feedback correction\nUse the corrected behavior from reviewed feedback." +}); +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(searchFileKnowledge({ query: "corrected behavior", user, rootDir: tempRoot, limit: 5 })[0].id, "correction.feedback-correction"); +fs.writeFileSync(currencyKnowledgePath, [ + "---", + "id: community.currency", + "title: Community currency", + "scope: community", + "status: active", + "priority: 5", + "visibility: user", + "tags: currency, coins", + "primary_name: gold stars", + "---", + "# Coins", + "The community currency is gold stars, not points." +].join("\n")); +assert(searchFileKnowledge({ query: "commands", user, rootDir: tempRoot, limit: 5 })[0].facts.includes("The public currency is gold stars.")); +fs.rmSync(correctionKnowledgePath, { force: true }); +assert.equal(searchFileKnowledge({ query: "currency coins", user, rootDir: tempRoot, limit: 5 }).some((item) => item.id === "correction.currency-name"), false); +fs.rmSync(tempRoot, { recursive: true, force: true }); + +const migrationRoot = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-okf-migrate-")); +ensureKnowledgeDirs(migrationRoot); +const legacyPlaceholderPath = path.join(migrationRoot, "knowledge", "community", "legacy.md"); +fs.writeFileSync(legacyPlaceholderPath, [ + "---", + "id: community.legacy", + "title: Legacy placeholder", + "scope: community", + "status: active", + "visibility: user", + "---", + "# Legacy", + "The old token is {community.currency.primary_name}.", + "The new token stays {{community.currency.primary_name}}." +].join("\n")); +const migratedPlaceholders = migrateSingleBracePlaceholders(migrationRoot); +assert.equal(migratedPlaceholders.changed, 1); +const migratedPlaceholderContent = fs.readFileSync(legacyPlaceholderPath, "utf8"); +assert(migratedPlaceholderContent.includes("The old token is {{community.currency.primary_name}}.")); +assert(migratedPlaceholderContent.includes("The new token stays {{community.currency.primary_name}}.")); +fs.rmSync(migrationRoot, { recursive: true, force: true }); + +const generatedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-okf-generated-")); +fs.mkdirSync(path.join(generatedRoot, "plugins", "test-plugin"), { recursive: true }); +fs.writeFileSync(path.join(generatedRoot, "package.json"), JSON.stringify({ + name: "lumi-test", + version: "1.2.3", + description: "Generated test core." +}, null, 2)); +fs.writeFileSync(path.join(generatedRoot, "README.md"), "# Lumi Test\n\nGenerated README."); +fs.mkdirSync(path.join(generatedRoot, "src", "web"), { recursive: true }); +fs.writeFileSync(path.join(generatedRoot, "src", "web", "server.js"), "app.get('/admin/test', handler);\napp.post('/api/test', handler);\n"); +fs.writeFileSync(path.join(generatedRoot, "plugins", "test-plugin", "plugin.json"), JSON.stringify({ + id: "test-plugin", + name: "Test Plugin", + version: "0.0.1", + description: "Generated plugin knowledge." +}, null, 2)); +fs.writeFileSync(path.join(generatedRoot, "plugins", "test-plugin", "index.js"), [ + "router.get('/settings', handler);", + "commandRouter.registerCommands('test-plugin', [{ trigger: 'test', description: 'Test command.' }]);" +].join("\n")); +const generated = generateKnowledgeFiles(generatedRoot); +const normalizedGenerated = generated.map((file) => file.replace(/\\/g, "/")); +assert(normalizedGenerated.some((file) => file.endsWith("knowledge/core/lumi-core.md"))); +assert(normalizedGenerated.some((file) => file.endsWith("knowledge/plugins/test-plugin.md"))); +const generatedCore = fs.readFileSync(path.join(generatedRoot, "knowledge", "core", "lumi-core.md"), "utf8"); +assert(generatedCore.includes("GET /admin/test")); +assert(generatedCore.includes("## Route Reference")); +assert(generatedCore.includes("### GET /admin/test")); +assert(generatedCore.includes("Response format:")); +const generatedPlugin = fs.readFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), "utf8"); +assert(generatedPlugin.includes("Test Plugin")); +assert(generatedPlugin.includes("### GET /plugins/test-plugin/settings")); +assert(generatedPlugin.includes("Inputs:")); +assert(generatedPlugin.includes("test")); +fs.writeFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), [ + "---", + "id: plugin.test-plugin", + "title: Manual Test Plugin", + "generated: false", + "editable: true", + "---", + "# Manual" +].join("\n")); +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); 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 a8f2247..97ce620 100644 --- a/plugins/okf/views/admin.ejs +++ b/plugins/okf/views/admin.ejs @@ -5,7 +5,14 @@ pageTitle: "OKF Management", description: "Manage role-gated knowledge entries, review state, version history, and OKF-specific editing permissions." }) %> + + <% if (activeTab === "general") { %>
+ - Reset + Reset
+ <% } else if (activeTab === "community") { %> +

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

+ <% } else { %> +

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

+ <% } %> + + <% categories.forEach((category) => { %> + + <% }) %> + + + <% tags.forEach((tag) => { %> + + <% }) %> + +<% if (activeTab === "general") { %>

Entries

<%= entries.length %> entr<%= entries.length === 1 ? "y" : "ies" %> shown.

- Open OKF +
+ + Open OKF +
<% if (!entries.length) { %>
No OKF entries match this filter.
@@ -68,7 +94,7 @@ <% entries.forEach((entry) => { %> - <%= entry.title %> + <%= entry.title %>

<%= entry.slug %> · <%= entry.category || "General" %>

<%= entry.status %> @@ -82,18 +108,189 @@ <% } %>
+<% } %> -
+<% if (activeTab === "community") { %> +
-

<%= selected ? "Edit OKF entry" : "Create OKF entry" %>

-

Markdown is sanitized before rendering. User, moderator, and admin content are filtered server-side.

+

Community OKF files

+

Edit community-specific Markdown knowledge stored under knowledge/community. Generated core and plugin files stay separate.

- <% if (selected) { %> - Create new +
+
+
+ + + + + +
+

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

+ <% if (!communityFiles.length) { %> +
No community OKF files yet.
+ <% } else { %> +
+ + + + <% communityFiles.forEach((file) => { %> + + + + + + + + <% }) %> + +
FileCategoryStatusVisibilityUpdated
<%= file.title %>

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

<%= file.category || "Community" %><%= file.status %><%= file.visibility %><%= file.updated_at ? new Date(file.updated_at).toLocaleString() : "Unknown" %>
+
+ <% } %>
-
+
+ <%= selectedCommunity ? "Edit file" : "Create file" %> + +
+ + +
+
+ + + Used as the Markdown filename. +
+
+ + +
+
+ + + Existing categories appear as suggestions. +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Use comma-separated tags. Existing tags appear as suggestions while typing. +
+
+ + + You can reference visible frontmatter values with placeholders such as {{community.currency.primary_name}}. +
+ <% if (selectedCommunity && (selectedCommunity.generated || !selectedCommunity.editable)) { %> +
+

This file is marked generated or non-editable and cannot be saved from this editor.

+
+ <% } %> +
+ + <% if (selectedCommunity) { %> + Create new file + <% } %> +
+ +
+ <% if (selectedCommunity) { %> + + <% } %> +
+<% } %> + +<% if (activeTab === "system") { %> +
+
+
+

System-generated OKF

+

Read-only generated knowledge used by AI support and scoped by user permission level.

+
+
+
+
+ + + + + +
+

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

+ <% if (!systemFiles.length) { %> +
No generated OKF files were found.
+ <% } else { %> +
+ + + + <% systemFiles.forEach((file) => { %> + + + + + + + + <% }) %> + +
FileScopeCategoryStatusVisibility
<%= file.title %>

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

<%= file.scope %><%= file.category || "General" %><%= file.status %><%= file.visibility %>
+
+ + <% } %> +
+
Role-scoped support context

User, moderator, and admin visibility still applies during retrieval. Casual requests receive support-level facts; development-focused admin requests may use deeper technical details.

+ <% if (selectedSystemFile) { %> + + <% } else if (systemFiles.length) { %> +
Select a generated OKF file to preview its current Markdown.
+ <% } %> +
+<% } %> + +<% if (selected) { %> +
+ + +<% } %> -<% if (okfAccess.canManagePermissions) { %> + + +<% if (activeTab === "general" && okfAccess.canManagePermissions) { %>
@@ -319,4 +613,120 @@ <% } %>
<% } %> + <%- include("../../../src/web/views/partials/layout-bottom") %> diff --git a/plugins/throne_wishlist/README.md b/plugins/throne_wishlist/README.md index ea13f46..52d1efb 100644 --- a/plugins/throne_wishlist/README.md +++ b/plugins/throne_wishlist/README.md @@ -50,14 +50,31 @@ prevent delivery attempts to other enabled destinations. Common placeholders: -`{contract_version}`, `{event_id}`, `{event_type}`, -`{webhook_identifier}`, `{webhook_uuid}`, `{received_at}`, -`{received_at_iso}`, `{creator_id}`, `{creator_username}`, -`{gifter_username}`, `{message}`, `{item_name}`, -`{item_thumbnail_url}`, `{price}`, `{amount}`, `{currency}`, -`{is_surprise_gift}`, `{price_display}`, `{amount_display}`, `{value}`, -`{value_display}`, and `{payload_json}`. +`{{plugin.throne_wishlist.contract_version}}`, +`{{plugin.throne_wishlist.event_id}}`, +`{{plugin.throne_wishlist.event_type}}`, +`{{plugin.throne_wishlist.webhook_identifier}}`, +`{{plugin.throne_wishlist.webhook_uuid}}`, +`{{plugin.throne_wishlist.received_at}}`, +`{{plugin.throne_wishlist.received_at_iso}}`, +`{{plugin.throne_wishlist.creator_id}}`, +`{{plugin.throne_wishlist.creator_username}}`, +`{{plugin.throne_wishlist.gifter_username}}`, +`{{plugin.throne_wishlist.message}}`, +`{{plugin.throne_wishlist.item_name}}`, +`{{plugin.throne_wishlist.item_thumbnail_url}}`, +`{{plugin.throne_wishlist.price}}`, +`{{plugin.throne_wishlist.amount}}`, +`{{plugin.throne_wishlist.currency}}`, +`{{plugin.throne_wishlist.is_surprise_gift}}`, +`{{plugin.throne_wishlist.price_display}}`, +`{{plugin.throne_wishlist.amount_display}}`, +`{{plugin.throne_wishlist.value}}`, and +`{{plugin.throne_wishlist.value_display}}`. Documented data fields are also available with a `data.` prefix, such as -`{data.item_name}`. Unknown placeholders remain unchanged in rendered messages. -The admin page warns when they are saved. +`{{plugin.throne_wishlist.data.item_name}}`. Legacy single-brace placeholders +still render for existing templates, but new templates should use the namespaced +double-brace syntax. Raw payload JSON is intentionally not available to +user-visible message templates. Unknown placeholders remain unchanged in +rendered messages. The admin page warns when they are saved. diff --git a/plugins/throne_wishlist/index.js b/plugins/throne_wishlist/index.js index 779ffc2..90afd61 100644 --- a/plugins/throne_wishlist/index.js +++ b/plugins/throne_wishlist/index.js @@ -4,6 +4,7 @@ const path = require("path"); const discord = require("discord.js"); const { log } = require("../../src/services/logger"); const { getPlatformStatus } = require("../../src/services/platforms"); +const placeholderService = require("../../src/services/placeholders"); const PLUGIN_ID = "throne_wishlist"; const NAMESPACE = "throne"; @@ -21,11 +22,11 @@ const EVENT_TYPES = [ const PLATFORMS = ["discord", "twitch", "youtube", "kick"]; const DEFAULT_TEMPLATES = { gift_purchased: - "🎁 {gifter_username} bought {item_name} from {creator_username}'s Throne wishlist!", + "🎁 {{gifter_username}} bought {{item_name}} from {{creator_username}}'s Throne wishlist!", contribution_purchased: - "💝 {gifter_username} contributed {amount_display} toward {item_name} on {creator_username}'s Throne wishlist!", + "💝 {{gifter_username}} contributed {{amount_display}} toward {{item_name}} on {{creator_username}}'s Throne wishlist!", gift_crowdfunded: - "🎉 {item_name} was fully crowdfunded on {creator_username}'s Throne wishlist!" + "🎉 {{item_name}} was fully crowdfunded on {{creator_username}}'s Throne wishlist!" }; const PLACEHOLDERS = new Set([ "contract_version", @@ -68,6 +69,7 @@ module.exports = { init({ web, webhooks, + placeholders = placeholderService, db, settings, discordClient, @@ -76,6 +78,7 @@ module.exports = { }) { ensureTables(db); ensureDefaults(db); + registerPlaceholderSupport(placeholders); const webhookApi = webhooks || global.lumiFrameworks?.webhooks || null; registerStoredEndpoints({ db, webhookApi, clients: { discordClient, twitchClient, youtubeClient } }); @@ -256,6 +259,21 @@ module.exports = { return res.redirect(`/plugins/${PLUGIN_ID}`); } const unknown = findUnknownPlaceholders(template); + const placeholderValidation = placeholders.validateTemplate({ + fieldId: "plugin.throne_wishlist.message_template", + template, + outputAudience: "user", + user: req.session.user + }); + if (!placeholderValidation.ok) { + req.session.flash = { + type: "error", + message: `Template contains unavailable or unauthorized placeholder(s): ${ + placeholderValidation.errors.map((error) => error.token || error.id || error.reason).slice(0, 5).join(", ") + }.` + }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } db.prepare( "UPDATE throne_event_message_templates SET enabled = ?, template = ?, updated_at = ? WHERE event_type = ? AND platform = ?" ).run(enabled ? 1 : 0, template, Date.now(), eventType, platform); @@ -388,6 +406,44 @@ function ensureDefaults(db) { } } +function registerPlaceholderSupport(placeholders) { + if (!placeholders?.registerFieldPolicy || !placeholders?.registerPlaceholders) return; + placeholders.registerFieldPolicy({ + field_id: "plugin.throne_wishlist.message_template", + label: "Throne Wishlist event message", + field_type: "chat_message", + output_audience: "user", + min_editor_role: "admin", + allowed_namespaces: ["plugin.throne_wishlist"], + max_sensitivity: "public_safe" + }); + placeholders.registerPlaceholders(Array.from(PLACEHOLDERS).sort().map((placeholder) => ({ + id: `plugin.throne_wishlist.${placeholder}`, + namespace: "plugin.throne_wishlist", + label: humanizePlaceholderLabel(placeholder), + description: `Throne event value: ${placeholder}.`, + value_type: placeholder === "payload_json" ? "json" : "string", + sensitivity: placeholder === "payload_json" ? "admin" : "public_safe", + min_editor_role: "admin", + min_viewer_role: "user", + allowed_field_types: ["chat_message"], + plugin_id: PLUGIN_ID, + aliases: [placeholder], + resolver: ({ runtimeContext }) => { + const tokens = runtimeContext?.throneTokens || runtimeContext?.tokens || {}; + return tokens[placeholder] ?? ""; + } + }))); +} + +function humanizePlaceholderLabel(value) { + return String(value || "") + .split(".") + .pop() + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + function registerStoredEndpoints({ db, webhookApi, clients }) { if (!webhookApi) { log("warn", "Throne Wishlist webhook framework unavailable"); @@ -579,7 +635,7 @@ async function sendPlatformMessages({ db, payload, endpoint, receivedAt, clients "WHERE t.event_type = ? AND t.enabled = 1 AND d.enabled = 1" ).all(payload.event_type); const tasks = rows.map(async (row) => { - const message = sanitizePlatformText(renderTemplate(row.template, tokens)); + const message = sanitizePlatformText(await renderTemplate(row.template, tokens)); if (!message.trim()) { throw new Error(`Rendered ${row.platform} message is empty.`); } @@ -689,12 +745,23 @@ function buildTokens({ payload, endpoint, receivedAt }) { return tokens; } -function renderTemplate(template, tokens) { - return (template || "").replace(/\{([^{}]+)\}/g, (full, key) => - Object.prototype.hasOwnProperty.call(tokens, key.trim()) - ? String(tokens[key.trim()]) - : full - ); +async function renderTemplate(template, tokens) { + const rendered = await placeholderService.renderTemplate({ + fieldId: "plugin.throne_wishlist.message_template", + template, + outputAudience: "user", + user: null, + runtimeContext: { + runtime: true, + throneTokens: tokens + } + }); + return (rendered.rendered || "").replace(/\{([^{}]+)\}/g, (full, key) => { + const normalizedKey = String(key || "").trim(); + return Object.prototype.hasOwnProperty.call(tokens, normalizedKey) + ? String(tokens[normalizedKey]) + : full; + }); } function formatMoney(value, currency) { @@ -981,15 +1048,22 @@ function ensureSidebarNavItem(settings) { function findUnknownPlaceholders(template) { const unknown = []; - for (const match of (template || "").matchAll(/\{([^{}]+)\}/g)) { - const key = match[1].trim(); - if (!PLACEHOLDERS.has(key) && !unknown.includes(`{${key}}`)) { - unknown.push(`{${key}}`); + for (const match of (template || "").matchAll(/\{\{\s*([^{}]+?)\s*\}\}|\{([^{}]+)\}/g)) { + const full = match[0]; + const key = normalizeTemplatePlaceholderKey(match[1] || match[2] || ""); + if (!PLACEHOLDERS.has(key) && !unknown.includes(full)) { + unknown.push(full); } } return unknown; } +function normalizeTemplatePlaceholderKey(value) { + const key = String(value || "").trim(); + const prefix = "plugin.throne_wishlist."; + return key.startsWith(prefix) ? key.slice(prefix.length) : key; +} + function headerValue(headers, name) { const value = headers?.[name.toLowerCase()]; return Array.isArray(value) ? value[0] : (value || "").toString(); diff --git a/plugins/throne_wishlist/views/admin.ejs b/plugins/throne_wishlist/views/admin.ejs index 8a525a3..c1acbc1 100644 --- a/plugins/throne_wishlist/views/admin.ejs +++ b/plugins/throne_wishlist/views/admin.ejs @@ -152,7 +152,7 @@
Available placeholders
- <% placeholders.forEach((placeholder) => { %>{<%= placeholder %>} <% }) %> + <% placeholders.forEach((placeholder) => { %>{{plugin.throne_wishlist.<%= placeholder %>}} <% }) %>
@@ -189,7 +189,7 @@
- +
diff --git a/scripts/verify-command-preview-confirmations.js b/scripts/verify-command-preview-confirmations.js index a235267..6c52160 100644 --- a/scripts/verify-command-preview-confirmations.js +++ b/scripts/verify-command-preview-confirmations.js @@ -132,6 +132,9 @@ async function run() { assert(appScript.includes("${confirmLabel(form, submitter)} in ${remaining}")); assert(appScript.includes('button.disabled = remaining > 0')); assert(appScript.includes('fetch("/api/destructive-confirmations"')); + assert(appScript.includes("destructiveToken: timedDestructiveToken")); + assert(appScript.includes("destructiveFetch(action")); + assert(appScript.includes('headers.set("X-Confirmation-Token", token)')); assert(appScript.includes("event.preventDefault();")); assert(appScript.includes("expiryTimer")); assert(appScript.includes("resetDestructive")); diff --git a/scripts/verify-feedback-system.js b/scripts/verify-feedback-system.js index 756f39c..fb7d719 100644 --- a/scripts/verify-feedback-system.js +++ b/scripts/verify-feedback-system.js @@ -51,6 +51,8 @@ try { user_agent: "verification-agent", viewport: "1200x800", screenshot_mode: "target", + similar_feedback_confirmation: "distinct_or_additional_context", + similar_feedback_ids: "12345678-abcd,invalid id", hidden: "must not persist" } }, { id: "user-1" }, { @@ -65,6 +67,8 @@ try { assert.equal(entry.scope_label, "Clicked element: Save settings"); assert.equal(entry.target_metadata.secret, undefined); assert.equal(entry.diagnostics.hidden, undefined); + assert.equal(entry.diagnostics.similar_feedback_confirmation, "distinct_or_additional_context"); + assert.equal(entry.diagnostics.similar_feedback_ids, "12345678-abcd"); assert.equal(entry.screenshot.mime, "image/png"); assert.equal(feedback.listPublicFeedback({ userId: "user-1" })[0].is_mine, true); assert.equal(feedback.listMyFeedback("user-1").length, 1); @@ -174,6 +178,28 @@ try { assert.equal(cleanedScreenshot, "feedback/screenshots/cleanup-shot.png"); assert.equal(cleanedAttachment, "feedback/attachments/cleanup-note.txt"); + const compactExport = feedback.buildFeedbackJobExport({ ids: [entry.id, cleanupEntry.id] }); + assert.equal(compactExport.taskfile.schema, "lumi.feedback.codex_taskfile.v1"); + assert.equal(Array.isArray(compactExport.taskfile.tasks), true); + assert.equal(compactExport.taskfile.source_feedback[0].ref, "F1"); + assert.equal(compactExport.taskfile.requirements, undefined); + assert.equal(compactExport.taskfile.context, undefined); + assert.equal(compactExport.taskfile.debug_only_feedback, undefined); + assert.equal(JSON.stringify(compactExport.taskfile).includes("http://localhost"), false); + assert(compactExport.taskfile.tasks.some((task) => task.context?.some((value) => value.includes("Likely settings form regression")))); + assert.equal(compactExport.debug_export.feedback.length, 2); + + feedback.adminUpdateFeedback(cleanupEntry.id, { + status: "closed", + category: cleanupEntry.category, + severity: cleanupEntry.severity, + status_note: "Closed for export eligibility verification." + }, { id: "admin-1" }); + const actionableOnlyExport = feedback.buildFeedbackJobExport({ ids: [entry.id, cleanupEntry.id] }); + assert.equal(actionableOnlyExport.taskfile.source_feedback.length, 1); + assert.equal(actionableOnlyExport.taskfile.source_feedback[0].id, entry.id); + assert.equal(actionableOnlyExport.debug_export.feedback.length, 2); + const feedbackView = path.join(root, "src", "web", "views", "feedback.ejs"); const adminView = path.join(root, "src", "web", "views", "admin-feedback.ejs"); const commonLocals = { @@ -231,6 +257,12 @@ try { assert(adminRendered.includes("Remove attachments")); assert(adminRendered.includes("Plugin/area")); assert(adminRendered.includes("1 also affected")); + const layoutBottom = fs.readFileSync(path.join(root, "src", "web", "views", "partials", "layout-bottom.ejs"), "utf8"); + const appScript = fs.readFileSync(path.join(root, "src", "web", "public", "app.js"), "utf8"); + assert(layoutBottom.includes("data-feedback-similar-confirm-input")); + assert(layoutBottom.includes("Mine is distinct or adds useful context")); + assert(appScript.includes("similar_feedback_confirmation")); + assert(appScript.includes("similar_feedback_confirmation_required")); let deletedScreenshot = ""; feedback.deleteFeedback(entry.id, { diff --git a/scripts/verify-placeholders.js b/scripts/verify-placeholders.js new file mode 100644 index 0000000..bdb3581 --- /dev/null +++ b/scripts/verify-placeholders.js @@ -0,0 +1,140 @@ +const assert = require("assert"); +const placeholders = require("../src/services/placeholders"); +const { getSetting, setSetting } = require("../src/services/settings"); + +const { + placeholders: placeholderMap, + fieldPolicies +} = placeholders._internals; + +placeholderMap.clear(); +fieldPolicies.clear(); +placeholders.registerCorePlaceholders(); +placeholders.registerFieldPolicy({ + field_id: "okf.markdown", + label: "OKF Markdown", + field_type: "okf_markdown", + output_audience: "user", + min_editor_role: "user", + allowed_namespaces: [], + max_sensitivity: "admin" +}); + +const user = { id: "user-1", username: "Viewer", isAdmin: false, isMod: false }; +const mod = { id: "mod-1", username: "Mod", isAdmin: false, isMod: true }; +const admin = { id: "admin-1", username: "Admin", isAdmin: true, isMod: true }; + +let catalog = placeholders.catalog({ + fieldId: "core.custom_commands.static_response", + user: mod, + outputAudience: "user" +}); +assert(catalog.placeholders.some((entry) => entry.token === "{{core.main.bot_name}}")); +assert(catalog.placeholders.some((entry) => entry.token === "{{user.public.display_name}}")); + +placeholders.registerPlaceholder({ + id: "core.main.admin_note", + namespace: "core.main", + label: "Admin note", + sensitivity: "admin", + min_editor_role: "admin", + min_viewer_role: "admin", + allowed_field_types: ["command_response"], + resolver: () => "hidden" +}); + +catalog = placeholders.catalog({ + fieldId: "core.custom_commands.static_response", + user: admin, + outputAudience: "user" +}); +assert.equal(catalog.placeholders.some((entry) => entry.token === "{{core.main.admin_note}}"), false); + +const invalid = placeholders.validateTemplate({ + fieldId: "core.custom_commands.static_response", + template: "Hello {{core.main.admin_note}}", + user: admin, + outputAudience: "user" +}); +assert.equal(invalid.ok, false); +assert.equal(invalid.errors[0].reason, "viewer_role_forbidden"); + +(async () => { + const rendered = await placeholders.renderTemplate({ + fieldId: "core.custom_commands.static_response", + template: "Hi {{user.public.display_name}}, prefix is {{core.main.command_prefix}}.", + user, + outputAudience: "user", + runtimeContext: { + runtime: true, + user: { displayName: "SammyCat" } + } + }); + assert.equal(rendered.ok, true); + assert.match(rendered.rendered, /^Hi SammyCat, prefix is .+\.$/); + + const previousGuildId = getSetting("discord_guild_id", ""); + try { + setSetting("discord_guild_id", "guild-1"); + placeholders.registerPlatformPlaceholders({ + discordClient: { + guilds: { + cache: new Map([ + ["guild-1", { + name: "Cozy Carnage", + memberCount: 42, + createdAt: new Date("2025-01-01T00:00:00.000Z"), + channels: { cache: new Map([ + ["text", { type: 0 }], + ["voice", { type: 2 }] + ]) }, + roles: { cache: new Map([["role", {}]]) }, + emojis: { cache: new Map([["emoji", {}]]) }, + premiumSubscriptionCount: 3 + }] + ]) + } + } + }); + const platformCatalog = placeholders.catalog({ + fieldId: "okf.markdown", + user: admin, + outputAudience: "user" + }); + assert(platformCatalog.placeholders.some((entry) => entry.token === "{{platform.discord.guild.member_count}}")); + + const platformRendered = await placeholders.renderTemplate({ + fieldId: "okf.markdown", + template: "Discord has {{platform.discord.guild.member_count}} members.", + user: admin, + outputAudience: "user", + runtimeContext: { runtime: true } + }); + assert.equal(platformRendered.rendered, "Discord has 42 members."); + } finally { + setSetting("discord_guild_id", previousGuildId); + } + + placeholders.registerPlaceholder({ + id: "okf.admin.secret", + namespace: "okf.admin", + label: "Admin secret", + sensitivity: "admin", + min_editor_role: "admin", + min_viewer_role: "admin", + allowed_field_types: ["okf_markdown"], + resolver: () => "secret" + }); + const userVisibleAdminPlaceholder = placeholders.validateTemplate({ + fieldId: "okf.markdown", + template: "{{okf.admin.secret}}", + user: admin, + outputAudience: "user" + }); + assert.equal(userVisibleAdminPlaceholder.ok, false); + assert.equal(userVisibleAdminPlaceholder.errors[0].reason, "viewer_role_forbidden"); + console.log("Placeholder verification passed."); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/main.js b/src/main.js index e88c220..5d40932 100644 --- a/src/main.js +++ b/src/main.js @@ -15,6 +15,7 @@ const { createCommandRouter } = require("./services/command-router"); const { registerTopCommand } = require("./services/top"); const logger = require("./services/logger"); const { isPlatformEnabled } = require("./services/platforms"); +const { registerCorePlaceholders } = require("./services/placeholders"); const { isSafeModeRequested, markStartupVerification @@ -23,6 +24,7 @@ const { async function main() { migrate(); ensureDefaults(); + registerCorePlaceholders(); logger.hookConsole(); const safeModeRequested = isSafeModeRequested(); const startupMarker = markStartupVerification(); diff --git a/src/services/command-router.js b/src/services/command-router.js index 814a655..d5d1856 100644 --- a/src/services/command-router.js +++ b/src/services/command-router.js @@ -6,6 +6,7 @@ const { normalizeCommandResult } = require("./commands"); const { getEnabledPlatformIds, normalizePlatformSelection } = require("./platforms"); +const placeholders = require("./placeholders"); function createCommandRouter({ settings }) { const commandMap = new Map(); @@ -175,7 +176,19 @@ async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) { await safeReply(reply, "Command ran but returned no output."); } } else { - await safeReply(reply, row.response); + const rendered = await placeholders.renderTemplate({ + fieldId: "core.custom_commands.static_response", + template: row.response, + outputAudience: "user", + user: { + id: ctx.user.id, + username: ctx.user.username, + isAdmin: false, + isMod: false + }, + runtimeContext: { ctx, user: ctx.user, platform, runtime: true } + }); + await safeReply(reply, rendered.rendered); } recordCommandUsage(`custom:${trigger}`); return true; diff --git a/src/services/feedback.js b/src/services/feedback.js index e2cd465..9dc87a8 100644 --- a/src/services/feedback.js +++ b/src/services/feedback.js @@ -126,6 +126,9 @@ const STATUS_HELP = Object.freeze({ deleted: "Deleted by an administrator." }); +const DEFAULT_FEEDBACK_EXPORT_TOKEN_LIMIT = 8000; +const MAX_FEEDBACK_EXPORT_TOKEN_LIMIT = 24000; + function createFeedback(input, actor, options = {}) { if (!actor?.id) { throw new Error("Feedback requires a logged-in user."); @@ -357,6 +360,204 @@ function listFeedbackForAdmin(filters = {}) { }); } +function buildFeedbackJobExport({ ids = [], filters = {}, all = false, tokenLimit } = {}) { + const limit = normalizeFeedbackExportTokenLimit(tokenLimit); + const generatedAt = new Date().toISOString(); + const requestedIds = Array.isArray(ids) + ? ids.map((id) => cleanText(id, 80)).filter(Boolean) + : []; + const entries = all + ? listFeedbackForAdmin(filters).filter(isFeedbackExportEligible) + : requestedIds + .map((id) => getFeedbackForAdmin(id)) + .filter(Boolean) + .filter(isFeedbackExportEligible); + const uniqueEntries = []; + const seen = new Set(); + for (const entry of entries) { + if (!seen.has(entry.id)) { + seen.add(entry.id); + uniqueEntries.push(entry); + } + } + if (!uniqueEntries.length) { + throw new Error("No eligible feedback items were selected for export."); + } + const exportedFeedback = uniqueEntries.map(feedbackToCodexExport); + const taskFeedback = exportedFeedback.filter(isFeedbackCodexTaskfileEligible); + const debugOnlyFeedback = exportedFeedback.filter((item) => !isFeedbackCodexTaskfileEligible(item)); + const reporterByFeedbackId = new Map(uniqueEntries.map((entry) => [entry.id, entry.submitter_id || ""])); + const debugExport = buildFeedbackDebugExport({ + exportedFeedback, + generatedAt, + tokenLimit: limit, + all + }); + return { + taskfile: buildCompactFeedbackTaskfile({ + exportedFeedback: taskFeedback, + debugOnlyFeedback, + generatedAt, + reporterByFeedbackId + }), + debug_export: debugExport + }; +} + +function buildFeedbackDebugExport({ exportedFeedback, generatedAt, tokenLimit, all }) { + const primary = exportedFeedback[0]; + return { + schema: "lumi.feedback.codex_job.v1", + trigger_phrase: "internal-feedback-to-codex-taskfile", + generated_at: generatedAt, + token_context_limit: tokenLimit, + source: { + system: "Lumi feedback", + selection: all ? "all eligible feedback matching current filters" : "selected feedback", + feedback_count: exportedFeedback.length, + feedback_ids: exportedFeedback.map((item) => item.id) + }, + privacy: { + stripped: [ + "submitter identity", + "private note actor identity", + "raw browser diagnostics", + "local storage paths", + "screenshot and attachment binary data" + ], + retained: [ + "feedback title, category, severity, status, and scope", + "issue description and reproduction fields", + "public comments and admin replies visible to submitters", + "sanitized private admin work-note context", + "safe page, element, and source references" + ] + }, + codex_taskfile: { + objective: exportedFeedback.length === 1 + ? `Resolve Lumi feedback: ${primary.title}` + : `Resolve ${exportedFeedback.length} related Lumi feedback items.`, + context: { + summary: exportedFeedback.length === 1 + ? compactSentence(primary.description || primary.title, 420) + : `This export contains ${exportedFeedback.length} feedback items selected by an administrator.`, + affected_areas: Array.from(new Set(exportedFeedback.map((item) => item.scope.label).filter(Boolean))), + source_references: exportedFeedback.flatMap((item) => item.source_references).slice(0, 30) + }, + requirements: exportedFeedback.map((item) => ({ + feedback_id: item.id, + requirement: compactSentence(item.description || item.title, 700), + category: item.category, + severity: item.severity, + status: item.status + })), + acceptance_criteria: exportedFeedback.map((item) => ({ + feedback_id: item.id, + criteria: [ + "The reported behavior is corrected or the requested improvement is implemented.", + "Existing behavior outside the affected area remains unchanged.", + "Relevant UI text and button language stays clear for non-technical admins." + ] + })), + validation_steps: exportedFeedback.map((item) => ({ + feedback_id: item.id, + steps: validationStepsForExport(item) + })), + instructions: [ + "Use this JSON as the local taskfile for Codex work.", + "Read the codebase before editing and preserve existing Lumi conventions.", + "Do not reintroduce stripped private data into commits, logs, screenshots, or responses.", + "Update TODO.md/taskfile.txt if this work changes tracked progress." + ] + }, + feedback: exportedFeedback + }; +} + +function buildCompactFeedbackTaskfile({ exportedFeedback, debugOnlyFeedback = [], generatedAt, reporterByFeedbackId = new Map() }) { + const rawTasks = exportedFeedback.map(feedbackToCompactTask); + const tasks = groupCompactFeedbackTasks(rawTasks, exportedFeedback, reporterByFeedbackId); + const severity = normalizedExportSeverity(tasks.map((task) => task.severity)); + const priority = normalizedPriorityForSeverity(severity); + const sourceRefById = new Map(exportedFeedback.map((item, index) => [item.id, `F${index + 1}`])); + const itemById = new Map(exportedFeedback.map((item) => [item.id, item])); + return { + schema: "lumi.feedback.codex_taskfile.v1", + trigger_phrase: "internal-feedback-to-codex-taskfile", + generated_at: generatedAt, + objective: compactExportObjective(tasks), + severity, + priority, + source_feedback: exportedFeedback.map((item) => compactSourceFeedback(item, sourceRefById.get(item.id))), + tasks: tasks.map((task) => compactTaskForOutput(task, sourceRefById, itemById)), + global_constraints: [ + "Preserve existing Lumi permissions, feedback visibility, and admin-only export behavior.", + "Do not expose submitter identity, private note authors, raw diagnostics, secrets, local file paths, or binary attachments.", + "Use sanitized private admin work notes only as task context; do not quote or expose sensitive private data.", + "Do not invent exact repository paths; use repo-area hints unless the route-to-file mapping is known.", + "Keep changes scoped to the reported feedback unless broader code paths are directly required." + ] + }; +} + +function compactTaskForOutput(task, sourceRefById, itemById) { + const feedbackIds = Array.from(new Set((task.feedback_ids || [task.feedback_id]).filter(Boolean))); + const sourceRefs = feedbackIds.map((id) => sourceRefById.get(id)).filter(Boolean); + const primary = feedbackIds.map((id) => itemById.get(id)).find(Boolean); + const context = uniqueCompact([ + task.pattern_summary, + ...(task.affected_areas || []), + ...(task.reported_error_messages || []), + ...feedbackIds.flatMap((id) => compactContextNotesForFeedback(itemById.get(id) || {}) + .map((note) => `${note.kind}: ${note.body}`)) + ], 10, 420); + const problemIdentifiers = uniqueCompact([ + ...sourceRefs.map((ref) => `feedback:${ref}`), + ...(task.areas_to_inspect || []).map((area) => area.route ? `route:${area.route}` : ""), + ...(task.affected_targets || []).map((target) => `target:${target}`) + ], 12, 240); + return { + task_id: task.task_id || slugForTaskId(primary?.title || task.objective), + source_refs: sourceRefs, + title: redactPrivateText(primary?.title || task.pattern_summary || task.objective, 180), + severity: task.severity, + priority: task.priority, + ...(task.support_count ? { support_count: task.support_count } : {}), + ...(context.length ? { context } : {}), + ...(problemIdentifiers.length ? { problem_identifiers: problemIdentifiers } : {}), + areas_to_inspect: compactAreasForOutput(task.areas_to_inspect || []), + requirements: compactTaskTextArray(task.requirements, "requirement", 12, 700), + acceptance_criteria: compactTaskTextArray(task.acceptance_criteria, "criteria", 10, 600), + verification: compactTaskTextArray(task.validation, "step", 10, 600), + ...((task.risk_notes || []).length ? { risk_notes: uniqueCompact(task.risk_notes, 6, 400) } : {}) + }; +} + +function compactAreasForOutput(areas) { + const seen = new Set(); + const output = []; + for (const area of areas) { + const route = redactUrlForExport(area.route); + const exactPaths = uniqueCompact(area.exact_paths || [], 8, 220); + const hints = uniqueCompact(area.repo_area_hints || [], 8, 180); + const key = `${route}|${exactPaths.join("|")}|${hints.join("|")}`; + if (seen.has(key)) continue; + seen.add(key); + output.push({ + ...(route ? { route } : {}), + ...(exactPaths.length ? { exact_paths: exactPaths } : {}), + ...(hints.length ? { repo_area_hints: hints } : {}) + }); + } + return output.slice(0, 10); +} + +function compactTaskTextArray(values, key, limit, max) { + return uniqueCompact((values || []).map((item) => ( + typeof item === "string" ? item : item?.[key] + )), limit, max); +} + function addSubmitterComment(id, body, actor) { const row = db.prepare("SELECT id, submitter_id, status, deleted_at FROM feedback_entries WHERE id = ?").get(id); if (!row || row.deleted_at || row.status === "deleted" || !actor?.id) { @@ -573,6 +774,875 @@ function feedbackOptions() { }; } +function normalizeFeedbackExportTokenLimit(value) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) return DEFAULT_FEEDBACK_EXPORT_TOKEN_LIMIT; + return Math.max(1000, Math.min(MAX_FEEDBACK_EXPORT_TOKEN_LIMIT, parsed)); +} + +function isFeedbackExportEligible(entry) { + return Boolean(entry && !entry.deleted_at && entry.status !== "deleted"); +} + +function feedbackToCodexExport(entry) { + return { + id: entry.id, + title: compactSentence(entry.summary, 180), + category: entry.category_label || entry.category, + severity: entry.severity_label || entry.severity, + status: { + value: entry.status, + label: entry.status_label || entry.status, + help: compactSentence(entry.status_help, 240) + }, + scope: { + type: entry.scope_type, + type_label: entry.scope_type_label || entry.scope_type, + label: compactSentence(entry.scope_label_display || entry.scope_label, 240) + }, + support_count: entry.support_count || 0, + created_at: isoFromMs(entry.created_at), + last_activity_at: isoFromMs(entry.last_activity_at), + description: compactSentence(entry.description, 1800), + steps_to_reproduce: compactSentence(entry.steps_to_reproduce, 1400), + expected_behavior: compactSentence(entry.expected_behavior, 1000), + actual_behavior: compactSentence(entry.actual_behavior, 1000), + public_comments: exportVisibleComments(entry.comments), + context_notes: exportFeedbackContextNotes(entry.comments), + status_history: exportStatusHistory(entry.history), + source_references: exportSourceReferences(entry) + }; +} + +function isFeedbackCodexTaskfileEligible(item) { + const status = String(item?.status?.value || item?.status || "").toLowerCase(); + return !["archived", "closed", "deleted", "duplicate", "fixed", "not_planned", "rejected", "solved", "wont_fix"].includes(status); +} + +function feedbackToCompactTask(item) { + const severity = normalizedFeedbackSeverity(item); + const route = routeFromSourceReferences(item.source_references); + const area = compactAreaLabel(item, route); + const summary = redactPrivateText(item.description || item.title, 900); + const expected = redactPrivateText(item.expected_behavior, 500); + const actual = redactPrivateText(item.actual_behavior, 500); + return { + feedback_id: item.id, + feedback_ids: [item.id], + objective: `Resolve feedback ${item.id}: ${redactPrivateText(item.title, 160)}`, + severity, + priority: normalizedPriorityForSeverity(severity), + status: item.status.label, + support_count: item.support_count || 0, + affected_areas: [area].filter(Boolean), + areas_to_inspect: areasToInspectForFeedback(item, route), + requirements: compactRequirementsForFeedback(item, summary, expected, actual), + acceptance_criteria: compactAcceptanceCriteriaForFeedback(item, expected, actual), + validation: compactValidationForFeedback(item, route) + }; +} + +function groupCompactFeedbackTasks(tasks, exportedFeedback, reporterByFeedbackId = new Map()) { + if (tasks.length <= 1) return tasks; + const itemById = new Map(exportedFeedback.map((item) => [item.id, item])); + const groups = []; + for (const task of tasks) { + const item = itemById.get(task.feedback_id); + const signature = feedbackGroupingSignature(item, task); + const candidate = { task, item, signature }; + const group = groups.find((entry) => shouldGroupFeedbackCandidate(candidate, entry.members)); + if (group) { + group.members.push(candidate); + } else { + groups.push({ members: [candidate] }); + } + } + return groups.map((group) => ( + group.members.length === 1 + ? group.members[0].task + : compactGroupedTask(group.members, reporterByFeedbackId) + )); +} + +function feedbackGroupingSignature(item, task) { + const route = routeFromSourceReferences(item?.source_references || []); + const targetPaths = feedbackAffectedTargets(item); + const text = feedbackGroupingText(item); + const rootKey = feedbackRootCauseKey(text); + const errors = extractFeedbackErrorMessages(text); + return { + id: item?.id || task.feedback_id, + route, + route_family: routeFamilyKey(route), + target_key: targetPaths.map(normalizeGroupingText).filter(Boolean)[0] || normalizeGroupingText(item?.scope?.label || ""), + root_key: rootKey, + error_messages: errors, + tokens: tokenSet(text), + text + }; +} + +function shouldGroupFeedbackCandidate(candidate, members) { + return members.some((member) => shouldGroupFeedbackSignatures(candidate.signature, member.signature)); +} + +function shouldGroupFeedbackSignatures(a, b) { + if (!a || !b) return false; + if (a.error_messages.some((message) => b.error_messages.includes(message))) return true; + if (a.root_key && a.root_key === b.root_key) return true; + const sameRoute = a.route_family && a.route_family === b.route_family; + const sameTarget = a.target_key && b.target_key && (a.target_key === b.target_key || a.target_key.includes(b.target_key) || b.target_key.includes(a.target_key)); + const similarity = jaccardScore(a.tokens, b.tokens); + if (sameRoute && sameTarget && similarity >= 0.25) return true; + if (sameRoute && similarity >= 0.42) return true; + return false; +} + +function compactGroupedTask(members, reporterByFeedbackId) { + const tasks = members.map((member) => member.task); + const items = members.map((member) => member.item).filter(Boolean); + const feedbackIds = tasks.flatMap((task) => task.feedback_ids || [task.feedback_id]).filter(Boolean); + const severity = normalizedExportSeverity(tasks.map((task) => task.severity)); + const affectedTargets = uniqueCompact(items.flatMap(feedbackAffectedTargets), 14, 220); + const reportedErrors = uniqueCompact(members.flatMap((member) => member.signature.error_messages), 8, 240); + const reporters = new Set(feedbackIds.map((id) => reporterByFeedbackId.get(id)).filter(Boolean)); + const sourceFeedback = items.map((item) => ({ + id: item.id, + title: redactPrivateText(item.title, 160), + human_reference: `Feedback ${item.id}: ${redactPrivateText(item.title, 160)}` + })); + const taskId = slugForTaskId(feedbackRootCauseKey(members.map((member) => member.signature.text).join(" ")) || items.map((item) => item.title).join(" ")); + const patternSummary = groupedPatternSummary(items, members, affectedTargets, reportedErrors); + return { + task_id: taskId, + feedback_ids: feedbackIds, + objective: `Resolve grouped feedback pattern: ${patternSummary}`, + severity, + priority: normalizedPriorityForSeverity(severity), + source_feedback_count: feedbackIds.length, + ...(reporters.size ? { distinct_reporter_count: reporters.size } : {}), + support_count: tasks.reduce((total, task) => total + (task.support_count || 0), 0), + status: Array.from(new Set(tasks.map((task) => task.status).filter(Boolean))).join(", "), + pattern_summary: patternSummary, + source_feedback: sourceFeedback, + affected_targets: affectedTargets, + reported_error_messages: reportedErrors, + affected_areas: uniqueCompact(tasks.flatMap((task) => task.affected_areas), 12, 180), + areas_to_inspect: mergeAreasToInspect(tasks), + requirements: groupedRequirements(tasks, patternSummary), + acceptance_criteria: groupedAcceptanceCriteria(tasks), + validation: groupedValidation(tasks), + risk_notes: groupedRiskNotes(members) + }; +} + +function feedbackGroupingText(item) { + if (!item) return ""; + return [ + item.title, + item.category, + item.scope?.label, + item.description, + item.steps_to_reproduce, + item.expected_behavior, + item.actual_behavior, + ...(item.context_notes || []).map((note) => note.body), + ...humanReadableSourceReferences(item.source_references || []).flatMap((reference) => Object.values(reference)) + ].filter(Boolean).join(" "); +} + +function feedbackRootCauseKey(text) { + const normalized = normalizeGroupingText(text); + const hasAny = (words) => words.some((word) => normalized.includes(word)); + if (hasAny(["delete", "remove", "reset", "archive", "clear", "destructive"]) && hasAny(["timed", "confirmation", "confirm"])) { + return "destructive-confirmation-flow"; + } + if (hasAny(["feedback", "modal", "form"]) && hasAny(["close", "reset", "draft", "progress"])) { + return "feedback-modal-draft-retention"; + } + if (hasAny(["codex", "taskfile", "export"]) && hasAny(["feedback", "group", "similar", "duplicate", "compress"])) { + return "feedback-codex-export-grouping"; + } + if (hasAny(["user", "lookup", "selection", "search"]) && hasAny(["filter", "select", "grant", "permission"])) { + return "shared-user-lookup"; + } + if (hasAny(["update", "repo", "zip"]) && hasAny(["git", "cache", "clone", "metadata"])) { + return "repo-update-flow"; + } + return ""; +} + +function extractFeedbackErrorMessages(text) { + const value = redactPrivateText(text, 5000); + const matches = []; + const patterns = [ + /\b(?:TypeError|ReferenceError|SyntaxError|RangeError|SqliteError|ENOENT|EACCES|EPERM|Cannot\s+(?:GET|POST|PUT|DELETE|PATCH))[:\s][^.\n]{0,220}/gi, + /\b(?:no such table|no such file or directory|not a git repository|requires? a timed event|timed confirmation)[^.\n]{0,220}/gi + ]; + for (const pattern of patterns) { + for (const match of value.matchAll(pattern)) { + matches.push(compactSentence(match[0], 240)); + } + } + return uniqueCompact(matches, 8, 240); +} + +function feedbackAffectedTargets(item) { + const references = humanReadableSourceReferences(item?.source_references || []); + const targets = []; + for (const reference of references) { + if (reference.type === "target_element") { + targets.push(reference.path || reference.selector || reference.label); + } else if (reference.type === "page") { + targets.push(reference.route || reference.title); + } + } + if (item?.scope?.label) targets.push(item.scope.label); + return uniqueCompact(targets, 8, 220); +} + +function routeFamilyKey(route) { + const path = pagePathKey(route); + if (!path) return ""; + const parts = path.split("/").filter(Boolean); + if (!parts.length) return "/"; + if (parts[0] === "plugins" && parts[1]) return `/plugins/${parts[1]}`; + if (parts[0] === "admin" && parts[1]) return `/admin/${parts[1]}`; + return `/${parts[0]}`; +} + +function normalizeGroupingText(value) { + return cleanText(value, 1000) + .toLowerCase() + .replace(/https?:\/\/[^\s]+/g, " ") + .replace(/[^a-z0-9/._-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function uniqueCompact(values, limit = 12, max = 180) { + const seen = new Set(); + const output = []; + for (const value of values || []) { + const text = redactPrivateText(value, max); + const key = normalizeGroupingText(text); + if (!text || !key || seen.has(key)) continue; + seen.add(key); + output.push(text); + if (output.length >= limit) break; + } + return output; +} + +function slugForTaskId(value) { + const slug = normalizeGroupingText(value) + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return slug || "grouped-feedback-task"; +} + +function dedupeObjectsByText(items, key, limit = 12) { + const seen = new Set(); + const output = []; + for (const item of items || []) { + const text = redactPrivateText(item?.[key], 900); + const normalized = normalizeGroupingText(text); + if (!text || !normalized || seen.has(normalized)) continue; + seen.add(normalized); + output.push({ ...item, [key]: text }); + if (output.length >= limit) break; + } + return output; +} + +function groupedPatternSummary(items, members, affectedTargets, reportedErrors) { + const rootKey = members.map((member) => member.signature.root_key).find(Boolean); + if (rootKey === "destructive-confirmation-flow") { + return "Multiple destructive actions appear to share broken timed confirmation wiring."; + } + if (rootKey === "feedback-modal-draft-retention") { + return "Multiple reports point to feedback modal progress being lost or reset too easily."; + } + if (rootKey === "feedback-codex-export-grouping") { + return "Multiple reports point to feedback-to-Codex export producing duplicated or overly verbose tasks."; + } + if (reportedErrors.length) { + return `Multiple reports share the error pattern: ${reportedErrors[0]}`; + } + if (affectedTargets.length) { + return `Multiple reports affect related UI areas, including ${affectedTargets.slice(0, 3).join(", ")}.`; + } + return `Multiple feedback reports appear related: ${items.map((item) => redactPrivateText(item.title, 80)).slice(0, 3).join("; ")}.`; +} + +function groupedRequirements(tasks, patternSummary) { + const requirements = [{ + feedback_id: tasks.flatMap((task) => task.feedback_ids || [task.feedback_id]).join(","), + requirement: `Fix the shared underlying behavior instead of applying separate one-off fixes: ${patternSummary}` + }]; + for (const task of tasks) { + requirements.push(...task.requirements); + } + return dedupeObjectsByText(requirements, "requirement", 14); +} + +function groupedAcceptanceCriteria(tasks) { + const criteria = tasks.flatMap((task) => task.acceptance_criteria); + criteria.unshift({ + feedback_id: tasks.flatMap((task) => task.feedback_ids || [task.feedback_id]).join(","), + criteria: "All grouped source feedback items are resolved by the same implementation pattern or intentionally separated with a documented reason." + }); + return dedupeObjectsByText(criteria, "criteria", 12); +} + +function groupedValidation(tasks) { + const validation = tasks.flatMap((task) => task.validation); + validation.unshift({ + feedback_id: tasks.flatMap((task) => task.feedback_ids || [task.feedback_id]).join(","), + step: "Verify each affected route/target from the grouped source feedback references." + }); + return dedupeObjectsByText(validation, "step", 12); +} + +function groupedRiskNotes(members) { + const rootKeys = new Set(members.map((member) => member.signature.root_key).filter(Boolean)); + if (rootKeys.size) return []; + return ["Grouped by route/target/text similarity; confirm the shared root cause during code inspection before broad changes."]; +} + +function compactSourceFeedback(item, ref) { + const routes = uniqueCompact( + humanReadableSourceReferences(item.source_references) + .filter((reference) => reference.type === "page") + .map((reference) => reference.route), + 5, + 300 + ); + return { + ref, + id: item.id, + title: redactPrivateText(item.title, 180), + category: item.category, + severity: normalizedFeedbackSeverity(item), + priority: normalizedPriorityForSeverity(normalizedFeedbackSeverity(item)), + status: item.status.label, + scope: redactPrivateText(item.scope.label, 180), + support_count: item.support_count || 0, + ...(routes.length ? { routes } : {}) + }; +} + +function compactExportObjective(tasks) { + if (!tasks.length) { + return "No actionable feedback items were selected for the default Codex taskfile."; + } + if (tasks.length === 1) { + return tasks[0].objective; + } + return `Resolve ${tasks.length} actionable Lumi feedback items without duplicating fixes across shared code paths.`; +} + +function compactExportContext(tasks, debugOnlyFeedback) { + if (!tasks.length) { + const count = debugOnlyFeedback.length; + return `${count} selected feedback item(s) are closed, duplicate, rejected, solved, or otherwise non-actionable. Use full debug export for review.`; + } + if (tasks.length === 1) { + return `One actionable feedback item affects ${tasks[0].affected_areas.join(", ") || "an unspecified Lumi area"}.`; + } + const areas = Array.from(new Set(tasks.flatMap((task) => task.affected_areas))).slice(0, 6); + return `${tasks.length} actionable feedback items affect ${areas.join(", ") || "multiple Lumi areas"}.`; +} + +function compactRequirementsForFeedback(item, summary, expected, actual) { + const requirements = [ + { + feedback_id: item.id, + requirement: summary || `Address the reported ${item.category.toLowerCase()} feedback.` + } + ]; + if (expected) { + requirements.push({ + feedback_id: item.id, + requirement: `Match expected behavior: ${expected}` + }); + } + if (actual) { + requirements.push({ + feedback_id: item.id, + requirement: `Eliminate reported actual behavior: ${actual}` + }); + } + for (const note of item.context_notes || []) { + const body = redactPrivateText(note.body, 360); + if (body) { + requirements.push({ + feedback_id: item.id, + requirement: `Account for ${note.kind.toLowerCase()}: ${body}` + }); + } + } + return requirements.slice(0, 8); +} + +function compactAcceptanceCriteriaForFeedback(item, expected, actual) { + const criteria = []; + if (actual) { + criteria.push({ + feedback_id: item.id, + criteria: `The reported failure no longer occurs: ${actual}` + }); + } + if (expected) { + criteria.push({ + feedback_id: item.id, + criteria: `The affected UI or behavior matches the expected outcome: ${expected}` + }); + } + const category = String(item.category || "").toLowerCase(); + if (category.includes("visual") || category.includes("layout")) { + criteria.push({ + feedback_id: item.id, + criteria: "The affected layout remains usable on desktop and mobile viewport widths." + }); + } else if (category.includes("accessibility")) { + criteria.push({ + feedback_id: item.id, + criteria: "The affected control remains keyboard-accessible and has clear labels or status text." + }); + } else if (category.includes("permission") || category.includes("access")) { + criteria.push({ + feedback_id: item.id, + criteria: "The fix preserves existing role and permission boundaries." + }); + } else if (category.includes("broken") || category.includes("bug") || category.includes("unexpected")) { + criteria.push({ + feedback_id: item.id, + criteria: "The affected action gives a visible success or error result instead of silently failing." + }); + } + criteria.push({ + feedback_id: item.id, + criteria: "No private feedback data, diagnostics, screenshots, tokens, or local paths are exposed." + }); + return criteria.slice(0, 5); +} + +function compactValidationForFeedback(item, route) { + const validation = []; + const steps = redactPrivateText(item.steps_to_reproduce, 450); + if (steps) { + validation.push({ + feedback_id: item.id, + step: `Reproduce from feedback steps and confirm the new behavior: ${steps}` + }); + } + if (route) { + validation.push({ + feedback_id: item.id, + step: `Manually verify the affected route or page area: ${route}` + }); + } + validation.push({ + feedback_id: item.id, + step: "Run the relevant repo verification command(s) for the touched web/service files." + }); + return validation; +} + +function buildCompactRiskNotes(tasks, debugOnlyFeedback) { + const notes = []; + if (debugOnlyFeedback.length) { + notes.push(`${debugOnlyFeedback.length} selected feedback item(s) were excluded from the default taskfile because their statuses are closed, duplicate, rejected, solved, archived, or not planned. They remain available in full debug export.`); + } + for (const note of tasks.flatMap((task) => task.risk_notes || [])) { + notes.push(note); + } + if (tasks.some((task) => task.severity === "critical")) { + notes.push("Critical feedback may involve security, urgent breakage, or sensitive workflows; minimize logging and avoid exposing private data."); + } + if (tasks.some((task) => task.areas_to_inspect.some((area) => area.exact_paths.length === 0))) { + notes.push("Some affected areas only have route or repo-area hints. Inspect the codebase before choosing exact files."); + } + return notes; +} + +function normalizedFeedbackSeverity(item) { + const raw = String(item?.severity || item?.category || "").toLowerCase(); + if (raw.includes("security") || raw.includes("urgent")) return "critical"; + if (raw.includes("broken") || raw.includes("performance")) return "high"; + if (raw.includes("confusing") || raw.includes("unexpected") || raw.includes("accessibility") || raw.includes("permission")) return "medium"; + return "low"; +} + +function normalizedExportSeverity(values = []) { + const order = ["low", "medium", "high", "critical"]; + return values.reduce((highest, value) => ( + order.indexOf(value) > order.indexOf(highest) ? value : highest + ), "low"); +} + +function normalizedPriorityForSeverity(severity) { + return { + critical: "p0", + high: "p1", + medium: "p2", + low: "p3" + }[severity] || "p3"; +} + +function compactAreaLabel(item, route) { + const scope = compactScopeLabel(item.scope?.label, route); + if (route && scope) return `${scope} (${route})`; + return scope || route || "Unspecified Lumi area"; +} + +function compactScopeLabel(label, route) { + const scope = redactPrivateText(label, 140); + if (/^clicked element:/i.test(scope) && scope.length > 90) { + return route ? "Clicked element on affected route" : "Clicked element"; + } + return scope; +} + +function routeFromSourceReferences(references = []) { + const page = references.find((reference) => reference.type === "page" && reference.url); + return redactUrlForExport(page?.url); +} + +function mergeAreasToInspect(tasks) { + const map = new Map(); + for (const task of tasks) { + const taskFeedbackIds = task.feedback_ids || [task.feedback_id].filter(Boolean); + for (const area of task.areas_to_inspect) { + const key = `${area.route || ""}:${area.label || ""}:${area.repo_area_hints.join("|")}`; + if (!map.has(key)) { + map.set(key, { + ...area, + feedback_ids: [] + }); + } + const areaFeedbackIds = area.feedback_ids?.length ? area.feedback_ids : taskFeedbackIds; + map.get(key).feedback_ids.push(...areaFeedbackIds); + } + } + return Array.from(map.values()).map((area) => ({ + ...area, + feedback_ids: Array.from(new Set(area.feedback_ids.filter(Boolean))) + })); +} + +function areasToInspectForFeedback(item, route) { + const mapped = routeInspectionHints(route); + return [{ + feedback_ids: [item.id], + label: compactAreaLabel(item, route), + route: route || null, + exact_paths: mapped.exact_paths, + repo_area_hints: mapped.repo_area_hints, + source_reference: humanReadableSourceReferences(item.source_references)[0] || null + }]; +} + +function routeInspectionHints(route) { + const normalized = String(route || "").replace(/\/+$/, "") || "/"; + const known = [ + { + prefix: "/admin/feedback", + exact_paths: [ + "src/web/views/admin-feedback.ejs", + "src/web/public/app.js", + "src/web/public/lumi-components.css", + "src/web/server.js", + "src/services/feedback.js" + ], + repo_area_hints: ["admin feedback review UI", "feedback service/export routes"] + }, + { + prefix: "/feedback", + exact_paths: [ + "src/web/views/feedback.ejs", + "src/web/views/partials/layout-bottom.ejs", + "src/web/public/app.js", + "src/web/server.js", + "src/services/feedback.js" + ], + repo_area_hints: ["public feedback UI", "feedback submission routes"] + }, + { + prefix: "/admin/navigation", + exact_paths: [ + "src/web/views/admin-navigation.ejs", + "src/web/public/app.js", + "src/web/public/lumi-components.css", + "src/web/server.js" + ], + repo_area_hints: ["admin navigation builder"] + }, + { + prefix: "/admin/settings", + exact_paths: [ + "src/web/views/admin-settings.ejs", + "src/web/server.js", + "src/services/settings.js" + ], + repo_area_hints: ["admin settings UI", "settings persistence"] + }, + { + prefix: "/admin/updates", + exact_paths: [ + "src/web/views/admin-updates.ejs", + "src/web/public/app.js", + "src/web/server.js", + "src/services/repo-update.js" + ], + repo_area_hints: ["admin update UI", "repo update services"] + } + ]; + const match = known.find((entry) => normalized === entry.prefix || normalized.startsWith(`${entry.prefix}/`)); + if (match) { + return { + exact_paths: match.exact_paths, + repo_area_hints: match.repo_area_hints + }; + } + if (normalized.startsWith("/plugins/")) { + const slug = normalized.split("/").filter(Boolean)[1] || ""; + return { + exact_paths: [], + repo_area_hints: [ + slug ? `plugin route /plugins/${slug}` : "plugin route", + "plugin WebUI route/view", + "plugin backend module" + ] + }; + } + if (normalized.startsWith("/admin")) { + return { + exact_paths: ["src/web/server.js"], + repo_area_hints: ["admin WebUI route", "matching admin EJS view", "shared Lumi component CSS/JS"] + }; + } + return { + exact_paths: [], + repo_area_hints: ["matching route/view", "shared Lumi web UI JavaScript", "related service module"] + }; +} + +function humanReadableSourceReferences(references = []) { + return references + .filter((reference) => ["page", "target_element", "linked_issue", "linked_correction"].includes(reference.type)) + .map((reference) => { + if (reference.type === "page") { + return { + type: "page", + title: redactPrivateText(reference.title, 160), + route: redactUrlForExport(reference.url) + }; + } + if (reference.type === "target_element") { + const label = redactPrivateText(reference.label || reference.aria_label || reference.title || reference.role || reference.tag, 120); + return { + type: "target_element", + path: redactPrivateText(reference.path, 220), + selector: redactPrivateText(reference.selector, 220), + label: label.length > 90 ? "Clicked element" : label + }; + } + return { + type: reference.type, + value: redactPrivateText(reference.value, 220) + }; + }) + .filter((reference) => Object.values(reference).some(Boolean)); +} + +function exportVisibleComments(comments = []) { + return comments + .filter((comment) => comment.visible_to_submitter && comment.kind !== "work_note") + .map((comment) => ({ + kind: comment.kind_label || comment.kind, + body: compactSentence(comment.body, 900), + created_at: isoFromMs(comment.created_at) + })) + .filter((comment) => comment.body); +} + +function exportFeedbackContextNotes(comments = []) { + return comments + .filter((comment) => ["submitter_comment", "public_comment", "admin_reply", "work_note"].includes(comment.kind)) + .map((comment) => ({ + kind: feedbackContextKindLabel(comment.kind), + visibility: comment.visible_to_submitter ? "submitter_visible" : "admin_private", + body: redactPrivateText(comment.body, 900), + created_at: isoFromMs(comment.created_at) + })) + .filter((comment) => comment.body) + .slice(-8); +} + +function compactContextNotesForFeedback(item) { + return (item.context_notes || []) + .map((note) => ({ + kind: note.kind, + visibility: note.visibility, + body: redactPrivateText(note.body, 360) + })) + .filter((note) => note.body) + .slice(-5); +} + +function feedbackContextKindLabel(kind) { + return { + submitter_comment: "Submitter comment", + public_comment: "Community comment", + admin_reply: "Admin reply to submitter", + work_note: "Private admin work note" + }[kind] || "Feedback note"; +} + +function exportStatusHistory(history = []) { + return history.map((row) => ({ + status: row.status_label || row.status, + note: compactSentence(row.note, 400), + created_at: isoFromMs(row.created_at) + })); +} + +function exportSourceReferences(entry) { + const references = []; + const currentUrl = safeUrlReference(entry.current_url); + if (currentUrl) { + references.push({ + type: "page", + url: currentUrl, + title: compactSentence(entry.page_title, 240) + }); + } else if (entry.page_title) { + references.push({ + type: "page", + title: compactSentence(entry.page_title, 240) + }); + } + const target = exportTargetReference(entry.target_metadata || {}); + if (Object.keys(target).length) { + references.push({ type: "target_element", ...target }); + } + if (entry.linked_issue) { + references.push({ type: "linked_issue", value: compactSentence(entry.linked_issue, 500) }); + } + if (entry.linked_correction) { + references.push({ type: "linked_correction", value: compactSentence(entry.linked_correction, 500) }); + } + if (entry.screenshot) { + references.push({ + type: "screenshot_metadata", + mime: entry.screenshot.mime, + size_kb: Math.max(1, Math.round((entry.screenshot.size || 0) / 1024)) + }); + } + for (const attachment of entry.attachments || []) { + references.push({ + type: "attachment_metadata", + name: compactSentence(attachment.original_name, 180), + mime: compactSentence(attachment.mime, 120), + kind: compactSentence(attachment.kind, 80), + size_kb: Math.max(1, Math.round((attachment.size || 0) / 1024)) + }); + } + return references.filter((reference) => Object.values(reference).some((value) => value !== "" && value !== null && value !== undefined)); +} + +function exportTargetReference(target = {}) { + const allowed = { + path: 500, + selector: 500, + tag: 40, + role: 80, + label: 220, + text: 320, + aria_label: 220, + title: 220, + heading: 220, + viewport: 80 + }; + return Object.fromEntries(Object.entries(allowed) + .map(([key, max]) => [key, compactSentence(target[key], max)]) + .filter(([, value]) => value)); +} + +function validationStepsForExport(item) { + const steps = []; + if (item.steps_to_reproduce) { + steps.push(`Reproduce using the supplied steps: ${compactSentence(item.steps_to_reproduce, 500)}`); + } + const pageRef = item.source_references.find((reference) => reference.type === "page"); + if (pageRef?.url) { + steps.push(`Verify the affected page/route: ${pageRef.url}`); + } + if (item.actual_behavior || item.expected_behavior) { + steps.push("Compare the fixed behavior against the exported expected and actual behavior fields."); + } + steps.push("Run the repo's relevant verification commands and record any commands that could not be run."); + return steps; +} + +function compactSentence(value, max = 500) { + const cleaned = cleanText(value, Math.max(max * 2, max)); + if (!cleaned) return ""; + if (cleaned.length <= max) return cleaned; + return `${cleaned.slice(0, Math.max(0, max - 3)).trimEnd()}...`; +} + +function redactPrivateText(value, max = 500) { + const cleaned = cleanText(value, Math.max(max * 2, max)) + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]") + .replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, "[ip]") + .replace(/\b([a-z0-9_-]*(?:token|secret|session|password|passwd|api[_-]?key|auth)[a-z0-9_-]*)\s*[:=]\s*[^,\s;]+/gi, "$1=[redacted]") + .replace(/([?&](?:token|secret|session|password|passwd|api_key|apikey|auth|code)=)[^&#\s]+/gi, "$1[redacted]") + .replace(/\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+/gi, "[auth header]") + .trim(); + if (cleaned.length <= max) return cleaned; + return `${cleaned.slice(0, Math.max(0, max - 3)).trimEnd()}...`; +} + +function isoFromMs(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) return null; + return new Date(numeric).toISOString(); +} + +function safeUrlReference(raw) { + const value = cleanText(raw, 1000); + if (!value) return ""; + try { + const url = new URL(value, "http://lumi.local"); + if (!["http:", "https:"].includes(url.protocol)) return ""; + const base = url.origin === "http://lumi.local" ? "" : url.origin; + return `${base}${url.pathname}${url.hash}`; + } catch { + return ""; + } +} + +function redactUrlForExport(raw) { + const value = cleanText(raw, 1000); + if (!value) return ""; + try { + const url = new URL(value, "http://lumi.local"); + if (!["http:", "https:"].includes(url.protocol)) return ""; + for (const key of [...url.searchParams.keys()]) { + if (/^(?:token|secret|session|password|passwd|api_key|apikey|auth|code)$/i.test(key)) { + url.searchParams.set(key, "[redacted]"); + } + } + return `${url.pathname || "/"}${url.search}${url.hash}`; + } catch { + return ""; + } +} + function normalizeFeedbackInput(input = {}) { const summary = cleanText(input.summary, 140); const description = cleanText(input.description, 6000); @@ -606,9 +1676,9 @@ function normalizeFeedbackInput(input = {}) { function deriveScopeLabel(scopeType, metadata, pageTitle, currentUrl) { const pageLabel = pageTitle || pagePathLabel(currentUrl) || "this page"; - const elementLabel = metadata.label || metadata.text || metadata.aria_label || metadata.title || metadata.selector; + const elementLabel = metadata.path || metadata.label || metadata.aria_label || metadata.title || metadata.selector || metadata.text; if (scopeType === "element") { - return cleanText(elementLabel ? `Clicked element: ${elementLabel}` : `Clicked element on ${pageLabel}`, 240); + return cleanText(elementLabel || `Clicked element on ${pageLabel}`, 240); } if (scopeType === "feature") return cleanText(`Feature/page: ${pageLabel}`, 240); if (scopeType === "plugin") { @@ -804,6 +1874,7 @@ function decorateLabels(row) { function sanitizeTargetMetadata(value) { return { selector: cleanText(value.selector, 500), + path: cleanSemanticTargetPath(value.path), tag: cleanText(value.tag, 40), text: cleanText(value.text, 300), aria_label: cleanText(value.aria_label, 200), @@ -817,6 +1888,18 @@ function sanitizeTargetMetadata(value) { }; } +function cleanSemanticTargetPath(value) { + const raw = cleanText(value, 500); + if (!raw) return ""; + const parts = raw + .split(";") + .map((part) => cleanText(part, 140)) + .filter(Boolean) + .slice(0, 5); + if (!parts.length) return ""; + return parts.join(";"); +} + function sanitizeDiagnostics(value) { return { user_agent: cleanText(value.user_agent, 500), @@ -824,11 +1907,23 @@ function sanitizeDiagnostics(value) { language: cleanText(value.language, 80), dom_snapshot: cleanText(value.dom_snapshot, 6000), screenshot_mode: cleanText(value.screenshot_mode, 40), - screenshot_source: cleanText(value.screenshot_source, 40) + screenshot_source: cleanText(value.screenshot_source, 40), + similar_feedback_confirmation: value.similar_feedback_confirmation === "distinct_or_additional_context" + ? "distinct_or_additional_context" + : "", + similar_feedback_ids: cleanText(value.similar_feedback_ids, 500) + .split(",") + .map((id) => id.trim()) + .filter((id) => /^[a-z0-9-]{8,80}$/i.test(id)) + .slice(0, 5) + .join(",") }; } function normalizeScreenshot(value = {}) { + if (!value || typeof value !== "object") { + return { path: null, mime: null, size: null }; + } const pathValue = cleanText(value.path, 500); const mime = cleanText(value.mime, 80); const size = Number(value.size || 0); @@ -933,6 +2028,7 @@ module.exports = { FEEDBACK_STATUSES, USER_VISIBLE_STATUSES, adminUpdateFeedback, + buildFeedbackJobExport, cleanupFeedback, deleteFeedback, createFeedback, diff --git a/src/services/placeholders.js b/src/services/placeholders.js new file mode 100644 index 0000000..81d00f5 --- /dev/null +++ b/src/services/placeholders.js @@ -0,0 +1,714 @@ +const { db } = require("./db"); +const { getSetting } = require("./settings"); +const { hasAccess } = require("./rbac"); + +const ROLE_LEVELS = Object.freeze({ + public: 0, + user: 1, + mod: 2, + moderator: 2, + admin: 3, + internal: 4 +}); + +const SENSITIVITY_LEVELS = Object.freeze({ + public_safe: 0, + user: 1, + moderator: 2, + admin: 3, + internal: 4, + secret_never_render: 99 +}); + +const VALUE_TYPES = new Set(["string", "number", "boolean", "url", "json", "date"]); +const placeholders = new Map(); +const fieldPolicies = new Map(); + +function normalizeRole(value, fallback = "user") { + const role = String(value || fallback).trim().toLowerCase(); + if (role === "moderator") return "mod"; + return Object.prototype.hasOwnProperty.call(ROLE_LEVELS, role) ? role : fallback; +} + +function roleLevel(value) { + return ROLE_LEVELS[normalizeRole(value, "public")] ?? 0; +} + +function normalizeSensitivity(value, fallback = "public_safe") { + const sensitivity = String(value || fallback).trim().toLowerCase(); + return Object.prototype.hasOwnProperty.call(SENSITIVITY_LEVELS, sensitivity) + ? sensitivity + : fallback; +} + +function sensitivityLevel(value) { + return SENSITIVITY_LEVELS[normalizeSensitivity(value)] ?? 0; +} + +function normalizeId(value) { + return String(value || "") + .trim() + .replace(/^\{\{\s*|\s*\}\}$/g, "") + .replace(/[^A-Za-z0-9_.-]/g, "_") + .replace(/_{2,}/g, "_") + .replace(/^\.|\.$/g, ""); +} + +function tokenFor(id) { + return `{{${normalizeId(id)}}}`; +} + +function namespaceFor(id) { + const parts = normalizeId(id).split(".").filter(Boolean); + return parts.length > 1 ? parts.slice(0, -1).join(".") : parts[0] || ""; +} + +function normalizeStringList(value) { + return (Array.isArray(value) ? value : [value]) + .flatMap((item) => String(item || "").split(/[,\n]/)) + .map((item) => item.trim()) + .filter(Boolean); +} + +function normalizeDefinition(definition = {}) { + const id = normalizeId(definition.id || definition.token); + if (!id) { + throw new Error("Placeholder id is required."); + } + const sensitivity = normalizeSensitivity(definition.sensitivity); + if (sensitivity === "secret_never_render") { + throw new Error("secret_never_render placeholders must not be registered."); + } + const namespace = normalizeId(definition.namespace || namespaceFor(id)); + const aliases = normalizeStringList(definition.aliases || []) + .map(normalizeId) + .filter((alias) => alias && alias !== id); + return { + id, + token: tokenFor(id), + namespace, + aliases, + label: String(definition.label || id).trim(), + description: String(definition.description || "").trim(), + value_type: VALUE_TYPES.has(definition.value_type) ? definition.value_type : "string", + sensitivity, + min_editor_role: normalizeRole(definition.min_editor_role || "user"), + min_viewer_role: normalizeRole(definition.min_viewer_role || "user"), + allowed_field_types: normalizeStringList(definition.allowed_field_types || []), + cache_ttl_seconds: Math.max(0, Number(definition.cache_ttl_seconds) || 0), + group: String(definition.group || namespace || "General").trim(), + example: definition.example === undefined ? null : String(definition.example), + plugin_id: definition.plugin_id ? String(definition.plugin_id).trim() : null, + resolver: typeof definition.resolver === "function" ? definition.resolver : () => "", + available: typeof definition.available === "function" ? definition.available : null + }; +} + +function normalizePolicy(policy = {}) { + const field_id = normalizeId(policy.field_id); + if (!field_id) { + throw new Error("Placeholder field policy id is required."); + } + return { + field_id, + label: String(policy.label || field_id).trim(), + field_type: String(policy.field_type || "text").trim(), + output_audience: normalizeRole(policy.output_audience || "user"), + min_editor_role: normalizeRole(policy.min_editor_role || "user"), + allowed_namespaces: normalizeStringList(policy.allowed_namespaces || []), + allowed_placeholder_ids: normalizeStringList(policy.allowed_placeholder_ids || []).map(normalizeId), + max_sensitivity: normalizeSensitivity(policy.max_sensitivity || "public_safe"), + description: String(policy.description || "").trim() + }; +} + +function registerPlaceholder(definition) { + const normalized = normalizeDefinition(definition); + placeholders.set(normalized.id, normalized); + return normalized; +} + +function registerPlaceholders(definitions = []) { + return definitions.map(registerPlaceholder); +} + +function unregisterPlaceholder(id) { + return placeholders.delete(normalizeId(id)); +} + +function unregisterNamespace(namespace) { + const normalized = normalizeId(namespace); + for (const id of Array.from(placeholders.keys())) { + const definition = placeholders.get(id); + if (id === normalized || id.startsWith(`${normalized}.`) || definition?.namespace === normalized || definition?.namespace?.startsWith(`${normalized}.`)) { + placeholders.delete(id); + } + } +} + +function registerFieldPolicy(policy) { + const normalized = normalizePolicy(policy); + fieldPolicies.set(normalized.field_id, normalized); + return normalized; +} + +function getFieldPolicy(fieldId) { + return fieldPolicies.get(normalizeId(fieldId)) || null; +} + +function isPluginEnabled(pluginId) { + if (!pluginId) return true; + try { + const row = db.prepare("SELECT enabled FROM plugins WHERE id = ?").get(pluginId); + return !row || Boolean(row.enabled); + } catch { + return true; + } +} + +function userRole(user) { + if (user?.isAdmin) return "admin"; + if (user?.isMod) return "mod"; + return user ? "user" : "public"; +} + +function roleAllows(user, role) { + const normalized = normalizeRole(role, "user"); + if (normalized === "user") return Boolean(user); + return hasAccess(user, normalized); +} + +function namespaceAllowed(definition, policy) { + if (policy.allowed_placeholder_ids.includes(definition.id)) return true; + if (!policy.allowed_namespaces.length) return true; + return policy.allowed_namespaces.some((namespace) => { + const normalized = normalizeId(namespace); + return definition.id === normalized || + definition.namespace === normalized || + definition.namespace.startsWith(`${normalized}.`) || + definition.id.startsWith(`${normalized}.`); + }); +} + +function fieldTypeAllowed(definition, policy) { + return !definition.allowed_field_types.length || + definition.allowed_field_types.includes(policy.field_type); +} + +function availabilityAllows(definition, context) { + if (!isPluginEnabled(definition.plugin_id)) return false; + if (!definition.available) return true; + try { + return Boolean(definition.available(context)); + } catch { + return false; + } +} + +function checkPlaceholderAccess(definition, policy, { user, outputAudience, runtimeContext } = {}) { + if (!definition || !policy) { + return { allowed: false, reason: "unknown_placeholder" }; + } + const audience = normalizeRole(outputAudience || policy.output_audience, policy.output_audience); + if (definition.sensitivity === "secret_never_render") { + return { allowed: false, reason: "secret_never_render" }; + } + const runtimeOnly = Boolean(runtimeContext?.runtime); + if (!runtimeOnly && (!roleAllows(user, policy.min_editor_role) || !roleAllows(user, definition.min_editor_role))) { + return { allowed: false, reason: "editor_role_forbidden" }; + } + if (roleLevel(audience) < roleLevel(definition.min_viewer_role)) { + return { allowed: false, reason: "viewer_role_forbidden" }; + } + if (sensitivityLevel(definition.sensitivity) > sensitivityLevel(policy.max_sensitivity)) { + return { allowed: false, reason: "sensitivity_forbidden" }; + } + if (!namespaceAllowed(definition, policy)) { + return { allowed: false, reason: "namespace_forbidden" }; + } + if (!fieldTypeAllowed(definition, policy)) { + return { allowed: false, reason: "field_type_forbidden" }; + } + if (!availabilityAllows(definition, { user, policy, outputAudience: audience, runtimeContext })) { + return { allowed: false, reason: "placeholder_unavailable" }; + } + return { allowed: true, reason: "allowed" }; +} + +function findDefinition(token) { + const id = normalizeId(token); + if (placeholders.has(id)) return placeholders.get(id); + for (const definition of placeholders.values()) { + if (definition.aliases.includes(id)) return definition; + } + return null; +} + +function parsePlaceholders(template) { + const found = []; + const matcher = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g; + let match = null; + while ((match = matcher.exec(String(template || "")))) { + found.push({ + token: match[0], + id: normalizeId(match[1]), + index: match.index + }); + } + return found; +} + +function catalog({ fieldId, user, outputAudience, runtimeContext } = {}) { + const policy = getFieldPolicy(fieldId); + if (!policy) return { policy: null, placeholders: [] }; + const audience = normalizeRole(outputAudience || policy.output_audience, policy.output_audience); + const available = Array.from(placeholders.values()) + .filter((definition) => checkPlaceholderAccess(definition, policy, { + user, + outputAudience: audience, + runtimeContext + }).allowed) + .map((definition) => ({ + id: definition.id, + token: definition.token, + namespace: definition.namespace, + label: definition.label, + description: definition.description, + value_type: definition.value_type, + sensitivity: definition.sensitivity, + group: definition.group, + example: definition.sensitivity === "public_safe" ? definition.example : null + })) + .sort((a, b) => a.token.localeCompare(b.token)); + return { + policy: { + field_id: policy.field_id, + label: policy.label, + field_type: policy.field_type, + output_audience: audience + }, + placeholders: available + }; +} + +function validateTemplate({ fieldId, template, user, outputAudience, runtimeContext } = {}) { + const policy = getFieldPolicy(fieldId); + const errors = []; + if (!policy) { + return { + ok: false, + errors: [{ token: "", reason: "unknown_field_policy" }] + }; + } + for (const token of parsePlaceholders(template)) { + const definition = findDefinition(token.id); + const access = checkPlaceholderAccess(definition, policy, { + user, + outputAudience, + runtimeContext + }); + if (!access.allowed) { + errors.push({ + token: token.token, + id: token.id, + reason: access.reason + }); + } + } + return { ok: errors.length === 0, errors }; +} + +async function renderTemplate({ fieldId, template, user, outputAudience, runtimeContext, fallback = "[unavailable]" } = {}) { + const policy = getFieldPolicy(fieldId); + if (!policy) { + return { + ok: false, + rendered: String(template || ""), + errors: [{ token: "", reason: "unknown_field_policy" }] + }; + } + const errors = []; + const rendered = await replaceAsync(String(template || ""), /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, async (match, rawId) => { + const id = normalizeId(rawId); + const definition = findDefinition(id); + const access = checkPlaceholderAccess(definition, policy, { + user, + outputAudience, + runtimeContext + }); + if (!access.allowed) { + errors.push({ token: match, id, reason: access.reason }); + return fallback; + } + try { + const value = await definition.resolver({ + user, + policy, + outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience), + runtimeContext, + token: match, + id + }); + return stringifyResolvedValue(value); + } catch (error) { + errors.push({ token: match, id, reason: "resolver_failed" }); + return fallback; + } + }); + return { ok: errors.length === 0, rendered, errors }; +} + +function stringifyResolvedValue(value) { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value); +} + +function replaceAsync(value, matcher, replacer) { + const matches = []; + value.replace(matcher, (...args) => { + matches.push(args); + return args[0]; + }); + return matches.reduce(async (pending, args) => { + const current = await pending; + const replacement = await replacer(...args); + return current.replace(args[0], replacement); + }, Promise.resolve(value)); +} + +function registerCorePlaceholders() { + registerFieldPolicy({ + field_id: "core.custom_commands.static_response", + label: "Custom command response", + field_type: "command_response", + output_audience: "user", + min_editor_role: "mod", + allowed_namespaces: ["core.main", "user.public"], + max_sensitivity: "public_safe" + }); + registerPlaceholders([ + { + id: "core.main.bot_name", + namespace: "core.main", + label: "Bot name", + description: "Current Lumi site/bot display name.", + value_type: "string", + sensitivity: "public_safe", + min_editor_role: "user", + min_viewer_role: "user", + allowed_field_types: ["chat_message", "command_response", "admin_template", "okf_markdown"], + example: "Lumi", + resolver: () => getSetting("site_title", "Lumi") + }, + { + id: "core.main.command_prefix", + namespace: "core.main", + label: "Command prefix", + description: "Configured chat command prefix.", + value_type: "string", + sensitivity: "public_safe", + min_editor_role: "user", + min_viewer_role: "user", + allowed_field_types: ["chat_message", "command_response", "admin_template", "okf_markdown"], + example: "!", + resolver: () => getSetting("command_prefix", "!") + }, + { + id: "user.public.display_name", + namespace: "user.public", + label: "Viewer display name", + description: "Display name of the user who triggered the message, when available.", + value_type: "string", + sensitivity: "public_safe", + min_editor_role: "user", + min_viewer_role: "user", + allowed_field_types: ["chat_message", "command_response", "okf_markdown"], + resolver: ({ runtimeContext }) => + runtimeContext?.user?.displayName || + runtimeContext?.user?.username || + runtimeContext?.ctx?.user?.displayName || + runtimeContext?.ctx?.user?.username || + "" + } + ]); +} + +function registerPlatformPlaceholders({ discordClient, getTwitchClient, getYouTubeClient } = {}) { + registerPlaceholders([ + ...discordPlatformPlaceholders(discordClient), + ...twitchPlatformPlaceholders(getTwitchClient), + ...youtubePlatformPlaceholders(getYouTubeClient) + ]); +} + +function platformPlaceholder(base) { + return { + namespace: base.namespace, + sensitivity: base.sensitivity || "public_safe", + min_editor_role: base.min_editor_role || "user", + min_viewer_role: base.min_viewer_role || "user", + allowed_field_types: base.allowed_field_types || ["okf_markdown", "admin_template"], + group: base.group, + ...base + }; +} + +function discordPlatformPlaceholders(discordClient) { + const namespace = "platform.discord.guild"; + const group = "Discord guild"; + const guildValue = async (reader) => { + const guild = await resolveDiscordGuild(discordClient); + if (!guild) return ""; + try { + return reader(guild); + } catch { + return ""; + } + }; + return [ + platformPlaceholder({ + id: `${namespace}.name`, + namespace, + group, + label: "Discord server name", + description: "Name of the configured Discord server.", + example: "Cozy Carnage", + resolver: () => guildValue((guild) => guild.name || "") + }), + platformPlaceholder({ + id: `${namespace}.member_count`, + namespace, + group, + label: "Discord member count", + description: "Current member count reported by the Discord server cache/API.", + value_type: "number", + example: "128", + resolver: () => guildValue((guild) => guild.memberCount ?? guild.approximateMemberCount ?? "") + }), + platformPlaceholder({ + id: `${namespace}.created_at`, + namespace, + group, + label: "Discord server created at", + description: "ISO datetime when the configured Discord server was created.", + value_type: "date", + example: "2024-01-01T12:00:00.000Z", + resolver: () => guildValue((guild) => guild.createdAt?.toISOString?.() || (guild.createdTimestamp ? new Date(guild.createdTimestamp).toISOString() : "")) + }), + platformPlaceholder({ + id: `${namespace}.text_channel_count`, + namespace, + group, + label: "Discord text channel count", + description: "Number of text-like channels currently cached for the configured Discord server.", + value_type: "number", + example: "12", + resolver: () => guildValue((guild) => countDiscordChannels(guild, "text")) + }), + platformPlaceholder({ + id: `${namespace}.voice_channel_count`, + namespace, + group, + label: "Discord voice channel count", + description: "Number of voice/stage channels currently cached for the configured Discord server.", + value_type: "number", + example: "4", + resolver: () => guildValue((guild) => countDiscordChannels(guild, "voice")) + }), + platformPlaceholder({ + id: `${namespace}.channel_count`, + namespace, + group, + label: "Discord channel count", + description: "Total number of channels currently cached for the configured Discord server.", + value_type: "number", + example: "18", + resolver: () => guildValue((guild) => collectionSize(guild.channels?.cache)) + }), + platformPlaceholder({ + id: `${namespace}.role_count`, + namespace, + group, + label: "Discord role count", + description: "Number of roles currently cached for the configured Discord server.", + value_type: "number", + example: "8", + resolver: () => guildValue((guild) => collectionSize(guild.roles?.cache)) + }), + platformPlaceholder({ + id: `${namespace}.emoji_count`, + namespace, + group, + label: "Discord emoji count", + description: "Number of custom emojis currently cached for the configured Discord server.", + value_type: "number", + example: "24", + resolver: () => guildValue((guild) => collectionSize(guild.emojis?.cache)) + }), + platformPlaceholder({ + id: `${namespace}.boost_count`, + namespace, + group, + label: "Discord boost count", + description: "Premium subscription count reported by Discord when available.", + value_type: "number", + example: "3", + resolver: () => guildValue((guild) => guild.premiumSubscriptionCount ?? "") + }) + ]; +} + +function twitchPlatformPlaceholders(getTwitchClient) { + const namespace = "platform.twitch.channel"; + const group = "Twitch channel"; + return [ + platformPlaceholder({ + id: `${namespace}.primary_name`, + namespace, + group, + label: "Twitch primary channel", + description: "First configured Twitch channel name.", + example: "cozycarnage", + resolver: () => firstTwitchChannel() + }), + platformPlaceholder({ + id: `${namespace}.configured_count`, + namespace, + group, + label: "Twitch configured channel count", + description: "Number of configured Twitch channels.", + value_type: "number", + example: "1", + resolver: () => twitchChannels().length + }), + platformPlaceholder({ + id: `${namespace}.bot_username`, + namespace, + group, + label: "Twitch bot username", + description: "Configured Twitch bot username.", + example: "lumi_bot", + resolver: () => getSetting("twitch_bot_username", "") || "" + }), + platformPlaceholder({ + id: `${namespace}.connected`, + namespace, + group, + label: "Twitch bot connected", + description: "Whether the Twitch chat client is currently connected.", + value_type: "boolean", + example: "true", + resolver: () => Boolean(getTwitchClient?.()) + }) + ]; +} + +function youtubePlatformPlaceholders(getYouTubeClient) { + const namespace = "platform.youtube.channel"; + const group = "YouTube channel"; + return [ + platformPlaceholder({ + id: `${namespace}.id`, + namespace, + group, + label: "YouTube channel ID", + description: "Configured or hydrated YouTube bot channel ID.", + example: "UC...", + resolver: () => getYouTubeClient?.()?.channelId || getSetting("youtube_bot_channel_id", "") || "" + }), + platformPlaceholder({ + id: `${namespace}.name`, + namespace, + group, + label: "YouTube channel name", + description: "Hydrated YouTube bot channel name when the integration is connected.", + example: "Cozy Carnage", + resolver: () => getYouTubeClient?.()?.channelName || "" + }), + platformPlaceholder({ + id: `${namespace}.live_chat_active`, + namespace, + group, + label: "YouTube live chat active", + description: "Whether Lumi currently has an active YouTube live chat ID.", + value_type: "boolean", + example: "false", + resolver: () => Boolean(getYouTubeClient?.()?.liveChatId) + }), + platformPlaceholder({ + id: `${namespace}.connected`, + namespace, + group, + label: "YouTube bot connected", + description: "Whether the YouTube chat integration runtime is currently active.", + value_type: "boolean", + example: "true", + resolver: () => Boolean(getYouTubeClient?.()) + }) + ]; +} + +async function resolveDiscordGuild(discordClient) { + const guildId = getSetting("discord_guild_id", ""); + if (!discordClient || !guildId) return null; + const cached = discordClient.guilds?.cache?.get?.(guildId); + if (cached) return cached; + if (typeof discordClient.guilds?.fetch === "function") { + return discordClient.guilds.fetch(guildId).catch(() => null); + } + return null; +} + +function countDiscordChannels(guild, kind) { + const channels = Array.from(guild.channels?.cache?.values?.() || []); + return channels.filter((channel) => { + const type = channel?.type; + if (kind === "voice") { + return type === 2 || type === 13 || /voice|stage/i.test(String(type)); + } + return [0, 5, 10, 11, 12, 15, 16].includes(type) || /text|news|announcement|forum|media|thread/i.test(String(type)); + }).length; +} + +function collectionSize(collection) { + if (!collection) return ""; + if (typeof collection.size === "number") return collection.size; + if (Array.isArray(collection)) return collection.length; + return ""; +} + +function twitchChannels() { + return String(getSetting("twitch_channels", "") || "") + .split(/[,\s]+/) + .map((entry) => entry.trim().replace(/^#/, "")) + .filter(Boolean); +} + +function firstTwitchChannel() { + return twitchChannels()[0] || ""; +} + +module.exports = { + catalog, + checkPlaceholderAccess, + getFieldPolicy, + parsePlaceholders, + registerCorePlaceholders, + registerFieldPolicy, + registerPlaceholder, + registerPlaceholders, + registerPlatformPlaceholders, + renderTemplate, + unregisterNamespace, + unregisterPlaceholder, + validateTemplate, + _internals: { + normalizeId, + normalizePolicy, + normalizeDefinition, + placeholders, + fieldPolicies + } +}; diff --git a/src/services/plugins.js b/src/services/plugins.js index 9f38a2d..658690a 100644 --- a/src/services/plugins.js +++ b/src/services/plugins.js @@ -2,6 +2,7 @@ const path = require("path"); const fs = require("fs"); const { spawnSync } = require("child_process"); const { db } = require("./db"); +const placeholders = require("./placeholders"); const pluginsDir = path.join(__dirname, "..", "..", "plugins"); const cleanupHandlers = []; @@ -235,7 +236,8 @@ function loadEnabled({ webhooks, db, plugin, - commandRouter + commandRouter, + placeholders }); if (typeof cleanup === "function") { cleanupHandlers.push({ id: plugin.id, cleanup }); diff --git a/src/web/public/app.js b/src/web/public/app.js index 3592438..db6da9d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -149,6 +149,455 @@ } }); + const placeholderOptionCache = new Map(); + const placeholderFetchPromises = new Map(); + const placeholderSuggestBox = document.createElement("div"); + placeholderSuggestBox.className = "lumi-placeholder-suggest"; + placeholderSuggestBox.hidden = true; + document.body.append(placeholderSuggestBox); + const placeholderTreeModal = document.createElement("div"); + placeholderTreeModal.className = "modal-backdrop lumi-placeholder-tree-modal"; + placeholderTreeModal.hidden = true; + placeholderTreeModal.setAttribute("aria-hidden", "true"); + placeholderTreeModal.innerHTML = ` + + `; + document.body.append(placeholderTreeModal); + let activePlaceholderField = null; + let activePlaceholderToken = null; + let activePlaceholderSuggestions = []; + let activePlaceholderIndex = 0; + let activePlaceholderTreeOptions = []; + + const normalizePlaceholderValue = (value) => { + const raw = typeof value === "string" ? value : value?.token || value?.value || value?.placeholder || ""; + const text = String(raw || "").trim(); + if (!text) return ""; + return text.startsWith("{{") ? text : `{{${text.replace(/^\{+|\}+$/g, "")}}}`; + }; + const placeholderSourceKeyFor = (field) => { + const fieldId = field?.dataset.placeholderField || ""; + if (fieldId) { + return `field:${fieldId}:${field.dataset.placeholderOutputAudience || ""}`; + } + const sourceId = field?.dataset.placeholderOptions || ""; + return sourceId ? `inline:${sourceId}` : ""; + }; + const placeholderOptionsFor = (field) => { + const sourceKey = placeholderSourceKeyFor(field); + if (!sourceKey) return []; + if (placeholderOptionCache.has(sourceKey)) return placeholderOptionCache.get(sourceKey); + const fieldId = field?.dataset.placeholderField || ""; + if (fieldId) return []; + const sourceId = field?.dataset.placeholderOptions || ""; + const source = document.getElementById(sourceId); + let options = []; + try { + const parsed = JSON.parse(source?.textContent || "[]"); + options = Array.isArray(parsed) + ? parsed.map(normalizePlaceholderValue).filter(Boolean) + : []; + } catch { + options = []; + } + options = Array.from(new Set(options)).sort((a, b) => a.localeCompare(b)); + placeholderOptionCache.set(sourceKey, options); + return options; + }; + const ensurePlaceholderOptions = (field) => { + const fieldId = field?.dataset.placeholderField || ""; + if (!fieldId) return Promise.resolve(placeholderOptionsFor(field)); + const sourceKey = placeholderSourceKeyFor(field); + if (placeholderOptionCache.has(sourceKey)) { + return Promise.resolve(placeholderOptionCache.get(sourceKey)); + } + if (placeholderFetchPromises.has(sourceKey)) { + return placeholderFetchPromises.get(sourceKey); + } + const params = new URLSearchParams({ field_id: fieldId }); + const outputAudience = field.dataset.placeholderOutputAudience || ""; + if (outputAudience) params.set("output_audience", outputAudience); + const request = fetch(`/api/placeholders/catalog?${params.toString()}`, { cache: "no-store" }) + .then((response) => response.ok ? response.json() : Promise.reject(new Error("Placeholder catalog unavailable."))) + .then((payload) => { + const options = Array.from(new Set((payload.placeholders || []) + .map(normalizePlaceholderValue) + .filter(Boolean))) + .sort((a, b) => a.localeCompare(b)); + placeholderOptionCache.set(sourceKey, options); + placeholderFetchPromises.delete(sourceKey); + return options; + }) + .catch(() => { + placeholderFetchPromises.delete(sourceKey); + placeholderOptionCache.set(sourceKey, []); + return []; + }); + placeholderFetchPromises.set(sourceKey, request); + return request; + }; + const placeholderCandidatesFor = (options) => { + const candidates = new Set(options); + return Array.from(candidates).sort((a, b) => a.localeCompare(b)); + }; + const currentLevelPlaceholderSuggestions = (tokenValue, options, limit = 9) => { + const rawToken = String(tokenValue || "").replace(/^\{\{/, "").replace(/\}\}$/, ""); + const rawParts = rawToken.split("."); + const endsAtLevelBoundary = rawToken.endsWith("."); + const baseParts = endsAtLevelBoundary ? rawParts.filter(Boolean) : rawParts.slice(0, -1).filter(Boolean); + const segmentPrefix = endsAtLevelBoundary ? "" : rawParts[rawParts.length - 1] || ""; + const matchesBase = (parts) => baseParts.every((part, index) => parts[index]?.toLowerCase() === part.toLowerCase()); + const suggestions = new Map(); + options.forEach((option) => { + const inner = option.replace(/^\{\{|\}\}$/g, ""); + const parts = inner.split(".").filter(Boolean); + if (parts.length <= baseParts.length || !matchesBase(parts)) return; + const next = parts[baseParts.length]; + if (!next || !next.toLowerCase().startsWith(segmentPrefix.toLowerCase())) return; + const pathParts = [...baseParts, next]; + const suggestion = parts.length === pathParts.length + ? `{{${pathParts.join(".")}}}` + : `{{${pathParts.join(".")}.`; + suggestions.set(suggestion, suggestion); + }); + return Array.from(suggestions.values()) + .filter((suggestion) => suggestion !== tokenValue) + .sort((a, b) => a.localeCompare(b)) + .slice(0, limit); + }; + const placeholderTokenAtCaret = (field) => { + if (!field || !("selectionStart" in field)) return null; + const caret = field.selectionStart || 0; + const before = field.value.slice(0, caret); + const match = before.match(/(^|\s)(\{\{[^\s]*)$/); + if (!match) return null; + const token = match[2]; + return { + value: token, + start: caret - token.length, + end: caret + }; + }; + const placeholderTokensIn = (value) => { + const tokens = []; + const matcher = /(^|\s)(\{\{[^\s]+)/g; + let match = null; + while ((match = matcher.exec(String(value || "")))) { + const start = match.index + match[1].length; + tokens.push({ + value: match[2], + start, + end: start + match[2].length + }); + } + return tokens; + }; + const isValidPlaceholderToken = (token, options) => { + if (!token || !token.startsWith("{{")) return true; + if (!/^\{\{[A-Za-z0-9_.-]+\}\}$/.test(token)) return false; + return options.includes(token); + }; + const isCaretNearPlaceholder = (range, caret) => { + return caret >= range.start - 1 && caret <= range.end + 1; + }; + const updatePlaceholderValidity = (field, force = false) => { + const options = placeholderOptionsFor(field); + if (!field || !options.length) { + field?.classList?.remove("placeholder-invalid"); + return; + } + const caret = force || document.activeElement !== field + ? -1 + : "selectionStart" in field ? field.selectionStart || 0 : -1; + const invalid = placeholderTokensIn(field.value) + .some((token) => !isValidPlaceholderToken(token.value, options) && !isCaretNearPlaceholder(token, caret)); + field.classList.toggle("placeholder-invalid", invalid); + }; + const hidePlaceholderSuggest = () => { + placeholderSuggestBox.hidden = true; + placeholderSuggestBox.replaceChildren(); + activePlaceholderField = null; + activePlaceholderToken = null; + activePlaceholderSuggestions = []; + activePlaceholderIndex = 0; + }; + const positionPlaceholderSuggest = (field) => { + const rect = field.getBoundingClientRect(); + const width = Math.min(Math.max(rect.width, 260), window.innerWidth - 16); + placeholderSuggestBox.style.width = `${width}px`; + const boxRect = placeholderSuggestBox.getBoundingClientRect(); + const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8)); + const below = rect.bottom + 6; + const top = below + boxRect.height < window.innerHeight - 8 + ? below + : Math.max(8, rect.top - boxRect.height - 6); + placeholderSuggestBox.style.left = `${left}px`; + placeholderSuggestBox.style.top = `${top}px`; + }; + const renderPlaceholderSuggest = () => { + placeholderSuggestBox.replaceChildren(); + activePlaceholderSuggestions.forEach((suggestion, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = suggestion.type === "expand" + ? "lumi-placeholder-suggest-item lumi-placeholder-suggest-expand" + : "lumi-placeholder-suggest-item"; + button.textContent = suggestion.type === "expand" ? "Expand" : suggestion.value; + button.setAttribute("aria-selected", index === activePlaceholderIndex ? "true" : "false"); + button.addEventListener("mousedown", (event) => { + event.preventDefault(); + insertPlaceholderSuggestion(index); + }); + placeholderSuggestBox.append(button); + }); + placeholderSuggestBox.hidden = !activePlaceholderSuggestions.length; + if (activePlaceholderField && activePlaceholderSuggestions.length) { + positionPlaceholderSuggest(activePlaceholderField); + } + }; + function insertPlaceholderSuggestion(index = activePlaceholderIndex) { + if (!activePlaceholderField || !activePlaceholderToken) return; + const suggestion = activePlaceholderSuggestions[index]; + if (!suggestion) return; + if (suggestion.type === "expand") { + openPlaceholderTree(activePlaceholderField, activePlaceholderToken); + return; + } + const field = activePlaceholderField; + const before = field.value.slice(0, activePlaceholderToken.start); + const after = field.value.slice(activePlaceholderToken.end); + field.value = `${before}${suggestion.value}${after}`; + const caret = before.length + suggestion.value.length; + field.setSelectionRange?.(caret, caret); + field.dispatchEvent(new Event("input", { bubbles: true })); + updatePlaceholderValidity(field); + field.focus(); + if (suggestion.value.endsWith(".")) { + window.setTimeout(() => updatePlaceholderSuggest(field), 0); + } else { + hidePlaceholderSuggest(); + } + } + const updatePlaceholderSuggest = (field) => { + const sourceKey = placeholderSourceKeyFor(field); + if (field?.dataset.placeholderField && sourceKey && !placeholderOptionCache.has(sourceKey)) { + ensurePlaceholderOptions(field).then(() => { + if (document.activeElement === field || activePlaceholderField === field) { + updatePlaceholderSuggest(field); + } + }); + } + const options = placeholderOptionsFor(field); + updatePlaceholderValidity(field); + if (!options.length) { + hidePlaceholderSuggest(); + return; + } + const token = placeholderTokenAtCaret(field); + if (!token) { + hidePlaceholderSuggest(); + return; + } + const suggestions = currentLevelPlaceholderSuggestions(token.value, options, 9) + .map((value) => ({ type: "placeholder", value })); + suggestions.push({ type: "expand" }); + activePlaceholderField = field; + activePlaceholderToken = token; + activePlaceholderSuggestions = suggestions; + activePlaceholderIndex = 0; + renderPlaceholderSuggest(); + }; + document.querySelectorAll("[data-placeholder-options], [data-placeholder-field]").forEach((field) => { + ensurePlaceholderOptions(field).then(() => updatePlaceholderValidity(field)); + updatePlaceholderValidity(field); + field.addEventListener("input", () => updatePlaceholderSuggest(field)); + field.addEventListener("keyup", () => updatePlaceholderSuggest(field)); + field.addEventListener("click", () => updatePlaceholderSuggest(field)); + field.addEventListener("blur", () => { + updatePlaceholderValidity(field, true); + window.setTimeout(() => { + if (!placeholderSuggestBox.matches(":hover")) hidePlaceholderSuggest(); + }, 120); + }); + field.addEventListener("keydown", (event) => { + if (activePlaceholderField !== field || placeholderSuggestBox.hidden) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + activePlaceholderIndex = (activePlaceholderIndex + 1) % activePlaceholderSuggestions.length; + renderPlaceholderSuggest(); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + activePlaceholderIndex = (activePlaceholderIndex - 1 + activePlaceholderSuggestions.length) % activePlaceholderSuggestions.length; + renderPlaceholderSuggest(); + } else if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + insertPlaceholderSuggestion(); + } else if (event.key === "Escape") { + event.preventDefault(); + hidePlaceholderSuggest(); + } + }); + }); + const buildPlaceholderTree = (options) => { + const root = { children: new Map(), placeholder: "" }; + options.forEach((option) => { + const parts = option.replace(/^\{\{|\}\}$/g, "").split(".").filter(Boolean); + let node = root; + parts.forEach((part) => { + if (!node.children.has(part)) node.children.set(part, { label: part, children: new Map(), placeholder: "" }); + node = node.children.get(part); + }); + node.placeholder = option; + }); + return root; + }; + const nodeMatchesPlaceholderFilter = (node, filter) => { + if (!filter) return true; + const labelMatch = String(node.label || "").toLowerCase().includes(filter); + const placeholderMatch = String(node.placeholder || "").toLowerCase().includes(filter); + if (labelMatch || placeholderMatch) return true; + return Array.from(node.children.values()).some((child) => nodeMatchesPlaceholderFilter(child, filter)); + }; + const renderPlaceholderTreeNode = (node, filter, depth = 0) => { + const hasChildren = node.children.size > 0; + const matches = nodeMatchesPlaceholderFilter(node, filter); + const selfMatches = !filter || + String(node.label || "").toLowerCase().includes(filter) || + String(node.placeholder || "").toLowerCase().includes(filter); + if (node.placeholder && !hasChildren) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "lumi-placeholder-tree-leaf"; + if (filter && !selfMatches) button.classList.add("is-muted"); + button.style.setProperty("--placeholder-depth", depth); + button.append(renderPlaceholderTreeLabel(node, { depth, leaf: true })); + button.addEventListener("click", () => { + insertPlaceholderValueFromTree(node.placeholder); + }); + return button; + } + const details = document.createElement("details"); + details.className = "lumi-placeholder-tree-branch"; + if (filter && !matches) details.classList.add("is-muted"); + details.open = !filter || matches; + const summary = document.createElement("summary"); + summary.style.setProperty("--placeholder-depth", depth); + summary.append(renderPlaceholderTreeLabel(node, { depth, leaf: false })); + details.append(summary); + if (!filter || matches) { + Array.from(node.children.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([, child]) => details.append(renderPlaceholderTreeNode(child, filter, depth + 1))); + } + return details; + }; + const renderPlaceholderTreeLabel = (node, { depth = 0, leaf = false } = {}) => { + const fragment = document.createDocumentFragment(); + const appendMuted = (text) => { + if (!text) return; + const span = document.createElement("span"); + span.className = "lumi-placeholder-tree-muted-token"; + span.textContent = text; + fragment.append(span); + }; + const appendMain = (text) => { + const span = document.createElement("span"); + span.className = "lumi-placeholder-tree-token"; + span.textContent = text || "Placeholders"; + fragment.append(span); + }; + if (depth === 0) appendMuted("{{"); + appendMain(node.label || "Placeholders"); + if (leaf) { + appendMuted("}}"); + } else { + appendMuted("."); + } + return fragment; + }; + const renderPlaceholderTree = () => { + const tree = placeholderTreeModal.querySelector("[data-placeholder-tree]"); + const search = placeholderTreeModal.querySelector("[data-placeholder-tree-search]"); + if (!tree || !search) return; + tree.replaceChildren(); + const filter = search.value.trim().toLowerCase(); + const root = buildPlaceholderTree(activePlaceholderTreeOptions); + Array.from(root.children.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([, child]) => tree.append(renderPlaceholderTreeNode(child, filter, 0))); + }; + const openPlaceholderTree = (field, token) => { + const options = placeholderOptionsFor(field); + if (!options.length) return; + hidePlaceholderSuggest(); + activePlaceholderField = field; + activePlaceholderToken = token || placeholderTokenAtCaret(field) || { + value: "", + start: field.selectionStart || 0, + end: field.selectionEnd || field.selectionStart || 0 + }; + activePlaceholderTreeOptions = options; + placeholderTreeModal.hidden = false; + placeholderTreeModal.classList.add("is-open"); + placeholderTreeModal.setAttribute("aria-hidden", "false"); + const search = placeholderTreeModal.querySelector("[data-placeholder-tree-search]"); + if (search) { + search.value = ""; + search.oninput = renderPlaceholderTree; + } + renderPlaceholderTree(); + window.setTimeout(() => search?.focus(), 0); + }; + const closePlaceholderTree = () => { + placeholderTreeModal.classList.remove("is-open"); + placeholderTreeModal.setAttribute("aria-hidden", "true"); + placeholderTreeModal.hidden = true; + activePlaceholderTreeOptions = []; + }; + function insertPlaceholderValueFromTree(value) { + if (!activePlaceholderField || !activePlaceholderToken || !value) return; + const field = activePlaceholderField; + const before = field.value.slice(0, activePlaceholderToken.start); + const after = field.value.slice(activePlaceholderToken.end); + field.value = `${before}${value}${after}`; + const caret = before.length + value.length; + field.setSelectionRange?.(caret, caret); + field.dispatchEvent(new Event("input", { bubbles: true })); + updatePlaceholderValidity(field); + closePlaceholderTree(); + field.focus(); + } + placeholderTreeModal.querySelectorAll("[data-placeholder-tree-close]").forEach((button) => { + button.addEventListener("click", closePlaceholderTree); + }); + placeholderTreeModal.addEventListener("click", (event) => { + if (event.target === placeholderTreeModal) closePlaceholderTree(); + }); + window.addEventListener("scroll", () => { + if (activePlaceholderField && !placeholderSuggestBox.hidden) positionPlaceholderSuggest(activePlaceholderField); + }, true); + window.addEventListener("resize", () => { + if (activePlaceholderField && !placeholderSuggestBox.hidden) positionPlaceholderSuggest(activePlaceholderField); + }); + document.addEventListener("click", (event) => { + if (!placeholderSuggestBox.contains(event.target) && !event.target.closest?.("[data-placeholder-options], [data-placeholder-field]")) { + hidePlaceholderSuggest(); + } + }); + const selectorEscape = (value) => { if (window.CSS?.escape) return CSS.escape(value); return String(value || "").replace(/["\\]/g, "\\$&"); @@ -707,6 +1156,8 @@ const active = activeCallbackConfirm; if (!active) return; activeCallbackConfirm = null; + if (active.timer) window.clearInterval(active.timer); + if (active.expiryTimer) window.clearTimeout(active.expiryTimer); destructiveModal?.classList.remove("is-open"); destructiveModal?.setAttribute("aria-hidden", "true"); destructiveConfirm?.removeEventListener("click", active.onConfirm); @@ -714,6 +1165,76 @@ active.returnFocus?.focus?.(); }; + const requestDestructiveToken = async (action) => { + const response = await fetch("/api/destructive-confirmations", { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json" }, + body: JSON.stringify({ action }) + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || "Unable to prepare confirmation."); + return payload; + }; + + const normalizeDestructiveAction = (action) => { + try { + return new URL(action, window.location.origin).pathname; + } catch { + return ""; + } + }; + + const timedDestructiveToken = async ({ + action, + title = "Confirm action", + text = "This action cannot be undone.", + label = "Confirm", + danger = true + } = {}) => { + const normalizedAction = normalizeDestructiveAction(action); + if (!destructivePattern.test(normalizedAction)) { + throw new Error("This action is not registered as destructive."); + } + const payload = await requestDestructiveToken(normalizedAction); + const waitMs = Math.max(0, Number(payload.not_before || 0) - Date.now()); + if (!destructiveModal || !destructiveConfirm) { + if (!window.confirm(text)) return null; + if (waitMs > 0) await new Promise((resolve) => window.setTimeout(resolve, waitMs)); + return payload.token; + } + if (activeDestructive?.form) resetDestructive(activeDestructive.form); + if (activeCallbackConfirm) resetCallbackConfirm(false); + return new Promise((resolve) => { + const returnFocus = document.activeElement; + destructiveTitle.textContent = title; + destructiveDescription.textContent = text; + destructiveConfirm.disabled = true; + destructiveConfirm.classList.toggle("danger", danger); + destructiveModal.classList.add("is-open"); + destructiveModal.setAttribute("aria-hidden", "false"); + const update = () => { + const remaining = Math.max(0, Math.ceil((Number(payload.not_before || 0) - Date.now()) / 1000)); + destructiveConfirm.disabled = remaining > 0; + destructiveConfirm.textContent = remaining > 0 ? `${label} in ${remaining}` : label; + if (!remaining && activeCallbackConfirm?.timer) { + window.clearInterval(activeCallbackConfirm.timer); + activeCallbackConfirm.timer = null; + } + }; + const onConfirm = () => resetCallbackConfirm(payload.token); + activeCallbackConfirm = { + resolve, + returnFocus, + onConfirm, + timer: window.setInterval(update, 200), + expiryTimer: window.setTimeout(() => resetCallbackConfirm(null), Math.max(0, Number(payload.expires_at || 0) - Date.now())) + }; + destructiveConfirm.addEventListener("click", onConfirm); + update(); + destructiveConfirm.focus(); + }); + }; + window.LumiConfirm = { destructive({ title = "Confirm action", text = "This action cannot be undone.", label = "Confirm", danger = true } = {}) { if (!destructiveModal || !destructiveConfirm) { @@ -735,6 +1256,15 @@ destructiveConfirm.addEventListener("click", onConfirm); destructiveConfirm.focus(); }); + }, + destructiveToken: timedDestructiveToken, + async destructiveFetch(action, options = {}, confirmOptions = {}) { + const token = await timedDestructiveToken({ ...confirmOptions, action }); + if (!token) return null; + const headers = new Headers(options.headers || {}); + headers.set("X-Confirmation-Token", token); + if (!headers.has("Accept")) headers.set("Accept", "application/json"); + return fetch(action, { ...options, method: options.method || "POST", headers }); } }; @@ -817,13 +1347,7 @@ } try { - const response = await fetch("/api/destructive-confirmations", { - method: "POST", - headers: { "Content-Type": "application/json", "Accept": "application/json" }, - body: JSON.stringify({ action }) - }); - const payload = await response.json(); - if (!response.ok) throw new Error(payload.error || "Unable to prepare confirmation."); + const payload = await requestDestructiveToken(action); if (destructiveStates.get(form) !== state) return; startCountdown({ form, @@ -1054,6 +1578,8 @@ let attachedScreenshotName = "feedback-screenshot.png"; let feedbackSimilarTimer = null; let feedbackSimilarController = null; + let feedbackSimilarMatches = []; + const feedbackDraftStorageKey = "lumi.feedback.draft"; const parseJson = (value, fallback = {}) => { try { @@ -1112,6 +1638,76 @@ } } + const feedbackDraftFieldNames = [ + "summary", + "category", + "severity", + "scope_type", + "description", + "steps_to_reproduce", + "expected_behavior", + "actual_behavior" + ]; + + function saveFeedbackDraft() { + if (!feedbackForm) return; + const draft = {}; + feedbackDraftFieldNames.forEach((name) => { + const field = feedbackForm.elements[name]; + if (field) draft[name] = field.value || ""; + }); + draft.include_browser = Boolean(feedbackForm.querySelector("[data-feedback-include-browser]")?.checked); + draft.include_dom = Boolean(feedbackForm.querySelector("[data-feedback-include-dom]")?.checked); + draft.target_metadata = feedbackForm.querySelector("[data-feedback-target-metadata]")?.value || "{}"; + draft.current_url = feedbackForm.querySelector("[data-feedback-current-url]")?.value || ""; + draft.page_title = feedbackForm.querySelector("[data-feedback-page-title]")?.value || ""; + try { + window.sessionStorage?.setItem(feedbackDraftStorageKey, JSON.stringify(draft)); + } catch {} + } + + function loadFeedbackDraft() { + try { + return JSON.parse(window.sessionStorage?.getItem(feedbackDraftStorageKey) || "null") || null; + } catch { + return null; + } + } + + function restoreFeedbackDraft(draft) { + if (!feedbackForm || !draft) return false; + feedbackDraftFieldNames.forEach((name) => { + const field = feedbackForm.elements[name]; + if (field && draft[name] !== undefined) field.value = draft[name]; + }); + const includeBrowser = feedbackForm.querySelector("[data-feedback-include-browser]"); + const includeDom = feedbackForm.querySelector("[data-feedback-include-dom]"); + if (includeBrowser) includeBrowser.checked = Boolean(draft.include_browser); + if (includeDom) includeDom.checked = Boolean(draft.include_dom); + const targetInput = feedbackForm.querySelector("[data-feedback-target-metadata]"); + const urlInput = feedbackForm.querySelector("[data-feedback-current-url]"); + const titleInput = feedbackForm.querySelector("[data-feedback-page-title]"); + if (targetInput && draft.target_metadata) targetInput.value = draft.target_metadata; + if (urlInput && draft.current_url) urlInput.value = draft.current_url; + if (titleInput && draft.page_title) titleInput.value = draft.page_title; + activeFeedbackMetadata = parseJson(draft.target_metadata, activeFeedbackMetadata); + updateGeneratedScopeLabel(); + return true; + } + + function clearFeedbackDraft() { + try { + window.sessionStorage?.removeItem(feedbackDraftStorageKey); + } catch {} + } + + function hasMeaningfulFeedbackDraft(draft) { + if (!draft) return false; + const hasText = feedbackDraftFieldNames.some((name) => String(draft[name] || "").trim()); + const hasTarget = draft.target_metadata && draft.target_metadata !== "{}"; + return Boolean(hasText || hasTarget || draft.include_browser || draft.include_dom); + } + const sensitiveSelector = [ "input[type='password']", "input[type='hidden']", @@ -1151,25 +1747,112 @@ return safeText(match?.innerText || match?.textContent || "", 200); }; + const directChildText = (element, selector, max = 120) => { + const match = element?.querySelector?.(selector); + if (!match) return ""; + return safeText(Array.from(match.childNodes || []) + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => node.textContent || "") + .join(" "), max); + }; + + const elementOwnLabel = (element, max = 120) => { + if (!element || element.matches?.(sensitiveSelector)) return ""; + const explicit = + element.getAttribute?.("aria-label") || + element.getAttribute?.("title") || + element.getAttribute?.("data-feedback-label") || + element.getAttribute?.("data-label"); + if (explicit) return safeText(explicit, max); + if (["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName)) { + const label = element.id ? document.querySelector(`label[for="${selectorEscape(element.id)}"]`) : element.closest?.("label"); + return safeText(label?.innerText || element.getAttribute?.("placeholder") || element.name || element.tagName.toLowerCase(), max); + } + if (["BUTTON", "A", "SUMMARY", "LABEL"].includes(element.tagName)) { + return safeText(element.innerText || element.textContent || "", max); + } + return ""; + }; + + const semanticSectionLabel = (element) => { + if (!element) return ""; + const heading = + directChildText(element, ":scope > .section-header h1, :scope > .section-header h2, :scope > .section-header h3", 140) || + directChildText(element, ":scope > header h1, :scope > header h2, :scope > header h3", 140) || + directChildText(element, ":scope > h1, :scope > h2, :scope > h3", 140); + if (heading) return heading; + return element.id ? idToLabel(element.id) : ""; + }; + + const idToLabel = (value) => safeText(String(value || "").replace(/^okf-/, "OKF ").replace(/[-_]+/g, " "), 120) + .replace(/\b\w/g, (match) => match.toUpperCase()); + + const semanticContextLabel = (target) => { + const adminFeedbackItem = target?.closest?.(".feedback-admin-item"); + if (adminFeedbackItem) return "Feedback entry metadata"; + const details = target?.closest?.("details"); + if (details) { + const summary = elementOwnLabel(details.querySelector(":scope > summary"), 140); + if (summary) return summary; + } + const table = target?.closest?.("table"); + if (table) { + const header = target.closest("th"); + if (header) return safeText(header.textContent, 120); + const cell = target.closest("td"); + if (cell?.cellIndex >= 0) { + const heading = table.querySelectorAll("thead th")[cell.cellIndex]; + if (heading) return safeText(heading.textContent, 120); + } + return "Table row"; + } + const form = target?.closest?.("form"); + if (form) { + const field = target.closest(".field"); + const fieldLabel = field ? safeText(field.querySelector("label")?.innerText, 120) : ""; + if (fieldLabel) return fieldLabel; + const submit = form.querySelector("button[type='submit'], input[type='submit']"); + const submitLabel = safeText(submit?.innerText || submit?.value, 120); + if (submitLabel) return `${submitLabel} form`; + } + return elementOwnLabel(target, 120); + }; + + const semanticTargetPath = (target) => { + const segments = [window.location.pathname || "/"]; + const section = target?.closest?.("section.card, section, .card, main"); + const sectionLabel = semanticSectionLabel(section); + if (sectionLabel && !segments.includes(sectionLabel)) segments.push(sectionLabel); + const contextLabel = semanticContextLabel(target); + if (contextLabel && !segments.includes(contextLabel)) segments.push(contextLabel); + if (segments.length === 1) { + const fallback = elementOwnLabel(target, 120) || cssPath(target); + if (fallback) segments.push(fallback); + } + return segments.map((segment) => safeText(segment, 140)).filter(Boolean).join(";"); + }; + const safeElementText = (element) => { if (!element || element.matches?.(sensitiveSelector)) return ""; if (["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName)) { return ""; } - return safeText(element.innerText || element.textContent || "", 300); + return elementOwnLabel(element, 120); }; const feedbackTargetMetadata = (element) => { - const target = element?.closest?.("button, a, input, textarea, select, label, summary, [role], .card, section, main") || element || document.body; + const target = element?.closest?.("button, a, input, textarea, select, label, summary, [role], td, th, details, .card, section, main") || element || document.body; + const path = semanticTargetPath(target); return { selector: cssPath(target), + path, tag: target?.tagName?.toLowerCase?.() || "", text: safeElementText(target), aria_label: safeText(target?.getAttribute?.("aria-label"), 200), title: safeText(target?.getAttribute?.("title"), 200), role: safeText(target?.getAttribute?.("role"), 80), - label: nearestText(target, "label"), - heading: nearestText(target, "section, .card"), + label: elementOwnLabel(target, 120), + heading: path, page_url: window.location.href, page_title: document.title, viewport: `${window.innerWidth}x${window.innerHeight}` @@ -1183,8 +1866,8 @@ const generatedScopeLabel = (scope, metadata = {}) => { const pageLabel = currentPageLabel() || "this page"; - const elementLabel = metadata.label || metadata.text || metadata.aria_label || metadata.title || metadata.selector; - if (scope === "element") return elementLabel ? `Clicked element: ${elementLabel}` : `Clicked element on ${pageLabel}`; + const elementLabel = metadata.path || metadata.label || metadata.aria_label || metadata.title || metadata.selector; + if (scope === "element") return elementLabel || `Clicked element on ${pageLabel}`; if (scope === "feature") return `Feature/page: ${pageLabel}`; if (scope === "plugin") { const pluginMatch = window.location.pathname.match(/\/plugins\/([^/]+)/); @@ -1208,16 +1891,46 @@ highlightedFeedbackTarget = null; }; + const performFeedbackReset = () => { + if (!feedbackForm) return; + clearFeedbackDraft(); + clearAttachedScreenshot(); + feedbackForm.reset(); + setSelectOptions(feedbackForm.querySelector("[data-feedback-category]"), feedbackOptions.categories, "bug"); + setSelectOptions(feedbackForm.querySelector("[data-feedback-severity]"), feedbackOptions.severities, "minor"); + setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, "page"); + activeFeedbackTarget = null; + activeFeedbackMetadata = {}; + const targetInput = feedbackForm.querySelector("[data-feedback-target-metadata]"); + const urlInput = feedbackForm.querySelector("[data-feedback-current-url]"); + const titleInput = feedbackForm.querySelector("[data-feedback-page-title]"); + const diagnosticsInput = feedbackForm.querySelector("[data-feedback-diagnostics]"); + const status = feedbackForm.querySelector("[data-feedback-status]"); + if (targetInput) targetInput.value = "{}"; + if (urlInput) urlInput.value = window.location.href; + if (titleInput) titleInput.value = document.title; + if (diagnosticsInput) diagnosticsInput.value = "{}"; + clearFeedbackHighlight(); + clearSimilarFeedback(); + updateGeneratedScopeLabel(); + if (status) status.textContent = "Feedback draft reset."; + feedbackForm.querySelector("input[name='summary']")?.focus(); + }; + const openFeedbackModal = ({ scope = "page", target = null } = {}) => { if (!feedbackModal || !feedbackForm) return; hideContextMenu(); clearFeedbackHighlight(); + const draft = loadFeedbackDraft(); + const restoreDraft = hasMeaningfulFeedbackDraft(draft); feedbackForm.reset(); - clearAttachedScreenshot(); + if (!restoreDraft && !attachedScreenshotBlob) { + clearAttachedScreenshot(); + } setSelectOptions(feedbackForm.querySelector("[data-feedback-category]"), feedbackOptions.categories, "bug"); setSelectOptions(feedbackForm.querySelector("[data-feedback-severity]"), feedbackOptions.severities, "minor"); setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, scope); - const metadata = target ? feedbackTargetMetadata(target) : {}; + const metadata = restoreDraft ? parseJson(draft.target_metadata, target ? feedbackTargetMetadata(target) : {}) : target ? feedbackTargetMetadata(target) : {}; activeFeedbackTarget = target; activeFeedbackMetadata = metadata; const targetInput = feedbackForm.querySelector("[data-feedback-target-metadata]"); @@ -1229,10 +1942,13 @@ if (urlInput) urlInput.value = window.location.href; if (titleInput) titleInput.value = document.title; if (diagnosticsInput) diagnosticsInput.value = "{}"; + if (restoreDraft) { + restoreFeedbackDraft(draft); + } updateGeneratedScopeLabel(); clearSimilarFeedback(); if (status) status.textContent = ""; - if (target && scope === "element") { + if (target && scope === "element" && !restoreDraft) { highlightedFeedbackTarget = target; highlightedFeedbackTarget.classList.add("feedback-target-highlight"); } @@ -1241,8 +1957,9 @@ feedbackForm.querySelector("input[name='summary']")?.focus(); }; - const closeFeedbackModal = () => { + const closeFeedbackModal = ({ preserveDraft = true } = {}) => { if (!feedbackModal) return; + if (preserveDraft) saveFeedbackDraft(); feedbackModal.classList.remove("is-open"); feedbackModal.setAttribute("aria-hidden", "true"); clearFeedbackHighlight(); @@ -1257,7 +1974,7 @@ openFeedbackModal({ scope: openButton.dataset.feedbackScope || "page" }); return; } - if (event.target.closest("[data-feedback-close]") || event.target === feedbackModal) { + if (event.target.closest("[data-feedback-close]")) { event.preventDefault(); closeFeedbackModal(); } @@ -1266,12 +1983,34 @@ feedbackForm?.querySelector("[data-feedback-scope-type]")?.addEventListener("change", updateGeneratedScopeLabel); ["input", "change"].forEach((eventName) => { feedbackForm?.addEventListener(eventName, (event) => { - if (!event.target.matches?.("input[name='summary'], textarea[name='description'], select[name='category'], select[name='scope_type']")) return; - scheduleSimilarFeedbackLookup(); + const draftSelector = [ + "input[name='summary']", + "textarea[name='description']", + "textarea[name='steps_to_reproduce']", + "textarea[name='expected_behavior']", + "textarea[name='actual_behavior']", + "select[name='category']", + "select[name='severity']", + "select[name='scope_type']", + "[data-feedback-include-browser]", + "[data-feedback-include-dom]" + ].join(","); + if (event.target.matches?.(draftSelector)) { + saveFeedbackDraft(); + } + if (event.target.matches?.("input[name='summary'], textarea[name='description'], select[name='category'], select[name='scope_type']")) { + scheduleSimilarFeedbackLookup(); + } }); }); feedbackForm?.addEventListener("click", async (event) => { + const resetButton = event.target.closest("[data-feedback-reset]"); + if (resetButton) { + event.preventDefault(); + performFeedbackReset(); + return; + } const supportButton = event.target.closest("[data-feedback-support-similar]"); if (!supportButton) return; event.preventDefault(); @@ -1319,6 +2058,9 @@ const list = feedbackForm?.querySelector("[data-feedback-similar-list]"); if (box) box.hidden = true; if (list) list.replaceChildren(); + feedbackSimilarMatches = []; + const confirmation = feedbackForm?.querySelector("[data-feedback-similar-confirm-input]"); + if (confirmation) confirmation.checked = false; } function scheduleSimilarFeedbackLookup() { @@ -1327,12 +2069,12 @@ } async function loadSimilarFeedback() { - if (!feedbackForm) return; + if (!feedbackForm) return []; const summary = feedbackForm.querySelector("input[name='summary']")?.value || ""; const description = feedbackForm.querySelector("textarea[name='description']")?.value || ""; if (summary.trim().length < 6 && description.trim().length < 12) { clearSimilarFeedback(); - return; + return []; } feedbackSimilarController?.abort?.(); feedbackSimilarController = new AbortController(); @@ -1351,23 +2093,34 @@ }); const result = await response.json(); if (!response.ok) throw new Error(result.error || "Could not load similar feedback."); - renderSimilarFeedback(result.matches || []); + const matches = result.matches || []; + renderSimilarFeedback(matches); + return matches; } catch (error) { if (error.name !== "AbortError") { renderSimilarFeedback([]); } + return []; } } function renderSimilarFeedback(matches) { const box = feedbackForm?.querySelector("[data-feedback-similar]"); const list = feedbackForm?.querySelector("[data-feedback-similar-list]"); + const confirmation = feedbackForm?.querySelector("[data-feedback-similar-confirm-input]"); if (!box || !list) return; + const previousIds = feedbackSimilarMatches.map((item) => item.id).join(","); + const wasConfirmed = confirmation?.checked === true; list.replaceChildren(); if (!matches.length) { + feedbackSimilarMatches = []; + if (confirmation) confirmation.checked = false; box.hidden = true; return; } + feedbackSimilarMatches = matches; + const nextIds = matches.map((item) => item.id).join(","); + if (confirmation) confirmation.checked = wasConfirmed && previousIds === nextIds; matches.forEach((item) => { const row = document.createElement("article"); row.className = "feedback-similar-item"; @@ -1401,6 +2154,13 @@ const status = feedbackForm.querySelector("[data-feedback-status]"); const includeBrowser = feedbackForm.querySelector("[data-feedback-include-browser]")?.checked; const includeDom = feedbackForm.querySelector("[data-feedback-include-dom]")?.checked; + const matches = await loadSimilarFeedback(); + const similarConfirmation = feedbackForm.querySelector("[data-feedback-similar-confirm-input]"); + if (matches.length && !similarConfirmation?.checked) { + if (status) status.textContent = "Review the related feedback above, then confirm that this report is distinct or adds useful context."; + similarConfirmation?.focus(); + return; + } const formData = new FormData(feedbackForm); formData.set("target_metadata", JSON.stringify(parseJson(formData.get("target_metadata"), {}))); formData.set("diagnostics", JSON.stringify({ @@ -1419,6 +2179,12 @@ screenshot_mode: "user_attached", screenshot_source: attachedScreenshotSource } + : {}), + ...(matches.length && similarConfirmation?.checked + ? { + similar_feedback_confirmation: "distinct_or_additional_context", + similar_feedback_ids: feedbackSimilarMatches.map((item) => item.id).slice(0, 5).join(",") + } : {}) })); if (attachedScreenshotBlob) { @@ -1433,10 +2199,16 @@ body: formData }); const result = await response.json(); + if (response.status === 409 && result.reason === "similar_feedback_confirmation_required") { + renderSimilarFeedback(result.matches || []); + } if (!response.ok) throw new Error(result.error || "Feedback could not be submitted."); if (status) status.textContent = result.message || "Feedback submitted."; + clearFeedbackDraft(); + clearAttachedScreenshot(); + feedbackForm.reset(); window.setTimeout(() => { - closeFeedbackModal(); + closeFeedbackModal({ preserveDraft: false }); window.location.href = `/feedback?id=${encodeURIComponent(result.id)}`; }, 700); } catch (error) { @@ -1785,7 +2557,7 @@ } if (event.key === "Escape") { hideContextMenu(); - closeFeedbackModal(); + closeFeedbackExportModal(); } }); window.addEventListener("blur", hideContextMenu); @@ -1818,4 +2590,203 @@ contextTooltip?.remove(); contextTooltip = null; } + + const feedbackExportModal = document.querySelector("[data-feedback-export-modal]"); + const feedbackExportOutput = feedbackExportModal?.querySelector("[data-feedback-export-output]"); + const feedbackExportStatus = feedbackExportModal?.querySelector("[data-feedback-export-status]"); + const feedbackExportAiInput = document.querySelector("[data-feedback-export-ai]"); + const feedbackExportAiOptions = document.querySelector("[data-feedback-export-ai-options]"); + const feedbackExportMode = feedbackExportModal?.querySelector("[data-feedback-export-mode]"); + const feedbackExportOutputLabel = feedbackExportModal?.querySelector("[data-feedback-export-output-label]"); + let lastFeedbackExportPayload = null; + let lastFeedbackExportJson = ""; + + function feedbackExportTokenLimit() { + const value = Number.parseInt(document.querySelector("[data-feedback-export-token-limit]")?.value || "8000", 10); + if (!Number.isFinite(value)) return 8000; + return Math.max(1000, Math.min(24000, value)); + } + + function feedbackExportUseAi() { + return Boolean(feedbackExportAiInput?.checked && !feedbackExportAiInput.hidden); + } + + function feedbackExportModeValue() { + return feedbackExportMode?.value === "debug" ? "debug" : "taskfile"; + } + + function selectedFeedbackExportIds() { + return Array.from(document.querySelectorAll("[data-feedback-export-select]:checked")) + .map((input) => input.value) + .filter(Boolean); + } + + function feedbackExportFilters() { + const params = new URLSearchParams(window.location.search); + const filters = {}; + ["status", "category", "severity", "scope", "area", "submitter", "date_from", "date_to", "needs_action", "sort"].forEach((key) => { + const value = params.get(key); + if (value) filters[key] = value; + }); + return filters; + } + + function openFeedbackExportModal(json, message = "") { + if (!feedbackExportModal || !feedbackExportOutput) { + if (message) window.alert(message); + return; + } + lastFeedbackExportJson = json; + feedbackExportOutput.value = json; + if (feedbackExportStatus) feedbackExportStatus.textContent = message; + feedbackExportModal.hidden = false; + feedbackExportModal.setAttribute("aria-hidden", "false"); + feedbackExportModal.classList.add("is-open"); + feedbackExportOutput.focus(); + feedbackExportOutput.select(); + } + + function activeFeedbackExportObject(payload = lastFeedbackExportPayload) { + if (!payload) return null; + return feedbackExportModeValue() === "debug" + ? payload.debug_export || payload + : payload.taskfile || payload; + } + + function renderFeedbackExportPayload(payload, message = "Export ready.") { + lastFeedbackExportPayload = payload; + const output = activeFeedbackExportObject(payload); + const label = feedbackExportModeValue() === "debug" + ? "Full debug export JSON" + : "Codex taskfile JSON"; + if (feedbackExportOutputLabel) feedbackExportOutputLabel.textContent = label; + openFeedbackExportModal(JSON.stringify(output || {}, null, 2), message); + } + + function closeFeedbackExportModal() { + if (!feedbackExportModal) return; + feedbackExportModal.classList.remove("is-open"); + feedbackExportModal.setAttribute("aria-hidden", "true"); + feedbackExportModal.hidden = true; + } + + async function requestFeedbackExport(payload) { + if (feedbackExportStatus) feedbackExportStatus.textContent = "Building export..."; + const useAi = feedbackExportUseAi(); + const response = await fetch("/admin/feedback/export", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + ...payload, + use_ai: useAi ? "1" : "0", + ...(useAi ? { token_limit: feedbackExportTokenLimit() } : {}) + }) + }); + const result = await response.json().catch(() => ({ + ok: false, + error: `Feedback export returned ${response.status || "an"} invalid response.` + })); + if (!response.ok || !result.ok) { + throw new Error(result.error || "Feedback export failed."); + } + return result.export; + } + + async function runFeedbackExport(payload) { + try { + if (feedbackExportMode) feedbackExportMode.value = "taskfile"; + openFeedbackExportModal("", "Building export..."); + const result = await requestFeedbackExport(payload); + renderFeedbackExportPayload(result, "Export ready."); + } catch (error) { + lastFeedbackExportPayload = null; + openFeedbackExportModal("", error.message || "Feedback export failed."); + } + } + + async function copyFeedbackExportJson() { + if (!lastFeedbackExportJson) return; + try { + await navigator.clipboard?.writeText(lastFeedbackExportJson); + if (feedbackExportStatus) feedbackExportStatus.textContent = "Copied JSON to clipboard."; + } catch { + feedbackExportOutput?.select(); + document.execCommand?.("copy"); + if (feedbackExportStatus) feedbackExportStatus.textContent = "Selected JSON for copying."; + } + } + + function syncFeedbackExportAiOptions() { + if (feedbackExportAiOptions) { + feedbackExportAiOptions.hidden = !feedbackExportUseAi(); + } + } + + function downloadFeedbackExportJson() { + if (!lastFeedbackExportJson) return; + const blob = new Blob([lastFeedbackExportJson], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + const mode = feedbackExportModeValue() === "debug" ? "debug-export" : "codex-taskfile"; + link.href = url; + link.download = `lumi-feedback-${mode}-${new Date().toISOString().replace(/[:.]/g, "-")}.json`; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } + + feedbackExportAiInput?.addEventListener("change", syncFeedbackExportAiOptions); + feedbackExportMode?.addEventListener("change", () => { + if (lastFeedbackExportPayload) { + renderFeedbackExportPayload(lastFeedbackExportPayload, feedbackExportModeValue() === "debug" + ? "Showing full debug export." + : "Showing compact Codex taskfile."); + } + }); + syncFeedbackExportAiOptions(); + + document.addEventListener("click", (event) => { + if (event.target.closest("[data-feedback-export-select-label]")) { + event.stopPropagation(); + } + }, true); + + document.addEventListener("click", (event) => { + const single = event.target.closest("[data-feedback-export-one]"); + if (single) { + event.preventDefault(); + runFeedbackExport({ ids: [single.dataset.feedbackExportOne] }); + return; + } + if (event.target.closest("[data-feedback-export-selected]")) { + event.preventDefault(); + const ids = selectedFeedbackExportIds(); + if (!ids.length) { + openFeedbackExportModal("", "Select at least one feedback item first."); + return; + } + runFeedbackExport({ ids }); + return; + } + if (event.target.closest("[data-feedback-export-all]")) { + event.preventDefault(); + runFeedbackExport({ mode: "all", ...feedbackExportFilters() }); + return; + } + if (event.target.closest("[data-feedback-export-copy]")) { + event.preventDefault(); + copyFeedbackExportJson(); + return; + } + if (event.target.closest("[data-feedback-export-download]")) { + event.preventDefault(); + downloadFeedbackExportJson(); + return; + } + if (event.target.closest("[data-feedback-export-close]") || event.target === feedbackExportModal) { + event.preventDefault(); + closeFeedbackExportModal(); + } + }); })(); diff --git a/src/web/public/assistant-panels.js b/src/web/public/assistant-panels.js index 7c5d164..ee44e9e 100644 --- a/src/web/public/assistant-panels.js +++ b/src/web/public/assistant-panels.js @@ -34,6 +34,10 @@ return documentObject?.querySelector?.("[data-assistant-panel-slot]") || null; } + function syncMountedClass() { + documentObject?.body?.classList.toggle("has-assistant-panel", mounted.has("lumi_ai")); + } + function register(id, lifecycle) { if (!id || !lifecycle) return; lifecycles.set(id, lifecycle); @@ -58,6 +62,7 @@ } finally { current.root.remove(); mounted.delete(id); + syncMountedClass(); } } @@ -116,6 +121,7 @@ const root = createRoot(panel); target.appendChild(root); mounted.set(panel.panel_id, { root, version }); + syncMountedClass(); await loadAsset("script", panel.script, version); if (expectedSequence !== refreshSequence || mounted.get(panel.panel_id)?.root !== root) return; mountLifecycle(panel.panel_id, root); @@ -152,6 +158,7 @@ } } await reportDiagnostics(panels.find((panel) => panel?.panel_id === "lumi_ai")?.debug); + syncMountedClass(); } async function reportDiagnostics(debug) { diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css index 18c3d24..13bd852 100644 --- a/src/web/public/lumi-components.css +++ b/src/web/public/lumi-components.css @@ -1187,10 +1187,175 @@ input[type="color"] { max-height: 75vh; } +.modal.feedback-export-modal { + width: min(72rem, 92vw); + max-height: min(82vh, 60rem); +} + +.modal.okf-entry-modal-dialog { + width: min(72rem, 94vw); + max-height: min(86vh, 62rem); +} + +.modal.okf-entry-fullscreen-modal { + width: min(96vw, 118rem); + max-height: calc(100dvh - (var(--lumi-space-4) * 2)); + min-height: calc(100dvh - (var(--lumi-space-4) * 2)); +} + +input.okf-text-suggestion::-webkit-calendar-picker-indicator { + display: none !important; +} + +input.okf-text-suggestion { + appearance: textfield; +} + +.lumi-placeholder-suggest { + position: fixed; + z-index: 2400; + display: grid; + gap: 2px; + max-height: min(15rem, 50vh); + overflow: auto; + padding: var(--lumi-space-2); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-raised); + box-shadow: var(--lumi-shadow-lg); +} + +.lumi-placeholder-suggest[hidden] { + display: none; +} + +.lumi-placeholder-suggest-item { + width: 100%; + padding: var(--lumi-space-2) var(--lumi-space-3); + border: 0; + border-radius: var(--lumi-radius-sm); + background: transparent; + color: var(--lumi-text); + font-family: var(--lumi-font-mono); + font-size: 0.9rem; + text-align: left; + cursor: pointer; +} + +.lumi-placeholder-suggest-item:hover, +.lumi-placeholder-suggest-item[aria-selected="true"] { + background: color-mix(in srgb, var(--lumi-primary) 18%, transparent); +} + +.lumi-placeholder-suggest-expand { + margin-top: var(--lumi-space-1); + border-top: 1px solid var(--lumi-border); + color: var(--lumi-primary); + font-family: var(--lumi-font-display); + font-weight: 800; +} + +.lumi-placeholder-tree-dialog { + width: min(58rem, 94vw); + max-height: min(82vh, 54rem); +} + +.lumi-placeholder-tree { + display: grid; + gap: var(--lumi-space-1); + max-height: min(54vh, 34rem); + overflow: auto; + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-subtle); +} + +.lumi-placeholder-tree-branch, +.lumi-placeholder-tree-leaf { + font-family: var(--lumi-font-mono); +} + +.lumi-placeholder-tree-branch > summary, +.lumi-placeholder-tree-leaf { + min-height: 2.25rem; + padding: var(--lumi-space-2) var(--lumi-space-3); + padding-left: calc(var(--lumi-space-3) + (var(--placeholder-depth, 0) * 1.15rem)); + border-radius: var(--lumi-radius-sm); +} + +.lumi-placeholder-tree-branch > summary { + cursor: pointer; +} + +.lumi-placeholder-tree-leaf { + width: 100%; + border: 0; + background: transparent; + color: var(--lumi-text); + text-align: left; + cursor: pointer; +} + +.lumi-placeholder-tree-token { + color: var(--lumi-text); +} + +.lumi-placeholder-tree-muted-token { + color: var(--lumi-text-muted); + opacity: 0.42; +} + +.lumi-placeholder-tree-leaf:hover, +.lumi-placeholder-tree-branch > summary:hover { + background: color-mix(in srgb, var(--lumi-primary) 14%, transparent); +} + +.lumi-placeholder-tree-branch.is-muted > summary, +.lumi-placeholder-tree-leaf.is-muted { + opacity: 0.42; +} + +.placeholder-invalid { + background: + linear-gradient(color-mix(in srgb, var(--lumi-danger) 10%, transparent), color-mix(in srgb, var(--lumi-danger) 10%, transparent)), + var(--lumi-surface) !important; +} + .feedback-modal .modal-actions { + justify-content: space-evenly; + width: 100%; +} + +.feedback-export-modal textarea { + width: 100%; + min-height: min(52vh, 34rem); + font-family: var(--lumi-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + white-space: pre; +} + +.feedback-export-toolbar, +.feedback-export-modal .modal-actions { justify-content: center; } +.feedback-export-select { + margin-left: auto; +} + +.compact-field { + display: inline-flex; + align-items: center; + gap: var(--lumi-space-2); + color: var(--lumi-text-muted); + font-size: 0.9rem; + font-weight: 700; +} + +.compact-field input { + width: 8rem; +} + .modal-header h2, .modal-header h3 { margin-bottom: 0; @@ -1343,6 +1508,13 @@ details > summary { padding: var(--lumi-space-3); } + .modal.okf-entry-fullscreen-modal { + width: 100%; + max-height: calc(100dvh - (var(--lumi-space-2) * 2)); + min-height: calc(100dvh - (var(--lumi-space-2) * 2)); + padding: var(--lumi-space-3); + } + .hero { padding: var(--lumi-space-4); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 9d41cce..c7cb83f 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -1273,6 +1273,10 @@ body { cursor: pointer; } +body.has-assistant-panel .feedback-floating-button { + bottom: 96px; +} + .feedback-floating-badge { min-width: 22px; min-height: 22px; diff --git a/src/web/server.js b/src/web/server.js index 1721813..dc7f2c1 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -111,9 +111,11 @@ const { publishWebEvent, subscribeWebEvents } = require("../services/web-events"); +const placeholders = require("../services/placeholders"); const { addSubmitterComment, adminUpdateFeedback, + buildFeedbackJobExport, cleanupFeedback, createFeedback, deleteFeedback, @@ -130,6 +132,7 @@ const { notificationSummary, supportFeedback } = require("../services/feedback"); +const { saveCorrectionKnowledgeFile } = require("../../plugins/okf/backend/file_knowledge"); function ensureSessionSecret() { let secret = getSetting("session_secret"); @@ -263,6 +266,16 @@ function requireRole(role) { }; } +function formatPlaceholderErrors(errors = []) { + const tokens = errors + .map((error) => error.token || error.id || error.reason) + .filter(Boolean) + .slice(0, 5); + return tokens.length + ? `Unavailable or unauthorized placeholder(s): ${tokens.join(", ")}.` + : "Unavailable or unauthorized placeholder."; +} + function storeDiscordUser(user) { const avatar = user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128` @@ -1712,6 +1725,260 @@ function normalizeFeedbackRequestBody(body = {}) { }; } +function normalizeFeedbackExportRequest(body = {}) { + const rawIds = Array.isArray(body.ids) ? body.ids.join(",") : body.ids; + const ids = String(rawIds || "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + .slice(0, 100); + const tokenLimit = Number.parseInt(body.token_limit || body.tokenLimit, 10); + return { + all: body.mode === "all" || body.all === "1" || body.all === true, + ids, + useAi: body.use_ai === "1" || body.use_ai === "true" || body.useAi === true, + tokenLimit: Number.isFinite(tokenLimit) ? tokenLimit : undefined, + filters: { + status: body.status || "", + category: body.category || "", + severity: body.severity || "", + scope: body.scope || "", + area: body.area || "", + submitter: body.submitter || "", + date_from: body.date_from || "", + date_to: body.date_to || "", + needs_action: body.needs_action || "", + sort: body.sort || "last_activity" + } + }; +} + +function buildFeedbackCorrectionPayload(feedback, body = {}) { + if (!feedback) { + throw new Error("Feedback item was not found."); + } + const title = cleanAiTaskfileText(body.correction_title || feedback.summary || "Feedback correction", 180); + if (!title) { + throw new Error("Correction title is required."); + } + const sourceUrl = `/admin/feedback?feedback=${encodeURIComponent(feedback.id)}`; + const correctionBody = cleanCorrectionMarkdown(body.correction_body || [ + `# ${title}`, + "", + "## Correction", + cleanAiTaskfileText(feedback.expected_behavior || feedback.description || feedback.summary, 1800), + "", + "## Source feedback", + `Feedback ID: ${feedback.id}`, + `Category: ${feedback.category_label || feedback.category || "Feedback"}`, + `Severity: ${feedback.severity_label || feedback.severity || "Unknown"}`, + feedback.scope_label_display ? `Scope: ${cleanAiTaskfileText(feedback.scope_label_display, 400)}` : "", + feedback.current_url ? `Route: ${cleanAiTaskfileText(feedback.current_url, 500)}` : "", + "", + "## Reported behavior", + cleanAiTaskfileText(feedback.description || feedback.summary, 2400), + "", + feedback.actual_behavior ? "## Actual behavior" : "", + feedback.actual_behavior ? cleanAiTaskfileText(feedback.actual_behavior, 1200) : "", + "", + feedback.steps_to_reproduce ? "## Reproduction notes" : "", + feedback.steps_to_reproduce ? cleanAiTaskfileText(feedback.steps_to_reproduce, 1200) : "" + ].filter(Boolean).join("\n")); + return { + title, + slug: body.correction_slug || `feedback-${feedback.id}`, + id: body.correction_id || `correction.feedback.${feedback.id}`, + category: body.correction_category || "Feedback correction", + visibility: body.correction_visibility || "user", + status: body.correction_status || "active", + priority: body.correction_priority || 100, + tags: body.correction_tags || `feedback, correction, ${feedback.category || "feedback"}`, + source_feedback_id: feedback.id, + source_feedback_url: sourceUrl, + body: correctionBody + }; +} + +function cleanCorrectionMarkdown(value) { + return String(value || "") + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]") + .replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, "[ip]") + .replace(/\b([a-z0-9_-]*(?:token|secret|session|password|passwd|api[_-]?key|auth)[a-z0-9_-]*)\s*[:=]\s*[^,\s;]+/gi, "$1=[redacted]") + .replace(/\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+/gi, "[auth header]") + .replace(/\r\n?/g, "\n") + .replace(/\n{4,}/g, "\n\n\n") + .trim() + .slice(0, 12000); +} + +async function maybeAddAiFeedbackExportSummary(exportPayload, req, useAi) { + if (!useAi) return exportPayload; + const ai = feedbackExportAi(); + if (!isFeedbackExportAiAvailable()) { + return { + ...exportPayload, + debug_export: { + ...exportPayload.debug_export, + ai_summary: { + available: false, + reason: "Lumi AI is not available." + } + } + }; + } + const promptPayload = { + taskfile: exportPayload.taskfile + }; + const prompt = + "Improve this compact Lumi feedback Codex taskfile. " + + "Return strict JSON only. Allowed top-level keys: objective and tasks. " + + "Each task may contain only task_id, title, context, requirements, acceptance_criteria, verification, and risk_notes. " + + "Use provided sanitized context notes, including admin replies and private admin work notes, when they clarify the task. " + + "Keep existing task_id values. Do not add private note authors, submitter identity, emails, IPs, tokens, session data, local paths, raw diagnostics, screenshots, or invented exact repo paths. " + + "Keep output compact and directly actionable for a coding agent. " + + `Input JSON:\n${JSON.stringify(promptPayload)}`; + try { + const result = await ai.generate({ + message: prompt, + user: req.session.user, + sessionId: `admin-feedback-export:${req.session.user?.id || "admin"}:${Date.now()}`, + scope: "admin_feedback_export", + max_tokens: exportPayload.debug_export?.token_context_limit || 8000, + allowTools: false + }); + const text = String(result?.text || "").trim(); + const parsed = parseAiJsonObject(text); + const improvedTaskfile = parsed + ? mergeAiFeedbackTaskfilePatch(exportPayload.taskfile, parsed) + : exportPayload.taskfile; + return { + ...exportPayload, + taskfile: improvedTaskfile, + debug_export: { + ...exportPayload.debug_export, + ai_summary: { + available: true, + model_id: result?.model_id || null, + token_limit: exportPayload.debug_export?.token_context_limit || 8000, + content: parsed || text, + applied_to_taskfile: Boolean(parsed) + } + } + }; + } catch (error) { + return { + ...exportPayload, + debug_export: { + ...exportPayload.debug_export, + ai_summary: { + available: false, + reason: error.message || "AI summarization failed." + } + } + }; + } +} + +function mergeAiFeedbackTaskfilePatch(taskfile, patch) { + if (!patch || typeof patch !== "object" || Array.isArray(patch)) return taskfile; + const next = { ...taskfile }; + if (typeof patch.objective === "string" && patch.objective.trim().length >= 12) { + next.objective = cleanAiTaskfileText(patch.objective, 260); + } + if (Array.isArray(patch.tasks) && Array.isArray(next.tasks)) { + const patches = new Map(patch.tasks + .filter((task) => task && typeof task === "object" && !Array.isArray(task)) + .map((task) => [cleanAiTaskfileText(task.task_id, 100), task]) + .filter(([taskId]) => taskId)); + next.tasks = next.tasks.map((task) => { + const taskPatch = patches.get(task.task_id); + if (!taskPatch) return task; + const improved = { ...task }; + if (typeof taskPatch.title === "string" && taskPatch.title.trim()) { + improved.title = cleanAiTaskfileText(taskPatch.title, 180); + } + for (const [key, maxLength, maxItems] of [ + ["context", 500, 10], + ["requirements", 700, 14], + ["acceptance_criteria", 600, 10], + ["verification", 600, 10], + ["risk_notes", 400, 6] + ]) { + const values = normalizeAiStringArray(taskPatch[key], maxLength).slice(0, maxItems); + if (values.length) improved[key] = values; + } + return improved; + }); + } + return next; +} + +function normalizeAiTaskfileArray(value, maxLength, textKey) { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (typeof item === "string") return cleanAiTaskfileText(item, maxLength); + if (!item || typeof item !== "object" || Array.isArray(item)) return null; + const text = cleanAiTaskfileText(item[textKey] || item.text || item.value || "", maxLength); + if (!text) return null; + const out = { [textKey]: text }; + const feedbackId = cleanAiTaskfileText(item.feedback_id || item.feedbackId || "", 80); + if (feedbackId) out.feedback_id = feedbackId; + return out; + }) + .filter(Boolean) + .slice(0, 30); +} + +function normalizeAiStringArray(value, maxLength) { + if (!Array.isArray(value)) return []; + return value + .map((item) => cleanAiTaskfileText(item, maxLength)) + .filter(Boolean) + .slice(0, 12); +} + +function cleanAiTaskfileText(value, maxLength) { + return String(value || "") + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]") + .replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, "[ip]") + .replace(/\b([a-z0-9_-]*(?:token|secret|session|password|passwd|api[_-]?key|auth)[a-z0-9_-]*)\s*[:=]\s*[^,\s;]+/gi, "$1=[redacted]") + .replace(/\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+/gi, "[auth header]") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); +} + +function feedbackExportAi() { + return global.lumiFrameworks?.ai || global.lumiFrameworks?.lumi_ai || null; +} + +function isFeedbackExportAiAvailable() { + const ai = feedbackExportAi(); + if (!ai || typeof ai.generate !== "function") return false; + try { + const capabilities = typeof ai.capabilities === "function" ? ai.capabilities() : null; + if (capabilities && capabilities.enabled === false) return false; + } catch { + return false; + } + return true; +} + +function parseAiJsonObject(text) { + if (!text) return null; + const cleaned = text + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); + try { + const parsed = JSON.parse(cleaned); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + function parseBodyJson(value) { if (value && typeof value === "object") return value; try { @@ -2468,10 +2735,17 @@ async function verifyYouTubeSettings(settings) { function createWebServer({ loadPlugins, discordClient }) { const app = express(); const webhooks = createWebhookService(); + placeholders.registerCorePlaceholders(); + placeholders.registerPlatformPlaceholders({ + discordClient, + getTwitchClient, + getYouTubeClient + }); if (!global.lumiFrameworks) { global.lumiFrameworks = {}; } global.lumiFrameworks.webhooks = webhooks; + global.lumiFrameworks.placeholders = placeholders; const assetVersion = Date.now().toString(); const sessionStore = new BetterSqlite3Store({ client: db @@ -2667,6 +2941,10 @@ function createWebServer({ loadPlugins, discordClient }) { } }; }, + placeholders, + registerPlaceholder: placeholders.registerPlaceholder, + registerPlaceholders: placeholders.registerPlaceholders, + registerPlaceholderField: placeholders.registerFieldPolicy, emitEvent: publishWebEvent }; @@ -2686,6 +2964,37 @@ function createWebServer({ loadPlugins, discordClient }) { users: searchKnownUsers(req.query.q, { limit: req.query.limit || 30 }) }); }); + app.get("/api/placeholders/catalog", requireAuth, (req, res) => { + res.set("Cache-Control", "no-store"); + try { + const catalog = placeholders.catalog({ + fieldId: req.query.field_id, + outputAudience: req.query.output_audience, + user: req.session.user + }); + res.json({ ok: true, ...catalog }); + } catch (error) { + res.status(400).json({ ok: false, error: error.message }); + } + }); + app.post("/api/placeholders/preview", requireAuth, async (req, res) => { + res.set("Cache-Control", "no-store"); + try { + const rendered = await placeholders.renderTemplate({ + fieldId: req.body.field_id, + template: req.body.template, + outputAudience: req.body.output_audience, + user: req.session.user, + runtimeContext: { + user: req.session.user, + preview: true + } + }); + res.json({ ok: rendered.ok, rendered: rendered.rendered, errors: rendered.errors }); + } catch (error) { + res.status(400).json({ ok: false, error: error.message }); + } + }); app.get("/api/feedback/similar", requireAuth, (req, res) => { try { res.json({ @@ -2713,6 +3022,22 @@ function createWebServer({ loadPlugins, discordClient }) { throw new Error(req.feedbackUploadError); } const body = normalizeFeedbackRequestBody(req.body); + const similarMatches = findSimilarFeedback(body, { + userId: req.session.user.id, + limit: 5 + }); + const similarityConfirmed = body.diagnostics?.similar_feedback_confirmation === "distinct_or_additional_context"; + if (similarMatches.length && !similarityConfirmed) { + [...(req.files?.screenshot || []), ...(req.files?.attachments || [])].forEach((file) => { + if (file?.path) fs.rmSync(file.path, { force: true }); + }); + return res.status(409).json({ + ok: false, + reason: "similar_feedback_confirmation_required", + error: "Review the related feedback, then confirm that this report is distinct or adds useful context.", + matches: similarMatches + }); + } const screenshotFile = req.files?.screenshot?.[0] || null; const attachmentFiles = req.files?.attachments || []; const entry = createFeedback(body, req.session.user, { @@ -5203,10 +5528,63 @@ function createWebServer({ loadPlugins, discordClient }) { needs_action: req.query.needs_action || "", sort: req.query.sort || "last_activity" }, - feedbackOptions: feedbackOptions() + feedbackOptions: feedbackOptions(), + feedbackExportAiAvailable: isFeedbackExportAiAvailable() }); }); + app.post("/admin/feedback/export", requireRole("admin"), async (req, res) => { + try { + const request = normalizeFeedbackExportRequest(req.body); + const exportPayload = buildFeedbackJobExport({ + ids: request.ids, + filters: request.filters, + all: request.all, + tokenLimit: request.tokenLimit + }); + const payload = await maybeAddAiFeedbackExportSummary(exportPayload, req, request.useAi); + res.json({ ok: true, export: payload }); + } catch (error) { + res.status(400).json({ ok: false, error: error.message }); + } + }); + + app.post("/admin/feedback/:id/export", requireRole("admin"), async (req, res) => { + try { + const request = normalizeFeedbackExportRequest({ ...req.body, ids: req.params.id }); + const exportPayload = buildFeedbackJobExport({ + ids: request.ids, + all: false, + tokenLimit: request.tokenLimit + }); + const payload = await maybeAddAiFeedbackExportSummary(exportPayload, req, request.useAi); + res.json({ ok: true, export: payload }); + } catch (error) { + res.status(400).json({ ok: false, error: error.message }); + } + }); + + app.post("/admin/feedback/:id/create-okf-correction", requireRole("admin"), (req, res) => { + try { + const feedback = getFeedbackForAdmin(req.params.id); + const correction = saveCorrectionKnowledgeFile(process.cwd(), buildFeedbackCorrectionPayload(feedback, req.body)); + if (!correction) { + throw new Error("Correction OKF file could not be created."); + } + const linkedCorrection = correction.path || `knowledge/corrections/${req.params.id}.md`; + adminUpdateFeedback(req.params.id, { + ...req.body, + linked_correction: linkedCorrection, + work_note: `Created OKF correction: ${linkedCorrection}`, + status_note: req.body.status_note || `Created OKF correction: ${linkedCorrection}` + }, req.session.user); + setFlash(req, "success", `Correction OKF created at ${linkedCorrection}.`); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect(req.get("referer") || "/admin/feedback"); + }); + app.post("/admin/feedback/:id", requireRole("admin"), (req, res) => { try { const body = { ...req.body }; @@ -5371,6 +5749,18 @@ function createWebServer({ loadPlugins, discordClient }) { setFlash(req, "error", "Static commands require a response."); return res.redirect("/admin/commands"); } + if (mode !== "advanced") { + const placeholderValidation = placeholders.validateTemplate({ + fieldId: "core.custom_commands.static_response", + template: response, + outputAudience: "user", + user: req.session.user + }); + if (!placeholderValidation.ok) { + setFlash(req, "error", formatPlaceholderErrors(placeholderValidation.errors)); + return res.redirect("/admin/commands"); + } + } const now = Date.now(); const preview = isAdmin && mode === "advanced" ? await generateCommandPreview({ code, language }) @@ -5461,6 +5851,18 @@ function createWebServer({ loadPlugins, discordClient }) { setFlash(req, "error", "Static commands require a response."); return res.redirect("/admin/commands"); } + if (mode !== "advanced") { + const placeholderValidation = placeholders.validateTemplate({ + fieldId: "core.custom_commands.static_response", + template: response, + outputAudience: "user", + user: req.session.user + }); + if (!placeholderValidation.ok) { + setFlash(req, "error", formatPlaceholderErrors(placeholderValidation.errors)); + return res.redirect("/admin/commands"); + } + } const preview = isAdmin && mode === "advanced" ? await generateCommandPreview({ code, language }) : emptyCommandPreview(); diff --git a/src/web/views/admin-commands.ejs b/src/web/views/admin-commands.ejs index e13c999..01d91ac 100644 --- a/src/web/views/admin-commands.ejs +++ b/src/web/views/admin-commands.ejs @@ -47,7 +47,7 @@ <% } %>
- +
<% if (isAdmin) { %>
@@ -184,7 +184,7 @@ <% } %>
- +
<% if (isAdmin) { %>
diff --git a/src/web/views/admin-feedback.ejs b/src/web/views/admin-feedback.ejs index e3330f1..400dcab 100644 --- a/src/web/views/admin-feedback.ejs +++ b/src/web/views/admin-feedback.ejs @@ -88,6 +88,23 @@

<%= feedbackItems.length %> item(s) shown.

+ <% if (feedbackItems.length) { %> + +

Exports omit submitter identity, raw diagnostics, local file paths, and screenshot/attachment contents. Private work notes are sanitized and included only as admin Codex context.

+ <% } %> <% if (!feedbackItems.length) { %>
No feedback matches this filter.
<% } else { %> @@ -99,6 +116,10 @@ <%= item.summary %> <%= item.submitter_name || item.submitter_id %> · <%= item.category_label %> · <%= item.scope_label_display %> · <%= item.support_count || 0 %> also affected +
@@ -121,6 +125,7 @@