diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..e5531e3 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,21 @@ +name: secret scan + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - name: Scan tracked files for private credentials + run: python scripts/scan_secrets.py --tracked-only diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9e79eb5..cedfc1e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} cache: pip diff --git a/.gitignore b/.gitignore index f2a6be3..8066574 100644 --- a/.gitignore +++ b/.gitignore @@ -15,9 +15,18 @@ dist/ .env .env.* !.env.example +.envrc *.db *.sqlite *.sqlite3 *.log *.bak +*.key +*.pem secrets.json +.codex/ +.claude/ +.loop_memory/ +credentials.json +work/ +.worktrees diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..ac5fb58 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + "charliermarsh.ruff", + "tamasfe.even-better-toml", + "redhat.vscode-yaml" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..23cc308 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Loop Memory: server", + "type": "debugpy", + "request": "launch", + "module": "loop_memory.cli.main", + "args": [ + "serve", + "--port", + "7767" + ], + "console": "integratedTerminal", + "justMyCode": true + }, + { + "name": "Python: current test file", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "${file}", + "-q" + ], + "console": "integratedTerminal", + "justMyCode": true + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ac712c8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,27 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.terminal.activateEnvironment": true, + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.inlayHints.functionReturnTypes": true, + "python.analysis.inlayHints.variableTypes": true, + "editor.formatOnSave": true, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.fixAll.ruff": "explicit", + "source.organizeImports.ruff": "explicit" + } + }, + "ruff.nativeServer": "on", + "files.exclude": { + "**/__pycache__": true, + "**/.pytest_cache": true, + "**/.mypy_cache": true, + "**/.ruff_cache": true + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..5e21fe2 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,44 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Python: tests", + "type": "shell", + "command": "${workspaceFolder}/.venv/bin/pytest", + "args": [ + "-q" + ], + "group": { + "kind": "test", + "isDefault": true + }, + "problemMatcher": [] + }, + { + "label": "Python: Ruff check", + "type": "shell", + "command": "${workspaceFolder}/.venv/bin/ruff", + "args": [ + "check", + "loop_memory", + "tests" + ], + "group": "build", + "problemMatcher": [] + }, + { + "label": "Loop Memory: server", + "type": "shell", + "command": "${workspaceFolder}/.venv/bin/python", + "args": [ + "-m", + "loop_memory.cli.main", + "serve", + "--port", + "7767" + ], + "isBackground": true, + "problemMatcher": [] + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dcef9c..b5e693d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,192 @@ +## [Unreleased] + +### Settings drawer — UX pass +- **Security section** now ships a clear "Enabled / Disabled" status line plus a + one-line hint explaining what a bearer token does on this machine, instead + of leaking the raw i18n key when the translation was missing. +- **Processing toggles** (filter / re-score / distil / dry-run) and the + redaction switches (`Enable redaction`, `Process ...`) + use a unified card style: checkbox + title on the first row, description on + the second row, description text aligned with the card's left edge. +- **Storage & compaction** card pairs the interval input with a compact + "Last compressed" status card of matching height so the two read as one + row. +- **Manual actions** section explains the difference between `Run now` (writes) + and `Preview` (dry-run) inline. +- All new strings are localised in `loop_memory/serve/static/i18n/{en,zh}.json` + so the English and Chinese drawers stay in lockstep. +- Docs: new `docs/settings.md` is the canonical reference for every drawer + control; `docs/auto-capture.md` now lists the real launchd labels + (`com.loopmemory.codex`, `com.loopmemory.claude`, `com.loopmemory.openclaw`) + and the `kickstart -k` / log commands for the Claude watcher. + +### Visual layout regression fix +Restored the pre-`security-fix` visual layout of the Dashboard, LLM audit, +Settings subsections, and the four top-level tabs (timeline / dashboard / + wiki / graph). The four ` - - - - -
- -
- - -
- -
- - - -
- -
- - -
- - - - -
-
- -
-
-
-
- - -
-
-
- - - -
- - -
- - -
-
- - -
-
-

Insights

-
real-time view of how your memory system is evolving
-
-
- live - - -
-
- - -
-
- 📊 - Stats overview - - -
- -
-
-
Total
-
-
all memories
- -
-
-
Today
-
-
new in 24h
- -
-
-
Active
-
-
ready to recall
- -
-
-
Links
- -
graph edges
- -
-
-
Clusters
-
-
memory groups
- -
-
-
Avg score
-
-
importance×recency×use
- -
-
-
Decay %
-
-
below threshold
- -
-
-
Entities
-
-
graph nodes
- -
- -
-
Sources
-
-
upstream tools
- -
-
-
Wiki
-
-
distilled pages
- -
-
-
Recalls 24h
-
-
times surfaced
- -
-
- - -
-
- - - - 0% - -
-
🧠 Occupation
-
-
active / total
-
-
-
- - - - 0% - -
-
Citation
-
-
times surfaced
-
-
-
- - - - 0% - -
-
📉 Decay
-
-
decayed
-
-
-
-
- - -
-
- 🔄 - Lifecycle flow - - total -
- -
-
-
📥
-
-
Extracted
-
0%
-
-
-
-
-
-
Active
-
0%
-
-
-
-
📉
-
-
Decayed
-
0%
-
-
-
-
-
-
Merged
-
0%
-
-
-
-
🗄
-
-
Archived
-
0%
-
-
-
-
🗑
-
-
Forgotten
-
0%
-
-
-
- - -
-
- 🧬 - Self-improvement Pulse - - detect & resolve -
- -
-
-
-
⚠ Contradiction detection
-
-
-
-
- Scanning for contradictions… -
-
-
- -
-
📊 Decay score distribution
-
- - - -
-
-
- - -
-
- 🗜 - Compression & Granularity - - what to distil & how it's classified -
- -
- -
-
- 🗜 Memory compression -
-
-
-
-
compressible
-
-
-
-
avg length
-
-
-
-
progress
-
-
-
-
No items need compression yet.
-
-
- - -
-
- 🔬 Memory granularity -
-
-
-
-
-
🧠 Core
-
-
-
-
-
-
-
📋 Working
-
-
-
-
-
-
-
📝 Scratch
-
-
-
-
-
-
-
-
- - -
-
- 📈 - Data distribution - - type · status · 7-day trend -
- -
- -
- - - - types - - -
-
- - -
-
📊 Status distribution
- - - -
- - -
-
📈 7-day trend
- - - - - - - - - -
-
-
- - -
-
- 🔌 - Sources · Wiki · Ingest - - who contributes & what got distilled -
- -
- -
-
📡 Memory sources
-
- - - - sources - - -
-
-
- - -
-
📖 Wiki health
-
-
-
-
pages
-
-
-
-
avg imp
-
-
-
-
chars
-
-
-
-
refs
-
-
-
-
-
-
coverage
-
- - -
-
📥 Ingest rate (last 24h)
- - - - - - - - - -
-
-
-
- - -
-
- - Pipeline latency - - avg ms per stage -
-
-
- - -
-
- 🩺 - Source health · Weekly digest - - ingest freshness + natural-language recap -
- -
- -
-
- 🩺 Source health - -
-
-
Checking…
-
-
-
- - -
-
- 📝 Weekly digest - - - - - - - - - -
- -
-
Generating…
-
-
-
-
-
- - -
-
- 🔬 - LLM Audit · Write Guard - - token spend + pre-write filters -
- -
- -
-
- 🔬 LLM audit log - - - -
-
-
calls
-
tokens
-
avg ms
-
fails
-
-
-
- - -
-
- 🛡 Write guard - -
-
-
-
-
duplicates
-
-
-
-
too long
-
-
-
-
too short
-
-
-
-
low-signal
-
-
-
- WriteGuard intercepts ingest writes: drops near-duplicate, low-signal, or oversized candidates before they hit the store. -
-
-
-
- - -
-
- 🏗 - Underlying architecture - - how data flows end-to-end -
-
- - - - - - - - - -
-
- -
-
- -
-
- - - - - - -
-
- -
- -
-
- - - - - - - -
- -
-

Loading…

- -
-
-
- -
-
- - - 0 entities · 0 relations - - - - - - - -
-
-
- -
- -
- Wiki - Tag - Concept - Acronym -
-
-
-
-
- -
- -
- - - - - +
+ diff --git a/loop_memory/serve/static/js/App.js b/loop_memory/serve/static/js/App.js new file mode 100644 index 0000000..66273b8 --- /dev/null +++ b/loop_memory/serve/static/js/App.js @@ -0,0 +1,268 @@ +/** + * App — top-level shell that owns the global layout. + * + * Renders: TopBar, Sidebar, Tabs, the active tab pane, Settings drawer, + * RunStrip, Toast, Diagnostic modal. Listens for cross-component events + * (ingest, rescore, llm-run, rebuild-graph) and calls the API. + * + * The store is the cross-component bus: every component reads from it + * (lang, theme, stats, runStatus, activeTab) and a few write to it + * (TopBar writes stats, TopBar writes modelInfo, App writes runStatus). + * Components DO NOT call each other directly — the App listens to user + * events emitted by TopBar and orchestrates API calls. + */ +import { defineComponent, defineAsyncComponent, ref, computed, onMounted, onUnmounted, watch, nextTick } from './lib/vue.esm-browser.prod.js'; +import { store, t, applyTheme, applyLang, loadI18n, toast, registerActions } from './store.js'; +import { api } from './api.js'; + +import { TopBar } from './components/TopBar.js'; +import { Sidebar } from './components/Sidebar.js'; +import { Tabs } from './components/Tabs.js'; +import { Timeline } from './components/Timeline.js'; +import { Dashboard } from './components/Dashboard.js'; +import { Wiki } from './components/Wiki.js'; +import { KnowledgeGraph } from './components/KnowledgeGraph.js'; +// Modals: lazy-loaded — only fetched the first time the user opens them. +const Settings = defineAsyncComponent(() => + import('./components/Settings.js').then(module => module.Settings) +); +import { RunStrip } from './components/RunStrip.js'; +import { Toast } from './components/Toast.js'; +const Diagnostic = defineAsyncComponent(() => + import('./components/Diagnostic.js').then(module => module.Diagnostic) +); + +export const App = defineComponent({ + name: 'App', + components: { TopBar, Sidebar, Tabs, Timeline, Dashboard, Wiki, KnowledgeGraph, Settings, RunStrip, Toast, Diagnostic }, + setup() { + const settingsOpen = ref(false); + const diagOpen = ref(false); + let statusPoll = null; + let modelPoll = null; + + async function refreshStats() { + try { + const data = await api.stats(); + store.stats = { + ...store.stats, + memories: data.memories, sessions: data.sessions, + wiki_pages: data.wiki_pages || 0, avg_score: data.avg_score, + graph: Number.isFinite(data.entities) && Number.isFinite(data.relations) + ? `${data.entities}/${data.relations}` + : store.stats.graph, + dbPath: data.path, + }; + } catch (e) { /* ignore */ } + } + + async function refreshRunStatus() { + try { + const r = await api.llmStatus(); + store.runStatus = r || store.runStatus; + // Compute reachability from (api_key_set, last_test_ok, last_test_at). + // - unset : no key configured + // - ok : key set AND last_test_ok=true within the last 24h + // - stale : key set AND last_test_ok was true but >24h ago (or never) + // - fail : key set AND last_test_ok=false + const apiKeySet = !!r?.api_key_set; + const lastOk = r?.last_test_ok; + const lastAt = r?.last_test_at; + let reach = 'unset'; + if (apiKeySet) { + if (lastOk === true) { + const ageMs = lastAt ? (Date.now() / 1000 - lastAt) * 1000 : Infinity; + reach = ageMs <= 24 * 3600 * 1000 ? 'ok' : 'stale'; + } else if (lastOk === false) { + reach = 'fail'; + } else { + reach = 'stale'; + } + } + store.modelInfo = { + provider: r?.provider || 'rules', + model: r?.model || 'rules', + api_key_set: apiKeySet, + key_len: r?.key_len || 0, + reachability: reach, + last_test_ok: lastOk ?? null, + last_test_at: lastAt ?? null, + last_test_message: r?.last_test_message || '', + }; + if (r?.last_run && r.last_run !== store.lastRunId) { + store.lastRunId = r.last_run; + refreshStats(); + } + } catch (e) { /* ignore */ } + } + + function readTabFromUrl() { + try { + const usp = new URLSearchParams(location.search); + const tab = usp.get('tab'); + if (tab && ['timeline', 'dashboard', 'wiki', 'graph'].includes(tab)) { + store.activeTab = tab; + } + // Dev / screenshot helper: ?drawer=settings opens the settings + // drawer at boot so visual regressions can be captured without + // simulating a click. ?drawer=llm-config opens the LLM-only + // variant (used to capture the model-chip entry point). + const drawer = usp.get('drawer'); + if (drawer === 'settings') { + settingsMode.value = 'settings'; + settingsOpen.value = true; + } else if (drawer === 'llm-config') { + settingsMode.value = 'llm'; + settingsOpen.value = true; + } + } catch (e) { /* ignore */ } + } + + /* Keep the URL bar in sync with whatever tab is active — both + * user-driven (Tabs clicks) and externally-driven (Sidebar session + * picks, Open-wiki from graph, deep-link boot) write to the URL + * through this single path. */ + watch(() => store.activeTab, (tab) => { + if (!tab) return; + try { + const url = new URL(window.location.href); + if (url.searchParams.get('tab') !== tab) { + url.searchParams.set('tab', tab); + window.history.replaceState({}, '', url.toString()); + } + } catch (e) { /* ignore */ } + }); + + onMounted(async () => { + applyTheme(); + applyLang(); + readTabFromUrl(); + await loadI18n(); + applyTheme(); + applyLang(); + store.ready = true; + refreshStats(); + refreshRunStatus(); + statusPoll = setInterval(refreshRunStatus, 3000); + modelPoll = setInterval(refreshStats, 8000); + // Cmd+D shortcut — open Doctor diagnostic modal. + window.addEventListener('keydown', onGlobalKeydown); + }); + + onUnmounted(() => { + if (statusPoll) clearInterval(statusPoll); + if (modelPoll) clearInterval(modelPoll); + window.removeEventListener('keydown', onGlobalKeydown); + }); + + function onGlobalKeydown(e) { + if ((e.metaKey || e.ctrlKey) && (e.key === 'd' || e.key === 'D')) { + e.preventDefault(); + diagOpen.value = true; + } + } + + // --- Action handlers --- + async function onIngest() { + try { + const r = await api.ingest({ source: 'manual' }); + toast(t('action.ingestStarted', { n: r.ingested || 0 }), 2500); + refreshStats(); + } catch (e) { toast(t('common.error') + ': ' + e.message, 4000); } + } + async function onRescore() { + try { + await api.rescore(); + toast(t('action.rescoreDone'), 2000); + refreshStats(); + } catch (e) { toast(t('common.error') + ': ' + e.message, 4000); } + } + async function onLlmRun() { + try { + const r = await api.llmRun({}); + if (r.queued) toast(t('action.llmRunQueued'), 2000); + } catch (e) { toast(t('common.error') + ': ' + e.message, 4000); } + } + // Drawer mode is set by which entry the user clicked: + // - "settings" (gear icon): full drawer (LLM + ingest + schedule) + // - "llm" (model chip): only the LLM Connection section + const settingsMode = ref('settings'); + function onOpenSettings() { settingsMode.value = 'settings'; settingsOpen.value = true; } + function onOpenLlmConfig() { settingsMode.value = 'llm'; settingsOpen.value = true; } + function onOpenDiag() { diagOpen.value = true; } + + // Expose App-level UI controls through the shared actions bus so + // deeply-nested components (e.g. Dashboard's source-health card) + // can open the settings drawer without bubbling events up the tree. + registerActions({ + openSettings: () => settingsOpen.value = true, + openDiag: () => diagOpen.value = true, + // Dashboard's "运行进化" button → reuse the same LLM-run handler + // that TopBar's "立即整理" emits. Keeps a single source of truth. + llmRun: onLlmRun, + }); + function onOpenStats() { /* legacy stats popover — delegated to TopBar */ } + + async function onRebuildGraph() { + // Switch to the graph tab first so the user sees progress; the + // KnowledgeGraph component itself owns the rebuild request now. + store.activeTab = 'graph'; + // Wait a tick so the KG is mounted, then trigger its handler. + await nextTick(); + window.dispatchEvent(new CustomEvent('loop-memory:rebuild-graph')); + } + + async function onConsolidate() { + // Trigger LLM-driven consolidation (a.k.a. AI Run) — uses the same + // endpoint as the topbar's "AI Run" button, just navigated via kebab. + try { + const r = await api.llmRun({}); + if (r.queued) toast(t('action.llmRunQueued'), 2000); + } catch (e) { + toast(t('common.error') + ': ' + e.message, 4000); + } + } + + async function onOpenWiki(payload) { + // From graph dblclick: switch to wiki tab and request the editor to open. + store.activeTab = 'wiki'; + await nextTick(); + window.dispatchEvent(new CustomEvent('loop-memory:open-wiki', { detail: payload || {} })); + } + + return { + store, t, settingsOpen, settingsMode, diagOpen, + onIngest, onRescore, onLlmRun, + onOpenSettings, onOpenLlmConfig, onOpenStats, onOpenDiag, + onRebuildGraph, onOpenWiki, + dismissStrip: () => { store.stripDismissed = true; }, + }; + }, + template: /* html */ ` +
+ +
+ +
+ +
+ + + + +
+
+
+ + + + +
+ `, +}); diff --git a/loop_memory/serve/static/js/api.js b/loop_memory/serve/static/js/api.js new file mode 100644 index 0000000..13ff0a2 --- /dev/null +++ b/loop_memory/serve/static/js/api.js @@ -0,0 +1,189 @@ +/** + * API client for loop-memory. + * + * Single fetchJSON wrapper used by every component. Returns the parsed JSON + * body on 2xx, throws a structured error otherwise. Errors carry the HTTP + * status, response body, and the original URL so the UI can show useful + * toasts and so the test endpoint can tell the user exactly what failed. + */ +const API_BASE = ''; + +function buildUrl(path, params) { + let url = path.startsWith('http') ? path : API_BASE + path; + if (params && Object.keys(params).length > 0) { + const usp = new URLSearchParams(); + for (const k of Object.keys(params)) { + if (params[k] === undefined || params[k] === null || params[k] === '') continue; + usp.set(k, String(params[k])); + } + const qs = usp.toString(); + if (qs) url += (url.includes('?') ? '&' : '?') + qs; + } + return url; +} + +// Auth token for protected endpoints (set by Settings after login) +let _authToken = localStorage.getItem('loop_auth_token') || null; +export function setAuthToken(t) { _authToken = t; if (t) localStorage.setItem('loop_auth_token', t); else localStorage.removeItem('loop_auth_token'); } +export function getAuthToken() { return _authToken; } + +export async function fetchJSON(path, opts = {}) { + const { method = 'GET', params, body, headers = {}, timeoutMs = 30000, cache } = opts; + const url = buildUrl(path, params); + const ctrl = new AbortController(); + const tid = setTimeout(() => ctrl.abort(), timeoutMs); + const finalHeaders = { 'Accept': 'application/json', ...headers }; + if (_authToken) finalHeaders['Authorization'] = 'Bearer ' + _authToken; + if (body !== undefined && !(body instanceof FormData)) { + finalHeaders['Content-Type'] = 'application/json'; + } + let res; + try { + // Pass ``cache`` straight through to the underlying fetch() call so + // callers can override the default cache mode (e.g. ``'no-store'`` + // for high-volatility read endpoints like /api/sessions/counts). + const fetchOpts = { + method, + headers: finalHeaders, + body: body === undefined ? undefined + : body instanceof FormData ? body + : JSON.stringify(body), + signal: ctrl.signal, + }; + if (cache) fetchOpts.cache = cache; + res = await fetch(url, fetchOpts); + } catch (e) { + clearTimeout(tid); + if (e.name === 'AbortError') { + throw new ApiError(0, { error: { message: 'Request timed out' } }, url, 'timeout'); + } + throw new ApiError(0, { error: { message: e.message || 'Network error' } }, url, 'network'); + } + clearTimeout(tid); + let data = null; + const ct = res.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + try { data = await res.json(); } catch { data = null; } + } else { + try { data = await res.text(); } catch { data = null; } + } + if (!res.ok) { + throw new ApiError(res.status, data, url); + } + return data; +} + +export class ApiError extends Error { + constructor(status, body, url, kind) { + const detail = (body && (body.detail || body.error?.message)) || `HTTP ${status}`; + super(typeof detail === 'string' ? detail : JSON.stringify(detail)); + this.name = 'ApiError'; + this.status = status; + this.body = body; + this.url = url; + this.kind = kind || (status === 0 ? 'network' : status >= 500 ? 'server' : 'client'); + } +} + +/** Domain endpoints — short, named functions for clarity. */ +export const api = { + // Generic — also re-export the raw fetchJSON helper for the few + // components that need to call a route the api object doesn't + // wrap (e.g. Sidebar's /api/sessions/counts). + fetchJSON, + diag: () => fetchJSON('/api/diag'), + stats: () => fetchJSON('/api/stats'), + + // Memories + listMemories: (params) => fetchJSON('/api/memories', { params }), + getMemory: (id) => fetchJSON(`/api/memories/${id}`), + deleteMemory: (id) => fetchJSON(`/api/memories/${id}`, { method: 'DELETE' }), + recall: (query, limit = 50) => fetchJSON('/api/recall', { params: { query, limit } }), + + // Sessions + listSessions: (params) => fetchJSON('/api/sessions', { params }), + + // Wiki + listWiki: () => fetchJSON('/api/wiki'), + getWiki: (id) => fetchJSON(`/api/wiki/${id}`), + listContradictions: () => fetchJSON('/api/wiki/contradictions'), + scanContradictions: (params) => fetchJSON('/api/wiki/contradictions/scan', { method: 'POST', params }), + mergeWiki: (winnerId, payload) => fetchJSON(`/api/wiki/${winnerId}/merge`, { method: 'POST', body: payload }), + resolveWikiContradiction: (pageId) => fetchJSON(`/api/wiki/${pageId}/resolve`, { method: 'POST' }), + createWiki: (payload) => fetchJSON('/api/wiki', { method: 'POST', body: payload }), + updateWiki: (id, payload) => fetchJSON(`/api/wiki/${id}`, { method: 'PUT', body: payload }), + deleteWiki: (id) => fetchJSON(`/api/wiki/${id}`, { method: 'DELETE' }), + // Bulk-set ``scope`` on one or many wiki pages in a single + // round-trip. ``payload.page_ids`` is optional — omitting it + // applies to every page (used by the master "全局" toggle's + // bulk-ON path). + bulkScopeWiki: (payload) => fetchJSON('/api/wiki/bulk-scope', { method: 'POST', body: payload }), + + // Graph + graph: (params) => fetchJSON('/api/graph', { params }), + + // LLM admin + llmProviders: () => fetchJSON('/api/admin/llm/providers'), + llmConfig: async () => { + const r = await fetchJSON('/api/admin/llm/config'); + return r.config || r; + }, + llmTest: (payload) => fetchJSON('/api/admin/llm/test', { method: 'POST', body: payload }), + llmStatus: () => fetchJSON('/api/admin/llm/status'), + llmRun: (payload) => fetchJSON('/api/admin/llm/run', { method: 'POST', body: payload }), + llmSchedule: (payload) => fetchJSON('/api/admin/llm/schedule', { method: 'POST', body: payload }), + // Full save — writes the entire {provider, model, base_url, + // schedule, behaviour, api_key} tuple to the settings store. The + // ``/api/admin/llm/schedule`` endpoint stays around for genuine + // quick-toggle callers (the dashboard button, hooks, etc.) and + // merges into ``cfg.schedule`` flat — sending the full form to + // it would put the entire schedule object under + // ``cfg.schedule.schedule`` and silently drop the top-level + // ``enabled``/``mode`` flags, which is the persistence bug. + saveLlm: (payload) => fetchJSON('/api/admin/llm/config', { method: 'PUT', body: payload }), + + // Ingest / score + rescore: () => fetchJSON('/api/admin/rescore', { method: 'POST' }), + rebuildGraph: () => fetchJSON('/api/admin/graph/rebuild', { method: 'POST' }), + ingest: (source, path) => fetchJSON('/api/admin/ingest', { method: 'POST', params: { source, path } }), + // Watcher ingest-cadence settings. ``getIngestConfig`` returns the + // current values plus the defaults and bounds so the Settings UI + // can render hint text without hardcoding them. + getIngestConfig: () => fetchJSON('/api/admin/ingest/config'), + saveIngestConfig: (payload) => fetchJSON('/api/admin/ingest/config', { method: 'POST', body: payload }), + // Redaction toggle + preview. The preview endpoint runs the same + // pipeline the live ingest uses, so the UI can show exactly what + // a pasted snippet will look like after storage. + getRedactConfig: () => fetchJSON('/api/admin/redact'), + saveRedactConfig: (payload) => fetchJSON('/api/admin/redact', { method: 'POST', body: payload }), + redactPreview: (payload) => fetchJSON('/api/admin/redact/preview', { method: 'POST', body: payload }), + // Storage budget + manual compact trigger. The dashboard polls + // getStorage() to show live db size and the compactor's last + // run timestamp. + getStorage: () => fetchJSON('/api/admin/storage'), + saveStorageBudget: (payload) => fetchJSON('/api/admin/storage/budget', { method: 'POST', body: payload }), + runCompact: (params) => fetchJSON('/api/admin/compact', { method: 'POST', params }), + // Force-ingest endpoint: skips the watcher's idle window. Used by + // the IngestPopover "Force active session" button when the user + // has a long-running conversation and doesn't want to wait for the + // 60s idle timer. + forceIngest: (params) => fetchJSON('/api/admin/watcher/force-ingest', { method: 'POST', params }), + activeSession: (source) => fetchJSON('/api/admin/watcher/active-session', { params: { source } }), + + // Audit / runs + llmAudit: () => fetchJSON('/api/llm-audit'), + llmRuns: (params) => fetchJSON('/api/admin/llm/runs', { params }), + + // Other + contradiction: (params) => fetchJSON('/api/contradiction', { params }), + exportData: (format) => fetchJSON('/api/export', { params: { format } }), + + // Graph entity memories (memories that mention this entity). + graphEntityMemories: (name, limit = 10) => + fetchJSON(`/api/graph/entity/${encodeURIComponent(name)}/memories`, { params: { limit } }), + + // Auth token management + authTokenStatus: () => fetchJSON('/api/admin/auth/token'), + authTokenCreate: () => fetchJSON('/api/admin/auth/token', { method: 'POST' }), + authTokenDelete: () => fetchJSON('/api/admin/auth/token', { method: 'DELETE' }), +}; diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js new file mode 100644 index 0000000..91a41a8 --- /dev/null +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -0,0 +1,1353 @@ +/** + * Dashboard — Insights tab. + * + * Renders 11 KPI tiles (with sparklines) + 3 ring meters + lifecycle + + * pulse + compression + granularity + distribution + sources + pipeline + * latency + health/weekly + LLM audit + write-guard + architecture loop. + * Every section defends against missing fields so a partial /api/insights + * payload still renders. + * + * Faithful to the legacy vanilla-JS dashboard (pre-Vue commit 8498eca): + * - 11 KPIs each with an `ik-spark` SVG fed by a rolling 60-sample history. + * - Sub-labels include live numbers (`from N sources`, `N/M of total`, …). + * - Each ring card has both the SVG centre value AND an `irc-val` below. + * - Architecture diagram has a title + subtitle above the ring, emoji + * icons inside each node, and a file-anchor strip below. + * - WriteGuard header shows uptime (time since first audit record). + */ +import { defineComponent, ref, computed, onMounted, onUnmounted } from '../lib/vue.esm-browser.prod.js'; +import { store, t, timeAgo, toast, escapeHtml, sanitizeHtml, callAction, fmtNum, truncate, shortenPath, fmtDuration } from '../store.js'; +import { api } from '../api.js'; +import { RingMeter } from './RingMeter.js'; +import { SectionTitle } from './SectionTitle.js'; + +const SVGNS = 'http://www.w3.org/2000/svg'; +const KIND_TONE = { + episode: 'blue', fact: 'green', rule: 'amber', summary: 'purple', + scratch: 'slate', concept: 'cyan', plan: 'rose', reflection: 'violet', +}; +const STATUS_TONE = { + active: 'green', decayed: 'amber', forgotten: 'rose', archived: 'slate', +}; +const SOURCE_COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#06b6d4', '#a855f7', '#f43f5e']; + +// 7 stages with both an `icon` (emoji) and a `file` anchor used in the +// bottom strip of the architecture diagram. +const STAGE_DEFS = [ + { key: 'capture', tone: 'blue', icon: '📥', file: 'cli/main.py hook' }, + { key: 'reflect', tone: 'green', icon: '🪞', file: 'engine/reflect.py' }, + { key: 'score', tone: 'amber', icon: '⚖', file: 'jobs/consolidate.py' }, + { key: 'store', tone: 'purple', icon: '🗄', file: 'storage/sqlite_store.py' }, + { key: 'recall', tone: 'cyan', icon: '🔍', file: 'serve/app.py /api/recall' }, + { key: 'surface', tone: 'rose', icon: '📖', file: 'mcp/ + graph/' }, + { key: 'loopback', tone: 'amber', icon: '🔁', file: 'cli/main.py install-hooks' }, +]; + +// Pre-computed geometry for the architecture ring. R = ring radius, RR = +// arc radius (slightly outside the ring so the animated arrow doesn't +// cross the node cards). +const ARCH = (() => { + const W = 1200, H = 660; + const cx = 600, cy = 330, R = 220, RR = R + 14; + const positions = STAGE_DEFS.map((s, i) => { + const a = -Math.PI / 2 + i * (2 * Math.PI / STAGE_DEFS.length); + return { ...s, idx: i, a, x: cx + Math.cos(a) * R, y: cy + Math.sin(a) * R }; + }); + const arcs = positions.map((p, i) => { + const n = STAGE_DEFS.length; + const a1 = p.a + 0.32; + const a2 = positions[(i + 1) % n].a - 0.32; + const x1 = cx + Math.cos(a1) * RR; + const y1 = cy + Math.sin(a1) * RR; + const x2 = cx + Math.cos(a2) * RR; + const y2 = cy + Math.sin(a2) * RR; + return { d: 'M' + x1.toFixed(1) + ' ' + y1.toFixed(1) + + ' A' + RR + ' ' + RR + ' 0 0 1 ' + + x2.toFixed(1) + ' ' + y2.toFixed(1) }; + }); + // Spokes from each node to the hub edge. + const spokes = positions.map((p) => { + const dx = cx - p.x, dy = cy - p.y; + const d = Math.sqrt(dx * dx + dy * dy); + const ux = dx / d, uy = dy / d; + const x1 = p.x + ux * 67; // NODE_W/2 + 2 + const y1 = p.y + uy * 30; // NODE_H/2 + 2 + const x2 = cx - ux * 80; // hub radius 78 + const y2 = cy - uy * 80; + return { x1, y1, x2, y2 }; + }); + // File anchor strip at the bottom: 7 columns. + const legendY = H - 34; + const colW = W / STAGE_DEFS.length; + const anchors = STAGE_DEFS.map((s, i) => ({ + key: s.key, + file: s.file, + cx: colW * i + colW / 2, + y: legendY, + })); + return { W, H, cx, cy, R, positions, arcs, spokes, anchors }; +})(); + +// fmtNum, truncate, shortenPath, fmtDuration now live in store.js (shared +// with Timeline). Only safeArr stays local — it's a Dashboard-only guard. +function safeArr(x) { return Array.isArray(x) ? x : []; } + +export const Dashboard = defineComponent({ + name: 'Dashboard', + components: { RingMeter, SectionTitle }, + setup() { + const insights = ref(null); + const weeklyReport = ref(null); + const weeklyDays = ref(7); + const weeklyLoading = ref(false); + const weeklyError = ref(''); + const llmAudit = ref(null); + const writeGuard = ref(null); + const sourceHealth = ref(null); + const loading = ref(false); + const live = ref(false); + const lastRefresh = ref(0); + const resolvingId = ref(''); + + // Rolling history arrays for sparklines (max 60 samples). + const hist = { + total: [], today: [], active: [], links: [], clusters: [], + avg: [], decay: [], entities: [], + }; + + let pollHandle = null; + const resolvedPairs = new Set(); + + function contradictionKey(pair) { + const ids = [pair?.a?.id || '', pair?.b?.id || ''].sort(); + return `${ids[0]}|${ids[1]}`; + } + + function pushHistory(arr, v) { + arr.push(v); + if (arr.length > 60) arr.shift(); + } + + // Build an SVG `d` attribute for a sparkline. + function sparkPath(series, w = 100, h = 18) { + if (!series || series.length < 2) return ''; + const max = Math.max(...series, 1); + const min = Math.min(...series, 0); + const range = (max - min) || 1; + const step = w / (series.length - 1); + let d = ''; + series.forEach((v, i) => { + const x = i * step; + const y = h - ((v - min) / range) * (h - 4) - 2; + d += (i === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1) + ' '; + }); + return d.trim(); + } + + async function refresh() { + loading.value = true; + try { + const [stats, insightsData, health, audit, guard] = await Promise.all([ + api.stats().catch(() => null), + fetch('/api/insights').then(r => r.ok ? r.json() : null).catch(() => null), + fetch('/api/source-health').then(r => r.ok ? r.json() : null).catch(() => null), + fetch('/api/llm-audit?limit=24').then(r => r.ok ? r.json() : null).catch(() => null), + fetch('/api/write-guard').then(r => r.ok ? r.json() : null).catch(() => null), + ]); + if (stats) { + store.stats = { + ...store.stats, + memories: stats.memories, + sessions: stats.sessions, + wiki_pages: stats.wiki_pages || 0, + avg_score: stats.avg_score, + graph: insightsData + ? `${insightsData.overview?.entities || 0}/${insightsData.overview?.links || 0}` + : store.stats.graph, + dbPath: stats.path, + }; + } + if (insightsData?.pulse?.contradictions) { + insightsData.pulse.contradictions = insightsData.pulse.contradictions + .filter(pair => !resolvedPairs.has(contradictionKey(pair))); + } + insights.value = insightsData; + live.value = !!insightsData; + sourceHealth.value = health; + llmAudit.value = audit; + writeGuard.value = guard; + lastRefresh.value = Date.now(); + + // Update sparkline history when new insights arrive. + if (insightsData?.overview) { + const o = insightsData.overview; + pushHistory(hist.total, o.total || 0); + pushHistory(hist.today, o.today || 0); + pushHistory(hist.active, o.active || 0); + pushHistory(hist.links, o.links || 0); + pushHistory(hist.clusters, o.clusters || 0); + pushHistory(hist.avg, o.avg_score || 0); + pushHistory(hist.decay, o.decay_pct || 0); + pushHistory(hist.entities, o.entities || 0); + } + } catch (e) { live.value = false; } + finally { loading.value = false; } + } + + async function loadWeekly(days = 7, force = false) { + weeklyLoading.value = true; + weeklyError.value = ''; + try { + const url = `/api/weekly-report?days=${days}&_=${Date.now()}` + (force ? '&force=true' : ''); + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + weeklyReport.value = data; + weeklyDays.value = days; + } catch (e) { weeklyError.value = e?.message || 'failed'; } + finally { weeklyLoading.value = false; } + } + + async function resolvePair(pair, action) { + const aId = pair?.a?.id, bId = pair?.b?.id; + if (!aId || !bId) return; + const key = `${contradictionKey(pair)}|${action}`; + resolvingId.value = key; + try { + const url = `/api/contradictions/resolve?a=${encodeURIComponent(aId)}&b=${encodeURIComponent(bId)}&action=${encodeURIComponent(action)}`; + const res = await fetch(url, { method: 'POST' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + resolvedPairs.add(contradictionKey(pair)); + if (insights.value?.pulse?.contradictions) { + insights.value.pulse.contradictions = insights.value.pulse.contradictions + .filter(item => contradictionKey(item) !== contradictionKey(pair)); + } + const payload = await res.json().catch(() => ({})); + let toastKey = 'dash.pulse.resolvedDel'; + let toastVars = undefined; + if (action === 'ignore') { + toastKey = 'dash.pulse.resolvedIgnore'; + } else if (action === 'merge') { + if (payload && payload.merged) { + // Show a 'merged both into one' message; if the loser's text was + // actually appended (rather than being a substring already + // present in the winner), include the new total length. + toastKey = payload.appended + ? 'dash.pulse.resolvedMergeAppended' + : 'dash.pulse.resolvedMerge'; + if (payload.appended) { + toastVars = { length: fmtNum(payload.new_length || 0) }; + } + } else { + // One side was already missing — we still cleaned up. + toastKey = 'dash.pulse.resolvedMergeOneGone'; + } + } + // keepA / keepB still delete the loser side + toast(toastVars ? t(toastKey, toastVars) : t(toastKey), 2400); + void refresh(); + } catch (e) { + toast(t('dash.pulse.resolvedErr') + (e?.message || 'resolve failed'), 3200); + } + finally { resolvingId.value = ''; } + } + + async function copyWeekly() { + const md = weeklyReport.value?.markdown || ''; + if (!md) return; + try { await navigator.clipboard?.writeText(md); } catch (e) { /* ignore */ } + } + + // Tiny inline Markdown renderer for the weekly report card. + // + // The weekly digest comes back from the LLM as plain Markdown, but the + // UI was previously rendering it as a literal string — bullets showed + // up as `1. xxx` and bold/headings leaked through as raw asterisks. + // This renderer is intentionally tiny (no external deps) and only + // handles the subset that MiniMax-M2.7 actually emits: + // # h1..h6 →

..

+ // ## h2 / ### h3 →

/

+ // **bold** / *italic* → / + // `code` → + // - foo / 1. foo →
  • /
    1. + // ```fences``` →
      
      +    //   blank lines            → paragraph break
      +    //
      +    // We tokenize line-by-line, then render each token. Each token's text
      +    // is HTML-escaped *before* the inline rules run, so an adversarial
      +    // markdown string can never smuggle markup through.
      +    function _tokenizeWeeklyMarkdown(md) {
      +      if (!md) return [];
      +      const lines = String(md).split(/\n/);
      +      const tokens = [];
      +      let i = 0;
      +      while (i < lines.length) {
      +        const line = lines[i];
      +        if (/^```/.test(line)) {
      +          const lang = line.replace(/^```\s*/, '').trim();
      +          const body = [];
      +          i++;
      +          while (i < lines.length && !/^```/.test(lines[i])) {
      +            body.push(lines[i]); i++;
      +          }
      +          i++; // skip closing fence
      +          tokens.push({ kind: 'fence', lang, body: body.join('\n') });
      +          continue;
      +        }
      +        const h = line.match(/^(#{1,6})\s+(.+)$/);
      +        if (h) { tokens.push({ kind: 'heading', level: h[1].length, text: h[2] }); i++; continue; }
      +        if (/^[-*]\s+/.test(line)) {
      +          const items = [];
      +          while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
      +            items.push(lines[i].replace(/^[-*]\s+/, ''));
      +            i++;
      +          }
      +          tokens.push({ kind: 'ul', items });
      +          continue;
      +        }
      +        if (/^\d+\.\s+/.test(line)) {
      +          const items = [];
      +          while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
      +            items.push(lines[i].replace(/^\d+\.\s+/, ''));
      +            i++;
      +          }
      +          tokens.push({ kind: 'ol', items });
      +          continue;
      +        }
      +        if (!line.trim()) { i++; continue; }
      +        const para = [];
      +        while (i < lines.length && lines[i].trim()
      +               && !/^(#{1,6}\s|[-*]\s|\d+\.\s|```)/.test(lines[i])) {
      +          para.push(lines[i]); i++;
      +        }
      +        tokens.push({ kind: 'p', text: para.join('\n') });
      +      }
      +      return tokens;
      +    }
      +
      +    function _applyInline(s) {
      +      return s
      +        .replace(/`([^`]+)`/g, '$1')
      +        .replace(/\*\*([^*]+)\*\*/g, '$1')
      +        .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2');
      +    }
      +
      +    function renderWeeklyMarkdown(md) {
      +      const tokens = _tokenizeWeeklyMarkdown(md);
      +      return tokens.map(tok => {
      +        if (tok.kind === 'heading') {
      +          const safe = escapeHtml(tok.text);
      +          return `${_applyInline(safe)}`;
      +        }
      +        if (tok.kind === 'fence') {
      +          const lang = tok.lang ? ` data-lang="${escapeHtml(tok.lang)}"` : '';
      +          return `
      ${escapeHtml(tok.body)}
      `; + } + if (tok.kind === 'ul') { + return '
        ' + tok.items.map(it => '
      • ' + _applyInline(escapeHtml(it)) + '
      • ').join('') + '
      '; + } + if (tok.kind === 'ol') { + return '
        ' + tok.items.map(it => '
      1. ' + _applyInline(escapeHtml(it)) + '
      2. ').join('') + '
      '; + } + if (tok.kind === 'p') { + return '

      ' + _applyInline(escapeHtml(tok.text)).replace(/\n/g, '
      ') + '

      '; + } + return ''; + }).join('\n'); + } + + // Render the model chain-of-thought block as a collapsed
      . + function renderWeeklyThinking(think) { + if (!think || !think.trim()) return ''; + const escaped = escapeHtml(think); + const len = think.length; + const summary = t('dash.health.thinkSummary', { n: len }); + return `
      ${summary}
      ${escaped}
      `; + } + + const weeklyMarkdownHtml = computed(() => { + if (!weeklyReport.value) return ''; + const think = renderWeeklyThinking(weeklyReport.value.thinking); + const body = renderWeeklyMarkdown(weeklyReport.value.markdown); + // Sanitize before v-html to prevent XSS + return sanitizeHtml(think + body); + }); + + onMounted(() => { + refresh(); + pollHandle = setInterval(refresh, 6000); + // Weekly report is intentionally NOT refreshed in the polling loop + // (it is heavy and flickers the markdown content). Fetch only on + // explicit user action or when the user navigates back to the tab. + loadWeekly(weeklyDays.value); + }); + onUnmounted(() => { if (pollHandle) clearInterval(pollHandle); }); + + function donutArcPaths(rows) { + const items = safeArr(rows); + const total = items.reduce((a, b) => a + (b.count || 0), 0) || 1; + const r = 46, c = 2 * Math.PI * r; + let acc = 0; + return items.map((row, idx) => { + const start = acc / total; + acc += row.count; + const end = acc / total; + return { + color: SOURCE_COLORS[idx % SOURCE_COLORS.length], + dasharray: `${c * (end - start)} ${c}`, + dashoffset: -c * start, + }; + }); + } + + function trendPoints(rows, w = 300, h = 170) { + // Layout: + // viewBox height h = 170 + // plot area: y[10..132] (chart line lives here) + // label band: y[148..162] (x-axis dates sit BELOW the chart) + // The previous 140-tall viewBox forced labels to y=135 — directly + // on top of the line when a day's count was zero — so date text + // and chart stroke visibly collided. Adding 30px of bottom room + // moves labels cleanly out of the plot area. + const items = safeArr(rows); + if (!items.length) return { line: '', area: '', ticks: [], max: 1 }; + const max = Math.max(...items.map(r => r.count), 1); + const step = w / Math.max(1, items.length - 1); + const plotTop = 10; + const plotBottom = 132; + const pts = items.map((r, i) => { + const cx = i * step; + const cy = plotBottom - ((r.count || 0) / max) * (plotBottom - plotTop); + return [cx, cy]; + }); + const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' '); + const area = `${line} L${w},${plotBottom} L0,${plotBottom} Z`; + const ticks = items.map((r, i) => ({ x: i * step, label: r.date ? r.date.slice(5) : '', value: r.count })); + return { line, area, ticks, max, labelY: 156 }; + } + + function ingestBars(rows, w = 360, h = 118) { + // 24 buckets. Layout: 24 bars in w, but with breathing room. We expose + // x/y/width/height plus an `axis` position and a `tier` flag so the + // template can render the small hour ticks on every 4-hour mark. + const items = safeArr(rows); + const max = Math.max(...items.map(r => r.count || 0), 1); + const peak = Math.max(...items.map(r => r.count || 0), 0); + const plot = { left: 4, right: 4, top: 8, bottom: 18 }; + const innerW = w - plot.left - plot.right; + const innerH = h - plot.top - plot.bottom; + const baseline = plot.top + innerH; + const bw = innerW / Math.max(1, items.length); + return items.map((r, i) => { + const hourNum = Number((r.hour || '0').split(':')[0]) || 0; + const barH = Math.max(2, ((r.count || 0) / max) * innerH); + return { + x: plot.left + i * bw + Math.max(0.5, (bw - Math.max(2, bw - 2)) / 2), + w: Math.max(2, bw - 2), + y: baseline - barH, + h: barH, + baseline, + tickY: baseline + 4, + lblY: baseline + 14, + tick: hourNum % 4 === 0, // major tick every 4h + label: hourNum % 4 === 0 ? String(hourNum).padStart(2, '0') : '', + isPeak: r.count === peak && peak > 0, + active: r.count > 0, + hour: r.hour, + count: r.count, + }; + }); + } + + function barsFor(items, w = 360, h = 230) { + // 10 score bands. We give 3 layout regions inside the SVG height: + // y[top:28 .. baseline:182] : bars + // y[tickY:182 .. labelY:198] : axis tick + short label + // y[metaY:214] : optional per-bar full range (rare) + // + // Axis tick + short label both sit on the bucket BOUNDARY + // (right edge of bucket i = left edge of bucket i+1), which is the + // standard histogram layout. Bar geometry uses 4px inner padding so + // neighbouring bars don't visually touch, but the axis labels stay + // perfectly aligned with the tick lines regardless of that padding. + const list = safeArr(items); + const max = Math.max(...list.map(r => r.count || 0), 1); + const plot = { left: 14, right: 6, top: 28, bottom: 56 }; + const innerW = w - plot.left - plot.right; + const innerH = h - plot.top - plot.bottom; + const baseline = plot.top + innerH; // 174 + const bw = innerW / Math.max(1, list.length); + // Compact decimal label used on the decay-distribution x-axis. + // Old version dropped the leading zero for fractional values + // ("0.2" → ".2"), making the axis ambiguous at a glance. + // Use toFixed(1) so every tick reads consistently as + // "0", "0.2", "0.4", …, "1". + const fmtShort = (v) => (v == null ? '' : v >= 1 ? '1' : (Math.round(v * 10) / 10).toFixed(1)); + return list.map((r, i) => { + const upper = r.range ? r.range[1] : null; + const lower = r.range ? r.range[0] : null; + const bucketLeft = plot.left + i * bw; + const bucketRight = plot.left + (i + 1) * bw; + return { + // Bar — slightly inset inside its bucket for visual breathing room. + x: bucketLeft + (bw > 18 ? 4 : 1), + w: Math.max(6, bw - (bw > 18 ? 8 : 2)), + y: baseline - Math.max(2, ((r.count || 0) / max) * innerH), + h: Math.max(2, ((r.count || 0) / max) * innerH), + baseline, + countY: baseline - Math.max(2, ((r.count || 0) / max) * innerH) - 7, + // Tick + label both sit on the bucket BOUNDARY (right edge = + // upper bound of the score range). For the very last bucket + // that's the chart's right edge; for others it's the divider + // with the next bucket. Tick is 1px wide and centered on the + // boundary via x=tickX-0.5. + tickX: bucketRight, + labelX: bucketRight, + tickY: baseline + 6, + labelY: baseline + 20, + // short label: the upper bound of the bucket, formatted with + // a leading zero. We show every other bucket to keep the + // axis readable at narrow widths. + shortLabel: upper != null ? fmtShort(upper) : '', + // For decimal-aligned axis (the '.' of '0.2' lands on the + // tick), the template splits the label at '.' into a prefix + // (rendered with text-anchor=end just before the tick) and + // a '.suffix' (rendered with text-anchor=start right after). + // We pre-split it here so Vue's template stays simple and + // the gutter between prefix and suffix is exactly 0. + labelPrefix: upper != null ? String(fmtShort(upper)).split('.')[0] : '', + labelSuffix: upper != null + ? (String(fmtShort(upper)).indexOf('.') >= 0 + ? '.' + String(fmtShort(upper)).split('.')[1] + : '') + : '', + // full range label kept for the (rare) tooltip/full view use + label: (lower != null && upper != null) ? `${lower.toFixed(1).replace(/\b0\.0\b/g, '0')}–${upper.toFixed(1).replace(/\b1\.0\b/g, '1')}` : '', + showShortLabel: i % 2 === 1, // 0, 2, 4... hide; 1, 3, 5... show + peak: r.count > 0 && r.count === max, + count: r.count, + }; + }); + } + + function scoreDistributionTotal(items) { + return safeArr(items).reduce((sum, item) => sum + (item.count || 0), 0); + } + + const scoreBars = computed(() => barsFor( + (insights.value && insights.value.pulse && insights.value.pulse.score_distribution) || [] + )); + + function peakScoreRange(items) { + const list = safeArr(items); + if (!list.length) return '—'; + const peak = list.reduce((best, item) => (item.count || 0) > (best.count || 0) ? item : best, list[0]); + return peak.range ? `${peak.range[0].toFixed(1)}–${peak.range[1].toFixed(1)}` : '—'; + } + + function sourceBars(sources) { + const items = safeArr(sources); + const total = items.reduce((a, b) => a + (b.count || 0), 0) || 1; + return items.map((s, i) => ({ + ...s, pct: (s.count / total) * 100, + color: SOURCE_COLORS[i % SOURCE_COLORS.length], + })); + } + + function pipelineBars(rows) { + const items = safeArr(rows); + const max = Math.max(...items.map(r => r.avg_ms || 0), 1); + return items.map(r => ({ ...r, pct: ((r.avg_ms || 0) / max) * 100 })); + } + + function ingestPeakHour(rows) { + const items = safeArr(rows); + if (!items.length) return null; + const peak = items.reduce((best, r) => (r.count || 0) > (best.count || 0) ? r : best, items[0]); + if (!peak || !(peak.count > 0)) return null; + const total = items.reduce((a, b) => a + (b.count || 0), 0) || 1; + return `${peak.hour} · ${peak.count} (${Math.round(((peak.count || 0) / total) * 100)}%)`; + } + + /* Per-tone RGB hex table — used by the lifecycle beads for + * colour interpolation between adjacent stages. Keep in sync + * with .ins-lc-stage[data-tone='*'] in layout.css. */ + const LC_TONE_HEX = { + blue: '#3b82f6', + green: '#10b981', + amber: '#f59e0b', + purple: '#8b5cf6', + slate: '#64748b', + rose: '#f43f5e', + }; + + function lifecycleSegments(stages) { + const stg = stages || {}; + const labels = { + extracted: t('dash.lc.extracted'), active: t('dash.lc.active'), decayed: t('dash.lc.decayed'), + merged: t('dash.lc.merged'), archived: t('dash.lc.archived'), forgotten: t('dash.lc.forgotten'), + }; + const tones = { + extracted: 'blue', active: 'green', decayed: 'amber', + merged: 'purple', archived: 'slate', forgotten: 'rose', + }; + const icons = { + extracted: '📥', active: '⚡', decayed: '📉', + merged: '⊕', archived: '🗄', forgotten: '🗑', + }; + const order = ['extracted', 'active', 'decayed', 'merged', 'archived', 'forgotten']; + const total = order.reduce((a, k) => a + (stg[k] || 0), 0) || 1; + let acc = 0; + return order.map(k => { + const pct = ((stg[k] || 0) / total) * 100; + const seg = { + key: k, label: labels[k], tone: tones[k], + toneColor: LC_TONE_HEX[tones[k]], + icon: icons[k], count: stg[k] || 0, pct, x: acc, + }; + acc += pct; + return seg; + }); + } + + // Memoized derived views. These were previously plain functions invoked + // directly in the template (trendPoints 3x, donutArcPaths 2x with two + // different inputs, lifecycleSegments 2x per render). Wrapping them in + // computed caches the geometry between the 6s polls so we don't rebuild + // SVG paths on every unrelated re-render. + const trendPointsData = computed(() => trendPoints(insights.value?.distribution?.trend)); + const donutArcPathsTypes = computed(() => donutArcPaths(insights.value?.distribution?.types)); + const donutArcPathsSources = computed(() => donutArcPaths(insights.value?.sources)); + const lifecycleSegmentsData = computed(() => lifecycleSegments(insights.value?.stages)); + + // Uptime derived from the oldest LLM audit record (matches the + // legacy behaviour of "server has been recording for N"). + const guardUptime = computed(() => { + const recent = llmAudit.value?.recent || []; + if (!recent.length) return '—'; + const oldest = Math.min(...recent.map(r => r.ts || 0)); + if (!oldest) return '—'; + return fmtDuration(Date.now() / 1000 - oldest); + }); + + // Aggregate counts for the source-health top stats strip. + const sourceHealthStats = computed(() => { + const sh = sourceHealth.value; + if (!sh) return null; + const sources = sh.sources || []; + const total = sources.length; + const healthy = sources.filter(s => s.status === 'fresh').length; + const silent = sources.filter(s => s.status === 'silent' || s.status === 'never').length; + const mems = sources.reduce((a, b) => a + (b.count || 0), 0); + return { total, healthy, silent, mems }; + }); + + // Parse "80872 0 com.loopmemory.codex" into {pid, exit, label, ok} + // so we can show whether each launchd hook is alive or has died. + const parsedHooks = computed(() => { + const sh = sourceHealth.value; + if (!Array.isArray(sh && sh.hooks)) return []; + return sh.hooks.map(line => { + const m = String(line).trim().match(/^(-?\d+)\s+(-?\d+)\s+(\S+)/); + if (!m) return { raw: line, pid: null, exit: null, label: String(line).trim(), ok: null }; + const pid = Number(m[1]); + const exit = Number(m[2]); + const label = m[3]; + const ok = exit >= 0 && pid > 0; + return { raw: line, pid, exit, label, ok }; + }); + }); + + // True when any source is degraded/silent/never — used to decide + // whether to show the "Run doctor / open settings" recovery strip. + const healthNeedsAttention = computed(() => { + const sh = sourceHealth.value; + if (!sh) return false; + const sources = sh.sources || []; + if (sources.some(s => s.status === "silent" || s.status === "never" || s.status === "stale")) return true; + const hooks = (sh.hooks || []).join(" "); + return /\b-\d+\s/.test(hooks); + }); + + // Quick-action handlers — bubble out through the shared action bus + // registered by App.js (openSettings → settings drawer, openDiag → doctor). + function openSettingsFromHealth() { try { callAction("openSettings"); } catch (_e) {} } + function runDoctorFromHealth() { try { callAction("openDiag"); } catch (_e) {} } + + // Source-name → emoji icon for visual scanning. + function sourceIcon(name) { + const s = String(name || "").toLowerCase(); + if (s.includes("codex")) return "⌨"; + if (s.includes("claude")) return "✦"; + if (s.includes("openclaw") || s.includes("claw")) return "◈"; + if (s.includes("hermes")) return "◆"; + if (s.includes("gemini")) return "✧"; + if (s.includes("cursor")) return "▶"; + if (s.includes("chatgpt")) return "☷"; + return "●"; + } + + // reactive aliases for i18n + void computed(() => store.lang); + + // Friendly last-refresh label, e.g. "12:34:56" + const lastRefreshLabel = computed(() => { + if (!lastRefresh.value) return ''; + const d = new Date(lastRefresh.value); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; + }); + + return { + store, t, insights, loading, live, lastRefresh, lastRefreshLabel, + weeklyReport, weeklyLoading, weeklyError, weeklyDays, loadWeekly, copyWeekly, + weeklyMarkdownHtml, + llmAudit, writeGuard, sourceHealth, sourceHealthStats, parsedHooks, + healthNeedsAttention, openSettingsFromHealth, runDoctorFromHealth, + resolvePair, contradictionKey, resolvingId, + KIND_TONE, STATUS_TONE, SOURCE_COLORS, STAGE_DEFS, SVGNS, ARCH, + fmtNum, truncate, timeAgo, sparkPath, fmtDuration, shortenPath, + guardUptime, hist, sourceIcon, + ingestBars, ingestPeakHour, barsFor, + scoreDistributionTotal, peakScoreRange, scoreBars, + sourceBars, pipelineBars, + trendPointsData, donutArcPathsTypes, donutArcPathsSources, lifecycleSegmentsData, + onRefresh: refresh, + onRunEvolution: () => callAction('llmRun'), + }; + }, + template: /* html */ ` +
      +
      +
      +
      + +
      +

      {{ t('dash.ins.title') }}

      +
      {{ t('dash.ins.sub') }}
      +
      +
      +
      + + + {{ live ? t('dash.ins.live') : t('dash.ins.offline') }} + + + {{ lastRefreshLabel }} + +
      +
      + + +
      +
      + + +
      + + +
      +
      +
      +
      {{ t('dash.kpi.total') }}
      +
      {{ fmtNum(insights.overview && insights.overview.total) }}
      +
      {{ t('dash.kpi.totalSub2') }}{{ (insights.sources || []).length }} {{ t('dash.kpi.sourceCountUnit') }}
      + +
      +
      +
      {{ t('dash.kpi.today') }}
      +
      {{ fmtNum(insights.overview && insights.overview.today) }}
      +
      {{ t('dash.kpi.todaySub') }}
      + +
      +
      +
      {{ t('dash.kpi.active') }}
      +
      {{ fmtNum(insights.overview && insights.overview.active) }}
      +
      {{ fmtNum(insights.overview && insights.overview.active) }}/{{ fmtNum(insights.overview && insights.overview.total) }} {{ t('dash.kpi.ofTotal') }}
      + +
      +
      +
      {{ t('dash.kpi.avg') }}
      +
      {{ ((insights.overview && insights.overview.avg_score || 0) * 100).toFixed(0) }}%
      +
      {{ t('dash.kpi.avgSub') }}
      + +
      +
      +
      +
      +
      {{ t('dash.kpi.links') }}
      +
      {{ fmtNum(insights.overview && insights.overview.links) }}
      +
      {{ t('dash.kpi.linksSub') }}
      +
      +
      +
      {{ t('dash.kpi.clusters') }}
      +
      {{ fmtNum(insights.overview && insights.overview.clusters) }}
      +
      {{ fmtNum(insights.overview && insights.overview.clusters) }} {{ t('dash.kpi.groups') }}
      +
      +
      +
      {{ t('dash.kpi.decay') }}
      +
      {{ ((insights.overview && insights.overview.decay_pct) || 0).toFixed(0) }}%
      +
      {{ t('dash.kpi.decaySub') }}
      +
      +
      +
      {{ t('dash.kpi.entities') }}
      +
      {{ fmtNum(insights.overview && insights.overview.entities) }}
      +
      {{ t('dash.kpi.entitiesSub') }}
      +
      +
      +
      {{ t('dash.kpi.sources') }}
      +
      {{ fmtNum((insights.sources || []).reduce((a, b) => a + (b.count || 0), 0)) }}
      +
      {{ (insights.sources || []).length }} {{ t('dash.kpi.distinct') }}
      +
      +
      +
      {{ t('dash.kpi.wikiHealth') }}
      +
      {{ fmtNum(insights.wiki_health && insights.wiki_health.pages) }}
      +
      {{ fmtNum(insights.wiki_health && insights.wiki_health.pages) }} {{ t('dash.kpi.pages') }} · {{ t('dash.src.wikiImp') }} {{ insights.wiki_health && insights.wiki_health.avg_importance ? Math.round(insights.wiki_health.avg_importance * 100) + '%' : '—' }}
      +
      +
      +
      {{ t('dash.kpi.recall24h') }}
      +
      {{ fmtNum(insights.recall_24h && insights.recall_24h.total) }}
      +
      {{ fmtNum(insights.recall_24h && insights.recall_24h.unique_memories) }} {{ t('dash.kpi.uniqueMems') }}
      +
      +
      +
      + +
      + + + +
      +
      + + +
      + +
      +
      +
      {{ seg.icon }}
      +
      {{ fmtNum(seg.count) }}
      +
      {{ seg.label }}
      +
      {{ seg.pct.toFixed(0) }}%
      + +
      +
      +
      + + +
      + +
      +
      +
      + {{ t('dash.pulse.contradict') }} + {{ (insights.pulse.contradictions || []).length }} {{ t('dash.pulse.pairs') }} +
      +
      + {{ t('dash.pulse.noConflicts') }} +
      +
      +
      +
      A · {{ truncate(pair.a && pair.a.text, 50) }}
      +
      B · {{ truncate(pair.b && pair.b.text, 50) }}
      +
      + sim {{ ((pair.similarity || 0) * 100).toFixed(0) }}% +
      + + + + +
      +
      +
      +
      +
      +
      +
      + {{ t('dash.pulse.decayDist') }} +
      +
      + {{ t('dash.pulse.total') }} {{ fmtNum(scoreDistributionTotal(insights.pulse.score_distribution)) }} {{ t('dash.pulse.records') }} + {{ t('dash.pulse.peak') }} {{ peakScoreRange(insights.pulse.score_distribution) }} +
      + + + + + + + + + + + + + {{ b.label }} · {{ b.count }} + + {{ b.count }} + + {{ b.labelPrefix }}{{ b.labelSuffix }} + + + +
      +
      +
      +
      + + +
      + +
      +
      +
      🗜{{ t('dash.cmp.memComp') }}
      +
      +
      +
      {{ fmtNum(insights.compression.compressible_count) }}
      +
      {{ t('dash.cmp.compressible') }}
      +
      +
      +
      {{ fmtNum(insights.compression.avg_length) }}
      +
      {{ t('dash.cmp.avgLen') }}
      +
      +
      +
      {{ Math.round(insights.compression.compression_progress || 0) }}%
      +
      {{ t('dash.cmp.prog') }}
      +
      +
      +
      +
      + {{ item.kind || 'episode' }} + {{ truncate(item.text, 60) }} + + {{ Math.round((item.importance || 0) * 100) }}% + {{ t('dash.cmp.inWiki') }} + +
      +
      +
      {{ t('dash.cmp.empty') }}
      +
      + +
      +
      🔬{{ t('dash.gran.title') }}
      +
      +
      +
      +
      🧠 {{ t('dash.gran.core') }}
      +
      {{ fmtNum(insights.granularity.core_count) }}
      +
      +
      +
      {{ truncate(r.text, 50) }}
      +
      + i {{ (r.importance || 0).toFixed(2) }} + s {{ (r.score || 0).toFixed(2) }} +
      +
      +
      +
      +
      +
      📋 {{ t('dash.gran.working') }}
      +
      {{ fmtNum(insights.granularity.working_count) }}
      +
      +
      +
      {{ truncate(r.text, 50) }}
      +
      + i {{ (r.importance || 0).toFixed(2) }} + s {{ (r.score || 0).toFixed(2) }} +
      +
      +
      +
      +
      +
      📝 {{ t('dash.gran.scratch') }}
      +
      {{ fmtNum(insights.granularity.scratch_count) }}
      +
      +
      +
      {{ truncate(r.text, 50) }}
      +
      + i {{ (r.importance || 0).toFixed(2) }} + s {{ (r.score || 0).toFixed(2) }} +
      +
      +
      +
      +
      +
      +
      + + +
      + +
      +
      +
      📊 {{ t('common.types') }}
      + + + + + + {{ t('common.types') }} + {{ fmtNum((insights.distribution.types || []).reduce((a,b)=>a+(b.count||0),0)) }} + +
      +
      + + {{ row.kind }} + {{ fmtNum(row.count) }} +
      +
      +
      +
      +
      📈 {{ t('dash.dist.statusTitle') }}
      +
      +
      + + + {{ row.status }} + + + + + {{ fmtNum(row.count) }} +
      +
      +
      +
      +
      📈 {{ t('dash.dist.trendTitle') }}
      + + + + + + + + + + + + + {{ t.label }} + + +
      +
      +
      + + +
      + +
      +
      +
      📡 {{ t('dash.src.bySource') }}
      +
      + + + + + + {{ t('common.sources') }} + {{ (insights.sources || []).reduce((a,b)=>a+(b.count||0),0).toLocaleString() }} + +
      +
      + + {{ row.source }} + {{ fmtNum(row.count) }} +
      +
      +
      +
      +
      +
      📖 {{ t('dash.src.wikiHealth') }}
      +
      +
      {{ fmtNum(insights.wiki_health.pages) }}
      {{ t('dash.src.wikiPages') }}
      +
      {{ insights.wiki_health.avg_importance ? Math.round(insights.wiki_health.avg_importance * 100) + '%' : '—' }}
      {{ t('dash.src.wikiImp') }}
      +
      {{ fmtNum(insights.wiki_health.total_chars) }}
      {{ t('dash.src.wikiChars') }}
      +
      {{ fmtNum(insights.wiki_health.referenced_memories) }}
      {{ t('dash.src.wikiRefs') }}
      +
      +
      +
      +
      +
      + {{ t('dash.src.coverage') }} + {{ Math.min(100, Math.round((insights.wiki_health.referenced_memories / Math.max(1, insights.overview && insights.overview.total)) * 100)) }}% +
      +
      +
      +
      📥 {{ t('dash.src.ingest') }}★ {{ ingestPeakHour(insights.ingest_rate) }}
      + + + + + + + + + + + + + {{ b.hour }} · {{ b.count }} + + {{ b.label }} + + +
      + {{ t('dash.src.last24h') }} {{ fmtNum((insights.ingest_rate || []).reduce((a, b) => a + (b.count || 0), 0)) }} + {{ t('dash.src.recall24h') }} {{ fmtNum(insights.recall_24h && insights.recall_24h.total) }} +
      +
      +
      +
      + + +
      + +
      +
      +
      📦{{ t('dash.pipe.stage.' + row.stage, row.stage) }}
      +
      +
      {{ row.avg_ms }} ms
      +
      ×{{ row.count }}
      +
      +
      +
      + + +
      + +
      +
      +
      + 📡 {{ t('dash.health.sources') }} + {{ sourceHealth.overall }} +
      + + +
      +
      +
      {{ fmtNum(sourceHealthStats.total) }}
      +
      {{ t('dash.health.sources') }}
      +
      +
      +
      {{ fmtNum(sourceHealthStats.healthy) }}
      +
      {{ t('dash.health.healthyLabel') }}
      +
      +
      +
      {{ fmtNum(sourceHealthStats.silent) }}
      +
      {{ t('dash.health.silentLabel') }}
      +
      +
      +
      {{ fmtNum(sourceHealthStats.mems) }}
      +
      {{ t('dash.health.totalMems') }}
      +
      +
      + + +
      +
      + {{ t("dash.health.status." + s.status) }} + + + {{ s.source }} + + + {{ fmtNum(s.count) }} {{ t('dash.health.count') }} + + {{ t('dash.health.last') }} {{ s.last_ts ? timeAgo(s.last_ts) : '—' }} {{ t('dash.health.ago') }} + + +
      +
      + + +
      +
      + {{ t('dash.health.hooksLabel') }} + ● {{ t('dash.health.hookExitOk') }} + ● {{ parsedHooks.filter(h => !h.ok).length }} / {{ parsedHooks.length }} +
      +
      + + {{ h.label }} + {{ t('dash.health.hookPid', { pid: h.pid }) }} + + {{ t('dash.health.hookExitBad', { code: h.exit }) }} + + {{ t('dash.health.hookExitOk') }} +
      +
      + + +
      + {{ t('dash.health.recoveryTitle') }}: + + +
      +
      +
      +
      + 📝 {{ t('dash.health.weekly') }} + + + + + +
      +
      ⚠ {{ weeklyError }}
      +
      {{ t('dash.health.weeklyLoading') }}
      +
      +
      + + {{ weeklyReport.from_cache ? t('dash.health.weeklyCached') : t('dash.health.weeklyFresh') }} + · {{ weeklyReport.cache_key }} + + + {{ new Date(weeklyReport.generated_at * 1000).toLocaleString() }} + +
      +
      +
      +
      +
      + + +
      + +
      +
      +
      🤖 {{ t('dash.audit.llm') }}
      +
      +
      {{ fmtNum((llmAudit.stats && llmAudit.stats.calls) || llmAudit.total_calls) }}
      {{ t('dash.audit.calls') }}
      +
      {{ fmtNum((llmAudit.stats && llmAudit.stats.total_tokens) || llmAudit.total_tokens) }}
      {{ t('dash.audit.tokens') }}
      +
      {{ (llmAudit.stats && llmAudit.stats.avg_latency_ms) ? Math.round(llmAudit.stats.avg_latency_ms) : 0 }}
      {{ t('dash.audit.avgMs') }}
      +
      {{ fmtNum((llmAudit.stats && llmAudit.stats.failures) || llmAudit.failures) }}
      {{ t('dash.audit.fails') }}
      +
      +
      +
      + {{ it.kind || it.stage || '?' }} + {{ fmtNum(it.total_tokens || it.tokens) }} tok · {{ Math.round(it.latency_ms || it.elapsed_ms) }} ms + {{ (it.ok === 1 || it.ok === true || it.error == null) ? '✓' : '✕' }} +
      +
      +
      {{ t('dash.audit.empty') }}
      +
      +
      +
      + 🛡 {{ t('dash.audit.guard') }} + uptime {{ guardUptime }} +
      +
      +
      {{ fmtNum((writeGuard.totals && writeGuard.totals.duplicate) || writeGuard.duplicate) }}
      {{ t('dash.audit.duplicate') }}
      +
      {{ fmtNum((writeGuard.totals && writeGuard.totals.too_long) || writeGuard.too_long) }}
      {{ t('dash.audit.tooLong') }}
      +
      {{ fmtNum((writeGuard.totals && writeGuard.totals.too_short) || writeGuard.too_short) }}
      {{ t('dash.audit.tooShort') }}
      +
      {{ fmtNum((writeGuard.totals && writeGuard.totals.low_signal) || writeGuard.low_signal) }}
      {{ t('dash.audit.lowSignal') }}
      +
      +
      {{ t('dash.audit.guardHint') }}
      +
      +
      +
      + + +
      + +
      + + + + + + + + + + + + + + {{ t('dash.arch.loopTitle') }} + + + {{ t('dash.arch.loopSub') }} + + + + + + + + loop_memory + {{ t('dash.arch.coreStore') }} + {{ t('dash.arch.coreData') }} + + + + + {{ s.icon }} + + {{ t('dash.arch.' + s.key, s.key) }} + + + {{ t('dash.arch.' + s.key + 'Sub', s.file) }} + + + + + + + + + + + + {{ i + 1 }}. {{ t('dash.arch.' + a.key, a.key) }} + + {{ shortenPath(a.file) }} + + +
      +
      {{ t('dash.arch.loopSub') }}
      +
      + +
      {{ t('common.loading') }}
      +
      {{ t('dash.ins.offline') }}
      +
      +
      +`, +}); diff --git a/loop_memory/serve/static/js/components/Diagnostic.js b/loop_memory/serve/static/js/components/Diagnostic.js new file mode 100644 index 0000000..0e83a6f --- /dev/null +++ b/loop_memory/serve/static/js/components/Diagnostic.js @@ -0,0 +1,175 @@ +/** + * Diagnostic — quick subsystem health check modal. + * + * Two surfaces: + * 1. `/api/diag` JSON dump (legacy — kept for power users). + * 2. **Client integration panel** — the entry point users were missing + * for "how do I enable knowledge on my Codex / Claude / Hermes". + * Shows a per-client status row + a single button that runs the + * full ``loop-memory install-hooks`` pipeline and prints the + * resulting actions. + */ +import { defineComponent, ref, computed } from '../lib/vue.esm-browser.prod.js'; +import { t, toast } from '../store.js'; + +const CLIENT_META = { + codex: { icon: '⌨', label: 'Codex CLI' }, + claude: { icon: '✦', label: 'Claude Code' }, + hermes: { icon: '◆', label: 'Hermes' }, + openclaw:{ icon: '◈', label: 'OpenClaw' }, +}; + +export const Diagnostic = defineComponent({ + name: 'Diagnostic', + props: { open: { type: Boolean, default: false } }, + emits: ['close'], + setup(props, { emit }) { + const data = ref(null); + const loading = ref(false); + const hooks = ref(null); // /api/install-hooks GET response + const hooksLoading = ref(false); + const running = ref(false); + const lastResult = ref(null); // last POST result + + async function check() { + loading.value = true; + try { data.value = await fetch('/api/diag').then(r => r.ok ? r.json() : {ok: false}); } + catch (e) { data.value = { ok: false, error: e.message }; } + finally { loading.value = false; } + } + + async function loadHooks() { + hooksLoading.value = true; + try { + const r = await fetch('/api/install-hooks'); + hooks.value = r.ok ? await r.json() : { ok: false }; + } catch (e) { + hooks.value = { ok: false, error: e.message }; + } finally { + hooksLoading.value = false; + } + } + + async function runInstall() { + running.value = true; + try { + const r = await fetch('/api/install-hooks', { method: 'POST' }); + const j = r.ok ? await r.json() : { ok: false, error: 'HTTP ' + r.status }; + lastResult.value = j; + if (j.ok) { + toast(t('diag.hooks.done'), 2400); + await loadHooks(); + } else { + toast((t('common.error') || 'Error') + ': ' + (j.error || '?'), 4000); + } + } catch (e) { + lastResult.value = { ok: false, error: e.message }; + } finally { + running.value = false; + } + } + + function copyActions() { + const actions = (lastResult.value && lastResult.value.actions) || []; + if (!actions.length) return; + const text = '[loop-memory install-hooks]\n' + actions.map(a => ' · ' + a).join('\n'); + navigator.clipboard?.writeText(text).then(() => toast(t('diag.hooks.copied'), 1500)); + } + + const clientRows = computed(() => { + const src = (hooks.value && hooks.value.clients) || (lastResult.value && lastResult.value.clients) || {}; + return Object.entries(src).map(([key, c]) => { + const meta = CLIENT_META[key] || { icon: '●', label: key }; + const installed = !!c.installed; + const mcpOk = !!c.mcp_configured; + return { + key, + icon: meta.icon, + name: meta.label, + installed, + status: !installed ? 'absent' : mcpOk ? 'ok' : 'pending', + }; + }); + }); + + const allOk = computed(() => clientRows.value.every(r => r.status === 'ok')); + + return { + data, loading, check, + hooks, hooksLoading, loadHooks, + running, lastResult, runInstall, copyActions, + clientRows, allOk, + t, onClose: () => emit('close'), + }; + }, + watch: { + open(o) { + if (o) { + this.check(); + this.loadHooks(); + } + }, + }, + template: /* html */ ` + + + + `, +}); diff --git a/loop_memory/serve/static/js/components/IngestPopover.js b/loop_memory/serve/static/js/components/IngestPopover.js new file mode 100644 index 0000000..b526012 --- /dev/null +++ b/loop_memory/serve/static/js/components/IngestPopover.js @@ -0,0 +1,208 @@ +/** + * IngestPopover — choose which conversation sources to ingest. + * + * Why: the old TopBar 导入 button called /api/ingest with source=manual + * (404 + invalid source), so it never worked. This popover lets the user + * pick one or more of the registered loaders (codex / claude / hermes / + * openclaw) and runs them, surfacing per-source results. + */ +import { defineComponent, ref, computed, onMounted, onUnmounted, watch } from '../lib/vue.esm-browser.prod.js'; +import { store, t, toast } from '../store.js'; +import { api } from '../api.js'; + +const SOURCES = [ + { id: 'codex', label: 'Codex', desc: '~/.codex/sessions', icon: '◧' }, + { id: 'openclaw', label: 'OpenClaw', desc: '~/.openclaw (clawx)', icon: '✜' }, + { id: 'claude', label: 'Claude', desc: '~/.claude', icon: '◎' }, + { id: 'hermes', label: 'Hermes', desc: '~/.hermes', icon: '⚒' }, +]; + +export const IngestPopover = defineComponent({ + name: 'IngestPopover', + emits: ['close'], + setup(_, { emit }) { + // Default selection: only sources present in the local filesystem. + const selected = ref(new Set()); + const running = ref(false); + const progress = ref(''); // human-readable status + const results = ref(null); // last batch results {source: {files, root, error}} + + // Per-source active-session cache. The "⚡ ingest now" affordance + // is per-source rather than a separate dropdown, so the layout + // stays one coherent checklist instead of two competing panels. + const activeBySource = ref({}); // { codex: {name,size,mtime,age_seconds,path} | null, ... } + const forcePending = ref(new Set()); // source ids currently being force-ingested + + async function refreshAllActive() { + const out = {}; + await Promise.all(SOURCES.map(async (s) => { + try { + const r = await api.activeSession(s.id); + out[s.id] = (r && r.active) || null; + } catch (_e) { out[s.id] = null; } + })); + activeBySource.value = out; + } + + async function forceActive(sourceId) { + if (forcePending.value.has(sourceId)) return; + const next = new Set(forcePending.value); + next.add(sourceId); forcePending.value = next; + try { + const r = await api.forceIngest({ + source: sourceId, + active_only: 'true', + }); + const ok = r && r.ingested ? r.ingested : 0; + const err = r && r.errors ? r.errors : 0; + toast( + ok > 0 + ? `立即摄入完成 · ${SOURCES.find(s=>s.id===sourceId)?.label || sourceId} · ${ok} 个会话` + : (err > 0 ? `摄入失败: ${err} 个错误` : '当前活跃会话暂无新内容'), + 2800, + ); + window.dispatchEvent(new CustomEvent('loop-memory:ingest-done', { detail: r })); + store._refreshStats = (store._refreshStats || 0) + 1; + await refreshAllActive(); + } catch (e) { + toast(`摄入失败: ${e.message || e}`, 3500); + } finally { + const m = new Set(forcePending.value); m.delete(sourceId); forcePending.value = m; + } + } + + onMounted(() => { + refreshAllActive(); + // Re-poll every 30s so the active badge stays accurate while + // the popover stays open. + const t = setInterval(refreshAllActive, 30000); + // Clean up when component unmounts. + _cleanupTimer = t; + }); + let _cleanupTimer = null; + onUnmounted(() => { if (_cleanupTimer) clearInterval(_cleanupTimer); }); + + function toggle(id) { + const s = new Set(selected.value); + if (s.has(id)) s.delete(id); else s.add(id); + selected.value = s; + } + function selectAll() { + selected.value = new Set(SOURCES.map(s => s.id)); + } + function clearSelection() { + selected.value = new Set(); + } + + function sourceDisabled(id) { + return running.value && !selected.value.has(id); + } + + async function runAll() { + if (running.value) return; + const ids = [...selected.value]; + if (!ids.length) { + toast(t('action.ingestNoSource') || '请先选择至少一个数据源', 2200); + return; + } + running.value = true; + results.value = null; + const out = {}; + let total = 0; + try { + for (const id of ids) { + progress.value = `正在导入 ${id}…`; + try { + const r = await api.ingest(id); + out[id] = r || { files: 0 }; + total += Number(r?.files || r?.ingested || 0); + } catch (e) { + out[id] = { error: e?.message || 'failed' }; + } + } + results.value = out; + progress.value = `导入完成 · ${total} 个会话`; + toast((t('action.ingestStarted', { n: total }) || `导入完成 · ${total} 个`), 2400); + // Sidebar refresh hook: emit a global event the Sidebar listens to. + window.dispatchEvent(new CustomEvent('loop-memory:ingest-done', { detail: out })); + store._refreshStats = (store._refreshStats || 0) + 1; + } finally { + running.value = false; + } + } + + function formatSize(bytes) { + if (!bytes && bytes !== 0) return '—'; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024*1024) return (bytes/1024).toFixed(0) + ' KB'; + return (bytes/1024/1024).toFixed(1) + ' MB'; + } + function formatAge(seconds) { + if (seconds == null) return '—'; + if (seconds < 60) return Math.round(seconds) + 's 前'; + if (seconds < 3600) return Math.round(seconds/60) + 'm 前'; + return Math.round(seconds/3600) + 'h 前'; + } + + return { + SOURCES, store, t, + selected, running, progress, results, + activeBySource, forcePending, forceActive, + toggle, selectAll, clearSelection, runAll, + sourceDisabled, + formatSize, formatAge, + }; + }, + template: /* html */ ` +
      +
      +
      {{ t('action.ingest') }}
      + +
      +
      {{ t('action.ingestTip') }}
      + +
      +
      + + +
      + {{ activeBySource[src.id].name }} + + {{ formatSize(activeBySource[src.id].size) }} · + {{ formatAge(activeBySource[src.id].age_seconds) }} + + +
      +
      +
      + +
      + + + +
      +
      + `, +}); diff --git a/loop_memory/serve/static/js/components/KnowledgeGraph.js b/loop_memory/serve/static/js/components/KnowledgeGraph.js new file mode 100644 index 0000000..50fd17d --- /dev/null +++ b/loop_memory/serve/static/js/components/KnowledgeGraph.js @@ -0,0 +1,885 @@ +/** + * KnowledgeGraph — 3D Fibonacci-sphere knowledge graph. + * + * Renders entities on a Fibonacci-sphere (even distribution) and rotates + * the whole sphere around its vertical (Y) axis. Each tick: + * 1. Increment every node's longitude by baseOmega (independent of + * force-sim alpha — auto-rotation never freezes). + * 2. Project (lat, lon) → 3D → 2D screen position with depth. + * 3. Draw edges back-to-front, then nodes back-to-front, then HUD. + * + * Interaction model: + * - Hover → pause rotation, jiggle the hovered node, dim others. + * - Drag → drag a node, or pan the canvas (drag empty space). + * - Wheel → zoom toward cursor. + * - Click → select a node (highlight + show full label + side panel). + * - Dblclk → wiki_page node jumps to Wiki tab and opens editor. + * + * Toolbar parity (legacy): + * [Rebuild] [wiki/memory] [stats] ___ [search] [kind ⌄] [Fit] [⟳] [+] [-] + * + * Below the canvas: + * - Side panel (when node selected) — title, kind, mention count, + * connected edges (top 20 by weight) and evidence memories. + * - Bottom legend strip (Wiki/Tag/Concept/Acronym colored dots). + */ +import { defineComponent, ref, onMounted, onUnmounted, watch, nextTick, computed } from '../lib/vue.esm-browser.prod.js'; +import { store, t, toast } from '../store.js'; +import { api } from '../api.js'; + +const KIND_COLOR = { + concept: '#6366f1', + acronym: '#10b981', + cjk: '#f59e0b', + tag: '#ec4899', + url: '#0ea5e9', + path: '#a855f7', + wiki_page: '#f59e0b', +}; + +const KIND_LABEL = { + concept: 'Concept', + acronym: 'Acronym', + cjk: 'CJK', + tag: 'Tag', + url: 'URL', + path: 'Path', + wiki_page: 'Wiki', +}; +const KIND_LABEL_ZH = { + concept: '概念', + acronym: '缩写', + cjk: '中文', + tag: '标签', + url: '链接', + path: '路径', + wiki_page: '知识库', +}; + +function kindLabel(kind) { + return store.lang === 'zh' + ? (KIND_LABEL_ZH[kind] || kind) + : (KIND_LABEL[kind] || kind); +} + +export const KnowledgeGraph = defineComponent({ + name: 'KnowledgeGraph', + emits: ['open-wiki'], + setup(_, { emit }) { + const canvasRef = ref(null); + const wrapRef = ref(null); + const shellRef = ref(null); + const loading = ref(false); + const stats = ref({ entities: 0, relations: 0 }); + const filterText = ref(''); + const filterKind = ref(''); + const rebuildMode = ref('wiki'); // 'wiki' | 'memory' + const rotationEnabled = ref(true); // ⟳ toggle + const hoverNode = ref(null); + + // Side panel state + const selectedNode = ref(null); // node name (string) or null + const sideConnected = ref([]); // [{other, weight}] + const sideEvidence = ref([]); // [{text, ...}] + const sideLoading = ref(false); + const sideMentions = ref(0); + let graphSelectedKind = ''; + + // ---- Mutable draw state (plain objects, no Vue reactivity) ---- + let graphData = { entities: [], relations: [] }; + let graphNodes = {}; + let graphEdges = []; + let graphLayout = { scale: 1, tx: 0, ty: 0, dragging: null }; + let graphSelected = null; + let sim = null; + let animating = false; + let hoveredName = null; + let mouseInCanvas = false; + let lastFrame = 0; + let rafHandle = null; + + // ---- Tuning ---- + const params = { + baseOmega: 0.00055, + repulsion: 250, + springLen: 120, + springK: 0.012, + damping: 0.82, + alphaDecay: 0.04, + collideR: 18, + centerK: 0.018, + }; + + async function loadGraph() { + loading.value = true; + try { + const entLimit = window._graphEntLimit || 120; + const relLimit = Math.min(entLimit * 4, 600); + const g = await api.graph({ limit_entities: entLimit, limit_relations: relLimit }); + graphData = g; + stats.value = { + entities: (g.entities || []).length, + relations: (g.relations || []).length, + }; + if (Number.isFinite(g.stats?.entities) && Number.isFinite(g.stats?.relations)) { + store.stats = { + ...store.stats, + graph: `${g.stats.entities}/${g.stats.relations}`, + }; + } + + graphEdges = (g.relations || []).map((r, i) => ({ + src: r.src, dst: r.dst, weight: r.weight, + evidence: r.evidence, id: r.id, _i: i, + edgeKind: r.kind || 'related', + })); + graphNodes = {}; + for (const e of (g.entities || [])) { + graphNodes[e.name] = { + name: e.name, + kind: e.kind, + weight: e.weight, + mention: e.mention_count, + x: 0, y: 0, z: 0, + vx: 0, vy: 0, + lat0: 0, lon: 0, shellR: 1, + jitter: 0, + }; + } + seedFibonacci(); + ensureSim(); + fitToCanvas(); + drawCanvas(); + if (selectedNode.value && graphNodes[selectedNode.value]) { + showNodeDetails(graphNodes[selectedNode.value]); + } + } catch (e) { + console.error('graph load failed', e); + } finally { + loading.value = false; + } + } + + async function rebuildGraph() { + loading.value = true; + try { + const modeLabel = rebuildMode.value === 'wiki' + ? (store.lang === 'zh' ? '知识库' : 'distilled wiki') + : (store.lang === 'zh' ? '原始记忆' : 'raw memories'); + toast( + (store.lang === 'zh' ? `正在从${modeLabel}重建图谱…` : `Rebuilding graph from ${modeLabel}…`), + 2500, + ); + const r = await fetch(`/api/admin/graph/rebuild?clear=true&mode=${encodeURIComponent(rebuildMode.value)}`, { + method: 'POST', + }); + if (!r.ok) throw new Error('rebuild failed: HTTP ' + r.status); + const data = await r.json(); + toast( + (store.lang === 'zh' + ? `图谱已重建:${data.entities} 实体 / ${data.relations} 关系` + : `Graph rebuilt: ${data.entities} entities / ${data.relations} relations`), + 3000, + ); + await loadGraph(); + } catch (e) { + toast((store.lang === 'zh' ? '重建失败:' : 'Rebuild failed: ') + e.message, 4000); + } finally { + loading.value = false; + } + } + + function stableHash(s) { + let h = 0x811c9dc5 >>> 0; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h; + } + + function seedFibonacci() { + const names = Object.keys(graphNodes); + names.sort((a, b) => { + const ma = graphNodes[a].mention || 0; + const mb = graphNodes[b].mention || 0; + if (ma !== mb) return mb - ma; + return stableHash(a) - stableHash(b); + }); + const N = names.length; + if (N === 0) return; + const R = Math.max(220, Math.sqrt(N) * 32); + const PHI = Math.PI * (3 - Math.sqrt(5)); + const ordered = new Array(N); + const halfHigh = Math.min(N, Math.ceil(N * 0.5)); + let hi = 0, lo = halfHigh; + for (let i = 0; i < N; i++) { + if (i % 2 === 0 && hi < halfHigh) ordered[i] = names[hi++]; + else if (lo < N) ordered[i] = names[lo++]; + else ordered[i] = names[hi++]; + } + ordered.forEach((name, i) => { + const lat = Math.asin(1 - 2 * (i + 0.5) / N); + const lon0 = ((i * PHI) % (Math.PI * 2)); + const n = graphNodes[name]; + n.lat0 = lat; + n.lon = lon0; + n.shellR = R; + const p = project(n); + n.x = p.px; n.y = p.py; n.z = p.pz; + }); + } + + function project(n) { + const R = n.shellR || 220; + const lat = n.lat0 || 0; + const lon = n.lon || 0; + const px = R * Math.cos(lat) * Math.sin(lon); + const py = R * Math.sin(lat); + const pz = R * Math.cos(lat) * Math.cos(lon); + return { px, py, pz, depth: (pz + R) / (2 * R) }; + } + + function nodeRadius(n, depth) { + const sizeMul = 0.55 + 0.85 * depth; + const kindBonus = n.kind === 'wiki_page' ? 2.25 : n.kind === 'tag' ? 0.72 : 1; + return (3.5 + Math.sqrt(n.mention || 1) * 1.6) * sizeMul * kindBonus; + } + + function pickNode(cx, cy) { + let best = null; + let bestScore = Infinity; + for (const n of Object.values(graphNodes)) { + if (filterKind.value && n.kind !== filterKind.value) continue; + if (filterText.value && !n.name.toLowerCase().includes(filterText.value.toLowerCase())) continue; + const p = project(n); + const sx = graphLayout.tx + p.px * graphLayout.scale; + const sy = graphLayout.ty + p.py * graphLayout.scale; + const dx = sx - cx, dy = sy - cy; + const d = Math.sqrt(dx * dx + dy * dy); + const hitRadius = Math.max(14, Math.min(28, nodeRadius(n, p.depth) * graphLayout.scale + 8)); + if (d > hitRadius) continue; + const score = d / hitRadius - p.depth * 0.12; + if (score < bestScore) { + bestScore = score; + best = n; + } + } + return best; + } + + function stepRotation(ts) { + const frameScale = lastFrame ? Math.min(2.4, Math.max(0.25, (ts - lastFrame) / 16.67)) : 1; + const paused = !(rotationEnabled.value) || (mouseInCanvas && hoveredName) || graphLayout.dragging || !!graphSelected; + const rot = paused ? 0 : params.baseOmega * frameScale; + for (const n of Object.values(graphNodes)) { + if (n.lat0 == null) continue; + n.lon = (n.lon || 0) + rot; + const p = project(n); + n.x = p.px; + n.y = p.py; + n.z = p.pz; + } + lastFrame = ts; + } + + function hexA(hex, alpha) { + const a = Math.round(Math.max(0, Math.min(1, alpha)) * 255).toString(16).padStart(2, '0'); + return hex + a; + } + + function drawCanvas() { + const c = canvasRef.value; if (!c) return; + const ctx = c.getContext('2d'); + if (!ctx) return; + const dpr = window.devicePixelRatio || 1; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, c.width, c.height); + ctx.scale(dpr, dpr); + + const themeAttr = document.documentElement.getAttribute('data-theme') || 'light'; + const isDark = themeAttr === 'dark'; + const bg = isDark ? '#0b0e16' : '#fafbfd'; + const cssW = c.clientWidth, cssH = c.clientHeight; + + ctx.fillStyle = bg; + ctx.fillRect(0, 0, cssW, cssH); + + // Sphere outline + atmospheric shading + ctx.save(); + ctx.translate(graphLayout.tx, graphLayout.ty); + ctx.scale(graphLayout.scale, graphLayout.scale); + const nodes = Object.values(graphNodes); + let rMax = 0; + for (const n of nodes) { + const r = Math.sqrt(n.x * n.x + n.y * n.y); + if (r > rMax) rMax = r; + } + const sphereR = rMax + 4; + + const halo = ctx.createRadialGradient(0, 0, sphereR * 0.9, 0, 0, sphereR * 1.5); + halo.addColorStop(0, isDark ? 'rgba(99,102,241,0.0)' : 'rgba(99,102,241,0.0)'); + halo.addColorStop(0.7, isDark ? 'rgba(99,102,241,0.06)' : 'rgba(99,102,241,0.05)'); + halo.addColorStop(1, isDark ? 'rgba(99,102,241,0)' : 'rgba(99,102,241,0)'); + ctx.fillStyle = halo; + ctx.beginPath(); + ctx.arc(0, 0, sphereR * 1.5, 0, Math.PI * 2); + ctx.fill(); + + const shadow = ctx.createRadialGradient(-sphereR * 0.4, -sphereR * 0.4, sphereR * 0.3, 0, 0, sphereR); + shadow.addColorStop(0, isDark ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.18)'); + shadow.addColorStop(1, isDark ? 'rgba(0,0,0,0.45)' : 'rgba(15,23,42,0.06)'); + ctx.fillStyle = shadow; + ctx.beginPath(); + ctx.arc(0, 0, sphereR, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = isDark ? 'rgba(129,140,248,0.18)' : 'rgba(99,102,241,0.22)'; + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.arc(0, 0, sphereR, 0, Math.PI * 2); + ctx.stroke(); + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.ellipse(0, 0, sphereR, sphereR * 0.18, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.lineWidth = 0.6; + for (const latDeg of [-60, -30, 30, 60]) { + const lat = latDeg * Math.PI / 180; + const ry = sphereR * 0.18 * Math.cos(lat) * 0.5 + sphereR * 0.06; + const rx = sphereR * Math.cos(lat); + if (rx > 4) { + ctx.beginPath(); + ctx.ellipse(0, sphereR * Math.sin(lat), rx, Math.max(2, ry), 0, 0, Math.PI * 2); + ctx.stroke(); + } + } + ctx.strokeStyle = isDark ? 'rgba(129,140,248,0.10)' : 'rgba(99,102,241,0.14)'; + ctx.beginPath(); + ctx.ellipse(0, 0, sphereR * 0.18, sphereR, Math.PI / 6, 0, Math.PI * 2); + ctx.stroke(); + ctx.strokeStyle = isDark ? 'rgba(165,180,252,0.20)' : 'rgba(99,102,241,0.25)'; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(0, -sphereR - 8); + ctx.lineTo(0, sphereR + 8); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = isDark ? 'rgba(165,180,252,0.7)' : 'rgba(99,102,241,0.7)'; + ctx.beginPath(); ctx.arc(0, -sphereR - 6, 3, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(0, sphereR + 6, 3, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + + // Filtered visible nodes + projection + const visible = []; + for (const n of nodes) { + if (filterKind.value && n.kind !== filterKind.value) continue; + if (filterText.value && !n.name.toLowerCase().includes(filterText.value.toLowerCase())) continue; + const p = project(n); + visible.push({ n, ...p }); + } + visible.sort((a, b) => a.pz - b.pz); + const projMap = {}; + for (const v of visible) projMap[v.n.name] = v; + const visibleNames = new Set(visible.map(v => v.n.name)); + + const dimNonHover = !!(mouseInCanvas && hoveredName); + + // Edges (back-to-front) + ctx.save(); + ctx.translate(graphLayout.tx, graphLayout.ty); + ctx.scale(graphLayout.scale, graphLayout.scale); + for (const e of graphEdges) { + const a = graphNodes[e.src], b = graphNodes[e.dst]; + if (!a || !b) continue; + if (!visibleNames.has(a.name) || !visibleNames.has(b.name)) continue; + const pa = projMap[a.name], pb = projMap[b.name]; + if (!pa || !pb) continue; + const depthAvg = (pa.pz + pb.pz) / (2 * (pa.n.shellR || 1)); + const depthAlpha = 0.45 + 0.55 * depthAvg; + const edgeKind = e.edgeKind || 'related'; + let strokeColor; + if (edgeKind === 'tagged_with') strokeColor = hexA('#ec4899', 0.7 * depthAlpha); + else if (edgeKind === 'mentions') strokeColor = hexA('#6366f1', 0.55 * depthAlpha); + else if (edgeKind === 'related_to') strokeColor = hexA('#f59e0b', 0.75 * depthAlpha); + else strokeColor = hexA('#6366f1', 0.6 * depthAlpha); + if (dimNonHover && a.name !== hoveredName && b.name !== hoveredName) { + strokeColor = strokeColor.replace(/[\d.]+\)$/g, m => (parseFloat(m) * 0.18).toFixed(2) + ')'); + } + ctx.strokeStyle = strokeColor; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(pa.px, pa.py); + ctx.lineTo(pb.px, pb.py); + ctx.stroke(); + } + ctx.restore(); + + // Nodes (back-to-front) + for (const v of visible) { + const n = v.n; + const sx = graphLayout.tx + v.px * graphLayout.scale; + const sy = graphLayout.ty + v.py * graphLayout.scale; + const depthMul = 0.55 + 0.85 * v.depth; + const r = nodeRadius(n, v.depth) * graphLayout.scale * depthMul; + if (r <= 0.3) continue; + const baseFill = KIND_COLOR[n.kind] || KIND_COLOR.concept; + const isHover = hoveredName === n.name; + const isSelected = graphSelected && graphSelected.name === n.name; + const isFar = v.depth < 0.20; + if (isFar && !isHover && !isSelected) { + ctx.fillStyle = hexA(baseFill, 0.18); + ctx.beginPath(); + ctx.arc(sx, sy, Math.max(1.2, r * 0.7), 0, Math.PI * 2); + ctx.fill(); + continue; + } + const labelAlpha = (v.pz + (n.shellR || 1)) / (2 * (n.shellR || 1)); + const importantLabel = (n.kind === 'wiki_page' && labelAlpha > 0.24) || (n.mention || 0) >= 5; + const mediumLabel = n.kind === 'tag' || (n.mention || 0) >= 2; + if (isHover || isSelected) { + ctx.fillStyle = hexA(baseFill, 0.30); + ctx.beginPath(); + ctx.arc(sx, sy, r * (isHover ? 2.2 : 1.9), 0, Math.PI * 2); + ctx.fill(); + } + ctx.fillStyle = isHover || isSelected ? baseFill : hexA(baseFill, 0.78 + 0.22 * labelAlpha); + ctx.beginPath(); + ctx.arc(sx, sy, r, 0, Math.PI * 2); + ctx.fill(); + if (isHover || isSelected) { + ctx.lineWidth = 1.6; + ctx.strokeStyle = isDark ? '#f8fafc' : '#ffffff'; + ctx.stroke(); + } + if (v.pz > -50 && (importantLabel || mediumLabel || isHover || isSelected)) { + const focus = isHover || isSelected; + const maxChars = focus ? 42 : n.kind === 'wiki_page' ? 24 : 20; + const text = n.name.length > maxChars ? n.name.slice(0, maxChars - 1) + '…' : n.name; + const fontSize = focus ? 13 : n.kind === 'wiki_page' ? 12 : 10.5; + ctx.font = (focus ? '600 ' : '500 ') + fontSize + 'px var(--ui-font, system-ui)'; + const m = ctx.measureText(text); + const padX = focus ? 9 : n.kind === 'wiki_page' ? 6 : 2; + const lw = m.width + padX * 2; + const lh = 16; + let lx = sx; + let ly = sy - r - 8 - lh / 2; + if (focus) ly -= 2; + ctx.fillStyle = isDark ? 'rgba(15,23,42,0.85)' : 'rgba(255,255,255,0.92)'; + ctx.strokeStyle = baseFill; + ctx.lineWidth = 1; + ctx.beginPath(); + const x = lx - lw / 2; + const y = ly - lh / 2; + const rr = 6; + ctx.moveTo(x + rr, y); + ctx.arcTo(x + lw, y, x + lw, y + lh, rr); + ctx.arcTo(x + lw, y + lh, x, y + lh, rr); + ctx.arcTo(x, y + lh, x, y, rr); + ctx.arcTo(x, y, x + lw, y, rr); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = isDark ? '#fdf2f8' : '#1e1b4b'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, lx, ly); + } + } + } + + function fitToCanvas() { + const wrap = wrapRef.value; + if (!wrap) return; + const rect = wrap.getBoundingClientRect(); + const nodes = Object.values(graphNodes); + if (nodes.length === 0) return; + let rMax = 0; + for (const n of nodes) { + const r = Math.sqrt(n.x * n.x + n.y * n.y); + if (r > rMax) rMax = r; + } + const W = rect.width > 100 ? rect.width : 1000; + const H = rect.height > 100 ? rect.height : 700; + const R = rMax + 60; + const w = R * 2, h = R * 2; + graphLayout.scale = Math.min(W / w, H / h) * 0.96; + graphLayout.tx = W / 2; + graphLayout.ty = H / 2; + } + + function zoomBy(factor, cx, cy) { + const c = canvasRef.value; + if (!c) return; + const rect = c.getBoundingClientRect(); + const ccx = cx != null ? cx - rect.left : rect.width / 2; + const ccy = cy != null ? cy - rect.top : rect.height / 2; + const wx = (ccx - graphLayout.tx) / graphLayout.scale; + const wy = (ccy - graphLayout.ty) / graphLayout.scale; + graphLayout.scale = Math.max(0.2, Math.min(4, graphLayout.scale * factor)); + graphLayout.tx = ccx - wx * graphLayout.scale; + graphLayout.ty = ccy - wy * graphLayout.scale; + drawCanvas(); + } + function zoomIn() { zoomBy(1.25); } + function zoomOut() { zoomBy(1 / 1.25); } + /* Fit-to-window: animate scale + translate over ~260ms so the + * user can SEE the refit motion even when the graph is already + * roughly in place. The previous instant teleport produced a + * zero-pixel diff against the previous frame, so the button + * looked broken. */ + let fitTweenRaf = null; + function fit() { + // Snapshot the pre-fit transform FIRST, then compute the new + // fit, then tween from snapshot → new transform in a rAF loop. + const start = { + scale: graphLayout.scale, + tx: graphLayout.tx, + ty: graphLayout.ty, + }; + fitToCanvas(); // mutates graphLayout in place to the new fit + const target = { + scale: graphLayout.scale, + tx: graphLayout.tx, + ty: graphLayout.ty, + }; + // Restore start values so the tween animates from old → new + graphLayout.scale = start.scale; + graphLayout.tx = start.tx; + graphLayout.ty = start.ty; + const dur = 260; + const t0 = performance.now(); + function step() { + const t = Math.min(1, (performance.now() - t0) / dur); + const e = 1 - Math.pow(1 - t, 3); // ease-out-cubic + graphLayout.scale = start.scale + (target.scale - start.scale) * e; + graphLayout.tx = start.tx + (target.tx - start.tx) * e; + graphLayout.ty = start.ty + (target.ty - start.ty) * e; + drawCanvas(); + if (t < 1) { + fitTweenRaf = requestAnimationFrame(step); + } + } + if (fitTweenRaf) cancelAnimationFrame(fitTweenRaf); + fitTweenRaf = requestAnimationFrame(step); + } + + function ensureSim() { + if (sim) return; + sim = { tick() {}, restart() {}, setAlpha() {}, alpha: () => 0 }; + } + + function renderLoop(ts) { + if (!animating) return; + stepRotation(ts); + drawCanvas(); + rafHandle = requestAnimationFrame(renderLoop); + } + + function onWheel(e) { + e.preventDefault(); + const rect = canvasRef.value.getBoundingClientRect(); + const cx = e.clientX - rect.left, cy = e.clientY - rect.top; + const factor = Math.exp(-e.deltaY * 0.0015); + const wx = (cx - graphLayout.tx) / graphLayout.scale; + const wy = (cy - graphLayout.ty) / graphLayout.scale; + graphLayout.scale = Math.max(0.2, Math.min(4, graphLayout.scale * factor)); + graphLayout.tx = cx - wx * graphLayout.scale; + graphLayout.ty = cy - wy * graphLayout.scale; + drawCanvas(); + } + + function onCanvasMouseMove(e) { + const rect = canvasRef.value.getBoundingClientRect(); + const cx = e.clientX - rect.left, cy = e.clientY - rect.top; + const node = pickNode(cx, cy); + hoveredName = node ? node.name : null; + hoverNode.value = node ? node.name : null; + if (graphLayout.dragging) { + if (graphLayout.dragging.kind === 'pan') { + graphLayout.tx = graphLayout.dragging.tx0 + (e.clientX - graphLayout.dragging.x0); + graphLayout.ty = graphLayout.dragging.ty0 + (e.clientY - graphLayout.dragging.y0); + } else if (graphLayout.dragging.kind === 'node') { + const wx = (cx - graphLayout.tx) / graphLayout.scale; + const wy = (cy - graphLayout.ty) / graphLayout.scale; + const dragged = graphLayout.dragging.node; + const radius = dragged.shellR || 220; + const clampedY = Math.max(-radius * 0.98, Math.min(radius * 0.98, wy)); + dragged.lat0 = Math.asin(clampedY / radius); + const latitudeRadius = Math.max(1, radius * Math.cos(dragged.lat0)); + const sinLon = Math.max(-1, Math.min(1, wx / latitudeRadius)); + const nearLon = Math.asin(sinLon); + dragged.lon = Math.cos(dragged.lon) < 0 ? Math.PI - nearLon : nearLon; + } + } + } + + function onCanvasMouseDown(e) { + const rect = canvasRef.value.getBoundingClientRect(); + const cx = e.clientX - rect.left, cy = e.clientY - rect.top; + const node = pickNode(cx, cy); + if (node) { + graphLayout.dragging = { kind: 'node', node, ox: node.x, oy: node.y }; + graphSelected = node; + showNodeDetails(node); + } else { + graphLayout.dragging = { kind: 'pan', x0: e.clientX, y0: e.clientY, tx0: graphLayout.tx, ty0: graphLayout.ty }; + } + } + + function onCanvasMouseUp() { + if (graphLayout.dragging && graphLayout.dragging.kind === 'node') { + const n = graphLayout.dragging.node; + const p = project(n); + n.x = p.px; n.y = p.py; n.z = p.pz; + } + graphLayout.dragging = null; + } + + function onCanvasClick(e) { + const rect = canvasRef.value.getBoundingClientRect(); + const node = pickNode(e.clientX - rect.left, e.clientY - rect.top); + graphSelected = node || null; + showNodeDetails(node); + drawCanvas(); + } + + async function onCanvasDblClick(e) { + const rect = canvasRef.value.getBoundingClientRect(); + const node = pickNode(e.clientX - rect.left, e.clientY - rect.top); + if (!node) return; + if (node.kind === 'wiki_page' && node.name.startsWith('wiki:')) { + const slug = node.name.slice('wiki:'.length); + emit('open-wiki', { slug }); + } + } + + async function showNodeDetails(node) { + if (!node) { + selectedNode.value = null; + sideConnected.value = []; + sideEvidence.value = []; + sideMentions.value = 0; + graphSelectedKind = ''; + return; + } + selectedNode.value = node.name; + graphSelectedKind = node.kind || ''; + const connected = graphEdges + .filter(e => e.src === node.name || e.dst === node.name) + .map(e => ({ + other: e.src === node.name ? e.dst : e.src, + weight: e.weight, + evidence: e.evidence, + })); + connected.sort((a, b) => (b.weight || 0) - (a.weight || 0)); + sideConnected.value = connected.slice(0, 20); + // server-provided mention count (how many memories mention this entity). + sideMentions.value = node.mention || 0; + sideEvidence.value = []; + sideLoading.value = true; + try { + const evs = await api.graphEntityMemories(node.name, 8); + sideEvidence.value = (evs || []).map(m => ({ + id: m.id, + text: (m.text || '').slice(0, 200), + })); + } catch (e) { + sideEvidence.value = []; + } finally { + sideLoading.value = false; + } + } + + function dismissSide() { + graphSelected = null; + selectedNode.value = null; + sideConnected.value = []; + sideEvidence.value = []; + sideMentions.value = 0; + graphSelectedKind = ''; + drawCanvas(); + } + + function onCanvasEnter() { mouseInCanvas = true; } + function onCanvasLeave() { + mouseInCanvas = false; + hoveredName = null; + hoverNode.value = null; + } + + function toggleRotation() { + rotationEnabled.value = !rotationEnabled.value; + } + + function resizeCanvas() { + const c = canvasRef.value; + if (!c) return; + const dpr = window.devicePixelRatio || 1; + const target = wrapRef.value || c; + const rect = target.getBoundingClientRect(); + c.width = Math.max(1, Math.round(rect.width * dpr)); + c.height = Math.max(1, Math.round(rect.height * dpr)); + c.style.width = '100%'; + c.style.height = '100%'; + } + + let resizeObserver = null; + function onResize() { + resizeCanvas(); + fitToCanvas(); + drawCanvas(); + } + + watch(() => store.lang, () => drawCanvas()); + watch(() => store.theme, () => drawCanvas()); + watch(() => store.activeTab, async (tab) => { + if (tab !== 'graph') return; + await nextTick(); + resizeCanvas(); + fitToCanvas(); + drawCanvas(); + }); + + function onExternalRebuild() { rebuildGraph(); } + onMounted(async () => { + await nextTick(); + resizeCanvas(); + const cv = canvasRef.value; + cv.addEventListener('wheel', onWheel, { passive: false }); + cv.addEventListener('mousedown', onCanvasMouseDown); + cv.addEventListener('mousemove', onCanvasMouseMove); + cv.addEventListener('click', onCanvasClick); + cv.addEventListener('dblclick', onCanvasDblClick); + cv.addEventListener('mouseenter', onCanvasEnter); + cv.addEventListener('mouseleave', onCanvasLeave); + window.addEventListener('mouseup', onCanvasMouseUp); + window.addEventListener('resize', onResize); + if (window.ResizeObserver) { + resizeObserver = new ResizeObserver(onResize); + if (wrapRef.value) resizeObserver.observe(wrapRef.value); + } + await loadGraph(); + animating = true; + rafHandle = requestAnimationFrame(renderLoop); + window.addEventListener('loop-memory:rebuild-graph', onExternalRebuild); + }); + + onUnmounted(() => { + animating = false; + if (rafHandle) cancelAnimationFrame(rafHandle); + if (resizeObserver) resizeObserver.disconnect(); + window.removeEventListener('resize', onResize); + window.removeEventListener('mouseup', onCanvasMouseUp); + window.removeEventListener('loop-memory:rebuild-graph', onExternalRebuild); + }); + + const rotationLabel = computed(() => { + void store.ready; + void store.lang; + if (!rotationEnabled.value) return '⏸ ' + t('graph.rotationPaused'); + if (hoverNode.value) return '⏸ ' + t('graph.rotationPaused'); + return '↻ ' + t('graph.rotationSlow'); + }); + + const graphSelectedKindProxy = computed(() => graphSelectedKind); + + return { + store, t, + canvasRef, wrapRef, shellRef, loading, stats, + filterText, filterKind, rebuildMode, rotationEnabled, + hoverNode, rotationLabel, + // side panel + selectedNode, sideConnected, sideEvidence, sideLoading, sideMentions, + graphSelectedKind: graphSelectedKindProxy, + kindLabel, + // actions + rebuild: rebuildGraph, + refresh: loadGraph, + zoomIn, zoomOut, fit, toggleRotation, + onFilterKindChange: () => drawCanvas(), + dismissSide, + }; + }, + template: /* html */ ` +
      +
      + + + + {{ stats.entities }} {{ t('graph.entities') }} · + {{ stats.relations }} {{ t('graph.relations') }} + + + + + + + + +
      +
      +
      + +
      {{ t('common.loading') }}
      +
      +
      {{ hoverNode }}
      +
      {{ t('graph.hoverHint') }}
      +
      +
      + {{ rotationLabel }} +
      +
      + +
      + {{ t('kind.wiki') }} + {{ t('kind.tag') }} + {{ t('kind.concept') }} + {{ t('kind.acronym') }} +
      +
      +
      + `, +}); diff --git a/loop_memory/serve/static/js/components/RingMeter.js b/loop_memory/serve/static/js/components/RingMeter.js new file mode 100644 index 0000000..29049aa --- /dev/null +++ b/loop_memory/serve/static/js/components/RingMeter.js @@ -0,0 +1,51 @@ +/** + * RingMeter — circular progress ring with a label and optional sub-label. + * + * Used by the Dashboard's "ring meters" row (Occupation / Citation / Decay). + * The geometry is intentionally hard-coded here so the legacy CSS gradients + * / classnames continue to live in layout.css without a per-instance prop. + * + * The component is the textual+svg body of a .ins-ring-card; the consumer + * provides the label / value (0-100) / colour / sub-label. + */ +import { defineComponent, computed } from '../lib/vue.esm-browser.prod.js'; + +export const RingMeter = defineComponent({ + name: 'RingMeter', + props: { + label: { type: String, required: true }, + value: { type: Number, default: 0 }, + color: { type: String, default: '#6366f1' }, + sub: { type: String, default: '' }, + icon: { type: String, default: '' }, + }, + setup(props) { + const dashArray = computed(() => { + const r = 22, c = 2 * Math.PI * r; + const v = Math.max(0, Math.min(1, (props.value || 0) / 100)); + return { dash: c, offset: c * (1 - v) }; + }); + const displayValue = computed(() => { + const v = Number(props.value || 0); + return Number.isFinite(v) ? v.toFixed(0) : '0'; + }); + return { dashArray, displayValue }; + }, + template: /* html */ ` +
      + + + + {{ displayValue }}% + +
      +
      {{ icon }}{{ label }}
      +
      {{ displayValue }}%
      +
      {{ sub }}
      +
      +
      + `, +}); diff --git a/loop_memory/serve/static/js/components/RunStrip.js b/loop_memory/serve/static/js/components/RunStrip.js new file mode 100644 index 0000000..53c95ad --- /dev/null +++ b/loop_memory/serve/static/js/components/RunStrip.js @@ -0,0 +1,31 @@ +/** + * RunStrip — the bottom-of-page strip that shows when an AI distill + * is in progress (current/total progress bar + cancel button). + */ +import { defineComponent, computed } from '../lib/vue.esm-browser.prod.js'; +import { store, t } from '../store.js'; + +export const RunStrip = defineComponent({ + name: 'RunStrip', + emits: ['dismiss'], + setup(props, { emit }) { + const visible = computed(() => store.runStatus?.is_running && !store.stripDismissed); + const pct = computed(() => { + const p = store.runStatus?.progress || {}; + if (!p.total) return 0; + return Math.min(100, Math.round((p.current / p.total) * 100)); + }); + return { visible, pct, store, t, dismiss: () => emit('dismiss') }; + }, + template: /* html */ ` +
      +
      + + {{ store.runStatus?.progress?.message || t('run.running') }} +
      +
      +
      {{ pct }}%
      + +
      + `, +}); diff --git a/loop_memory/serve/static/js/components/SectionTitle.js b/loop_memory/serve/static/js/components/SectionTitle.js new file mode 100644 index 0000000..2c1ceb6 --- /dev/null +++ b/loop_memory/serve/static/js/components/SectionTitle.js @@ -0,0 +1,26 @@ +/** + * SectionTitle — the recurring " <bar> <right>" header used + * by every Dashboard section. The CSS classes (.ins-section-title, .ico, + * .bar, .right) remain unchanged so the styling section in layout.css + * keeps working without a per-instance prop. + * + * Pass `right` to show the trailing meta line; it is hidden when omitted. + */ +import { defineComponent } from '../lib/vue.esm-browser.prod.js'; + +export const SectionTitle = defineComponent({ + name: 'SectionTitle', + props: { + ico: { type: String, default: '' }, + title: { type: String, required: true }, + right: { type: String, default: '' }, + }, + template: /* html */ ` +<div class="ins-section-title"> + <span class="ico" v-if="ico">{{ ico }}</span> + <span>{{ title }}</span> + <span class="bar"></span> + <span class="right" v-if="right">{{ right }}</span> +</div> + `, +}); diff --git a/loop_memory/serve/static/js/components/Settings.js b/loop_memory/serve/static/js/components/Settings.js new file mode 100644 index 0000000..8464574 --- /dev/null +++ b/loop_memory/serve/static/js/components/Settings.js @@ -0,0 +1,1062 @@ +/** + * Settings — the right-side drawer that holds LLM config + scheduler. + * + * Faithful to the legacy vanilla-JS settings drawer (pre-Vue 8498eca): + * - 5 sections: Provider, Schedule, Behaviour, Actions, Recent runs. + * - Behaviour section lets the user tune batch size / temperature / + * max output / min importance / filter / score / summarise / dry-run. + * - Recent runs section shows the latest 20 LLM runs with status pill, + * trigger, timestamp and stats summary. + * - Drawer foot has Reset / Cancel / Save buttons (legacy parity). + * - Schedule includes weekday selector (visible when mode=weekly). + */ +import { defineComponent, ref, computed, onMounted, watch, reactive } from '../lib/vue.esm-browser.prod.js'; +import { store, t, toast, fmtTime, callAction } from '../store.js'; +import { api, ApiError, setAuthToken } from '../api.js'; + +const WEEKDAYS = [ + { v: 0, k: 'weekday.mon' }, { v: 1, k: 'weekday.tue' }, + { v: 2, k: 'weekday.wed' }, { v: 3, k: 'weekday.thu' }, + { v: 4, k: 'weekday.fri' }, { v: 5, k: 'weekday.sat' }, + { v: 6, k: 'weekday.sun' }, +]; + +export const Settings = defineComponent({ + name: 'Settings', + props: { + open: { type: Boolean, default: false }, + // Drawer mode: + // - "llm" : only the LLM Connection section (entered via + // the topbar model-chip — user clicked on the + // model name to configure it). + // - "settings": full drawer — Connection + Ingest + Schedule + // (entered via the gear icon — user wants to + // tweak anything, not just the LLM). + mode: { type: String, default: 'settings' }, + }, + emits: ['close'], + setup(props, { emit }) { + const providers = ref([]); + const cfg = reactive({ + provider: 'rules', model: 'rules', + base_url: '', api_key: '', + api_key_set: false, api_key_account: '', api_key_fingerprint: '', + schedule: { + enabled: false, mode: 'off', + interval_minutes: 60, hour: 3, minute: 0, weekday: 0, + after_ingest_idle_sec: 30, + }, + behaviour: { + batch_size: 50, temperature: 0.3, max_output_tokens: 800, + min_importance: 0.0, enable_filter: true, enable_score: true, + enable_summarize: true, dry_run: false, + }, + }); + // Ingest cadence — drives the background file watcher that + // auto-ingests finished transcripts from Codex / Claude / Hermes + // / OpenClaw. Kept SEPARATE from the LLM ``cfg`` block above + // because: + // * it lives in a different settings key (``ingest`` not + // ``llm_consolidator``); + // * its save round-trip is independent (the watcher is a + // separate process that hot-reloads every few ticks); + // * it has no api_key handling so it doesn't share the LLM + // save flow's fingerprint / key lifecycle. + const ingestCfg = reactive({ + idle_seconds: 300, // size-stable wait before ingesting + poll_seconds: 5, // directory scan period + defaults: { idle_seconds: 300, poll_seconds: 5 }, + notes: { min_idle_seconds: 30, min_poll_seconds: 1, + max_idle_seconds: 3600, max_poll_seconds: 60 }, + }); + const ingestSaving = ref(false); + const ingestHint = ref(false); + // ---- Redaction ---- + // Default ON. Loaded from /api/admin/redact; persist via the + // same endpoint. ``redactText`` is bound to the preview input + // so the user can paste any text and see what would land in + // long-term storage before actually writing it. + const redactCfg = reactive({ + enabled: true, + private_spans: true, + kinds: [], + notes: {}, + }); + const redactSaving = ref(false); + const redactHint = ref(false); + const redactPreview = reactive({ + input: '', + output: '', + counts: {}, + total: 0, + total_chars: 0, + busy: false, + }); + const testing = ref(false); + const testResult = ref(null); + const saving = ref(false); + const savedHint = ref(false); + // API key show/hide toggle + const keyVisible = ref(false); + // Auth token management + const authEnabled = ref(false); + const authLoading = ref(false); + // Confirm dialog state for destructive actions + const confirmDialog = ref(null); // { title, message, onConfirm } + function showConfirm(opts) { confirmDialog.value = opts; } + function hideConfirm() { confirmDialog.value = null; } + + // Recent runs + next-run + preview state (legacy parity) + const runs = ref([]); + const runsLoading = ref(false); + const nextRun = ref(null); + const previewItems = ref([]); + const previewLoading = ref(false); + const previewOpen = ref(false); + + async function load() { + let ing = null; + try { + const [p, c] = await Promise.all([ + api.llmProviders(), api.llmConfig(), + ]); + providers.value = p || []; + // /api/admin/llm/config returns {config: {...}, warnings, ...} + const actualCfg = (c && c.config) ? c.config : c; + Object.assign(cfg, actualCfg); + } catch (e) { /* ignore */ } + try { + ing = await api.getIngestConfig(); + } catch (e) { ing = null; } + // Hydrate ingestCfg from the dedicated endpoint. Falls back to + // the reactive defaults if the endpoint is unreachable (older + // server builds, or first paint before loadI18n lands). + if (ing && typeof ing === 'object') { + if (typeof ing.idle_seconds === 'number') ingestCfg.idle_seconds = ing.idle_seconds; + if (typeof ing.poll_seconds === 'number') ingestCfg.poll_seconds = ing.poll_seconds; + if (ing.defaults && typeof ing.defaults === 'object') { + ingestCfg.defaults = { ...ing.defaults }; + } + if (ing.notes && typeof ing.notes === 'object') { + ingestCfg.notes = { ...ing.notes }; + } + } + try { + const rd = await api.getRedactConfig().catch(() => null); + if (rd && typeof rd === 'object') { + redactCfg.enabled = (rd.enabled !== false); + redactCfg.private_spans = (rd.private_spans !== false); + if (Array.isArray(rd.kinds)) redactCfg.kinds = rd.kinds; + if (rd.notes) redactCfg.notes = rd.notes; + } + } catch (e) { /* ignore */ } + try { + const status = await api.llmStatus(); + if (status && status.provider) store.modelInfo = { + provider: status.provider, model: status.model || 'rules', + api_key_set: !!status.api_key_set, key_len: status.key_len || 0, + }; + nextRun.value = status?.next_run || null; + } catch (e) { /* ignore */ } + await refreshRuns(); + // Load auth token status + try { + const at = await api.authTokenStatus(); + authEnabled.value = at.enabled || false; + } catch (e) { /* ignore */ } + } + + async function refreshRuns() { + runsLoading.value = true; + try { + const data = await api.llmRuns({ limit: 20 }); + runs.value = Array.isArray(data) ? data : (data.runs || []); + } catch (e) { + runs.value = []; + } finally { + runsLoading.value = false; + } + } + + onMounted(load); + watch(() => props.open, (o) => { if (o) load(); }); + + const selectedProvider = computed(() => { + return providers.value.find(p => p.id === cfg.provider) || {}; + }); + + function onProviderChange() { + const p = selectedProvider.value; + if (p && p.default_model) cfg.model = p.default_model; + if (p && p.default_base_url && !cfg.base_url) cfg.base_url = p.default_base_url; + } + + async function onToggleAuth() { + authLoading.value = true; + try { + if (authEnabled.value) { + await api.authTokenDelete(); + authEnabled.value = false; + setAuthToken(null); + toast(t('settings.auth.disabled') || '客户端已禁用', 2000); + } else { + const r = await api.authTokenCreate(); + authEnabled.value = true; + setAuthToken(r.token); + // Show token briefly then hide it + toast(t('settings.auth.enabled') || '客户端已启用,请将记录字典', 4000); + } + } catch (e) { + toast(t('common.error') + ': ' + e.message, 4000); + } finally { + authLoading.value = false; + } + } + + async function onTest() { + testing.value = true; + testResult.value = null; + try { + const r = await api.llmTest({ + provider: cfg.provider, model: cfg.model, base_url: cfg.base_url, + api_key: cfg.api_key || undefined, + }); + testResult.value = r; + if (r.ok) { + toast(t('settings.test.ok', { ms: r.elapsed_ms || 0 }), 2500); + } else { + toast(t('settings.test.fail', { msg: r.error?.provider_message || r.error?.hint || 'unknown' }), 4000); + } + } catch (e) { + testResult.value = { ok: false, error: { provider_message: e.message } }; + } finally { + testing.value = false; + } + } + + async function onSave() { + saving.value = true; + try { + const payload = { + provider: cfg.provider, model: cfg.model, base_url: cfg.base_url, + schedule: cfg.schedule, behaviour: cfg.behaviour, + }; + if (cfg.api_key) payload.api_key = cfg.api_key; + // Use the full PUT endpoint so the entire config tuple + // (provider, model, base_url, schedule, behaviour, api_key) + // is written atomically. The ``/api/admin/llm/schedule`` POST + // endpoint only flat-merges keys into ``cfg.schedule``, so + // sending the full form there would nest ``schedule`` and + // ``behaviour`` under ``cfg.schedule.schedule`` / + // ``cfg.schedule.behaviour`` and silently leave the top-level + // ``enabled`` / ``mode`` flags stale — the "saved but still + // shows unconfigured" persistence bug. + await api.saveLlm(payload); + savedHint.value = true; + // Reload first so the in-memory ``cfg`` reflects what was + // actually persisted (handles ``api_key`` fingerprint, etc.) + // before we close the drawer. + await load(); + toast(t('settings.saved') || t('common.saved') || '\u5df2\u4fdd\u5b58', 1800); + cfg.api_key = ''; + // Auto-close the drawer so the user does not have to scroll + // back to the top to hit the close button after saving. + // The toast confirms the save; reopening shows the new state. + setTimeout(() => { + savedHint.value = false; + emit('close'); + }, 700); + } catch (e) { + toast(t('common.error') + ': ' + e.message, 4000); + } finally { + saving.value = false; + } + } + + async function onSaveIngest() { + ingestSaving.value = true; + try { + const r = await api.saveIngestConfig({ + idle_seconds: Number(ingestCfg.idle_seconds), + poll_seconds: Number(ingestCfg.poll_seconds), + }); + ingestHint.value = true; + toast(t('settings.ingest.saved', { + idle: ingestCfg.idle_seconds, poll: ingestCfg.poll_seconds, + }), 2200); + // Re-pull so any server-side normalization is reflected. + if (r && r.ingest) { + ingestCfg.idle_seconds = r.ingest.idle_seconds; + ingestCfg.poll_seconds = r.ingest.poll_seconds; + } + setTimeout(() => { ingestHint.value = false; }, 2500); + } catch (e) { + toast(t('settings.ingest.saveFail', { msg: e.message }), 4000); + } finally { + ingestSaving.value = false; + } + } + + // ---- Redaction handlers ---- + async function onSaveRedact() { + redactSaving.value = true; + try { + await api.saveRedactConfig({ + enabled: redactCfg.enabled, + private_spans: redactCfg.private_spans, + kinds: redactCfg.kinds, + }); + redactHint.value = true; + toast(t('settings.redact.saved'), 1800); + setTimeout(() => { redactHint.value = false; }, 2500); + } catch (e) { + toast(t('settings.redact.saveFail', { msg: e.message }), 4000); + } finally { + redactSaving.value = false; + } + } + + async function onRunRedactPreview() { + if (!redactPreview.input || !redactPreview.input.trim()) { + toast(t('settings.redact.emptyPreview'), 2000); + return; + } + redactPreview.busy = true; + try { + const r = await api.redactPreview({ text: redactPreview.input }); + redactPreview.output = r.text || ''; + redactPreview.counts = r.counts || {}; + redactPreview.total = r.total || 0; + redactPreview.total_chars = r.total_chars || 0; + } catch (e) { + toast(t('settings.redact.saveFail', { msg: e.message }), 4000); + } finally { + redactPreview.busy = false; + } + } + + function clearRedactPreview() { + redactPreview.input = ''; + redactPreview.output = ''; + redactPreview.counts = {}; + redactPreview.total = 0; + redactPreview.total_chars = 0; + } + + async function onClearKey() { + showConfirm({ + title: t('settings.apiKey.confirmClearTitle') || t('settings.apiKey.confirmClear'), + message: t('settings.apiKey.confirmClearMsg') || '', + onConfirm: async () => { + try { + // ``__clear__`` is the sentinel the PUT endpoint understands: + // it deletes the secret from the backend and flips + // ``api_key_set`` to false. We must NOT use the + // ``/api/admin/llm/schedule`` POST endpoint here — it + // would re-introduce the same persistence bug as + // ``onSave`` (nested ``schedule`` / ``behaviour``). + const payload = { + provider: cfg.provider, model: cfg.model, base_url: cfg.base_url, + schedule: cfg.schedule, behaviour: cfg.behaviour, + api_key: '__clear__', + }; + await api.saveLlm(payload); + cfg.api_key_set = false; + toast(t('settings.apiKey.cleared'), 2000); + await load(); + } catch (e) { + toast(t('toast.fail', { msg: e.message }), 4000); + } + }, + }); + } + + async function onReset() { + try { + await fetch('/api/admin/llm/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider: 'echo', model: 'rules', + schedule: { enabled: false, mode: 'off' }, + }), + }); + await load(); + toast(t('settings.saved')); + } catch (e) { + toast(t('toast.fail', { msg: e.message }), 4000); + } + } + + async function onRunNow() { + try { + await api.llmRun({}); + toast(t('action.runNowQueued') || t('action.llmRunQueued') || t('action.runNow'), 2000); + setTimeout(refreshRuns, 1500); + } catch (e) { + toast(t('toast.fail', { msg: e.message }), 4000); + } + } + + async function onPreview() { + previewOpen.value = true; + previewLoading.value = true; + previewItems.value = []; + try { + const r = await fetch('/api/admin/llm/run?dry_run=true&limit=20', { method: 'POST' }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data = await r.json(); + previewItems.value = data.preview || []; + } catch (e) { + previewItems.value = []; + toast(t('toast.fail', { msg: e.message }), 4000); + } finally { + previewLoading.value = false; + } + } + + function statusKey(s) { return 'settings.run.status.' + (s || ''); } + function triggerKey(s) { return 'settings.run.trigger.' + (s || ''); } + function statLine(stats) { + if (!stats) return ''; + return t('settings.run.stats', { + kept: stats.kept || 0, + dropped: stats.dropped || 0, + rescored: stats.importance_updated || 0, + merged: stats.resummarized || 0, + }); + } + + const WEEKDAY_NAMES_ZH = ['一','二','三','四','五','六','日']; + const WEEKDAY_NAMES_EN = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; + function nextRunText() { + if ((cfg.schedule.mode || 'off') === 'off') { + return t('settings.schedule.statusOff'); + } + if (!nextRun.value) return ''; + const ts = nextRun.value; + const d = new Date(ts * 1000); + if (Number.isNaN(d.getTime())) return ''; + const when = fmtTime(ts); + const mode = t('settings.schedule.' + (cfg.schedule.mode || 'off')); + return t('settings.schedule.statusOn', { when, mode }); + } + function scheduleModeHint() { + const m = cfg.schedule.mode || 'off'; + const isZh = (store.lang || 'zh') === 'zh'; + const wdName = (isZh ? WEEKDAY_NAMES_ZH : WEEKDAY_NAMES_EN)[cfg.schedule.weekday || 0]; + if (m === 'realtime') return t('settings.schedule.realtimeHint', { sec: cfg.schedule.after_ingest_idle_sec || 30 }); + if (m === 'weekly') return t('settings.schedule.weeklyHint', { weekday: wdName, hour: cfg.schedule.hour, minute: String(cfg.schedule.minute).padStart(2,'0') }); + if (m === 'daily') return t('settings.schedule.dailyHint', { hour: cfg.schedule.hour, minute: String(cfg.schedule.minute).padStart(2,'0') }); + if (m === 'hourly') return t('settings.schedule.hourlyHint'); + if (m === 'interval') return t('settings.schedule.intervalHint', { n: cfg.schedule.interval_minutes }); + return ''; + } + + // The UI no longer shows an explicit "enabled" checkbox — the + // mode dropdown's 'off' option IS the disable. Auto-derive + // schedule.enabled from mode so scheduler.py keeps seeing a + // coherent state. + watch(() => cfg.schedule.mode, (m) => { + cfg.schedule.enabled = (m || 'off') !== 'off'; + }, { immediate: true }); + + // --- storage budget + manual compact --------------------------- + const storageCfg = reactive({ + max_bytes_mb: 200, + max_memories: 8000, + auto_compact: false, + compact_interval_hours: 24, + defaults: { max_bytes_mb: 200, max_memories: 8000, compact_interval_hours: 24 }, + current_mb: 0, + current_memories: 0, + last_compact_text: '—', + }); + const storageSaving = ref(false); + const storageHint = ref(''); + const storageCompacting = ref(false); + const storageStats = ref({ memories: 0, db_size_bytes: 0 }); + const DEFAULT_STORAGE = { + max_bytes_mb: 200, max_memories: 8000, + auto_compact: false, compact_interval_hours: 24, + }; + + function bytesToMb(b) { + if (!b) return 0; + return Math.round((Number(b) / (1024 * 1024)) * 10) / 10; + } + function _fmtLastCompact(ts) { + if (!ts) return t('settings.storage.never'); + try { + const d = new Date(Number(ts) * 1000); + return d.toLocaleString(); + } catch (_e) { + return '—'; + } + } + + async function refreshStorage() { + try { + const data = await api.getStorage(); + const budget = (data && data.budget) || {}; + storageCfg.max_bytes_mb = Math.round((Number(budget.max_bytes || DEFAULT_STORAGE.max_bytes_mb * 1024 * 1024) / (1024 * 1024)) || DEFAULT_STORAGE.max_bytes_mb); + storageCfg.max_memories = Number(budget.max_memories || DEFAULT_STORAGE.max_memories); + storageCfg.auto_compact = !!budget.auto_compact; + storageCfg.compact_interval_hours = Number(budget.compact_interval_hours || DEFAULT_STORAGE.compact_interval_hours); + const br = (data && data.breakdown) || {}; + storageCfg.current_mb = bytesToMb(br.db_size_bytes || data.db_size_bytes); + storageCfg.current_memories = Number(br.memories || 0); + storageStats.value = { + memories: storageCfg.current_memories, + db_size_bytes: Number(br.db_size_bytes || data.db_size_bytes || 0), + }; + const last = (data && data.last_compact) || {}; + storageCfg.last_compact_text = _fmtLastCompact(last.finished_at); + } catch (e) { + // best-effort — leave defaults in place + } + } + + async function onSaveStorage() { + storageSaving.value = true; + storageHint.value = ''; + try { + const payload = { + max_bytes: Math.max(0, Math.round(Number(storageCfg.max_bytes_mb || 0) * 1024 * 1024)), + max_memories: Math.max(0, Math.round(Number(storageCfg.max_memories || 0))), + auto_compact: !!storageCfg.auto_compact, + compact_interval_hours: Math.max(1, Math.round(Number(storageCfg.compact_interval_hours || 24))), + }; + await api.saveStorageBudget(payload); + storageHint.value = t('settings.storage.savedHint'); + setTimeout(() => { storageHint.value = ''; }, 2500); + await refreshStorage(); + } catch (e) { + storageHint.value = t('settings.storage.saveFailed', { error: String(e && e.message || e) }); + } finally { + storageSaving.value = false; + } + } + + async function onRunCompact() { + storageCompacting.value = true; + storageHint.value = t('settings.storage.compacting'); + try { + const r = await api.runCompact({ force: true, mode: "heuristic" }); + const result = (r && r.result) || r || {}; + const d = result.result || result; + storageHint.value = t('settings.storage.compactDone', { + deleted: d.deleted_memories || 0, + sessions: d.digested_sessions || 0, + }); + await refreshStorage(); + } catch (e) { + storageHint.value = t('settings.storage.compactFailed', { error: String(e && e.message || e) }); + } finally { + storageCompacting.value = false; + setTimeout(() => { if (storageHint.value && storageHint.value.startsWith(t('settings.storage.compactDone', { deleted: 0, sessions: 0 }).slice(0, 5))) storageHint.value = ''; }, 4000); + } + } + + watch(() => props.open, (v) => { if (v) refreshStorage(); }); + + function onClose() { emit('close'); } + function openClientHooksPanel() { + // Close the drawer first, then ask App.js to open the diagnostic + // modal which now owns the per-client "Configure all" button. + emit('close'); + try { callAction('openDiag'); } catch (_e) {} + } + + return { cfg, providers, selectedProvider, onProviderChange, + testing, testResult, onTest, + saving, savedHint, onSave, onClearKey, onReset, onRunNow, onPreview, + ingestCfg, ingestSaving, ingestHint, onSaveIngest, + redactCfg, redactSaving, redactHint, onSaveRedact, + redactPreview, onRunRedactPreview, clearRedactPreview, + storageCfg, storageSaving, storageHint, onSaveStorage, + storageCompacting, onRunCompact, storageStats, + runs, runsLoading, refreshRuns, nextRun, nextRunText, scheduleModeHint, + previewItems, previewLoading, previewOpen, + statusKey, triggerKey, statLine, + WEEKDAYS, store, t, onClose, openClientHooksPanel, + confirmDialog, showConfirm, hideConfirm }; + }, + template: /* html */ ` +<aside v-show="open" class="drawer" role="dialog" aria-label="Settings" @click.self="onClose"> + <div class="drawer-body"> + <header class="drawer-head"> + <div class="drawer-head-text"> + <h2>{{ t(mode === 'llm' ? 'settings.title.llm' : 'settings.title') }}</h2> + <p class="drawer-subtitle">{{ t(mode === 'llm' ? 'settings.subtitle.llm' : 'settings.subtitle') }}</p> + </div> + <button class="icon-btn" @click="onClose" type="button" aria-label="Close"> + <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"> + <path d="M3 3l10 10M13 3L3 13"/> + </svg> + </button> + </header> + + <!-- Client integration entry point — discoverable from the top of + settings so users find it without reading the README. + Hidden in 'mode=llm' so the LLM config view stays focused. --> + <button v-if="mode !== 'llm'" class="drawer-link-cta" type="button" @click="openClientHooksPanel" + :title="t('settings.hooks.tooltip')"> + <span class="drawer-link-ico" aria-hidden="true">🪝</span> + <span class="drawer-link-text"> + <strong>{{ t('settings.hooks.ctaTitle') }}</strong> + <small>{{ t('settings.hooks.ctaSub') }}</small> + </span> + <span class="drawer-link-arrow" aria-hidden="true">→</span> + </button> + + <!-- Connection (LLM info — global, used by every LLM feature) --> + <section class="sec-connection"> + <h3>{{ t('settings.section.connection') }}</h3> + <p class="sec-scope">{{ t('settings.connection.usedBy') }}</p> + <label> + <span>{{ t('settings.provider') }}</span> + <select v-model="cfg.provider" @change="onProviderChange"> + <option v-for="p in providers" :key="p.id" :value="p.id">{{ p.label }}</option> + </select> + </label> + <p v-if="selectedProvider.description" class="hint">{{ selectedProvider.description }}</p> + <label> + <span>{{ t('settings.model') }}</span> + <input v-model="cfg.model" :placeholder="selectedProvider.default_model || 'gpt-4o-mini'" /> + </label> + <label v-if="selectedProvider.needs_base_url !== false"> + <span>{{ t('settings.baseUrl') }}</span> + <input v-model="cfg.base_url" :placeholder="selectedProvider.default_base_url || ''" /> + </label> + <label v-if="selectedProvider.needs_api_key !== false"> + <span> + {{ t('settings.apiKey') }} + <span v-if="cfg.api_key_set" class="key-status saved"> + <span class="key-dot"></span>{{ t('settings.apiKey.configured') }} + <span v-if="cfg.api_key_fingerprint" class="key-fp">{{ cfg.api_key_fingerprint }}</span> + </span> + <span v-else class="key-status missing"> + <span class="key-dot"></span>{{ t('settings.apiKey.missing') }} + </span> + </span> + <div class="api-key-row"> + <input :type="keyVisible ? 'text' : 'password'" v-model="cfg.api_key" + :placeholder="cfg.api_key_set ? t('settings.apiKey.edit') : t('settings.apiKey.placeholder')" + autocomplete="off" /> + <button class="btn small tertiary" @click="keyVisible = !keyVisible" type="button" :title="keyVisible ? t('common.hide') : t('common.show')"> + {{ keyVisible ? '🙈' : '👁' }} + </button> + <button class="btn small danger" v-if="cfg.api_key_set" @click="onClearKey" type="button"> + {{ t('settings.apiKey.clear') }} + </button> + </div> + <p class="hint">{{ t('settings.apiKey.hint') }}</p> + </label> + + <div class="test-row"> + <button class="btn small primary" :disabled="testing" @click="onTest"> + {{ testing ? t('common.testing') : t('settings.test') }} + </button> + <span v-if="testResult" class="test-result" :class="{ ok: testResult.ok, fail: !testResult.ok }"> + {{ testResult.ok ? t('settings.test.ok', { ms: testResult.elapsed_ms || 0 }) : (testResult.error?.provider_message || testResult.error?.hint || 'failed') }} + </span> + </div> + </section> + + <!-- Ingest — how often the background watcher scans / ingests. + Hidden in 'mode=llm' (ingest cadence is independent of the LLM). --> + <section v-if="mode !== 'llm'" class="sec-ingest"> + <h3>{{ t('settings.section.ingest') }}</h3> + <p class="sec-scope">{{ t('settings.ingest.scope') }}</p> + <div class="row-2"> + <label> + <span>{{ t('settings.ingest.idle') }}</span> + <input type="number" v-model.number="ingestCfg.idle_seconds" + :min="ingestCfg.notes.min_idle_seconds || 30" + :max="ingestCfg.notes.max_idle_seconds || 3600" /> + <small class="hint"> + {{ t('settings.ingest.idleHint', { + def: ingestCfg.defaults.idle_seconds || 300, + min: ingestCfg.notes.min_idle_seconds || 30, + max: ingestCfg.notes.max_idle_seconds || 3600, + }) }} + </small> + </label> + <label> + <span>{{ t('settings.ingest.poll') }}</span> + <input type="number" v-model.number="ingestCfg.poll_seconds" + :min="ingestCfg.notes.min_poll_seconds || 1" + :max="ingestCfg.notes.max_poll_seconds || 60" /> + <small class="hint"> + {{ t('settings.ingest.pollHint', { + def: ingestCfg.defaults.poll_seconds || 5, + }) }} + </small> + </label> + </div> + <div class="action-row" style="gap:8px;flex-wrap:wrap;margin-top:8px;"> + <button class="btn primary" type="button" :disabled="ingestSaving" + @click="onSaveIngest"> + {{ ingestSaving ? t('common.saving') : t('action.save') }} + </button> + <button class="btn secondary" type="button" + @click="ingestCfg.idle_seconds = ingestCfg.defaults.idle_seconds; + ingestCfg.poll_seconds = ingestCfg.defaults.poll_seconds" + :title="t('settings.ingest.resetToDefaults')"> + {{ t('settings.ingest.resetDefaults') }} + </button> + <span v-if="ingestHint" class="ingest-hint">{{ t('settings.ingest.liveHint') }}</span> + </div> + </section> + + <!-- Storage budget + compaction cadence. Hidden in 'mode=llm' — + the LLM Connection drawer should not surface maintenance + controls; compaction lives behind the gear icon. The section + is split into two sub-groups (budget / cadence) so the + related knobs are visually clustered. --> + <section v-if="mode !== 'llm'" class="sec-storage"> + <h3>{{ t('settings.section.storage') }}</h3> + <p class="sec-scope">{{ t('settings.storage.scope') }}</p> + + <div class="settings-subsection"> + <div class="settings-subsection-head">{{ t('settings.storage.headBudget') || '容量上限' }}</div> + <div class="row-2"> + <label> + <span>{{ t('settings.storage.maxBytes') }}</span> + <input type="number" v-model.number="storageCfg.max_bytes_mb" + :min="0" :max="2048" /> + <small class="hint">{{ t('settings.storage.maxBytesHint', { def: storageCfg.defaults.max_bytes_mb, current: storageCfg.current_mb }) }}</small> + </label> + <label> + <span>{{ t('settings.storage.maxMemories') }}</span> + <input type="number" v-model.number="storageCfg.max_memories" + :min="0" :max="100000" /> + <small class="hint">{{ t('settings.storage.maxMemoriesHint', { def: storageCfg.defaults.max_memories, current: storageCfg.current_memories }) }}</small> + </label> + </div> + </div> + + <div class="settings-subsection"> + <div class="settings-subsection-head">{{ t('settings.storage.headCadence') || '压缩触发' }}</div> + <label class="row-toggle"> + <input type="checkbox" v-model="storageCfg.auto_compact" /> + <span> + <strong>{{ t('settings.storage.autoCompact') }}</strong> + <small>{{ t('settings.storage.autoCompactHint') }}</small> + </span> + </label> + <div v-if="storageCfg.auto_compact" class="row-2 storage-cadence-grid"> + <label> + <span>{{ t('settings.storage.intervalHours') }}</span> + <input type="number" v-model.number="storageCfg.compact_interval_hours" + :min="1" :max="720" /> + <small class="hint">{{ t('settings.storage.intervalHoursHint', { def: storageCfg.defaults.compact_interval_hours }) }}</small> + </label> + <div class="storage-meter"> + <span>{{ t('settings.storage.lastCompact') }}</span> + <strong>{{ storageCfg.last_compact_text }}</strong> + </div> + </div> + </div> + + <div class="action-row" style="gap:8px;flex-wrap:wrap;margin-top:8px;"> + <button class="btn primary" type="button" :disabled="storageSaving" + @click="onSaveStorage"> + {{ storageSaving ? t('common.saving') : t('action.save') }} + </button> + <button class="btn secondary" type="button" :disabled="storageCompacting" + @click="onRunCompact"> + {{ storageCompacting ? t('settings.storage.compacting') : t('settings.storage.runCompact') }} + </button> + <span v-if="storageHint" class="ingest-hint">{{ storageHint }}</span> + </div> + </section> + + <!-- Redaction — secrets auto-stripped from stored memories and + exported markdown. Hidden in 'mode=llm' (LLM Connection + doesn't own privacy — redaction is project-wide). The + preview textarea lets the user paste any text and see + exactly what would land in long-term storage. Split into + "toggles" and "preview" subsections for visual rhythm. --> + <section v-if="mode !== 'llm'" class="sec-redact"> + <h3>{{ t('settings.section.redact') }}</h3> + <p class="sec-scope">{{ t('settings.redact.scope') }}</p> + + <div class="settings-subsection"> + <div class="settings-subsection-head">{{ t('settings.redact.headToggles') || '规则开关' }}</div> + <label class="row-toggle"> + <input type="checkbox" v-model="redactCfg.enabled" /> + <span> + <strong>{{ t('settings.redact.enabled') }}</strong> + <small>{{ t('settings.redact.enabledHint') }}</small> + </span> + </label> + <label class="row-toggle"> + <input type="checkbox" v-model="redactCfg.private_spans" /> + <span> + <strong>{{ t('settings.redact.privateSpans') }}</strong> + <small>{{ t('settings.redact.privateSpansHint') }}</small> + </span> + </label> + <details class="redact-kinds"> + <summary>{{ t('settings.redact.kindsSummary', { n: redactCfg.kinds.length }) }}</summary> + <ul class="kind-list"> + <li v-for="k in redactCfg.kinds" :key="k"> + <code>{{ k }}</code> + </li> + </ul> + </details> + </div> + + <div class="settings-subsection"> + <div class="settings-subsection-head">{{ t('settings.redact.headPreview') || '实时预览' }}</div> + <div class="redact-preview"> + <label> + <span>{{ t('settings.redact.previewLabel') }}</span> + <textarea v-model="redactPreview.input" rows="3" + :placeholder="t('settings.redact.previewPlaceholder')"></textarea> + </label> + <div class="redact-preview-actions"> + <button class="btn small secondary" type="button" :disabled="redactPreview.busy" + @click="onRunRedactPreview"> + {{ redactPreview.busy ? t('common.loading') : t('settings.redact.previewRun') }} + </button> + <button class="btn small tertiary" type="button" @click="clearRedactPreview" + :disabled="!redactPreview.input && !redactPreview.output"> + {{ t('common.clear') }} + </button> + </div> + <div v-if="redactPreview.output" class="redact-output"> + <div class="redact-output-text"> + <small class="hint">{{ t('settings.redact.previewResult') }}</small> + <pre>{{ redactPreview.output }}</pre> + </div> + <div class="redact-output-meta" v-if="redactPreview.total > 0"> + <span class="redact-pill" + v-for="(n, kind) in redactPreview.counts" :key="kind"> + {{ kind }} × {{ n }} + </span> + <small class="hint"> + {{ t('settings.redact.previewTotal', { n: redactPreview.total, chars: redactPreview.total_chars }) }} + </small> + </div> + </div> + </div> + </div> + + <div class="action-row" style="gap:8px;flex-wrap:wrap;margin-top:8px;"> + <button class="btn primary" type="button" :disabled="redactSaving" + @click="onSaveRedact"> + {{ redactSaving ? t('common.saving') : t('action.save') }} + </button> + <span v-if="redactHint" class="redact-hint">{{ t('settings.redact.liveHint') }}</span> + </div> + </section> + + <!-- Schedule — when the consolidation job auto-runs (LLM info above is global). + Hidden in 'mode=llm' (schedule is independent of the LLM provider). --> + <section v-if="mode !== 'llm'" class="sec-schedule"> + <h3>{{ t('settings.section.schedule') }}</h3> + <p class="sec-scope">{{ t('settings.section.consolidationScope') }}</p> + <label> + <span>{{ t('settings.schedule.mode') }}</span> + <select v-model="cfg.schedule.mode"> + <option value="off">{{ t('settings.schedule.off') }}</option> + <option value="realtime">{{ t('settings.schedule.realtime') }}</option> + <option value="hourly">{{ t('settings.schedule.hourly') }}</option> + <option value="daily">{{ t('settings.schedule.daily') }}</option> + <option value="weekly">{{ t('settings.schedule.weekly') }}</option> + <option value="interval">{{ t('settings.schedule.everyN') }}</option> + </select> + </label> + <p class="sched-hint mode-hint">{{ scheduleModeHint() || t('settings.schedule.offHint') }}</p> + <label v-if="cfg.schedule.mode === 'interval'"> + <span>{{ t('settings.schedule.interval') }}</span> + <input type="number" v-model.number="cfg.schedule.interval_minutes" min="1" max="1440" /> + </label> + <div v-if="cfg.schedule.mode === 'daily' || cfg.schedule.mode === 'weekly'" class="row-2"> + <label> + <span>{{ t('settings.schedule.hour') }}</span> + <input type="number" v-model.number="cfg.schedule.hour" min="0" max="23" /> + </label> + <label> + <span>{{ t('settings.schedule.minute') }}</span> + <input type="number" v-model.number="cfg.schedule.minute" min="0" max="59" /> + </label> + </div> + <label v-if="cfg.schedule.mode === 'weekly'"> + <span>{{ t('settings.schedule.weekday') }}</span> + <select v-model.number="cfg.schedule.weekday"> + <option v-for="w in WEEKDAYS" :key="w.v" :value="w.v">{{ t(w.k) }}</option> + </select> + </label> + <label v-if="cfg.schedule.mode === 'realtime'"> + <span>{{ t('settings.schedule.realtimeIdle') }}</span> + <input type="number" v-model.number="cfg.schedule.after_ingest_idle_sec" min="5" max="600" /> + </label> + <div class="sched-status" :class="{ on: cfg.schedule.enabled && (cfg.schedule.mode || 'off') !== 'off', off: !cfg.schedule.enabled || (cfg.schedule.mode || 'off') === 'off' }"> + <span class="dot"></span> + <span class="text">{{ nextRunText() }}</span> + </div> + </section> + + <!-- Behaviour — consolidation-job-only knobs --> + <!-- Behaviour — batch size / min importance / filters etc. + Hidden in 'mode=llm'. Switch rows follow the same + .row-toggle pattern as storage / redaction so the + description (small) sits BELOW the label and the toggle + sits on the RIGHT for visual consistency. --> + <section v-if="mode !== 'llm'" class="sec-behaviour"> + <h3>{{ t('settings.section.behaviour') }}</h3> + <p class="sec-scope">{{ t('settings.behaviour.scope') }}</p> + + <div class="settings-subsection"> + <div class="settings-subsection-head">{{ t('settings.behaviour.headNumbers') || '基本参数' }}</div> + <div class="row-2"> + <label> + <span>{{ t('settings.batchSize') }}</span> + <input type="number" v-model.number="cfg.behaviour.batch_size" min="1" max="500" /> + <small class="hint">{{ t('settings.batchSizeHint') }}</small> + </label> + <label> + <span>{{ t('settings.temperature') }}</span> + <input type="number" v-model.number="cfg.behaviour.temperature" step="0.1" min="0" max="2" /> + <small class="hint">{{ t('settings.temperatureHint') }}</small> + </label> + </div> + <div class="row-2"> + <label> + <span>{{ t('settings.maxOutput') }}</span> + <input type="number" v-model.number="cfg.behaviour.max_output_tokens" min="64" max="4096" /> + <small class="hint">{{ t('settings.maxOutputHint') }}</small> + </label> + <label> + <span>{{ t('settings.minImp') }}</span> + <input type="number" v-model.number="cfg.behaviour.min_importance" step="0.05" min="0" max="1" /> + <small class="hint">{{ t('settings.minImpHint') }}</small> + </label> + </div> + </div> + + <div class="settings-subsection"> + <div class="settings-subsection-head">{{ t('settings.behaviour.headToggles') || '处理开关' }}</div> + <div class="behaviour-switches"> + <label class="row-toggle"> + <input type="checkbox" v-model="cfg.behaviour.enable_filter" /> + <span> + <strong>{{ t('settings.filter') }}</strong> + <small>{{ t('settings.filterHint') }}</small> + </span> + </label> + <label class="row-toggle"> + <input type="checkbox" v-model="cfg.behaviour.enable_score" /> + <span> + <strong>{{ t('settings.score') }}</strong> + <small>{{ t('settings.scoreHint') }}</small> + </span> + </label> + <label class="row-toggle"> + <input type="checkbox" v-model="cfg.behaviour.enable_summarize" /> + <span> + <strong>{{ t('settings.summary') }}</strong> + <small>{{ t('settings.summaryHint') }}</small> + </span> + </label> + <label class="row-toggle"> + <input type="checkbox" v-model="cfg.behaviour.dry_run" /> + <span> + <strong>{{ t('settings.dryRun') }}</strong> + <small>{{ t('settings.dryRunHint') }}</small> + </span> + </label> + </div> + </div> + </section> + + <!-- Security: Auth token --> + <section v-if="mode !== 'llm'"> + <h3>{{ t('settings.section.security') }}</h3> + <div class="auth-row"> + <strong>{{ authEnabled ? t('settings.auth.enabledStatus') : t('settings.auth.disabledStatus') }}</strong> + <small>{{ authEnabled ? t('settings.auth.enabledHint') : t('settings.auth.disabledHint') }}</small> + </div> + <div class="action-row" style="margin-top:8px;"> + <button class="btn" :class="authEnabled ? 'danger' : 'primary'" type="button" + :disabled="authLoading" @click="onToggleAuth"> + {{ authLoading ? t('settings.auth.working') : authEnabled ? t('settings.auth.disable') : t('settings.auth.enable') }} + </button> + </div> + </section> + + <!-- Actions (Run now / Preview) --> + <section> + <h3>{{ t('settings.section.actions') }}</h3> + <p class="sec-scope">{{ t('settings.section.actionsHint') }}</p> + <div class="action-row" style="gap:8px;flex-wrap:wrap;"> + <button class="btn primary" type="button" @click="onRunNow">{{ t('settings.runNow') }}</button> + <button class="btn secondary" type="button" @click="onPreview">{{ t('settings.preview') }}</button> + </div> + <div v-if="previewOpen" style="margin-top:8px;"> + <div class="preview-list"> + <div v-if="previewLoading" class="preview-row" style="color:var(--text-faint);justify-content:center;"> + {{ t('common.loading') }} + </div> + <div v-else-if="!previewItems.length" class="preview-row" style="color:var(--text-faint);justify-content:center;"> + {{ t('settings.preview.empty') }} + </div> + <div v-for="p in previewItems" v-else :key="p.id" class="preview-row"> + <span class="badge" :class="p.would_drop ? 'drop' : 'keep'"> + {{ p.would_drop ? t('settings.drop') : t('settings.keep') }} + </span> + <span class="text" :title="p.text">{{ p.text }}</span> + <span class="meta" style="color:var(--text-faint);">{{ Math.round((p.importance || 0) * 100) }}%</span> + </div> + </div> + </div> + </section> + + <!-- Recent runs --> + <section> + <h3>{{ t('settings.section.runs') }}</h3> + <div v-if="runsLoading" class="status-line" style="font-size:11.5px;color:var(--text-faint);"> + {{ t('common.loading') }} + </div> + <div v-else-if="!runs.length" class="run-row" style="color:var(--text-faint);justify-content:center;"> + {{ t('settings.noRuns') }} + </div> + <div v-else class="run-list"> + <div v-for="r in runs" :key="r.id" class="run-row"> + <span class="pill" :class="r.status">{{ t(statusKey(r.status)) }}</span> + <span class="meta">{{ t(triggerKey(r.trigger)) }} · {{ r.started_at ? new Date(r.started_at * 1000).toLocaleString() : '—' }}</span> + <span class="stats">{{ statLine(r.stats) }}</span> + </div> + </div> + </section> + </div> + + <div class="drawer-foot"> + <button class="btn secondary" type="button" @click="onReset">{{ t('action.reset') }}</button> + <div style="flex:1"></div> + <button class="btn tertiary" type="button" @click="onClose">{{ t('action.cancel') }}</button> + <button class="btn primary" type="button" :disabled="saving" @click="onSave"> + {{ saving ? t('common.saving') : t('action.save') }} + </button> + </div> + + <!-- Inline confirm dialog (replaces browser confirm()) --> + <div v-if="confirmDialog" class="modal-backdrop" @click.self="hideConfirm"> + <div class="modal" style="max-width:360px;z-index:200"> + <header class="modal-head"><h3>{{ confirmDialog.title }}</h3></header> + <div class="modal-body">{{ confirmDialog.message }}</div> + <footer class="modal-foot"> + <button class="btn tertiary" @click="hideConfirm">{{ t('action.cancel') }}</button> + <button class="btn primary" @click="() => { confirmDialog.onConfirm(); hideConfirm(); }"> + {{ t('action.confirm') }} + </button> + </footer> + </div> + </div> +</aside> + `, +}); diff --git a/loop_memory/serve/static/js/components/Sidebar.js b/loop_memory/serve/static/js/components/Sidebar.js new file mode 100644 index 0000000..44d745d --- /dev/null +++ b/loop_memory/serve/static/js/components/Sidebar.js @@ -0,0 +1,221 @@ +/** + * Sidebar — left-rail session list. + * + * Improvements over the previous version: + * - Source filter is a pill strip (Codex / Claude / Hermes / OpenClaw + * / All) instead of a dropdown; the count badge next to each pill + * tells you how many sessions are in that bucket. + * - Time labels use ended_at (which is what the API actually returns) + * instead of the missing `last_seen` field. + * - Limit raised from 100 → 300 so Codex sessions aren't hidden + * under heavier OpenClaw traffic. + * - Auto-refresh whenever the global ingest event fires so users see + * new Codex/OpenClaw sessions land without a manual reload. + * - Visual polish: rounded cards, source color stripe on the left, + * hollow badge for the "active" session, friendly empty state, + * inline clear filter on the active session. + */ +import { defineComponent, ref, computed, onMounted, onUnmounted, watch } from '../lib/vue.esm-browser.prod.js'; +import { store, t, timeAgo, fmtTime } from '../store.js'; +import { api } from '../api.js'; + +const SOURCE_META = { + codex: { label: 'Codex', tone: '#10b981', glyph: '◧' }, + openclaw: { label: 'OpenClaw', tone: '#f59e0b', glyph: '✜' }, + claude: { label: 'Claude', tone: '#8b5cf6', glyph: '◎' }, + hermes: { label: 'Hermes', tone: '#ec4899', glyph: '⚒' }, + 'codex-desktop-thread-2026-07-10': { label: 'Codex (legacy)', tone: '#64748b', glyph: '◧' }, +}; + +function metaFor(source) { + return SOURCE_META[source] || { label: source || '—', tone: '#64748b', glyph: '·' }; +} + +function shortTitle(s) { + const t0 = (s.title || '').trim(); + if (t0) { + // Prefix from cron/job wrappers: "[cron:...] …" → strip the bracket. + return t0.replace(/^\[cron:[^\]]+\]\s*/, '').slice(0, 70); + } + return (s.id || '').slice(0, 14); +} + +export const Sidebar = defineComponent({ + name: 'Sidebar', + setup() { + const sessions = ref([]); + const counts = ref({}); // source -> { sessions, turns } + const filter = ref('all'); + const loading = ref(false); + const lastRefreshedAt = ref(0); + + async function refresh() { + loading.value = true; + try { + const params = { limit: 300 }; + if (filter.value && filter.value !== 'all') params.source = filter.value; + const data = await api.listSessions(params); + sessions.value = Array.isArray(data) ? data : (data.sessions || []); + // Refresh per-source counts alongside the session list so the + // pills on the sidebar reflect what we just fetched. Without + // this, the pills could keep showing stale numbers (or 0) if + // /api/sessions/counts ever returned empty and only that + // endpoint was retried later. + await refreshCounts(); + } catch (e) { + sessions.value = []; + } finally { + loading.value = false; + lastRefreshedAt.value = Date.now(); + } + } + + async function refreshCounts() { + try { + // ``cache: 'no-store'`` so the pill counts can never show a + // stale snapshot — they're tiny and the user notices when + // they're wrong. + const c = await api.fetchJSON('/api/sessions/counts', { cache: 'no-store' }); + counts.value = c && c.by_source ? c : { by_source: c || {} }; + } catch (e) { + // silent — counts are decorative + } + } + + const sourcePills = computed(() => { + const seen = new Set(); + const list = []; + list.push({ id: 'all', label: t('sidebar.allSources'), tone: '#475569', glyph: '⊞', count: counts.value.all?.sessions }); + for (const s of sessions.value) { + if (!s.source || seen.has(s.source)) continue; + seen.add(s.source); + const meta = metaFor(s.source); + const c = (counts.value.by_source || {})[s.source]; + list.push({ + id: s.source, + label: meta.label, + tone: meta.tone, + glyph: meta.glyph, + count: c ? c.sessions : undefined, + }); + } + // Make sure Codex / Claude / Hermes / OpenClaw are visible even when + // no records are returned yet (otherwise the pill disappears and the + // user can't pre-select it). + for (const id of ['codex', 'openclaw', 'claude', 'hermes']) { + if (seen.has(id)) continue; + const meta = metaFor(id); + const c = (counts.value.by_source || {})[id]; + list.push({ + id, + label: meta.label, + tone: meta.tone, + glyph: meta.glyph, + count: c ? c.sessions : 0, + }); + } + return list; + }); + + function onPickSource(id) { + filter.value = id; + refresh(); + } + + function onClearSession() { + store.activeSession = ''; + refresh(); + } + + function onClickSession(s) { + // Clicking a session in the list should ALWAYS drop the user + // into the timeline tab — no matter what tab is currently shown + // on the right — so the conversation fragments for that session + // are immediately visible. + store.activeSession = store.activeSession === s.id ? '' : s.id; + store.activeTab = 'timeline'; + } + + onMounted(() => { + refresh(); + refreshCounts(); + watch(() => store.stats.sessions, refresh); + // Refresh when IngestPopover reports new sessions were imported so the + // left list reflects them without a manual reload. + const onIngest = () => { refresh(); refreshCounts(); }; + window.addEventListener('loop-memory:ingest-done', onIngest); + // Periodic auto-refresh every 30s so new files dropped into the watch + // directory show up even when the user clicks nothing. + const poller = setInterval(() => { refresh(); refreshCounts(); }, 30_000); + onUnmounted(() => { + window.removeEventListener('loop-memory:ingest-done', onIngest); + clearInterval(poller); + }); + }); + + // Expose on window for devtools quick inspection: + // window.__sidebar.counts.value + if (typeof window !== 'undefined') { + window.__sidebar = window.__sidebar || {}; + window.__sidebar.counts = counts; + window.__sidebar.sessions = sessions; + } + return { + sessions, counts, filter, loading, sourcePills, lastRefreshedAt, + onPickSource, onClickSession, onClearSession, + metaFor, shortTitle, timeAgo, fmtTime, t, store, + }; + }, + template: /* html */ ` +<aside class="sidebar"> + <div class="sidebar-head"> + <h2>{{ t('sidebar.sessions') }}</h2> + <button v-if="store.activeSession" class="sidebar-clear" :title="t('sidebar.clearFilter')" @click="onClearSession">×</button> + </div> + + <div class="source-pills"> + <button v-for="p in sourcePills" :key="p.id" + class="src-pill" :class="{ active: filter === p.id }" + :style="{ '--tone': p.tone }" + @click="onPickSource(p.id)"> + <span class="glyph">{{ p.glyph }}</span> + <span class="label">{{ p.label }}</span> + <span class="cnt" v-if="p.count != null">{{ p.count }}</span> + </button> + </div> + + <div v-if="store.activeSession" class="active-banner"> + <span class="dot"></span> + <span class="lbl">{{ t('sidebar.filteringBySession') }}</span> + <button class="x" @click="onClearSession" :title="t('sidebar.clearFilter')">×</button> + </div> + + <div class="sessions" v-if="sessions.length"> + <button v-for="s in sessions" :key="s.id" + class="session" + :class="{ active: store.activeSession === s.id }" + :data-source="s.source" + @click="onClickSession(s)"> + <div class="src-stripe"></div> + <div class="src-glyph">{{ metaFor(s.source).glyph }}</div> + <div class="meta"> + <div class="title">{{ shortTitle(s) }}</div> + <div class="row"> + <span class="src-name">{{ metaFor(s.source).label }}</span> + <span class="dot-sep">·</span> + <span class="msgs" :title="t('sidebar.memoryCount')">{{ s.message_count || 0 }} {{ t('sidebar.turns') }}</span> + <span class="dot-sep">·</span> + <span class="ago" :title="fmtTime(s.ended_at)">{{ timeAgo(s.ended_at) }}</span> + </div> + </div> + </button> + </div> + <div class="empty" v-else-if="!loading"> + <div class="empty-icon">◌</div> + <div class="empty-text">{{ t('sidebar.empty') }}</div> + <div class="empty-hint">{{ t('sidebar.emptyHint') }}</div> + </div> + <div class="loading" v-else>{{ t('common.loading') }}</div> +</aside> + `, +}); diff --git a/loop_memory/serve/static/js/components/Tabs.js b/loop_memory/serve/static/js/components/Tabs.js new file mode 100644 index 0000000..c165e09 --- /dev/null +++ b/loop_memory/serve/static/js/components/Tabs.js @@ -0,0 +1,49 @@ +/** + * Tab bar — Timeline / Dashboard / Wiki / Knowledge graph. + * + * The active tab lives in `store.activeTab` so other components can react + * to it. URL `?tab=` is read on app boot and written back when the user + * switches, so a deep-link to a specific view round-trips. + * + * Each tab is rendered as a real <button role="tab"> (instead of a plain + * <div>) so screen readers, keyboard focus, and the native Enter/Space + * activation all work without any extra JS. The container carries + * role="tablist" so the group is announced as a tab list. + */ +import { defineComponent, computed, watch } from '../lib/vue.esm-browser.prod.js'; +import { store, t } from '../store.js'; + +export const Tabs = defineComponent({ + name: 'Tabs', + setup() { + const tabs = computed(() => ([ + { id: 'timeline', label: t('tab.timeline') }, + { id: 'dashboard', label: t('tab.dashboard') }, + { id: 'wiki', label: t('tab.wiki'), badge: store.stats.wiki_pages }, + { id: 'graph', label: t('tab.graph'), badge: (typeof store.stats.graph === 'string' ? store.stats.graph.split('/')[1] : 0) || 0 }, + ])); + + function setTab(id) { + // App.js owns the URL sync via a watcher on store.activeTab, + // so all writers (this component, Sidebar session picks, + // Open-wiki from graph) reach the URL through the same path. + store.activeTab = id; + } + + return { tabs, store, setTab }; + }, + template: /* html */ ` +<nav class="tabs" role="tablist"> + <button v-for="tb in tabs" :key="tb.id" + type="button" + class="tab" :class="{ active: store.activeTab === tb.id }" + :data-tab="tb.id" + role="tab" + :aria-selected="store.activeTab === tb.id" + @click="setTab(tb.id)"> + <span>{{ tb.label }}</span> + <span class="badge" v-if="tb.badge">{{ tb.badge }}</span> + </button> +</nav> + `, +}); diff --git a/loop_memory/serve/static/js/components/Timeline.js b/loop_memory/serve/static/js/components/Timeline.js new file mode 100644 index 0000000..08f33ef --- /dev/null +++ b/loop_memory/serve/static/js/components/Timeline.js @@ -0,0 +1,234 @@ +/** + * Timeline — the "memory list" view, the default tab. + * + * Faithful to the legacy vanilla-JS renderTimeline (pre-Vue 8498eca): + * - Each card shows: kind-icon (emoji), kind label, polish-spark ✨ if + * the memory was AI-distilled (updated_at > created_at), full kind + * label, source chip, relative time, full timestamp, score %, body, + * visual score-bar, importance %, tags, and a copy-to-clipboard + * action button. + * - Search uses /api/recall so Chinese tokenisation + importance + * ranking both apply. + * + * Sidebar integration: when the user picks a session in the left rail + * the timeline re-fetches with that session_id filter, and a banner at + * the top makes the active scope obvious. Clearing the filter (banner + * or sidebar) restores the all-session view. + */ +import { defineComponent, ref, computed, onMounted, watch } from '../lib/vue.esm-browser.prod.js'; +import { store, t, timeAgo, fmtTime, toast } from '../store.js'; +import { api } from '../api.js'; + +const KIND_ICON = { + fact: '#', + episode: '⏵', + plan: '✱', + reflection: '✦', + turn: '↻', + rule: '§', + summary: '∑', + scratch: '·', + concept: '◇', +}; + +export const Timeline = defineComponent({ + name: 'Timeline', + setup() { + const memories = ref([]); + const recallMeta = ref(null); + const loading = ref(false); + const q = ref(''); + const kind = ref(''); + const minScore = ref(0); + const since = ref(''); + const until = ref(''); + const activeSessionInfo = ref(null); // session label when filter is on + + async function refresh() { + loading.value = true; + recallMeta.value = null; + try { + let rows; + const sessionId = store.activeSession || ''; + if (q.value.trim()) { + // Recall API does its own ranking; we still keep the active + // session in scope if one is selected. + const r = await api.recall(q.value.trim(), 100); + rows = (r.memories || []).map(m => ({ ...m, recall_score: m.score })); + if (sessionId) rows = rows.filter(m => m.session_id === sessionId); + recallMeta.value = { wiki: r.wiki, entities: r.entities, tokens: r.tokens }; + } else { + const params = { + kind: kind.value, + min_score: minScore.value || undefined, + since: since.value ? new Date(since.value).getTime() / 1000 : undefined, + until: until.value ? new Date(until.value).getTime() / 1000 : undefined, + session_id: sessionId || undefined, + limit: 200, + }; + const data = await api.listMemories(params); + rows = Array.isArray(data) ? data : (data.memories || data.items || []); + } + if (kind.value) rows = rows.filter(r => r.kind === kind.value); + if (minScore.value) rows = rows.filter(r => (r.score || r.importance || 0) >= Number(minScore.value)); + memories.value = rows; + + // Resolve the active session's display label via the sessions + // endpoint so the banner can show the original chat title. + if (sessionId) { + try { + const list = await api.listSessions({ limit: 500 }); + const arr = Array.isArray(list) ? list : (list.sessions || []); + const found = arr.find(s => s.id === sessionId); + if (found) { + const t = (found.title || '').replace(/^\[cron:[^\]]+\]\s*/, ''); + activeSessionInfo.value = { id: sessionId, label: (t || found.id).slice(0, 60), source: found.source, message_count: found.message_count }; + } else { + activeSessionInfo.value = { id: sessionId, label: sessionId.slice(0, 12), source: null }; + } + } catch (e) { + activeSessionInfo.value = { id: sessionId, label: sessionId.slice(0, 12) }; + } + } else { + activeSessionInfo.value = null; + } + } catch (e) { + memories.value = []; + } finally { + loading.value = false; + } + } + + function resetFilters() { + q.value = ''; kind.value = ''; minScore.value = 0; since.value = ''; until.value = ''; + store.activeSession = ''; + refresh(); + } + + function onClearSession() { + store.activeSession = ''; + refresh(); + } + + function onSearchSubmit(e) { + e.preventDefault(); + refresh(); + } + + function scoreFmt(s) { + if (s == null) return '—'; + return (s * 100).toFixed(1) + '%'; + } + + function kindIcon(k) { return KIND_ICON[k] || '·'; } + + function isPolished(m) { + return m && m.updated_at && m.created_at && (m.updated_at - m.created_at) > 1; + } + + async function onCopy(m) { + try { + await navigator.clipboard?.writeText(m.text || ''); + toast(t('toast.copied')); + } catch (e) { /* ignore */ } + } + + function onClickMemory(m) { + store.activeMemory = m.id; + } + + onMounted(() => refresh()); + // Re-fetch when the active session changes. + watch(() => store.activeSession, refresh); + // Re-fetch when a new memory is recorded. + watch(() => store.stats.memories, refresh); + + return { + store, t, memories, recallMeta, loading, q, kind, minScore, since, until, + activeSessionInfo, + refresh, resetFilters, onClearSession, onSearchSubmit, scoreFmt, kindIcon, isPolished, + onCopy, onClickMemory, timeAgo, fmtTime, KIND_ICON, + }; + }, + template: /* html */ ` +<div class="tab-pane" id="pane-timeline"> + <div class="tl-wrap"> + <form class="tl-toolbar" @submit="onSearchSubmit"> + <input class="tl-q" type="text" v-model="q" :placeholder="t('timeline.searchPlaceholder')" /> + <select v-model="kind" @change="refresh"> + <option value="">{{ t('timeline.allKinds') }}</option> + <option value="episode">{{ t('kind.episode') }}</option> + <option value="fact">{{ t('kind.fact') }}</option> + <option value="rule">{{ t('kind.rule') }}</option> + <option value="plan">{{ t('kind.plan') }}</option> + <option value="reflection">{{ t('kind.reflection') }}</option> + </select> + <input type="number" v-model.number="minScore" min="0" max="1" step="0.05" :placeholder="t('timeline.minScore')" @change="refresh" /> + <input type="date" v-model="since" @change="refresh" :title="t('timeline.since')" /> + <input type="date" v-model="until" @change="refresh" :title="t('timeline.until')" /> + <button type="button" class="tl-btn ghost" @click="resetFilters">{{ t('timeline.reset') }}</button> + </form> + + <div v-if="store.activeSession" class="tl-scope-banner"> + <span class="dot"></span> + <span class="lbl"> + <b>{{ t('timeline.sessionScope') }}</b> + <span class="src-tag" v-if="activeSessionInfo && activeSessionInfo.source">{{ activeSessionInfo.source }}</span> + <span class="title">{{ activeSessionInfo ? activeSessionInfo.label : store.activeSession.slice(0, 12) }}</span> + </span> + <button type="button" class="x" :title="t('timeline.clearScope')" @click="onClearSession">×</button> + </div> + + <div v-if="recallMeta" class="recall-meta"> + <span v-if="recallMeta.wiki && recallMeta.wiki.length" class="recall-hint"> + {{ t('recall.wikiMatches', { n: recallMeta.wiki.length }) }} + </span> + <span v-if="recallMeta.entities && recallMeta.entities.length" class="recall-hint"> + {{ t('recall.entityMatches', { n: recallMeta.entities.length }) }} + </span> + </div> + + <div class="tl-summary" v-if="!loading || memories.length"> + <span class="count-pill">{{ memories.length }} {{ t('timeline.memories') }}</span> + <span class="range" v-if="memories.length">{{ fmtTime(memories[memories.length-1].created_at) }} → {{ fmtTime(memories[0].created_at) }}</span> + </div> + + <div class="tl-list" v-if="memories.length"> + <article v-for="m in memories" :key="m.id" + class="bubble kind-{{ m.kind || 'turn' }}" + :class="{ active: store.activeMemory === m.id, polished: isPolished(m) }" + :data-id="m.id" + @click="onClickMemory(m)"> + <div class="head"> + <span class="kind-icon">{{ kindIcon(m.kind) }}</span> + <span class="kind-lbl">{{ t('kind.' + (m.kind || 'episode')) }}</span> + <span v-if="isPolished(m)" class="polish-spark">✨ {{ store.lang === 'zh' ? '已浓缩' : 'AI' }}</span> + <span class="dot-sep" v-if="isPolished(m)">·</span> + <span :title="fmtTime(m.created_at)">{{ timeAgo(m.created_at) }}</span> + <span v-if="m.source" class="src-chip" :class="m.source">{{ m.source }}</span> + <span style="flex:1"></span> + <span class="score-val">{{ scoreFmt(m.score ?? m.importance) }}</span> + </div> + <div class="text">{{ m.text }}</div> + <div class="score-bar"><span :style="{ width: scoreFmt(m.score ?? m.importance) }"></span></div> + <div class="foot"> + <span class="meta-item">{{ t('common.importance') }} <strong>{{ Math.round((m.importance || 0) * 100) }}%</strong></span> + <span v-if="m.tags && m.tags.length" class="meta-item"> + <code>{{ m.tags.slice(0, 4).join(', ') }}</code> + </span> + <span class="meta-item" :title="fmtTime(m.created_at)">{{ fmtTime(m.created_at) }}</span> + </div> + <div class="actions"> + <button type="button" :title="t('timeline.copy')" @click.stop="onCopy(m)">⧉</button> + </div> + </article> + </div> + <div class="empty" v-else-if="!loading"> + <div class="empty-icon">◌</div> + <div class="empty-text">{{ store.activeSession ? t('timeline.emptyForSession') : t('timeline.empty') }}</div> + </div> + <div class="loading" v-else>{{ t('common.loading') }}</div> + </div> +</div> + `, +}); diff --git a/loop_memory/serve/static/js/components/Toast.js b/loop_memory/serve/static/js/components/Toast.js new file mode 100644 index 0000000..8fcd12b --- /dev/null +++ b/loop_memory/serve/static/js/components/Toast.js @@ -0,0 +1,16 @@ +/** + * Toast — bottom-center transient notification. + */ +import { defineComponent, computed } from '../lib/vue.esm-browser.prod.js'; +import { store } from '../store.js'; + +export const Toast = defineComponent({ + name: 'Toast', + setup() { + const visible = computed(() => !!store.toast); + return { visible, store }; + }, + template: /* html */ ` +<div class="toast" :class="{ show: visible }">{{ store.toast?.msg || '' }}</div> + `, +}); diff --git a/loop_memory/serve/static/js/components/TopBar.js b/loop_memory/serve/static/js/components/TopBar.js new file mode 100644 index 0000000..f2332e2 --- /dev/null +++ b/loop_memory/serve/static/js/components/TopBar.js @@ -0,0 +1,220 @@ +/** + * TopBar — the header bar at the top of every page. + * + * Shows: brand, stats pills, run-status indicator, model chip, action + * buttons, language / theme / settings menu. + * + * The legacy vanilla-JS code mixed state mutation into 20+ event + * listeners scattered through the file. Here the bar is a single Vue + * component with one event-emitter for the actions (clicking "AI Run" + * bubbles up to App which knows how to call the API). + */ +import { defineComponent, computed, ref, onMounted, onUnmounted } from '../lib/vue.esm-browser.prod.js'; +import { store, patchPrefs, toast, t, timeAgo, fmtTime } from '../store.js'; +import { IngestPopover } from './IngestPopover.js'; +import { api, ApiError } from '../api.js'; + +export const TopBar = defineComponent({ + name: 'TopBar', + components: { IngestPopover }, + emits: ['ingest', 'rescore', 'llm-run', 'open-settings', 'open-llm-config', 'open-stats', 'open-diag', 'rebuild-graph', 'consolidate'], + setup(props, { emit }) { + const statsOpen = ref(false); + const toolsOpen = ref(false); + const ingestOpen = ref(false); + + const runLabel = computed(() => { + if (store.runStatus && store.runStatus.is_running) { + const p = store.runStatus.progress || {}; + if (p.total > 0) return `${p.current}/${p.total}`; + return '…'; + } + return t('topbar.run.idle'); + }); + + // Model-chip tooltip — picks one of four i18n keys based on + // reachability. The chip itself is a small pill (icon + model + // name + tiny dot); the tooltip carries the full status text so + // the topbar stays narrow. + const modelChipTip = computed(() => { + const r = store.modelInfo.reachability || 'unset'; + const provider = store.modelInfo.provider || 'rules'; + const model = store.modelInfo.model || 'rules'; + const ctx = { provider, model, msg: store.modelInfo.last_test_message || '' }; + return t('model.tip.' + r, ctx); + }); + + const runState = computed(() => store.runStatus && store.runStatus.is_running ? 'running' : 'idle'); + + function toggleStats() { statsOpen.value = !statsOpen.value; toolsOpen.value = false; ingestOpen.value = false; } + function toggleTools() { toolsOpen.value = !toolsOpen.value; statsOpen.value = false; ingestOpen.value = false; } + function toggleIngest() { ingestOpen.value = !ingestOpen.value; statsOpen.value = false; toolsOpen.value = false; } + function closeAll() { statsOpen.value = false; toolsOpen.value = false; ingestOpen.value = false; } + + function setLang(l) { patchPrefs({ lang: l }); closeAll(); } + function setTheme(th) { patchPrefs({ theme: th }); closeAll(); } + + function onDocClick(e) { + if (!e.target.closest('#stats-chip') && !e.target.closest('#stats-pop')) statsOpen.value = false; + if (!e.target.closest('.tb-tools') && !e.target.closest('.tb-tools-menu')) toolsOpen.value = false; + if (!e.target.closest('.tb-ingest') && !e.target.closest('.tb-ingest-menu')) ingestOpen.value = false; + } + onMounted(() => document.addEventListener('click', onDocClick)); + onUnmounted(() => document.removeEventListener('click', onDocClick)); + + return { + store, t, runLabel, runState, modelChipTip, + statsOpen, toolsOpen, ingestOpen, + toggleStats, toggleTools, toggleIngest, closeAll, + setLang, setTheme, + onIngest: () => { closeAll(); toggleIngest(); }, + onRescore: () => { closeAll(); emit('rescore'); }, + onLlmRun: () => { closeAll(); emit('llm-run'); }, + onOpenSettings:() => { closeAll(); emit('open-settings'); }, + onOpenLlmConfig:() => { closeAll(); emit('open-llm-config'); }, + onOpenStats: () => emit('open-stats'), + onOpenDiag: () => { closeAll(); emit('open-diag'); }, + onRebuildGraph:() => { closeAll(); emit('rebuild-graph'); }, + onConsolidate: () => { closeAll(); emit('consolidate'); }, + }; + }, + template: /* html */ ` +<header class="topbar"> + <div class="topbar-brand"> + <img src="static/img/logo-mark.svg" + alt="Loop Memory" + class="logo-mark logo-mark-dark" /> + <img src="static/img/logo-light.svg" + alt="Loop Memory" + class="logo-mark logo-mark-light" /> + <div class="brand"> + <span class="brand-name">{{ t('app.title') }}</span> + <span class="brand-tag">{{ t('app.tagline') }}</span> + </div> + </div> + + <div style="position:relative;"> + <span class="stats-pills" id="stats-chip" role="button" tabindex="0" + aria-haspopup="true" :aria-label="t('topbar.stats')" + :title="t('topbar.statsDetails')" @click="toggleStats"> + <span class="stats-pill" :title="t('stat.memories')"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="10" height="10" rx="2"/><path d="M5.5 7h5M5.5 9.5h5"/></svg> + <strong>{{ store.stats.memories || '…' }}</strong> + </span> + <span class="stats-pill" :title="t('stat.sessions')"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="6" r="2.5"/><path d="M3 13c.7-2.3 2.7-3.5 5-3.5s4.3 1.2 5 3.5"/></svg> + <strong>{{ store.stats.sessions || '…' }}</strong> + </span> + <span class="stats-pill" :title="t('stat.scoreLabel')"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 1.5l2 4.2 4.6.7-3.3 3.2.8 4.6L8 12l-4.1 2.2.8-4.6L1.4 6.4 6 5.7z"/></svg> + <strong>{{ store.stats.avg_score ? (store.stats.avg_score * 100).toFixed(0) + '%' : '…' }}</strong> + </span> + </span> + <div class="stats-pop" id="stats-pop" v-show="statsOpen" @click.stop> + <div class="row"><span class="label">{{ t('stat.memories') }}</span><span class="val">{{ store.stats.memories || '…' }}</span></div> + <div class="row"><span class="label">{{ t('stat.sessions') }}</span><span class="val">{{ store.stats.sessions || '…' }}</span></div> + <div class="row"><span class="label">{{ t('stat.graph') }}</span><span class="val">{{ store.stats.graph || '0/0' }}</span></div> + <div class="row"><span class="label">{{ t('stat.scoreLabel') }}</span><span class="val">{{ store.stats.avg_score ? (store.stats.avg_score * 100).toFixed(1) + '%' : '…' }}</span></div> + <hr/> + <div class="row"><span class="label">{{ t('stat.dbPath') }}</span><span class="val" style="font-family:var(--mono); font-size:11px; cursor:pointer;" :title="store.stats.dbPath">{{ store.stats.dbPath ? (store.stats.dbPath.length > 36 ? '…' + store.stats.dbPath.slice(-34) : store.stats.dbPath) : '…' }}</span></div> + </div> + </div> + + <div class="run-status" v-show="store.runStatus && store.runStatus.is_running"> + <span class="run-status-dot"></span> + <span class="run-status-label">{{ runLabel }}</span> + </div> + + <div class="spacer"></div> + + <div class="group-right topbar-command-bar"> + <!-- + Model entry — always shows provider + status so users know + (a) which model is in use, (b) whether an API key has been set, + and (c) that clicking opens the configurator. The previous + design had transparent background + transparent border, which + made the entry visually disappear into the topbar. + --> + <button class="model-chip" id="model-chip" + :data-reach="store.modelInfo.reachability" + role="button" type="button" + :title="modelChipTip" + @click="onOpenLlmConfig"> + <span class="m-icon"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"> + <circle cx="8" cy="8" r="3"/> + <path d="M2.5 8h2M11.5 8h2M8 2.5v2M8 11.5v2"/> + <path d="M3.8 3.8l1.4 1.4M10.8 10.8l1.4 1.4M3.8 12.2l1.4-1.4M10.8 5.2l1.4-1.4"/> + </svg> + </span> + <span class="m-info"> + <span class="m-name">{{ store.modelInfo.model || 'rules' }}</span> + <span class="m-dot" :data-reach="store.modelInfo.reachability" :aria-label="t('model.dot.' + store.modelInfo.reachability)"> + <span class="m-dot-inner"></span> + </span> + </span> + </button> + + <div class="tb-command-group"> + <div class="tb-ingest"> + <button class="tb-action tb-ingest-trigger" :class="{ active: ingestOpen }" + :title="t('action.ingestTip')" @click.stop="toggleIngest"> + <svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 1l3.5 4H9v6H7V5H4.5L8 1zM2 13h12v1.5H2z"/></svg> + <span>{{ t('action.ingest') }}</span> + <svg class="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M5 6.5L8 9.5l3-3"/></svg> + </button> + <IngestPopover v-show="ingestOpen" @close="ingestOpen=false" /> + </div> + </div> + + <div class="tb-tools"> + <button class="tb-action tb-tools-trigger" :class="{ active: toolsOpen }" + :title="t('topbar.toolsTip')" @click.stop="toggleTools"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M3 8h10M3 12h10"/><circle cx="6" cy="4" r="1.5" fill="var(--surface)"/><circle cx="10" cy="8" r="1.5" fill="var(--surface)"/><circle cx="7" cy="12" r="1.5" fill="var(--surface)"/></svg> + <span>{{ t('topbar.tools') }}</span> + <svg class="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M5 6.5L8 9.5l3-3"/></svg> + </button> + <div class="tb-tools-menu" v-show="toolsOpen" @click.stop> + <div class="tb-tools-heading">{{ t('topbar.maintenance') }}</div> + <button class="tb-tools-item" @click="onRescore"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M13 8a5 5 0 11-1.5-3.6"/><path d="M10 2.5h3v3"/></svg> + <span><b>{{ t('action.rescore') }}</b><small>{{ t('action.rescoreTip') }}</small></span> + </button> + <button class="tb-tools-item" @click="onConsolidate"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M4.5 8h7M6 12h4"/></svg> + <span><b>{{ t('action.consolidate') }}</b><small>{{ t('topbar.consolidateTip') }}</small></span> + </button> + <button class="tb-tools-item" @click="onRebuildGraph"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="5.5"/><path d="M8 4.5v3.8l2.6 1.5"/></svg> + <span><b>{{ t('topbar.rebuildGraph') }}</b><small>{{ t('topbar.rebuildGraphTip') }}</small></span> + </button> + <div class="tb-tools-heading">{{ t('topbar.system') }}</div> + <button class="tb-tools-item" @click="onOpenDiag"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2.5 8h2l1.3-3 2.4 6 1.5-3H13.5"/></svg> + <span><b>{{ t('topbar.doctor') }}</b><small>⌘D</small></span> + </button> + </div> + </div> + + <div class="tb-utility-group"> + <div class="tb-seg lang" :title="t('topbar.language')"> + <button :class="{ active: store.lang === 'zh' }" @click="setLang('zh')">中</button> + <button :class="{ active: store.lang === 'en' }" @click="setLang('en')">EN</button> + </div> + <div class="tb-seg theme" :title="t('topbar.theme')"> + <button :class="{ active: store.theme === 'auto' }" :title="t('topbar.themeAuto')" @click="setTheme('auto')">A</button> + <button :class="{ active: store.theme === 'light' }" :title="t('topbar.themeLight')" @click="setTheme('light')">☀</button> + <button :class="{ active: store.theme === 'dark' }" :title="t('topbar.themeDark')" @click="setTheme('dark')">☾</button> + </div> + <button class="icon-btn-circle settings-shortcut" :title="t('topbar.settingsTip')" :aria-label="t('action.settings')" @click="onOpenSettings"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> + <!-- cog/gear — distinct from the sun icon used for theme toggle --> + <path d="M8 1.6l.6 1.4 1.4-.2.4 1.3 1.3.5-.2 1.4 1 .9-.7 1.2.6 1.2-1.2.6-.2 1.4-1.4.2-.5 1.3-1.3-.2-.9 1-1.2-.7-1.2.6-.6-1.2L2 11.7l-.2-1.4-1.3-.5.2-1.4-1-.9.7-1.2-.6-1.2 1.2-.6.2-1.4 1.4-.2.5-1.3 1.3.2.9-1z"/> + <circle cx="8" cy="8" r="2.4"/> + </svg> + </button> + </div> + </div> +</header> + `, +}); diff --git a/loop_memory/serve/static/js/components/Wiki.js b/loop_memory/serve/static/js/components/Wiki.js new file mode 100644 index 0000000..a85f5a7 --- /dev/null +++ b/loop_memory/serve/static/js/components/Wiki.js @@ -0,0 +1,603 @@ +/** + * Wiki — distilled knowledge pages. + * + * Cards in a responsive grid; each card shows title, summary, top bullets, + * tags, importance, and last-updated. Click a card to open the editor + * (or just expand inline). The legacy code had a 200-line renderWiki + * function that hand-built HTML strings — Vue's template syntax is + * significantly easier to scan and edit. + */ +import { defineComponent, ref, computed, onMounted, onUnmounted, watch } from '../lib/vue.esm-browser.prod.js'; +import { defineAsyncComponent } from '../lib/vue.esm-browser.prod.js'; +import { store, t, toast, fmtTime } from '../store.js'; +import { api } from '../api.js'; +// WikiEditor is heavy and only used when the user clicks "edit" on a +// page — lazy-load it so the rest of Wiki stays cheap. +const WikiEditor = defineAsyncComponent(() => + import('./WikiEditor.js').then(module => module.WikiEditor) +); + +export const Wiki = defineComponent({ + name: 'Wiki', + components: { WikiEditor }, + setup() { + const pages = ref([]); + const q = ref(''); + const sort = ref('updated_desc'); + const scopeFilter = ref('all'); // 'all'|'global'|'codex'|... + const loading = ref(false); + const expanded = ref(null); + const editing = ref(null); + const contradictions = ref([]); + const contrLoading = ref(false); + const showContradictions = ref(false); + // Scope tokens mirror WikiEditor.SCOPE_TOKENS + const SCOPE_TOKENS = ['global', 'codex', 'claude', 'hermes', 'openclaw']; + // Default scope applied to every page when the master 全局 switch + // is flipped OFF. ``codex`` is the most common client in this + // workspace, so it's a safe "scoped to one client" default that + // matches the per-card toggle's fallback. Users can refine each + // card afterwards via the per-card toggle or the WikiEditor. + const SCOPE_OFF_DEFAULT = 'codex'; + + async function refresh() { + loading.value = true; + try { + const data = await api.listWiki(); + pages.value = Array.isArray(data) ? data : (data.pages || []); + } catch (e) { + pages.value = []; + } finally { + loading.value = false; + } + } + async function refreshContradictions() { + contrLoading.value = true; + try { + const data = await api.listContradictions(); + contradictions.value = (data && data.items) || []; + } catch (e) { + contradictions.value = []; + } finally { + contrLoading.value = false; + } + } + async function scanContradictions() { + contrLoading.value = true; + try { + await api.scanContradictions({ threshold: 0.45 }); + await refreshContradictions(); + } catch (e) { + // best-effort; UI shows stale list + } finally { + contrLoading.value = false; + } + } + async function resolveContradiction(pageId) { + try { + await api.resolveWikiContradiction(pageId); + await refreshContradictions(); + } catch (e) { + // ignore + } + } + async function quickMerge(pageId, loserId) { + try { + await api.mergeWiki(pageId, { loser_id: loserId }); + await Promise.all([refresh(), refreshContradictions()]); + } catch (e) { + // ignore + } + } + + const visible = computed(() => { + let rows = pages.value; + if (q.value.trim()) { + const needle = q.value.trim().toLowerCase(); + rows = rows.filter(p => + (p.title || '').toLowerCase().includes(needle) || + (p.summary || '').toLowerCase().includes(needle) || + (p.slug || '').toLowerCase().includes(needle)); + } + // Scope filter: 'all' = everything; otherwise the page must + // either be 'global' (visible to everyone) OR include this + // client token in its comma-separated scope list. + if (scopeFilter.value && scopeFilter.value !== 'all') { + const tok = scopeFilter.value; + rows = rows.filter(p => { + const s = (p.scope || 'global').toString().toLowerCase(); + if (s === 'global') return true; // global visible to all + const tokens = s.split(',').map(x => x.trim()).filter(Boolean); + return tokens.includes(tok); + }); + } + const cmp = (a, b) => { + if (sort.value === 'updated_desc') return (b.updated_at || 0) - (a.updated_at || 0); + if (sort.value === 'importance_desc') return (b.importance || 0) - (a.importance || 0); + if (sort.value === 'title_asc') return (a.title || '').localeCompare(b.title || ''); + return 0; + }; + return [...rows].sort(cmp); + }); + + /** + * Extract every bullet ("- ...") from a wiki body so the card can show the + * full list. Previously this was capped at the first 6 lines, which made + * freshly-distilled pages look truncated. The new distillation policy is + * "completeness over compactness", so the card needs to surface every + * atomic fact the LLM produced. + */ + function bulletsOf(p) { + const lines = (p.body || '').split('\n'); + return lines.filter(l => l.startsWith('- ')); + } + /** Render the scope chips that show which clients a page is + * visible to. Always returns an array of strings; consumers + * iterate as-is. */ + function scopeTokensOf(p) { + const s = (p.scope || 'global').toString().toLowerCase(); + return s.split(',').map(x => x.trim()).filter(Boolean); + } + function scopeChipLabel(tok) { + return ('wiki.scope.chip.' + tok); + } + + function expand(id) { expanded.value = expanded.value === id ? null : id; } + function edit(id) { editing.value = id; } + + function onNew() { editing.value = 'new'; } + + const importing = ref(false); + const exporting = ref(false); + + async function onExport() { + exporting.value = true; + try { + const res = await fetch('/api/wiki/export?format=json&limit=2000'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); + a.href = url; + a.download = `loop-memory-wiki-${ts}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + toast(t('wiki.exportFail', { msg: e.message }), 4000); + } finally { + exporting.value = false; + } + } + + function onImportClick() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,.md,.markdown,application/json,text/markdown,text/plain'; + input.addEventListener('change', async () => { + const file = input.files && input.files[0]; + if (!file) return; + importing.value = true; + try { + const text = await file.text(); + let body; + if (/\.md(?:arkdown)?$/i.test(file.name) || text.trim().startsWith('#')) { + body = { format: 'markdown', markdown: text }; + } else { + try { + const parsed = JSON.parse(text); + const pages = Array.isArray(parsed) ? parsed : (parsed.pages || []); + body = { format: 'json', pages }; + } catch (e) { + throw new Error('not a valid JSON file: ' + e.message); + } + } + const r = await fetch('/api/wiki/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!r.ok) { + const txt = await r.text(); + throw new Error(`HTTP ${r.status} — ${txt.slice(0, 200)}`); + } + const out = await r.json(); + if (out.total === 0) { + toast(t('wiki.importEmpty'), 3000); + } else { + toast(t('wiki.importSuccess', { + created: out.created, updated: out.updated, skipped: out.skipped, + }), 3500); + await refresh(); + } + } catch (e) { + toast(t('wiki.importError', { msg: e.message }), 4000); + } finally { + importing.value = false; + } + }); + input.click(); + } + + async function saveEdit(payload) { + try { + if (editing.value === 'new') { + await api.createWiki(payload); + } else { + await api.updateWiki(editing.value, payload); + } + editing.value = null; + await refresh(); + } catch (e) { + toast(t('wiki.saveError') + ': ' + e.message, 4000); + } + } + + // Inline confirmation modal — replaces the legacy browser confirm() so + // destructive actions stay within the app chrome (no native dialog + // blocking the page). Stored as a single ref so the template only + // needs to render one modal regardless of how many confirm call sites + // there are. + const confirmDialog = ref(null); + function showConfirm(opts) { confirmDialog.value = opts; } + function hideConfirm() { confirmDialog.value = null; } + + async function removePage(p) { + showConfirm({ + title: t('wiki.confirmDeleteTitle') || t('wiki.confirmDelete'), + message: t('wiki.confirmDeleteMsg') || t('wiki.confirmDelete'), + onConfirm: async () => { + try { + await api.deleteWiki(p.id); + await refresh(); + } catch (e) { + toast(t('wiki.deleteError') + ': ' + e.message, 4000); + } + }, + }); + } + + async function openWikiBySlug(slug) { + if (!slug) return; + try { + const list = await api.listWiki(); + const found = (list || []).find(x => x.slug === slug || x.title === slug); + if (found) editing.value = found.id; + } catch (e) { /* ignore */ } + } + function onOpenWiki(e) { + const slug = (e && e.detail && e.detail.slug) || ''; + store.activeTab = 'wiki'; + openWikiBySlug(slug); + } + onMounted(() => { + refresh(); + window.addEventListener('loop-memory:open-wiki', onOpenWiki); + }); + onUnmounted(() => { + window.removeEventListener('loop-memory:open-wiki', onOpenWiki); + }); + watch(() => store.stats.wiki_pages, refresh); + // When the user navigates back to the wiki tab, refresh in case the + // list went stale (distillation may have added/removed pages). + watch(() => store.activeTab, (id) => { + if (id === 'wiki') { + refresh(); + refreshContradictions(); + } + }); + + // ------------------------------------------------------------------ + // Per-card 全局 toggle + toolbar master 全局 toggle. + // + // Design contract: + // - Each wiki page carries a ``scope`` field (existing schema) + // where 'global' means "shared with every agent", otherwise a + // comma-list of client tokens ('codex', 'claude', …). + // - Default is NOT global: distilled pages get the source-client + // of the evidence memories (see ``_scope_for_evidence`` in + // ``llm_consolidate.py``), so the per-card toggle is OFF on + // newly-distilled pages. + // - The toolbar master toggle has three visible states: + // * on (every page is global — bulk ON) + // * off (no automatic bulk change) + // * "mixed" (some pages are global, some are not) — shown + // as a half-tinted knob so the user knows they have + // already diverged from the master. + // - Master is OPTIMISTIC: flipping it ON calls ``bulk-scope`` + // to write 'global' to every page in one round-trip, then + // refreshes the local list. The visible "mixed" state + // resolves itself automatically as the response lands. + // - When the user manually flips a SINGLE card OFF while the + // master is ON, the master auto-flips to "mixed" — that's + // the rule "关闭某一个知识全局生效后,上面的全局生效自动关闭". + // ------------------------------------------------------------------ + const masterGlobal = ref(false); // local optimistic state + const bulkBusy = ref(false); // disable toggle while inflight + + /** True iff the page is shared with every client. */ + function isGlobal(p) { + const s = (p && p.scope || '').toString().toLowerCase(); + if (!s) return true; // schema default = global + // "global" alone, or starting with "global," => global. + return s === 'global' || s.split(',').map(x => x.trim()).includes('global'); + } + + /** + * Toggle a single card's 全局 switch. Persists the change via + * ``bulk-scope`` with the explicit ``page_ids`` list — avoids a + * per-page PUT round-trip. Also nudges ``masterGlobal`` to + * reflect the partial-state rule. + */ + async function toggleCardGlobal(p) { + if (bulkBusy.value) return; + // If the page is currently global, flipping OFF means we + // switch to a per-client scope. The natural fallback is the + // page's existing scope tokens (e.g. 'codex,claude'); if it + // was 'global', fall back to the single most-recent source + // recorded in ``tags``/``evidence`` or just 'codex' so the + // page is no longer shared with every agent. + const cur = (p.scope || '').toString().toLowerCase(); + let nextScope; + if (isGlobal(p)) { + // Going global → not-global. Preserve whatever non-global + // tokens were there, otherwise fall back to 'codex' so the + // page is at least scoped to one client. + const nonGlobal = cur.split(',') + .map(x => x.trim()) + .filter(x => x && x !== 'global' && SCOPE_TOKENS.includes(x)); + nextScope = nonGlobal.length ? nonGlobal.join(',') : 'codex'; + } else { + nextScope = 'global'; + } + bulkBusy.value = true; + // Optimistic local update so the UI reflects the flip + // immediately, before the network round-trip. + const idx = pages.value.findIndex(x => x.id === p.id); + if (idx >= 0) { + pages.value[idx] = { ...pages.value[idx], scope: nextScope }; + } + // Partial-state rule: if the master was ON and the user + // flipped a single card OFF, the master has to follow. + if (masterGlobal.value && nextScope !== 'global') { + masterGlobal.value = false; + } + try { + await api.bulkScopeWiki({ scope: nextScope, page_ids: [p.id] }); + } catch (e) { + // Roll back the optimistic update on failure. + if (idx >= 0) { + pages.value[idx] = p; + } + toast(t('wiki.globalToggleFail', { msg: e.message }), 4000); + } finally { + bulkBusy.value = false; + } + } + + /** + * Toggle the master 全局 switch in the toolbar. Both directions + * are now REAL bulk writes (single round-trip each): + * + * * ON → every page becomes ``scope='global'``. Master UI + * flips ON, every per-card knob follows. + * * OFF → every page loses its global flag. Each page is set + * to the SCOPE_OFF_DEFAULT scope (``'codex'``) so it + * is no longer shared with every client. Users who + * want a different client can still flip individual + * cards afterwards; the per-card toggle continues to + * work as before. + * + * Previously, master OFF was a pure local state change and + * pages stayed global — the user reported this as confusing: + * "若拨动关闭全局按钮,要全部关闭所有的全局设置" — flipping + * the master OFF should actually turn off global everywhere. + */ + async function toggleMasterGlobal() { + if (bulkBusy.value) return; + const turningOn = !masterGlobal.value; + const targetScope = turningOn ? 'global' : SCOPE_OFF_DEFAULT; + bulkBusy.value = true; + // Optimistic local state flip so the knob reacts instantly; + // the bulk write below will reconcile once it returns. + masterGlobal.value = turningOn; + try { + const r = await api.bulkScopeWiki({ scope: targetScope }); + await refresh(); + toast( + turningOn + ? t('wiki.masterGlobalOn', { n: r.updated || 0 }) + : t('wiki.masterGlobalOff', { n: r.updated || 0 }), + 2200, + ); + } catch (e) { + // Roll back the optimistic flip on failure. + masterGlobal.value = !turningOn; + toast(t('wiki.globalToggleFail', { msg: e.message }), 4000); + } finally { + bulkBusy.value = false; + } + } + + // Whenever the page list changes (refresh / new distillation), + // re-derive the master state. ON iff EVERY page is currently + // global; OFF otherwise (the "mixed" partial-state rule). + watch(pages, (rows) => { + if (!Array.isArray(rows) || !rows.length) { + masterGlobal.value = false; + return; + } + // Don't clobber a "true" master while the user is mid-bulk-ON. + if (bulkBusy.value && masterGlobal.value) return; + masterGlobal.value = rows.every(isGlobal); + }, { deep: true }); + + return { + store, t, pages, q, sort, scopeFilter, SCOPE_TOKENS, + loading, visible, expanded, editing, bulletsOf, + scopeTokensOf, scopeChipLabel, + refresh, expand, edit, onNew, onExport, onImportClick, importing, exporting, saveEdit, removePage, fmtTime, + contradictions, contrLoading, showContradictions, refreshContradictions, scanContradictions, resolveContradiction, quickMerge, + isGlobal, toggleCardGlobal, toggleMasterGlobal, masterGlobal, bulkBusy, + confirmDialog, showConfirm, hideConfirm, + }; + }, + template: /* html */ ` +<div class="tab-pane" id="pane-wiki"> + <div class="wiki-wrap"> + <div class="wiki-toolbar"> + <input class="wiki-q" type="text" v-model="q" :placeholder="t('wiki.searchPlaceholder')" /> + <select v-model="sort"> + <option value="updated_desc">{{ t('wiki.sort.updated') }}</option> + <option value="importance_desc">{{ t('wiki.sort.importance') }}</option> + <option value="title_asc">{{ t('wiki.sort.title') }}</option> + </select> + <select v-model="scopeFilter" :title="t('wiki.scope.hint')"> + <option value="all">{{ t('wiki.scope.filter.all') }}</option> + <option value="global">{{ t('wiki.scope.filter.global') }}</option> + <option value="codex">{{ t('wiki.scope.filter.codex') }}</option> + <option value="claude">{{ t('wiki.scope.filter.claude') }}</option> + <option value="hermes">{{ t('wiki.scope.filter.hermes') }}</option> + <option value="openclaw">{{ t('wiki.scope.filter.openclaw') }}</option> + </select> + <span class="spacer"></span> + <label class="master-global-toggle" + :class="{ on: masterGlobal, busy: bulkBusy }" + :title="t('wiki.masterGlobal.hint')"> + <input type="checkbox" + :checked="masterGlobal" + :disabled="bulkBusy" + @change="toggleMasterGlobal" /> + <span class="mgt-knob" aria-hidden="true"></span> + <span class="mgt-text">{{ t('wiki.masterGlobal.label') }}</span> + </label> + <button class="tb-action ghost" :title="t('wiki.exportTip')" :disabled="exporting" @click="onExport"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M8 1v9M4.5 6.5L8 10l3.5-3.5M2 12v2.5h12V12"/></svg> + <span>{{ exporting ? '…' : t('wiki.export') }}</span> + </button> + <button class="tb-action ghost" :title="t('wiki.importTip')" :disabled="importing" @click="onImportClick"> + <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M8 15V6M4.5 9.5L8 6l3.5 3.5M2 2.5V0h12v2.5"/></svg> + <span>{{ importing ? '…' : t('wiki.import') }}</span> + </button> + <button class="tb-action primary" @click="onNew"> + <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14"><path d="M8 1v14M1 8h14" stroke="currentColor" stroke-width="2"/></svg> + <span>{{ t('wiki.new') }}</span> + </button> + </div> + + <!-- Contradictions banner — only renders when there are pages + that flag a conflict. Each item shows both titles and a + one-click "merge winner / dissolve loser" button, plus a + "not actually a conflict" escape hatch that clears the + flag without losing the page. --> + <div v-if="contradictions.length" class="wiki-contradictions"> + <header class="wc-head"> + <h4>{{ t('wiki.contradict.title') }} ({{ contradictions.length }})</h4> + <div class="wc-ctrls"> + <button class="btn ghost small" type="button" + :disabled="contrLoading" @click="scanContradictions"> + {{ contrLoading ? '…' : t('wiki.contradict.rescan') }} + </button> + </div> + </header> + <ul class="wc-list"> + <li v-for="row in contradictions" :key="row.id" class="wc-row"> + <div class="wc-pair"> + <strong>{{ row.title }}</strong> + <span class="vs">{{ t('wiki.contradict.vs') }}</span> + <strong v-for="p in row.partners" :key="p.id">{{ p.title }}</strong> + </div> + <div class="wc-summary-row"> + <span class="wc-summary-text">{{ row.summary || '—' }}</span> + <span class="wc-summary-text" v-for="p in row.partners" :key="'s'+p.id"> + · {{ p.summary || '—' }} + </span> + </div> + <div class="wc-actions"> + <button class="btn primary small" type="button" + @click="quickMerge(row.id, row.partners[0].id)"> + {{ t('wiki.contradict.mergeInto', { title: row.title }) }} + </button> + <button class="btn ghost small" type="button" + @click="resolveContradiction(row.id)"> + {{ t('wiki.contradict.notConflict') }} + </button> + </div> + </li> + </ul> + </div> + + <div v-if="visible.length" class="wiki-grid"> + <article v-for="p in visible" :key="p.id" class="wiki-card"> + <div class="wc-head"> + <h3 class="wc-title">{{ p.title || p.slug }}</h3> + <span class="wc-imp" :title="t('wiki.importance')"> + {{ Math.round((p.importance || 0) * 100) }}% + </span> + </div> + <div class="wc-summary">{{ p.summary }}</div> + <div class="wc-scopes" v-if="scopeTokensOf(p).length"> + <span class="scope-pill" v-for="tok in scopeTokensOf(p)" :key="tok" + :class="'scope-pill-' + tok"> + {{ t(scopeChipLabel(tok)) }} + </span> + </div> + <label class="card-global-toggle" + :class="{ on: isGlobal(p), busy: bulkBusy }" + :title="isGlobal(p) ? t('wiki.cardGlobal.onHint') : t('wiki.cardGlobal.offHint')"> + <input type="checkbox" + :checked="isGlobal(p)" + :disabled="bulkBusy" + @change="toggleCardGlobal(p)" /> + <span class="cgt-knob" aria-hidden="true"></span> + <span class="cgt-text">{{ t('wiki.cardGlobal.label') }}</span> + </label> + <ul class="wc-bullets"> + <li v-for="(b, i) in bulletsOf(p)" :key="i">{{ b.replace(/^-\\s*/, '') }}</li> + </ul> + <div class="wc-tags" v-if="p.tags && p.tags.length"> + <span v-for="tag in p.tags" :key="tag" class="tag">#{{ tag }}</span> + </div> + <div class="wc-foot"> + <span class="wc-meta"> + <span :data-source="p.tags && p.tags.includes('fact') ? 'fact' : 'episode'"> + {{ fmtTime(p.updated_at) }} + </span> + </span> + <div class="wc-actions"> + <button class="wc-btn" @click="expand(p.id)"> + {{ expanded === p.id ? t('action.close') : t('wiki.preview') }} + </button> + <button class="wc-btn" @click="edit(p.id)">{{ t('action.edit') }}</button> + <button class="wc-btn danger" @click="removePage(p)">{{ t('action.delete') }}</button> + </div> + </div> + <pre class="wc-body" v-if="expanded === p.id">{{ p.body }}</pre> + </article> + </div> + <div class="empty" v-else-if="!loading">{{ t('wiki.empty') }}</div> + <div class="loading" v-else>{{ t('common.loading') }}</div> + + <WikiEditor v-if="editing" + :page-id="editing" + @save="saveEdit" + @cancel="editing = null" /> + + <!-- Inline confirmation modal — replaces the legacy browser confirm() so + destructive actions stay inside the app shell. --> + <div v-if="confirmDialog" class="modal-backdrop" @click.self="hideConfirm"> + <div class="modal" style="max-width:360px"> + <header class="modal-head"><h3>{{ confirmDialog.title }}</h3></header> + <div class="modal-body">{{ confirmDialog.message }}</div> + <footer class="modal-foot"> + <button class="btn ghost" @click="hideConfirm">{{ t('action.cancel') }}</button> + <button class="btn primary" @click="() => { confirmDialog.onConfirm(); hideConfirm(); }">{{ t('action.confirm') }}</button> + </footer> + </div> + </div> + </div> +</div> + `, +}); diff --git a/loop_memory/serve/static/js/components/WikiEditor.js b/loop_memory/serve/static/js/components/WikiEditor.js new file mode 100644 index 0000000..666ecbe --- /dev/null +++ b/loop_memory/serve/static/js/components/WikiEditor.js @@ -0,0 +1,184 @@ +/** + * WikiEditor — minimal new/edit modal for a wiki page. + * + * The full editor (markdown preview, evidence picker, etc.) is huge in + * the legacy code; here we ship a focused 3-field form so users can fix + * typos and create new pages. Future iterations can grow the editor + * without rewriting the surrounding list view. + */ +import { defineComponent, ref, computed, watch } from '../lib/vue.esm-browser.prod.js'; +import { store, t } from '../store.js'; +import { api } from '../api.js'; + +export const WikiEditor = defineComponent({ + name: 'WikiEditor', + props: { + pageId: { type: [String, null], required: true }, + }, + emits: ['save', 'cancel'], + setup(props, { emit }) { + const loading = ref(false); + const title = ref(''); + const summary = ref(''); + const body = ref(''); + const tags = ref(''); + const importance = ref(0.5); + // Scope — which clients should see this wiki page on recall. + // 'global' is exclusive (mutually exclusive with per-client chips). + const SCOPE_TOKENS = ['global', 'codex', 'claude', 'hermes', 'openclaw']; + const scope = ref(['global']); + // Auto-save draft to localStorage so edits survive refresh + const DRAFT_KEY = 'loop_wiki_draft_v1'; + let saveTimer = null; + function saveDraft() { + if (props.pageId !== 'new') return; // only draft new pages + try { + localStorage.setItem(DRAFT_KEY, JSON.stringify({ + title: title.value, summary: summary.value, body: body.value, + tags: tags.value, importance: importance.value, scope: scope.value, + savedAt: Date.now(), + })); + } catch (_) {} + } + function loadDraft() { + if (props.pageId !== 'new') return; + try { + const raw = localStorage.getItem(DRAFT_KEY); + if (!raw) return; + const d = JSON.parse(raw); + // Only restore if draft is < 24h old + if (d.savedAt && Date.now() - d.savedAt < 86400000) { + title.value = d.title || ''; + summary.value = d.summary || ''; + body.value = d.body || ''; + tags.value = d.tags || ''; + importance.value = d.importance ?? 0.5; + scope.value = d.scope || ['global']; + } + } catch (_) {} + } + function clearDraft() { + try { localStorage.removeItem(DRAFT_KEY); } catch (_) {} + } + // Debounced auto-save on any field change + function scheduleDraftSave() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(saveDraft, 1200); + } + watch([title, summary, body, tags, importance, scope], scheduleDraftSave, { deep: true }); + + async function load() { + if (props.pageId === 'new') { + // Try to restore draft first, then clear it + loadDraft(); + clearDraft(); + return; + } + clearDraft(); // clear any stale draft on successful load + loading.value = true; + try { + const p = await api.getWiki(props.pageId); + title.value = p.title || ''; + summary.value = p.summary || ''; + body.value = p.body || ''; + tags.value = (p.tags || []).join(', '); + importance.value = p.importance || 0.5; + const rawScope = (p.scope || 'global').toString().toLowerCase(); + const tokens = rawScope.split(',').map(s => s.trim()).filter(Boolean); + scope.value = tokens.length ? tokens : ['global']; + } catch (e) { + // ignore + } finally { + loading.value = false; + } + } + + watch(() => props.pageId, load, { immediate: true }); + + function toggleScope(token) { + const cur = new Set(scope.value); + if (token === 'global') { + // global is exclusive: clicking it clears the per-client list + scope.value = ['global']; + return; + } + cur.delete('global'); // any per-client click leaves global + if (cur.has(token)) cur.delete(token); else cur.add(token); + // Fallback: if user clears everything, fall back to global. + scope.value = cur.size ? Array.from(cur) : ['global']; + } + + function onSave() { + const payload = { + title: title.value.trim(), + summary: summary.value.trim(), + body: body.value, + tags: tags.value.split(',').map(s => s.trim()).filter(Boolean), + importance: Number(importance.value) || 0.5, + scope: scope.value.join(','), + }; + emit('save', payload); + } + + return { + loading, title, summary, body, tags, importance, scope, + SCOPE_TOKENS, toggleScope, t, onSave, onCancel: () => emit('cancel'), + }; + }, + template: /* html */ ` +<div class="modal-backdrop" @click.self="onCancel"> + <div class="modal wiki-editor"> + <header class="modal-head"> + <h3>{{ pageId === 'new' ? t('wiki.new') : t('wiki.edit') }}</h3> + <button class="x" @click="onCancel">×</button> + </header> + <div class="modal-body" v-if="!loading"> + <label> + <span>{{ t('wiki.field.title') }}</span> + <input type="text" v-model="title" :placeholder="t('wiki.titlePlaceholder')" /> + </label> + <label> + <span>{{ t('wiki.field.summary') }}</span> + <textarea v-model="summary" rows="2" :placeholder="t('wiki.summaryPlaceholder')"></textarea> + </label> + <label> + <span>{{ t('wiki.field.body') }}</span> + <textarea v-model="body" rows="14" :placeholder="t('wiki.bodyPlaceholder')"></textarea> + </label> + <div class="row-2"> + <label> + <span>{{ t('wiki.field.tags') }}</span> + <input type="text" v-model="tags" :placeholder="t('wiki.tagsPlaceholder')" /> + </label> + <label> + <span>{{ t('wiki.field.importance') }} ({{ Math.round(importance * 100) }}%)</span> + <input type="range" v-model.number="importance" min="0" max="1" step="0.05" /> + </label> + </div> + <label class="row-scope"> + <span> + {{ t('wiki.field.scope') }} + <em class="sec-hint">— {{ t('wiki.scope.hint') }}</em> + </span> + <div class="scope-chips" role="group"> + <button + v-for="tok in SCOPE_TOKENS" :key="tok" + type="button" + class="scope-chip" + :class="{ active: scope.includes(tok), 'is-global': tok === 'global' }" + :aria-pressed="scope.includes(tok) ? 'true' : 'false'" + @click="toggleScope(tok)"> + {{ t('wiki.scope.' + tok) }} + </button> + </div> + </label> + </div> + <div class="loading" v-else>{{ t('common.loading') }}</div> + <footer class="modal-foot"> + <button class="btn ghost" @click="onCancel">{{ t('action.cancel') }}</button> + <button class="btn primary" @click="onSave">{{ t('action.save') }}</button> + </footer> + </div> +</div> + `, +}); diff --git a/loop_memory/serve/static/js/composables/useI18n.js b/loop_memory/serve/static/js/composables/useI18n.js new file mode 100644 index 0000000..2386584 --- /dev/null +++ b/loop_memory/serve/static/js/composables/useI18n.js @@ -0,0 +1,15 @@ +/** + * Composable that returns the i18n `t()` function bound to the current + * language. Components use: + * + * const { t } = useI18n(); + * const label = t('topbar.stats'); + * + * Re-renders automatically because `t` reads `store.lang` reactively + * (Vue's reactivity tracks the dependency). + */ +import { store, t } from '../store.js'; + +export function useI18n() { + return { t, lang: () => store.lang }; +} diff --git a/loop_memory/serve/static/js/lib/vue.esm-browser.prod.js b/loop_memory/serve/static/js/lib/vue.esm-browser.prod.js new file mode 100644 index 0000000..a7cb0a1 --- /dev/null +++ b/loop_memory/serve/static/js/lib/vue.esm-browser.prod.js @@ -0,0 +1,9 @@ +/** +* vue v3.4.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**//*! #__NO_SIDE_EFFECTS__ */let e,t,n,r,i,l,s,o,a;function c(e,t){let n=new Set(e.split(","));return t?e=>n.has(e.toLowerCase()):e=>n.has(e)}let u={},d=[],p=()=>{},h=()=>!1,f=e=>111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&(e.charCodeAt(2)>122||97>e.charCodeAt(2)),m=e=>e.startsWith("onUpdate:"),g=Object.assign,y=(e,t)=>{let n=e.indexOf(t);n>-1&&e.splice(n,1)},b=Object.prototype.hasOwnProperty,_=(e,t)=>b.call(e,t),S=Array.isArray,x=e=>"[object Map]"===O(e),C=e=>"[object Set]"===O(e),T=e=>"[object Date]"===O(e),k=e=>"[object RegExp]"===O(e),w=e=>"function"==typeof e,E=e=>"string"==typeof e,A=e=>"symbol"==typeof e,N=e=>null!==e&&"object"==typeof e,I=e=>(N(e)||w(e))&&w(e.then)&&w(e.catch),R=Object.prototype.toString,O=e=>R.call(e),M=e=>O(e).slice(8,-1),L=e=>"[object Object]"===O(e),P=e=>E(e)&&"NaN"!==e&&"-"!==e[0]&&""+parseInt(e,10)===e,$=c(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),F=c("bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo"),V=e=>{let t=Object.create(null);return n=>t[n]||(t[n]=e(n))},D=/-(\w)/g,B=V(e=>e.replace(D,(e,t)=>t?t.toUpperCase():"")),U=/\B([A-Z])/g,j=V(e=>e.replace(U,"-$1").toLowerCase()),H=V(e=>e.charAt(0).toUpperCase()+e.slice(1)),q=V(e=>e?`on${H(e)}`:""),W=(e,t)=>!Object.is(e,t),K=(e,...t)=>{for(let n=0;n<e.length;n++)e[n](...t)},z=(e,t,n,r=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:r,value:n})},G=e=>{let t=parseFloat(e);return isNaN(t)?e:t},J=e=>{let t=E(e)?Number(e):NaN;return isNaN(t)?e:t},X=()=>e||(e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{}),Q=c("Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error");function Z(e){if(S(e)){let t={};for(let n=0;n<e.length;n++){let r=e[n],i=E(r)?en(r):Z(r);if(i)for(let e in i)t[e]=i[e]}return t}if(E(e)||N(e))return e}let Y=/;(?![^(]*\))/g,ee=/:([^]+)/,et=/\/\*[^]*?\*\//g;function en(e){let t={};return e.replace(et,"").split(Y).forEach(e=>{if(e){let n=e.split(ee);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function er(e){let t="";if(E(e))t=e;else if(S(e))for(let n=0;n<e.length;n++){let r=er(e[n]);r&&(t+=r+" ")}else if(N(e))for(let n in e)e[n]&&(t+=n+" ");return t.trim()}function ei(e){if(!e)return null;let{class:t,style:n}=e;return t&&!E(t)&&(e.class=er(t)),n&&(e.style=Z(n)),e}let el=c("html,body,base,head,link,meta,style,title,address,article,aside,footer,header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,output,progress,select,textarea,details,dialog,menu,summary,template,blockquote,iframe,tfoot"),es=c("svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,feDistantLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,mesh,meshgradient,meshpatch,meshrow,metadata,mpath,path,pattern,polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,text,textPath,title,tspan,unknown,use,view"),eo=c("annotation,annotation-xml,maction,maligngroup,malignmark,math,menclose,merror,mfenced,mfrac,mfraction,mglyph,mi,mlabeledtr,mlongdiv,mmultiscripts,mn,mo,mover,mpadded,mphantom,mprescripts,mroot,mrow,ms,mscarries,mscarry,msgroup,msline,mspace,msqrt,msrow,mstack,mstyle,msub,msubsup,msup,mtable,mtd,mtext,mtr,munder,munderover,none,semantics"),ea=c("area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr"),ec=c("itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly");function eu(e,t){if(e===t)return!0;let n=T(e),r=T(t);if(n||r)return!!n&&!!r&&e.getTime()===t.getTime();if(n=A(e),r=A(t),n||r)return e===t;if(n=S(e),r=S(t),n||r)return!!n&&!!r&&function(e,t){if(e.length!==t.length)return!1;let n=!0;for(let r=0;n&&r<e.length;r++)n=eu(e[r],t[r]);return n}(e,t);if(n=N(e),r=N(t),n||r){if(!n||!r||Object.keys(e).length!==Object.keys(t).length)return!1;for(let n in e){let r=e.hasOwnProperty(n),i=t.hasOwnProperty(n);if(r&&!i||!r&&i||!eu(e[n],t[n]))return!1}}return String(e)===String(t)}function ed(e,t){return e.findIndex(e=>eu(e,t))}let ep=e=>!!(e&&!0===e.__v_isRef),eh=e=>E(e)?e:null==e?"":S(e)||N(e)&&(e.toString===R||!w(e.toString))?ep(e)?eh(e.value):JSON.stringify(e,ef,2):String(e),ef=(e,t)=>ep(t)?ef(e,t.value):x(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((e,[t,n],r)=>(e[em(t,r)+" =>"]=n,e),{})}:C(t)?{[`Set(${t.size})`]:[...t.values()].map(e=>em(e))}:A(t)?em(t):!N(t)||S(t)||L(t)?t:String(t),em=(e,t="")=>{var n;return A(e)?`Symbol(${null!=(n=e.description)?n:t})`:e};class eg{constructor(e=!1){this.detached=e,this._active=!0,this.effects=[],this.cleanups=[],this.parent=t,!e&&t&&(this.index=(t.scopes||(t.scopes=[])).push(this)-1)}get active(){return this._active}run(e){if(this._active){let n=t;try{return t=this,e()}finally{t=n}}}on(){t=this}off(){t=this.parent}stop(e){if(this._active){let t,n;for(t=0,n=this.effects.length;t<n;t++)this.effects[t].stop();for(t=0,n=this.cleanups.length;t<n;t++)this.cleanups[t]();if(this.scopes)for(t=0,n=this.scopes.length;t<n;t++)this.scopes[t].stop(!0);if(!this.detached&&this.parent&&!e){let e=this.parent.scopes.pop();e&&e!==this&&(this.parent.scopes[this.index]=e,e.index=this.index)}this.parent=void 0,this._active=!1}}}function ey(e){return new eg(e)}function ev(e,n=t){n&&n.active&&n.effects.push(e)}function eb(){return t}function e_(e){t&&t.cleanups.push(e)}class eS{constructor(e,t,n,r){this.fn=e,this.trigger=t,this.scheduler=n,this.active=!0,this.deps=[],this._dirtyLevel=4,this._trackId=0,this._runnings=0,this._shouldSchedule=!1,this._depsLength=0,ev(this,r)}get dirty(){if(2===this._dirtyLevel||3===this._dirtyLevel){this._dirtyLevel=1,eI();for(let e=0;e<this._depsLength;e++){let t=this.deps[e];if(t.computed&&(t.computed.value,this._dirtyLevel>=4))break}1===this._dirtyLevel&&(this._dirtyLevel=0),eR()}return this._dirtyLevel>=4}set dirty(e){this._dirtyLevel=e?4:0}run(){if(this._dirtyLevel=0,!this.active)return this.fn();let e=eE,t=n;try{return eE=!0,n=this,this._runnings++,ex(this),this.fn()}finally{eC(this),this._runnings--,n=t,eE=e}}stop(){this.active&&(ex(this),eC(this),this.onStop&&this.onStop(),this.active=!1)}}function ex(e){e._trackId++,e._depsLength=0}function eC(e){if(e.deps.length>e._depsLength){for(let t=e._depsLength;t<e.deps.length;t++)eT(e.deps[t],e);e.deps.length=e._depsLength}}function eT(e,t){let n=e.get(t);void 0!==n&&t._trackId!==n&&(e.delete(t),0===e.size&&e.cleanup())}function ek(e,t){e.effect instanceof eS&&(e=e.effect.fn);let n=new eS(e,p,()=>{n.dirty&&n.run()});t&&(g(n,t),t.scope&&ev(n,t.scope)),t&&t.lazy||n.run();let r=n.run.bind(n);return r.effect=n,r}function ew(e){e.effect.stop()}let eE=!0,eA=0,eN=[];function eI(){eN.push(eE),eE=!1}function eR(){let e=eN.pop();eE=void 0===e||e}function eO(){for(eA--;!eA&&eL.length;)eL.shift()()}function eM(e,t,n){if(t.get(e)!==e._trackId){t.set(e,e._trackId);let n=e.deps[e._depsLength];n!==t?(n&&eT(n,e),e.deps[e._depsLength++]=t):e._depsLength++}}let eL=[];function eP(e,t,n){for(let n of(eA++,e.keys())){let r;n._dirtyLevel<t&&(null!=r?r:r=e.get(n)===n._trackId)&&(n._shouldSchedule||(n._shouldSchedule=0===n._dirtyLevel),n._dirtyLevel=t),n._shouldSchedule&&(null!=r?r:r=e.get(n)===n._trackId)&&(n.trigger(),(!n._runnings||n.allowRecurse)&&2!==n._dirtyLevel&&(n._shouldSchedule=!1,n.scheduler&&eL.push(n.scheduler)))}eO()}let e$=(e,t)=>{let n=new Map;return n.cleanup=e,n.computed=t,n},eF=new WeakMap,eV=Symbol(""),eD=Symbol("");function eB(e,t,r){if(eE&&n){let t=eF.get(e);t||eF.set(e,t=new Map);let i=t.get(r);i||t.set(r,i=e$(()=>t.delete(r))),eM(n,i)}}function eU(e,t,n,r,i,l){let s=eF.get(e);if(!s)return;let o=[];if("clear"===t)o=[...s.values()];else if("length"===n&&S(e)){let e=Number(r);s.forEach((t,n)=>{("length"===n||!A(n)&&n>=e)&&o.push(t)})}else switch(void 0!==n&&o.push(s.get(n)),t){case"add":S(e)?P(n)&&o.push(s.get("length")):(o.push(s.get(eV)),x(e)&&o.push(s.get(eD)));break;case"delete":!S(e)&&(o.push(s.get(eV)),x(e)&&o.push(s.get(eD)));break;case"set":x(e)&&o.push(s.get(eV))}for(let e of(eA++,o))e&&eP(e,4);eO()}let ej=c("__proto__,__v_isRef,__isVue"),eH=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>"arguments"!==e&&"caller"!==e).map(e=>Symbol[e]).filter(A)),eq=function(){let e={};return["includes","indexOf","lastIndexOf"].forEach(t=>{e[t]=function(...e){let n=tC(this);for(let e=0,t=this.length;e<t;e++)eB(n,"get",e+"");let r=n[t](...e);return -1===r||!1===r?n[t](...e.map(tC)):r}}),["push","pop","shift","unshift","splice"].forEach(t=>{e[t]=function(...e){eI(),eA++;let n=tC(this)[t].apply(this,e);return eO(),eR(),n}}),e}();function eW(e){A(e)||(e=String(e));let t=tC(this);return eB(t,"has",e),t.hasOwnProperty(e)}class eK{constructor(e=!1,t=!1){this._isReadonly=e,this._isShallow=t}get(e,t,n){let r=this._isReadonly,i=this._isShallow;if("__v_isReactive"===t)return!r;if("__v_isReadonly"===t)return r;if("__v_isShallow"===t)return i;if("__v_raw"===t)return n===(r?i?th:tp:i?td:tu).get(e)||Object.getPrototypeOf(e)===Object.getPrototypeOf(n)?e:void 0;let l=S(e);if(!r){if(l&&_(eq,t))return Reflect.get(eq,t,n);if("hasOwnProperty"===t)return eW}let s=Reflect.get(e,t,n);return(A(t)?eH.has(t):ej(t))?s:(r||eB(e,"get",t),i)?s:tI(s)?l&&P(t)?s:s.value:N(s)?r?tg(s):tf(s):s}}class ez extends eK{constructor(e=!1){super(!1,e)}set(e,t,n,r){let i=e[t];if(!this._isShallow){let t=t_(i);if(tS(n)||t_(n)||(i=tC(i),n=tC(n)),!S(e)&&tI(i)&&!tI(n))return!t&&(i.value=n,!0)}let l=S(e)&&P(t)?Number(t)<e.length:_(e,t),s=Reflect.set(e,t,n,r);return e===tC(r)&&(l?W(n,i)&&eU(e,"set",t,n):eU(e,"add",t,n)),s}deleteProperty(e,t){let n=_(e,t);e[t];let r=Reflect.deleteProperty(e,t);return r&&n&&eU(e,"delete",t,void 0),r}has(e,t){let n=Reflect.has(e,t);return A(t)&&eH.has(t)||eB(e,"has",t),n}ownKeys(e){return eB(e,"iterate",S(e)?"length":eV),Reflect.ownKeys(e)}}class eG extends eK{constructor(e=!1){super(!0,e)}set(e,t){return!0}deleteProperty(e,t){return!0}}let eJ=new ez,eX=new eG,eQ=new ez(!0),eZ=new eG(!0),eY=e=>e,e0=e=>Reflect.getPrototypeOf(e);function e1(e,t,n=!1,r=!1){let i=tC(e=e.__v_raw),l=tC(t);n||(W(t,l)&&eB(i,"get",t),eB(i,"get",l));let{has:s}=e0(i),o=r?eY:n?tw:tk;return s.call(i,t)?o(e.get(t)):s.call(i,l)?o(e.get(l)):void(e!==i&&e.get(t))}function e2(e,t=!1){let n=this.__v_raw,r=tC(n),i=tC(e);return t||(W(e,i)&&eB(r,"has",e),eB(r,"has",i)),e===i?n.has(e):n.has(e)||n.has(i)}function e3(e,t=!1){return e=e.__v_raw,t||eB(tC(e),"iterate",eV),Reflect.get(e,"size",e)}function e6(e,t=!1){t||tS(e)||t_(e)||(e=tC(e));let n=tC(this);return e0(n).has.call(n,e)||(n.add(e),eU(n,"add",e,e)),this}function e4(e,t,n=!1){n||tS(t)||t_(t)||(t=tC(t));let r=tC(this),{has:i,get:l}=e0(r),s=i.call(r,e);s||(e=tC(e),s=i.call(r,e));let o=l.call(r,e);return r.set(e,t),s?W(t,o)&&eU(r,"set",e,t):eU(r,"add",e,t),this}function e8(e){let t=tC(this),{has:n,get:r}=e0(t),i=n.call(t,e);i||(e=tC(e),i=n.call(t,e)),r&&r.call(t,e);let l=t.delete(e);return i&&eU(t,"delete",e,void 0),l}function e5(){let e=tC(this),t=0!==e.size,n=e.clear();return t&&eU(e,"clear",void 0,void 0),n}function e9(e,t){return function(n,r){let i=this,l=i.__v_raw,s=tC(l),o=t?eY:e?tw:tk;return e||eB(s,"iterate",eV),l.forEach((e,t)=>n.call(r,o(e),o(t),i))}}function e7(e,t,n){return function(...r){let i=this.__v_raw,l=tC(i),s=x(l),o="entries"===e||e===Symbol.iterator&&s,a=i[e](...r),c=n?eY:t?tw:tk;return t||eB(l,"iterate","keys"===e&&s?eD:eV),{next(){let{value:e,done:t}=a.next();return t?{value:e,done:t}:{value:o?[c(e[0]),c(e[1])]:c(e),done:t}},[Symbol.iterator](){return this}}}}function te(e){return function(...t){return"delete"!==e&&("clear"===e?void 0:this)}}let[tt,tn,tr,ti]=function(){let e={get(e){return e1(this,e)},get size(){return e3(this)},has:e2,add:e6,set:e4,delete:e8,clear:e5,forEach:e9(!1,!1)},t={get(e){return e1(this,e,!1,!0)},get size(){return e3(this)},has:e2,add(e){return e6.call(this,e,!0)},set(e,t){return e4.call(this,e,t,!0)},delete:e8,clear:e5,forEach:e9(!1,!0)},n={get(e){return e1(this,e,!0)},get size(){return e3(this,!0)},has(e){return e2.call(this,e,!0)},add:te("add"),set:te("set"),delete:te("delete"),clear:te("clear"),forEach:e9(!0,!1)},r={get(e){return e1(this,e,!0,!0)},get size(){return e3(this,!0)},has(e){return e2.call(this,e,!0)},add:te("add"),set:te("set"),delete:te("delete"),clear:te("clear"),forEach:e9(!0,!0)};return["keys","values","entries",Symbol.iterator].forEach(i=>{e[i]=e7(i,!1,!1),n[i]=e7(i,!0,!1),t[i]=e7(i,!1,!0),r[i]=e7(i,!0,!0)}),[e,n,t,r]}();function tl(e,t){let n=t?e?ti:tr:e?tn:tt;return(t,r,i)=>"__v_isReactive"===r?!e:"__v_isReadonly"===r?e:"__v_raw"===r?t:Reflect.get(_(n,r)&&r in t?n:t,r,i)}let ts={get:tl(!1,!1)},to={get:tl(!1,!0)},ta={get:tl(!0,!1)},tc={get:tl(!0,!0)},tu=new WeakMap,td=new WeakMap,tp=new WeakMap,th=new WeakMap;function tf(e){return t_(e)?e:tv(e,!1,eJ,ts,tu)}function tm(e){return tv(e,!1,eQ,to,td)}function tg(e){return tv(e,!0,eX,ta,tp)}function ty(e){return tv(e,!0,eZ,tc,th)}function tv(e,t,n,r,i){if(!N(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;let l=i.get(e);if(l)return l;let s=e.__v_skip||!Object.isExtensible(e)?0:function(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}(M(e));if(0===s)return e;let o=new Proxy(e,2===s?r:n);return i.set(e,o),o}function tb(e){return t_(e)?tb(e.__v_raw):!!(e&&e.__v_isReactive)}function t_(e){return!!(e&&e.__v_isReadonly)}function tS(e){return!!(e&&e.__v_isShallow)}function tx(e){return!!e&&!!e.__v_raw}function tC(e){let t=e&&e.__v_raw;return t?tC(t):e}function tT(e){return Object.isExtensible(e)&&z(e,"__v_skip",!0),e}let tk=e=>N(e)?tf(e):e,tw=e=>N(e)?tg(e):e;class tE{constructor(e,t,n,r){this.getter=e,this._setter=t,this.dep=void 0,this.__v_isRef=!0,this.__v_isReadonly=!1,this.effect=new eS(()=>e(this._value),()=>tN(this,2===this.effect._dirtyLevel?2:3)),this.effect.computed=this,this.effect.active=this._cacheable=!r,this.__v_isReadonly=n}get value(){let e=tC(this);return(!e._cacheable||e.effect.dirty)&&W(e._value,e._value=e.effect.run())&&tN(e,4),tA(e),e.effect._dirtyLevel>=2&&tN(e,2),e._value}set value(e){this._setter(e)}get _dirty(){return this.effect.dirty}set _dirty(e){this.effect.dirty=e}}function tA(e){var t;eE&&n&&(e=tC(e),eM(n,null!=(t=e.dep)?t:e.dep=e$(()=>e.dep=void 0,e instanceof tE?e:void 0)))}function tN(e,t=4,n,r){let i=(e=tC(e)).dep;i&&eP(i,t)}function tI(e){return!!(e&&!0===e.__v_isRef)}function tR(e){return tM(e,!1)}function tO(e){return tM(e,!0)}function tM(e,t){return tI(e)?e:new tL(e,t)}class tL{constructor(e,t){this.__v_isShallow=t,this.dep=void 0,this.__v_isRef=!0,this._rawValue=t?e:tC(e),this._value=t?e:tk(e)}get value(){return tA(this),this._value}set value(e){let t=this.__v_isShallow||tS(e)||t_(e);W(e=t?e:tC(e),this._rawValue)&&(this._rawValue,this._rawValue=e,this._value=t?e:tk(e),tN(this,4))}}function tP(e){tN(e,4)}function t$(e){return tI(e)?e.value:e}function tF(e){return w(e)?e():t$(e)}let tV={get:(e,t,n)=>t$(Reflect.get(e,t,n)),set:(e,t,n,r)=>{let i=e[t];return tI(i)&&!tI(n)?(i.value=n,!0):Reflect.set(e,t,n,r)}};function tD(e){return tb(e)?e:new Proxy(e,tV)}class tB{constructor(e){this.dep=void 0,this.__v_isRef=!0;let{get:t,set:n}=e(()=>tA(this),()=>tN(this));this._get=t,this._set=n}get value(){return this._get()}set value(e){this._set(e)}}function tU(e){return new tB(e)}function tj(e){let t=S(e)?Array(e.length):{};for(let n in e)t[n]=tK(e,n);return t}class tH{constructor(e,t,n){this._object=e,this._key=t,this._defaultValue=n,this.__v_isRef=!0}get value(){let e=this._object[this._key];return void 0===e?this._defaultValue:e}set value(e){this._object[this._key]=e}get dep(){return function(e,t){let n=eF.get(e);return n&&n.get(t)}(tC(this._object),this._key)}}class tq{constructor(e){this._getter=e,this.__v_isRef=!0,this.__v_isReadonly=!0}get value(){return this._getter()}}function tW(e,t,n){return tI(e)?e:w(e)?new tq(e):N(e)&&arguments.length>1?tK(e,t,n):tR(e)}function tK(e,t,n){let r=e[t];return tI(r)?r:new tH(e,t,n)}let tz={GET:"get",HAS:"has",ITERATE:"iterate"},tG={SET:"set",ADD:"add",DELETE:"delete",CLEAR:"clear"};function tJ(e,t){}let tX={SETUP_FUNCTION:0,0:"SETUP_FUNCTION",RENDER_FUNCTION:1,1:"RENDER_FUNCTION",WATCH_GETTER:2,2:"WATCH_GETTER",WATCH_CALLBACK:3,3:"WATCH_CALLBACK",WATCH_CLEANUP:4,4:"WATCH_CLEANUP",NATIVE_EVENT_HANDLER:5,5:"NATIVE_EVENT_HANDLER",COMPONENT_EVENT_HANDLER:6,6:"COMPONENT_EVENT_HANDLER",VNODE_HOOK:7,7:"VNODE_HOOK",DIRECTIVE_HOOK:8,8:"DIRECTIVE_HOOK",TRANSITION_HOOK:9,9:"TRANSITION_HOOK",APP_ERROR_HANDLER:10,10:"APP_ERROR_HANDLER",APP_WARN_HANDLER:11,11:"APP_WARN_HANDLER",FUNCTION_REF:12,12:"FUNCTION_REF",ASYNC_COMPONENT_LOADER:13,13:"ASYNC_COMPONENT_LOADER",SCHEDULER:14,14:"SCHEDULER",COMPONENT_UPDATE:15,15:"COMPONENT_UPDATE"};function tQ(e,t,n,r){try{return r?e(...r):e()}catch(e){tY(e,t,n)}}function tZ(e,t,n,r){if(w(e)){let i=tQ(e,t,n,r);return i&&I(i)&&i.catch(e=>{tY(e,t,n)}),i}if(S(e)){let i=[];for(let l=0;l<e.length;l++)i.push(tZ(e[l],t,n,r));return i}}function tY(e,t,n,r=!0){if(t&&t.vnode,t){let r=t.parent,i=t.proxy,l=`https://vuejs.org/error-reference/#runtime-${n}`;for(;r;){let t=r.ec;if(t){for(let n=0;n<t.length;n++)if(!1===t[n](e,i,l))return}r=r.parent}let s=t.appContext.config.errorHandler;if(s){eI(),tQ(s,null,10,[e,i,l]),eR();return}}!function(e,t,n,r=!0){console.error(e)}(e,0,0,r)}let t0=!1,t1=!1,t2=[],t3=0,t6=[],t4=null,t8=0,t5=Promise.resolve(),t9=null;function t7(e){let t=t9||t5;return e?t.then(this?e.bind(this):e):t}function ne(e){t2.length&&t2.includes(e,t0&&e.allowRecurse?t3+1:t3)||(null==e.id?t2.push(e):t2.splice(function(e){let t=t3+1,n=t2.length;for(;t<n;){let r=t+n>>>1,i=t2[r],l=nl(i);l<e||l===e&&i.pre?t=r+1:n=r}return t}(e.id),0,e),nt())}function nt(){t0||t1||(t1=!0,t9=t5.then(function e(t){t1=!1,t0=!0,t2.sort(ns);try{for(t3=0;t3<t2.length;t3++){let e=t2[t3];e&&!1!==e.active&&tQ(e,e.i,e.i?15:14)}}finally{t3=0,t2.length=0,ni(),t0=!1,t9=null,(t2.length||t6.length)&&e()}}))}function nn(e){S(e)?t6.push(...e):t4&&t4.includes(e,e.allowRecurse?t8+1:t8)||t6.push(e),nt()}function nr(e,t,n=t0?t3+1:0){for(;n<t2.length;n++){let t=t2[n];if(t&&t.pre){if(e&&t.id!==e.uid)continue;t2.splice(n,1),n--,t()}}}function ni(e){if(t6.length){let e=[...new Set(t6)].sort((e,t)=>nl(e)-nl(t));if(t6.length=0,t4){t4.push(...e);return}for(t8=0,t4=e;t8<t4.length;t8++){let e=t4[t8];!1!==e.active&&e()}t4=null,t8=0}}let nl=e=>null==e.id?1/0:e.id,ns=(e,t)=>{let n=nl(e)-nl(t);if(0===n){if(e.pre&&!t.pre)return -1;if(t.pre&&!e.pre)return 1}return n},no=null,na=null;function nc(e){let t=no;return no=e,na=e&&e.type.__scopeId||null,t}function nu(e){na=e}function nd(){na=null}let np=e=>nh;function nh(e,t=no,n){if(!t||e._n)return e;let r=(...n)=>{let i;r._d&&iY(-1);let l=nc(t);try{i=e(...n)}finally{nc(l),r._d&&iY(1)}return i};return r._n=!0,r._c=!0,r._d=!0,r}function nf(e,t){if(null===no)return e;let n=lT(no),r=e.dirs||(e.dirs=[]);for(let e=0;e<t.length;e++){let[i,l,s,o=u]=t[e];i&&(w(i)&&(i={mounted:i,updated:i}),i.deep&&iw(l),r.push({dir:i,instance:n,value:l,oldValue:void 0,arg:s,modifiers:o}))}return e}function nm(e,t,n,r){let i=e.dirs,l=t&&t.dirs;for(let s=0;s<i.length;s++){let o=i[s];l&&(o.oldValue=l[s].value);let a=o.dir[r];a&&(eI(),tZ(a,n,8,[e.el,o,e,t]),eR())}}let ng=Symbol("_leaveCb"),ny=Symbol("_enterCb");function nv(){let e={isMounted:!1,isLeaving:!1,isUnmounting:!1,leavingVNodes:new Map};return nq(()=>{e.isMounted=!0}),nz(()=>{e.isUnmounting=!0}),e}let nb=[Function,Array],n_={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:nb,onEnter:nb,onAfterEnter:nb,onEnterCancelled:nb,onBeforeLeave:nb,onLeave:nb,onAfterLeave:nb,onLeaveCancelled:nb,onBeforeAppear:nb,onAppear:nb,onAfterAppear:nb,onAppearCancelled:nb},nS=e=>{let t=e.subTree;return t.component?nS(t.component):t},nx={name:"BaseTransition",props:n_,setup(e,{slots:t}){let n=lh(),r=nv();return()=>{let i=t.default&&nA(t.default(),!0);if(!i||!i.length)return;let l=i[0];if(i.length>1){for(let e of i)if(e.type!==iK){l=e;break}}let s=tC(e),{mode:o}=s;if(r.isLeaving)return nk(l);let a=nw(l);if(!a)return nk(l);let c=nT(a,s,r,n,e=>c=e);nE(a,c);let u=n.subTree,d=u&&nw(u);if(d&&d.type!==iK&&!i6(a,d)&&nS(n).type!==iK){let e=nT(d,s,r,n);if(nE(d,e),"out-in"===o&&a.type!==iK)return r.isLeaving=!0,e.afterLeave=()=>{r.isLeaving=!1,!1!==n.update.active&&(n.effect.dirty=!0,n.update())},nk(l);"in-out"===o&&a.type!==iK&&(e.delayLeave=(e,t,n)=>{nC(r,d)[String(d.key)]=d,e[ng]=()=>{t(),e[ng]=void 0,delete c.delayedLeave},c.delayedLeave=n})}return l}}};function nC(e,t){let{leavingVNodes:n}=e,r=n.get(t.type);return r||(r=Object.create(null),n.set(t.type,r)),r}function nT(e,t,n,r,i){let{appear:l,mode:s,persisted:o=!1,onBeforeEnter:a,onEnter:c,onAfterEnter:u,onEnterCancelled:d,onBeforeLeave:p,onLeave:h,onAfterLeave:f,onLeaveCancelled:m,onBeforeAppear:g,onAppear:y,onAfterAppear:b,onAppearCancelled:_}=t,x=String(e.key),C=nC(n,e),T=(e,t)=>{e&&tZ(e,r,9,t)},k=(e,t)=>{let n=t[1];T(e,t),S(e)?e.every(e=>e.length<=1)&&n():e.length<=1&&n()},w={mode:s,persisted:o,beforeEnter(t){let r=a;if(!n.isMounted){if(!l)return;r=g||a}t[ng]&&t[ng](!0);let i=C[x];i&&i6(e,i)&&i.el[ng]&&i.el[ng](),T(r,[t])},enter(e){let t=c,r=u,i=d;if(!n.isMounted){if(!l)return;t=y||c,r=b||u,i=_||d}let s=!1,o=e[ny]=t=>{s||(s=!0,t?T(i,[e]):T(r,[e]),w.delayedLeave&&w.delayedLeave(),e[ny]=void 0)};t?k(t,[e,o]):o()},leave(t,r){let i=String(e.key);if(t[ny]&&t[ny](!0),n.isUnmounting)return r();T(p,[t]);let l=!1,s=t[ng]=n=>{l||(l=!0,r(),n?T(m,[t]):T(f,[t]),t[ng]=void 0,C[i]!==e||delete C[i])};C[i]=e,h?k(h,[t,s]):s()},clone(e){let l=nT(e,t,n,r,i);return i&&i(l),l}};return w}function nk(e){if(nM(e))return(e=lt(e)).children=null,e}function nw(e){if(!nM(e))return e;let{shapeFlag:t,children:n}=e;if(n){if(16&t)return n[0];if(32&t&&w(n.default))return n.default()}}function nE(e,t){6&e.shapeFlag&&e.component?nE(e.component.subTree,t):128&e.shapeFlag?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function nA(e,t=!1,n){let r=[],i=0;for(let l=0;l<e.length;l++){let s=e[l],o=null==n?s.key:String(n)+String(null!=s.key?s.key:l);s.type===iq?(128&s.patchFlag&&i++,r=r.concat(nA(s.children,t,o))):(t||s.type!==iK)&&r.push(null!=o?lt(s,{key:o}):s)}if(i>1)for(let e=0;e<r.length;e++)r[e].patchFlag=-2;return r}/*! #__NO_SIDE_EFFECTS__ */function nN(e,t){return w(e)?g({name:e.name},t,{setup:e}):e}let nI=e=>!!e.type.__asyncLoader;/*! #__NO_SIDE_EFFECTS__ */function nR(e){let t;w(e)&&(e={loader:e});let{loader:n,loadingComponent:r,errorComponent:i,delay:l=200,timeout:s,suspensible:o=!0,onError:a}=e,c=null,u=0,d=()=>(u++,c=null,p()),p=()=>{let e;return c||(e=c=n().catch(e=>{if(e=e instanceof Error?e:Error(String(e)),a)return new Promise((t,n)=>{a(e,()=>t(d()),()=>n(e),u+1)});throw e}).then(n=>e!==c&&c?c:(n&&(n.__esModule||"Module"===n[Symbol.toStringTag])&&(n=n.default),t=n,n)))};return nN({name:"AsyncComponentWrapper",__asyncLoader:p,get __asyncResolved(){return t},setup(){let e=lp;if(t)return()=>nO(t,e);let n=t=>{c=null,tY(t,e,13,!i)};if(o&&e.suspense)return p().then(t=>()=>nO(t,e)).catch(e=>(n(e),()=>i?i7(i,{error:e}):null));let a=tR(!1),u=tR(),d=tR(!!l);return l&&setTimeout(()=>{d.value=!1},l),null!=s&&setTimeout(()=>{if(!a.value&&!u.value){let e=Error(`Async component timed out after ${s}ms.`);n(e),u.value=e}},s),p().then(()=>{a.value=!0,e.parent&&nM(e.parent.vnode)&&(e.parent.effect.dirty=!0,ne(e.parent.update))}).catch(e=>{n(e),u.value=e}),()=>a.value&&t?nO(t,e):u.value&&i?i7(i,{error:u.value}):r&&!d.value?i7(r):void 0}})}function nO(e,t){let{ref:n,props:r,children:i,ce:l}=t.vnode,s=i7(e,r,i);return s.ref=n,s.ce=l,delete t.vnode.ce,s}let nM=e=>e.type.__isKeepAlive,nL={name:"KeepAlive",__isKeepAlive:!0,props:{include:[String,RegExp,Array],exclude:[String,RegExp,Array],max:[String,Number]},setup(e,{slots:t}){let n=lh(),r=n.ctx,i=new Map,l=new Set,s=null,o=n.suspense,{renderer:{p:a,m:c,um:u,o:{createElement:d}}}=r,p=d("div");function h(e){nD(e),u(e,n,o,!0)}function f(e){i.forEach((t,n)=>{let r=lk(t.type);!r||e&&e(r)||m(n)})}function m(e){let t=i.get(e);!t||s&&i6(t,s)?s&&nD(s):h(t),i.delete(e),l.delete(e)}r.activate=(e,t,n,r,i)=>{let l=e.component;c(e,t,n,0,o),a(l.vnode,e,t,n,l,o,r,e.slotScopeIds,i),is(()=>{l.isDeactivated=!1,l.a&&K(l.a);let t=e.props&&e.props.onVnodeMounted;t&&lc(t,l.parent,e)},o)},r.deactivate=e=>{let t=e.component;im(t.m),im(t.a),c(e,p,null,1,o),is(()=>{t.da&&K(t.da);let n=e.props&&e.props.onVnodeUnmounted;n&&lc(n,t.parent,e),t.isDeactivated=!0},o)},ix(()=>[e.include,e.exclude],([e,t])=>{e&&f(t=>nP(e,t)),t&&f(e=>!nP(t,e))},{flush:"post",deep:!0});let g=null,y=()=>{null!=g&&(i$(n.subTree.type)?is(()=>{i.set(g,nB(n.subTree))},n.subTree.suspense):i.set(g,nB(n.subTree)))};return nq(y),nK(y),nz(()=>{i.forEach(e=>{let{subTree:t,suspense:r}=n,i=nB(t);if(e.type===i.type&&e.key===i.key){nD(i);let e=i.component.da;e&&is(e,r);return}h(e)})}),()=>{if(g=null,!t.default)return null;let n=t.default(),r=n[0];if(n.length>1)return s=null,n;if(!i3(r)||!(4&r.shapeFlag)&&!(128&r.shapeFlag))return s=null,r;let o=nB(r);if(o.type===iK)return s=null,o;let a=o.type,c=lk(nI(o)?o.type.__asyncResolved||{}:a),{include:u,exclude:d,max:p}=e;if(u&&(!c||!nP(u,c))||d&&c&&nP(d,c))return s=o,r;let h=null==o.key?a:o.key,f=i.get(h);return o.el&&(o=lt(o),128&r.shapeFlag&&(r.ssContent=o)),g=h,f?(o.el=f.el,o.component=f.component,o.transition&&nE(o,o.transition),o.shapeFlag|=512,l.delete(h),l.add(h)):(l.add(h),p&&l.size>parseInt(p,10)&&m(l.values().next().value)),o.shapeFlag|=256,s=o,i$(r.type)?r:o}}};function nP(e,t){return S(e)?e.some(e=>nP(e,t)):E(e)?e.split(",").includes(t):!!k(e)&&e.test(t)}function n$(e,t){nV(e,"a",t)}function nF(e,t){nV(e,"da",t)}function nV(e,t,n=lp){let r=e.__wdc||(e.__wdc=()=>{let t=n;for(;t;){if(t.isDeactivated)return;t=t.parent}return e()});if(nU(t,r,n),n){let e=n.parent;for(;e&&e.parent;)nM(e.parent.vnode)&&function(e,t,n,r){let i=nU(t,e,r,!0);nG(()=>{y(r[t],i)},n)}(r,t,n,e),e=e.parent}}function nD(e){e.shapeFlag&=-257,e.shapeFlag&=-513}function nB(e){return 128&e.shapeFlag?e.ssContent:e}function nU(e,t,n=lp,r=!1){if(n){let i=n[e]||(n[e]=[]),l=t.__weh||(t.__weh=(...r)=>{eI();let i=lf(n),l=tZ(t,n,e,r);return i(),eR(),l});return r?i.unshift(l):i.push(l),l}}let nj=e=>(t,n=lp)=>{ly&&"sp"!==e||nU(e,(...e)=>t(...e),n)},nH=nj("bm"),nq=nj("m"),nW=nj("bu"),nK=nj("u"),nz=nj("bum"),nG=nj("um"),nJ=nj("sp"),nX=nj("rtg"),nQ=nj("rtc");function nZ(e,t=lp){nU("ec",e,t)}let nY="components";function n0(e,t){return n6(nY,e,!0,t)||e}let n1=Symbol.for("v-ndc");function n2(e){return E(e)?n6(nY,e,!1)||e:e||n1}function n3(e){return n6("directives",e)}function n6(e,t,n=!0,r=!1){let i=no||lp;if(i){let n=i.type;if(e===nY){let e=lk(n,!1);if(e&&(e===t||e===B(t)||e===H(B(t))))return n}let l=n4(i[e]||n[e],t)||n4(i.appContext[e],t);return!l&&r?n:l}}function n4(e,t){return e&&(e[t]||e[B(t)]||e[H(B(t))])}function n8(e,t,n,r){let i;let l=n&&n[r];if(S(e)||E(e)){i=Array(e.length);for(let n=0,r=e.length;n<r;n++)i[n]=t(e[n],n,void 0,l&&l[n])}else if("number"==typeof e){i=Array(e);for(let n=0;n<e;n++)i[n]=t(n+1,n,void 0,l&&l[n])}else if(N(e)){if(e[Symbol.iterator])i=Array.from(e,(e,n)=>t(e,n,void 0,l&&l[n]));else{let n=Object.keys(e);i=Array(n.length);for(let r=0,s=n.length;r<s;r++){let s=n[r];i[r]=t(e[s],s,r,l&&l[r])}}}else i=[];return n&&(n[r]=i),i}function n5(e,t){for(let n=0;n<t.length;n++){let r=t[n];if(S(r))for(let t=0;t<r.length;t++)e[r[t].name]=r[t].fn;else r&&(e[r.name]=r.key?(...e)=>{let t=r.fn(...e);return t&&(t.key=r.key),t}:r.fn)}return e}function n9(e,t,n={},r,i){if(no.isCE||no.parent&&nI(no.parent)&&no.parent.isCE)return"default"!==t&&(n.name=t),i7("slot",n,r&&r());let l=e[t];l&&l._c&&(l._d=!1),iX();let s=l&&function e(t){return t.some(t=>!i3(t)||!!(t.type!==iK&&(t.type!==iq||e(t.children))))?t:null}(l(n)),o=i2(iq,{key:(n.key||s&&s.key||`_${t}`)+(!s&&r?"_fb":"")},s||(r?r():[]),s&&1===e._?64:-2);return!i&&o.scopeId&&(o.slotScopeIds=[o.scopeId+"-s"]),l&&l._c&&(l._d=!0),o}function n7(e,t){let n={};for(let r in e)n[t&&/[A-Z]/.test(r)?`on:${r}`:q(r)]=e[r];return n}let re=e=>e?lg(e)?lT(e):re(e.parent):null,rt=g(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>re(e.parent),$root:e=>re(e.root),$emit:e=>e.emit,$options:e=>rx(e),$forceUpdate:e=>e.f||(e.f=()=>{e.effect.dirty=!0,ne(e.update)}),$nextTick:e=>e.n||(e.n=t7.bind(e.proxy)),$watch:e=>iT.bind(e)}),rn=(e,t)=>e!==u&&!e.__isScriptSetup&&_(e,t),rr={get({_:e},t){let n,r,i;if("__v_skip"===t)return!0;let{ctx:l,setupState:s,data:o,props:a,accessCache:c,type:d,appContext:p}=e;if("$"!==t[0]){let r=c[t];if(void 0!==r)switch(r){case 1:return s[t];case 2:return o[t];case 4:return l[t];case 3:return a[t]}else{if(rn(s,t))return c[t]=1,s[t];if(o!==u&&_(o,t))return c[t]=2,o[t];if((n=e.propsOptions[0])&&_(n,t))return c[t]=3,a[t];if(l!==u&&_(l,t))return c[t]=4,l[t];r_&&(c[t]=0)}}let h=rt[t];return h?("$attrs"===t&&eB(e.attrs,"get",""),h(e)):(r=d.__cssModules)&&(r=r[t])?r:l!==u&&_(l,t)?(c[t]=4,l[t]):_(i=p.config.globalProperties,t)?i[t]:void 0},set({_:e},t,n){let{data:r,setupState:i,ctx:l}=e;return rn(i,t)?(i[t]=n,!0):r!==u&&_(r,t)?(r[t]=n,!0):!_(e.props,t)&&!("$"===t[0]&&t.slice(1) in e)&&(l[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:r,appContext:i,propsOptions:l}},s){let o;return!!n[s]||e!==u&&_(e,s)||rn(t,s)||(o=l[0])&&_(o,s)||_(r,s)||_(rt,s)||_(i.config.globalProperties,s)},defineProperty(e,t,n){return null!=n.get?e._.accessCache[t]=0:_(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}},ri=g({},rr,{get(e,t){if(t!==Symbol.unscopables)return rr.get(e,t,e)},has:(e,t)=>"_"!==t[0]&&!Q(t)});function rl(){return null}function rs(){return null}function ro(e){}function ra(e){}function rc(){return null}function ru(){}function rd(e,t){return null}function rp(){return rf().slots}function rh(){return rf().attrs}function rf(){let e=lh();return e.setupContext||(e.setupContext=lC(e))}function rm(e){return S(e)?e.reduce((e,t)=>(e[t]=null,e),{}):e}function rg(e,t){let n=rm(e);for(let e in t){if(e.startsWith("__skip"))continue;let r=n[e];r?S(r)||w(r)?r=n[e]={type:r,default:t[e]}:r.default=t[e]:null===r&&(r=n[e]={default:t[e]}),r&&t[`__skip_${e}`]&&(r.skipFactory=!0)}return n}function ry(e,t){return e&&t?S(e)&&S(t)?e.concat(t):g({},rm(e),rm(t)):e||t}function rv(e,t){let n={};for(let r in e)t.includes(r)||Object.defineProperty(n,r,{enumerable:!0,get:()=>e[r]});return n}function rb(e){let t=lh(),n=e();return lm(),I(n)&&(n=n.catch(e=>{throw lf(t),e})),[n,()=>lf(t)]}let r_=!0;function rS(e,t,n){tZ(S(e)?e.map(e=>e.bind(t.proxy)):e.bind(t.proxy),t,n)}function rx(e){let t;let n=e.type,{mixins:r,extends:i}=n,{mixins:l,optionsCache:s,config:{optionMergeStrategies:o}}=e.appContext,a=s.get(n);return a?t=a:l.length||r||i?(t={},l.length&&l.forEach(e=>rC(t,e,o,!0)),rC(t,n,o)):t=n,N(n)&&s.set(n,t),t}function rC(e,t,n,r=!1){let{mixins:i,extends:l}=t;for(let s in l&&rC(e,l,n,!0),i&&i.forEach(t=>rC(e,t,n,!0)),t)if(r&&"expose"===s);else{let r=rT[s]||n&&n[s];e[s]=r?r(e[s],t[s]):t[s]}return e}let rT={data:rk,props:rN,emits:rN,methods:rA,computed:rA,beforeCreate:rE,created:rE,beforeMount:rE,mounted:rE,beforeUpdate:rE,updated:rE,beforeDestroy:rE,beforeUnmount:rE,destroyed:rE,unmounted:rE,activated:rE,deactivated:rE,errorCaptured:rE,serverPrefetch:rE,components:rA,directives:rA,watch:function(e,t){if(!e)return t;if(!t)return e;let n=g(Object.create(null),e);for(let r in t)n[r]=rE(e[r],t[r]);return n},provide:rk,inject:function(e,t){return rA(rw(e),rw(t))}};function rk(e,t){return t?e?function(){return g(w(e)?e.call(this,this):e,w(t)?t.call(this,this):t)}:t:e}function rw(e){if(S(e)){let t={};for(let n=0;n<e.length;n++)t[e[n]]=e[n];return t}return e}function rE(e,t){return e?[...new Set([].concat(e,t))]:t}function rA(e,t){return e?g(Object.create(null),e,t):t}function rN(e,t){return e?S(e)&&S(t)?[...new Set([...e,...t])]:g(Object.create(null),rm(e),rm(null!=t?t:{})):t}function rI(){return{app:null,config:{isNativeTag:h,performance:!1,globalProperties:{},optionMergeStrategies:{},errorHandler:void 0,warnHandler:void 0,compilerOptions:{}},mixins:[],components:{},directives:{},provides:Object.create(null),optionsCache:new WeakMap,propsCache:new WeakMap,emitsCache:new WeakMap}}let rR=0,rO=null;function rM(e,t){if(lp){let n=lp.provides,r=lp.parent&&lp.parent.provides;r===n&&(n=lp.provides=Object.create(r)),n[e]=t}}function rL(e,t,n=!1){let r=lp||no;if(r||rO){let i=rO?rO._context.provides:r?null==r.parent?r.vnode.appContext&&r.vnode.appContext.provides:r.parent.provides:void 0;if(i&&e in i)return i[e];if(arguments.length>1)return n&&w(t)?t.call(r&&r.proxy):t}}function rP(){return!!(lp||no||rO)}let r$={},rF=()=>Object.create(r$),rV=e=>Object.getPrototypeOf(e)===r$;function rD(e,t,n,r){let i;let[l,s]=e.propsOptions,o=!1;if(t)for(let a in t){let c;if($(a))continue;let u=t[a];l&&_(l,c=B(a))?s&&s.includes(c)?(i||(i={}))[c]=u:n[c]=u:iI(e.emitsOptions,a)||a in r&&u===r[a]||(r[a]=u,o=!0)}if(s){let t=tC(n),r=i||u;for(let i=0;i<s.length;i++){let o=s[i];n[o]=rB(l,t,o,r[o],e,!_(r,o))}}return o}function rB(e,t,n,r,i,l){let s=e[n];if(null!=s){let e=_(s,"default");if(e&&void 0===r){let e=s.default;if(s.type!==Function&&!s.skipFactory&&w(e)){let{propsDefaults:l}=i;if(n in l)r=l[n];else{let s=lf(i);r=l[n]=e.call(null,t),s()}}else r=e}s[0]&&(l&&!e?r=!1:s[1]&&(""===r||r===j(n))&&(r=!0))}return r}let rU=new WeakMap;function rj(e){return!("$"===e[0]||$(e))}let rH=e=>"_"===e[0]||"$stable"===e,rq=e=>S(e)?e.map(ll):[ll(e)],rW=(e,t,n)=>{if(t._n)return t;let r=nh((...e)=>rq(t(...e)),n);return r._c=!1,r},rK=(e,t,n)=>{let r=e._ctx;for(let n in e){if(rH(n))continue;let i=e[n];if(w(i))t[n]=rW(n,i,r);else if(null!=i){let e=rq(i);t[n]=()=>e}}},rz=(e,t)=>{let n=rq(t);e.slots.default=()=>n},rG=(e,t,n)=>{for(let r in t)(n||"_"!==r)&&(e[r]=t[r])},rJ=(e,t,n)=>{let r=e.slots=rF();if(32&e.vnode.shapeFlag){let e=t._;e?(rG(r,t,n),n&&z(r,"_",e,!0)):rK(t,r)}else t&&rz(e,t)},rX=(e,t,n)=>{let{vnode:r,slots:i}=e,l=!0,s=u;if(32&r.shapeFlag){let e=t._;e?n&&1===e?l=!1:rG(i,t,n):(l=!t.$stable,rK(t,i)),s=t}else t&&(rz(e,t),s={default:1});if(l)for(let e in i)rH(e)||null!=s[e]||delete i[e]};function rQ(e,t,n,r,i=!1){if(S(e)){e.forEach((e,l)=>rQ(e,t&&(S(t)?t[l]:t),n,r,i));return}if(nI(r)&&!i)return;let l=4&r.shapeFlag?lT(r.component):r.el,s=i?null:l,{i:o,r:a}=e,c=t&&t.r,d=o.refs===u?o.refs={}:o.refs,p=o.setupState;if(null!=c&&c!==a&&(E(c)?(d[c]=null,_(p,c)&&(p[c]=null)):tI(c)&&(c.value=null)),w(a))tQ(a,o,12,[s,d]);else{let t=E(a),r=tI(a);if(t||r){let o=()=>{if(e.f){let n=t?_(p,a)?p[a]:d[a]:a.value;i?S(n)&&y(n,l):S(n)?n.includes(l)||n.push(l):t?(d[a]=[l],_(p,a)&&(p[a]=d[a])):(a.value=[l],e.k&&(d[e.k]=a.value))}else t?(d[a]=s,_(p,a)&&(p[a]=s)):r&&(a.value=s,e.k&&(d[e.k]=s))};s?(o.id=-1,is(o,n)):o()}}}let rZ=Symbol("_vte"),rY=e=>e.__isTeleport,r0=e=>e&&(e.disabled||""===e.disabled),r1=e=>"undefined"!=typeof SVGElement&&e instanceof SVGElement,r2=e=>"function"==typeof MathMLElement&&e instanceof MathMLElement,r3=(e,t)=>{let n=e&&e.to;return E(n)?t?t(n):null:n};function r6(e,t,n,{o:{insert:r},m:i},l=2){0===l&&r(e.targetAnchor,t,n);let{el:s,anchor:o,shapeFlag:a,children:c,props:u}=e,d=2===l;if(d&&r(s,t,n),(!d||r0(u))&&16&a)for(let e=0;e<c.length;e++)i(c[e],t,n,2);d&&r(o,t,n)}let r4={name:"Teleport",__isTeleport:!0,process(e,t,n,r,i,l,s,o,a,c){let{mc:u,pc:d,pbc:p,o:{insert:h,querySelector:f,createText:m,createComment:g}}=c,y=r0(t.props),{shapeFlag:b,children:_,dynamicChildren:S}=t;if(null==e){let e=t.el=m(""),c=t.anchor=m("");h(e,n,r),h(c,n,r);let d=t.target=r3(t.props,f),p=r5(d,t,m,h);d&&("svg"===s||r1(d)?s="svg":("mathml"===s||r2(d))&&(s="mathml"));let g=(e,t)=>{16&b&&u(_,e,t,i,l,s,o,a)};y?g(n,c):d&&g(d,p)}else{t.el=e.el,t.targetStart=e.targetStart;let r=t.anchor=e.anchor,u=t.target=e.target,h=t.targetAnchor=e.targetAnchor,m=r0(e.props),g=m?n:u;if("svg"===s||r1(u)?s="svg":("mathml"===s||r2(u))&&(s="mathml"),S?(p(e.dynamicChildren,S,g,i,l,s,o),ih(e,t,!0)):a||d(e,t,g,m?r:h,i,l,s,o,!1),y)m?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):r6(t,n,r,c,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){let e=t.target=r3(t.props,f);e&&r6(t,e,null,c,0)}else m&&r6(t,u,h,c,1)}r8(t)},remove(e,t,n,{um:r,o:{remove:i}},l){let{shapeFlag:s,children:o,anchor:a,targetStart:c,targetAnchor:u,target:d,props:p}=e;if(d&&(i(c),i(u)),l&&i(a),16&s){let e=l||!r0(p);for(let i=0;i<o.length;i++){let l=o[i];r(l,t,n,e,!!l.dynamicChildren)}}},move:r6,hydrate:function(e,t,n,r,i,l,{o:{nextSibling:s,parentNode:o,querySelector:a,insert:c,createText:u}},d){let p=t.target=r3(t.props,a);if(p){let a=p._lpa||p.firstChild;if(16&t.shapeFlag){if(r0(t.props))t.anchor=d(s(e),t,o(e),n,r,i,l),t.targetStart=a,t.targetAnchor=a&&s(a);else{t.anchor=s(e);let o=a;for(;o;){if(o&&8===o.nodeType){if("teleport start anchor"===o.data)t.targetStart=o;else if("teleport anchor"===o.data){t.targetAnchor=o,p._lpa=t.targetAnchor&&s(t.targetAnchor);break}}o=s(o)}t.targetAnchor||r5(p,t,u,c),d(a&&s(a),t,p,n,r,i,l)}}r8(t)}return t.anchor&&s(t.anchor)}};function r8(e){let t=e.ctx;if(t&&t.ut){let n=e.children[0].el;for(;n&&n!==e.targetAnchor;)1===n.nodeType&&n.setAttribute("data-v-owner",t.uid),n=n.nextSibling;t.ut()}}function r5(e,t,n,r){let i=t.targetStart=n(""),l=t.targetAnchor=n("");return i[rZ]=l,e&&(r(i,e),r(l,e)),l}let r9=!1,r7=()=>{r9||(console.error("Hydration completed but contains mismatches."),r9=!0)},ie=e=>e.namespaceURI.includes("svg")&&"foreignObject"!==e.tagName,it=e=>e.namespaceURI.includes("MathML"),ir=e=>ie(e)?"svg":it(e)?"mathml":void 0,ii=e=>8===e.nodeType;function il(e){let{mt:t,p:n,o:{patchProp:r,createText:i,nextSibling:l,parentNode:s,remove:o,insert:a,createComment:c}}=e,u=(n,r,o,c,f,_=!1)=>{_=_||!!r.dynamicChildren;let S=ii(n)&&"["===n.data,x=()=>m(n,r,o,c,f,S),{type:C,ref:T,shapeFlag:k,patchFlag:w}=r,E=n.nodeType;r.el=n,-2===w&&(_=!1,r.dynamicChildren=null);let A=null;switch(C){case iW:3!==E?""===r.children?(a(r.el=i(""),s(n),n),A=n):A=x():(n.data!==r.children&&(r7(),n.data=r.children),A=l(n));break;case iK:b(n)?(A=l(n),y(r.el=n.content.firstChild,n,o)):A=8!==E||S?x():l(n);break;case iz:if(S&&(E=(n=l(n)).nodeType),1===E||3===E){A=n;let e=!r.children.length;for(let t=0;t<r.staticCount;t++)e&&(r.children+=1===A.nodeType?A.outerHTML:A.data),t===r.staticCount-1&&(r.anchor=A),A=l(A);return S?l(A):A}x();break;case iq:A=S?h(n,r,o,c,f,_):x();break;default:if(1&k)A=1===E&&r.type.toLowerCase()===n.tagName.toLowerCase()||b(n)?d(n,r,o,c,f,_):x();else if(6&k){r.slotScopeIds=f;let e=s(n);if(A=S?g(n):ii(n)&&"teleport start"===n.data?g(n,n.data,"teleport end"):l(n),t(r,e,null,o,c,ir(e),_),nI(r)){let t;S?(t=i7(iq)).anchor=A?A.previousSibling:e.lastChild:t=3===n.nodeType?ln(""):i7("div"),t.el=n,r.component.subTree=t}}else 64&k?A=8!==E?x():r.type.hydrate(n,r,o,c,f,_,e,p):128&k&&(A=r.type.hydrate(n,r,o,c,ir(s(n)),f,_,e,u))}return null!=T&&rQ(T,null,c,r),A},d=(e,t,n,i,l,s)=>{s=s||!!t.dynamicChildren;let{type:a,props:c,patchFlag:u,shapeFlag:d,dirs:h,transition:m}=t,g="input"===a||"option"===a;if(g||-1!==u){let a;h&&nm(t,null,n,"created");let _=!1;if(b(e)){_=ip(i,m)&&n&&n.vnode.props&&n.vnode.props.appear;let r=e.content.firstChild;_&&m.beforeEnter(r),y(r,e,n),t.el=e=r}if(16&d&&!(c&&(c.innerHTML||c.textContent))){let r=p(e.firstChild,t,e,n,i,l,s);for(;r;){r7();let e=r;r=r.nextSibling,o(e)}}else 8&d&&e.textContent!==t.children&&(r7(),e.textContent=t.children);if(c){if(g||!s||48&u){let t=e.tagName.includes("-");for(let i in c)(g&&(i.endsWith("value")||"indeterminate"===i)||f(i)&&!$(i)||"."===i[0]||t)&&r(e,i,null,c[i],void 0,n)}else if(c.onClick)r(e,"onClick",null,c.onClick,void 0,n);else if(4&u&&tb(c.style))for(let e in c.style)c.style[e]}(a=c&&c.onVnodeBeforeMount)&&lc(a,n,t),h&&nm(t,null,n,"beforeMount"),((a=c&&c.onVnodeMounted)||h||_)&&ij(()=>{a&&lc(a,n,t),_&&m.enter(e),h&&nm(t,null,n,"mounted")},i)}return e.nextSibling},p=(e,t,r,s,o,c,d)=>{d=d||!!t.dynamicChildren;let p=t.children,h=p.length;for(let t=0;t<h;t++){let h=d?p[t]:p[t]=ll(p[t]),f=h.type===iW;if(e){if(f&&!d){let n=p[t+1];n&&(n=ll(n)).type===iW&&(a(i(e.data.slice(h.children.length)),r,l(e)),e.data=h.children)}e=u(e,h,s,o,c,d)}else f&&!h.children?a(h.el=i(""),r):(r7(),n(null,h,r,null,s,o,ir(r),c))}return e},h=(e,t,n,r,i,o)=>{let{slotScopeIds:u}=t;u&&(i=i?i.concat(u):u);let d=s(e),h=p(l(e),t,d,n,r,i,o);return h&&ii(h)&&"]"===h.data?l(t.anchor=h):(r7(),a(t.anchor=c("]"),d,h),h)},m=(e,t,r,i,a,c)=>{if(r7(),t.el=null,c){let t=g(e);for(;;){let n=l(e);if(n&&n!==t)o(n);else break}}let u=l(e),d=s(e);return o(e),n(null,t,d,u,r,i,ir(d),a),u},g=(e,t="[",n="]")=>{let r=0;for(;e;)if((e=l(e))&&ii(e)&&(e.data===t&&r++,e.data===n)){if(0===r)return l(e);r--}return e},y=(e,t,n)=>{let r=t.parentNode;r&&r.replaceChild(e,t);let i=n;for(;i;)i.vnode.el===t&&(i.vnode.el=i.subTree.el=e),i=i.parent},b=e=>1===e.nodeType&&"template"===e.tagName.toLowerCase();return[(e,t)=>{if(!t.hasChildNodes()){n(null,e,t),ni(),t._vnode=e;return}u(t.firstChild,e,null,null,null),ni(),t._vnode=e},u]}let is=ij;function io(e){return ic(e)}function ia(e){return ic(e,il)}function ic(e,t){var n;let r,l;X().__VUE__=!0;let{insert:s,remove:o,patchProp:a,createElement:c,createText:h,createComment:f,setText:m,setElementText:y,parentNode:b,nextSibling:x,setScopeId:C=p,insertStaticContent:T}=e,k=(e,t,n,r=null,i=null,l=null,s,o=null,a=!!t.dynamicChildren)=>{if(e===t)return;e&&!i6(e,t)&&(r=eo(e),en(e,i,l,!0),e=null),-2===t.patchFlag&&(a=!1,t.dynamicChildren=null);let{type:c,ref:u,shapeFlag:d}=t;switch(c){case iW:E(e,t,n,r);break;case iK:A(e,t,n,r);break;case iz:null==e&&R(t,n,r,s);break;case iq:q(e,t,n,r,i,l,s,o,a);break;default:1&d?L(e,t,n,r,i,l,s,o,a):6&d?W(e,t,n,r,i,l,s,o,a):64&d?c.process(e,t,n,r,i,l,s,o,a,eu):128&d&&c.process(e,t,n,r,i,l,s,o,a,eu)}null!=u&&i&&rQ(u,e&&e.ref,l,t||e,!t)},E=(e,t,n,r)=>{if(null==e)s(t.el=h(t.children),n,r);else{let n=t.el=e.el;t.children!==e.children&&m(n,t.children)}},A=(e,t,n,r)=>{null==e?s(t.el=f(t.children||""),n,r):t.el=e.el},R=(e,t,n,r)=>{[e.el,e.anchor]=T(e.children,t,n,r,e.el,e.anchor)},O=({el:e,anchor:t},n,r)=>{let i;for(;e&&e!==t;)i=x(e),s(e,n,r),e=i;s(t,n,r)},M=({el:e,anchor:t})=>{let n;for(;e&&e!==t;)n=x(e),o(e),e=n;o(t)},L=(e,t,n,r,i,l,s,o,a)=>{"svg"===t.type?s="svg":"math"===t.type&&(s="mathml"),null==e?P(t,n,r,i,l,s,o,a):D(e,t,i,l,s,o,a)},P=(e,t,n,r,i,l,o,u)=>{let d,p;let{props:h,shapeFlag:f,transition:m,dirs:g}=e;if(d=e.el=c(e.type,l,h&&h.is,h),8&f?y(d,e.children):16&f&&V(e.children,d,null,r,i,iu(e,l),o,u),g&&nm(e,null,r,"created"),F(d,e,e.scopeId,o,r),h){for(let e in h)"value"===e||$(e)||a(d,e,null,h[e],l,r);"value"in h&&a(d,"value",null,h.value,l),(p=h.onVnodeBeforeMount)&&lc(p,r,e)}g&&nm(e,null,r,"beforeMount");let b=ip(i,m);b&&m.beforeEnter(d),s(d,t,n),((p=h&&h.onVnodeMounted)||b||g)&&is(()=>{p&&lc(p,r,e),b&&m.enter(d),g&&nm(e,null,r,"mounted")},i)},F=(e,t,n,r,i)=>{if(n&&C(e,n),r)for(let t=0;t<r.length;t++)C(e,r[t]);if(i&&t===i.subTree){let t=i.vnode;F(e,t,t.scopeId,t.slotScopeIds,i.parent)}},V=(e,t,n,r,i,l,s,o,a=0)=>{for(let c=a;c<e.length;c++)k(null,e[c]=o?ls(e[c]):ll(e[c]),t,n,r,i,l,s,o)},D=(e,t,n,r,i,l,s)=>{let o;let c=t.el=e.el,{patchFlag:d,dynamicChildren:p,dirs:h}=t;d|=16&e.patchFlag;let f=e.props||u,m=t.props||u;if(n&&id(n,!1),(o=m.onVnodeBeforeUpdate)&&lc(o,n,t,e),h&&nm(t,e,n,"beforeUpdate"),n&&id(n,!0),(f.innerHTML&&null==m.innerHTML||f.textContent&&null==m.textContent)&&y(c,""),p?U(e.dynamicChildren,p,c,n,r,iu(t,i),l):s||Z(e,t,c,null,n,r,iu(t,i),l,!1),d>0){if(16&d)H(c,f,m,n,i);else if(2&d&&f.class!==m.class&&a(c,"class",null,m.class,i),4&d&&a(c,"style",f.style,m.style,i),8&d){let e=t.dynamicProps;for(let t=0;t<e.length;t++){let r=e[t],l=f[r],s=m[r];(s!==l||"value"===r)&&a(c,r,l,s,i,n)}}1&d&&e.children!==t.children&&y(c,t.children)}else s||null!=p||H(c,f,m,n,i);((o=m.onVnodeUpdated)||h)&&is(()=>{o&&lc(o,n,t,e),h&&nm(t,e,n,"updated")},r)},U=(e,t,n,r,i,l,s)=>{for(let o=0;o<t.length;o++){let a=e[o],c=t[o],u=a.el&&(a.type===iq||!i6(a,c)||70&a.shapeFlag)?b(a.el):n;k(a,c,u,null,r,i,l,s,!0)}},H=(e,t,n,r,i)=>{if(t!==n){if(t!==u)for(let l in t)$(l)||l in n||a(e,l,t[l],null,i,r);for(let l in n){if($(l))continue;let s=n[l],o=t[l];s!==o&&"value"!==l&&a(e,l,o,s,i,r)}"value"in n&&a(e,"value",t.value,n.value,i)}},q=(e,t,n,r,i,l,o,a,c)=>{let u=t.el=e?e.el:h(""),d=t.anchor=e?e.anchor:h(""),{patchFlag:p,dynamicChildren:f,slotScopeIds:m}=t;m&&(a=a?a.concat(m):m),null==e?(s(u,n,r),s(d,n,r),V(t.children||[],n,d,i,l,o,a,c)):p>0&&64&p&&f&&e.dynamicChildren?(U(e.dynamicChildren,f,n,i,l,o,a),(null!=t.key||i&&t===i.subTree)&&ih(e,t,!0)):Z(e,t,n,d,i,l,o,a,c)},W=(e,t,n,r,i,l,s,o,a)=>{t.slotScopeIds=o,null==e?512&t.shapeFlag?i.ctx.activate(t,n,r,s,a):z(t,n,r,i,l,s,a):G(e,t,a)},z=(e,t,n,r,l,s,o)=>{let a=e.component=function(e,t,n){let r=e.type,i=(t?t.appContext:e.appContext)||lu,l={uid:ld++,vnode:e,type:r,parent:t,appContext:i,root:null,next:null,subTree:null,effect:null,update:null,scope:new eg(!0),render:null,proxy:null,exposed:null,exposeProxy:null,withProxy:null,provides:t?t.provides:Object.create(i.provides),accessCache:null,renderCache:[],components:null,directives:null,propsOptions:function e(t,n,r=!1){let i=r?rU:n.propsCache,l=i.get(t);if(l)return l;let s=t.props,o={},a=[],c=!1;if(!w(t)){let i=t=>{c=!0;let[r,i]=e(t,n,!0);g(o,r),i&&a.push(...i)};!r&&n.mixins.length&&n.mixins.forEach(i),t.extends&&i(t.extends),t.mixins&&t.mixins.forEach(i)}if(!s&&!c)return N(t)&&i.set(t,d),d;if(S(s))for(let e=0;e<s.length;e++){let t=B(s[e]);rj(t)&&(o[t]=u)}else if(s)for(let e in s){let t=B(e);if(rj(t)){let n=s[e],r=o[t]=S(n)||w(n)?{type:n}:g({},n),i=r.type,l=!1,c=!0;if(S(i))for(let e=0;e<i.length;++e){let t=i[e],n=w(t)&&t.name;if("Boolean"===n){l=!0;break}"String"===n&&(c=!1)}else l=w(i)&&"Boolean"===i.name;r[0]=l,r[1]=c,(l||_(r,"default"))&&a.push(t)}}let p=[o,a];return N(t)&&i.set(t,p),p}(r,i),emitsOptions:function e(t,n,r=!1){let i=n.emitsCache,l=i.get(t);if(void 0!==l)return l;let s=t.emits,o={},a=!1;if(!w(t)){let i=t=>{let r=e(t,n,!0);r&&(a=!0,g(o,r))};!r&&n.mixins.length&&n.mixins.forEach(i),t.extends&&i(t.extends),t.mixins&&t.mixins.forEach(i)}return s||a?(S(s)?s.forEach(e=>o[e]=null):g(o,s),N(t)&&i.set(t,o),o):(N(t)&&i.set(t,null),null)}(r,i),emit:null,emitted:null,propsDefaults:u,inheritAttrs:r.inheritAttrs,ctx:u,data:u,props:u,attrs:u,slots:u,refs:u,setupState:u,setupContext:null,suspense:n,suspenseId:n?n.pendingId:0,asyncDep:null,asyncResolved:!1,isMounted:!1,isUnmounted:!1,isDeactivated:!1,bc:null,c:null,bm:null,m:null,bu:null,u:null,um:null,bum:null,da:null,a:null,rtg:null,rtc:null,ec:null,sp:null};return l.ctx={_:l},l.root=t?t.root:l,l.emit=iN.bind(null,l),e.ce&&e.ce(l),l}(e,r,l);nM(e)&&(a.ctx.renderer=eu),function(e,t=!1,n=!1){t&&i(t);let{props:r,children:l}=e.vnode,s=lg(e);(function(e,t,n,r=!1){let i={},l=rF();for(let n in e.propsDefaults=Object.create(null),rD(e,t,i,l),e.propsOptions[0])n in i||(i[n]=void 0);n?e.props=r?i:tm(i):e.type.props?e.props=i:e.props=l,e.attrs=l})(e,r,s,t),rJ(e,l,n),s&&function(e,t){let n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,rr);let{setup:r}=n;if(r){let n=e.setupContext=r.length>1?lC(e):null,i=lf(e);eI();let l=tQ(r,e,0,[e.props,n]);if(eR(),i(),I(l)){if(l.then(lm,lm),t)return l.then(n=>{lv(e,n,t)}).catch(t=>{tY(t,e,0)});e.asyncDep=l}else lv(e,l,t)}else lS(e,t)}(e,t),t&&i(!1)}(a,!1,o),a.asyncDep?(l&&l.registerDep(a,J,o),e.el||A(null,a.subTree=i7(iK),t,n)):J(a,e,t,n,l,s,o)},G=(e,t,n)=>{let r=t.component=e.component;if(function(e,t,n){let{props:r,children:i,component:l}=e,{props:s,children:o,patchFlag:a}=t,c=l.emitsOptions;if(t.dirs||t.transition)return!0;if(!n||!(a>=0))return(!!i||!!o)&&(!o||!o.$stable)||r!==s&&(r?!s||iL(r,s,c):!!s);if(1024&a)return!0;if(16&a)return r?iL(r,s,c):!!s;if(8&a){let e=t.dynamicProps;for(let t=0;t<e.length;t++){let n=e[t];if(s[n]!==r[n]&&!iI(c,n))return!0}}return!1}(e,t,n)){if(r.asyncDep&&!r.asyncResolved){Q(r,t,n);return}r.next=t,function(e){let t=t2.indexOf(e);t>t3&&t2.splice(t,1)}(r.update),r.effect.dirty=!0,r.update()}else t.el=e.el,r.vnode=t},J=(e,t,n,r,i,s,o)=>{let a=()=>{if(e.isMounted){let t,{next:n,bu:r,u:l,parent:c,vnode:u}=e;{let t=function e(t){let n=t.subTree.component;if(n)return n.asyncDep&&!n.asyncResolved?n:e(n)}(e);if(t){n&&(n.el=u.el,Q(e,n,o)),t.asyncDep.then(()=>{e.isUnmounted||a()});return}}let d=n;id(e,!1),n?(n.el=u.el,Q(e,n,o)):n=u,r&&K(r),(t=n.props&&n.props.onVnodeBeforeUpdate)&&lc(t,c,n,u),id(e,!0);let p=iR(e),h=e.subTree;e.subTree=p,k(h,p,b(h.el),eo(h),e,i,s),n.el=p.el,null===d&&iP(e,p.el),l&&is(l,i),(t=n.props&&n.props.onVnodeUpdated)&&is(()=>lc(t,c,n,u),i)}else{let o;let{el:a,props:c}=t,{bm:u,m:d,parent:p}=e,h=nI(t);if(id(e,!1),u&&K(u),!h&&(o=c&&c.onVnodeBeforeMount)&&lc(o,p,t),id(e,!0),a&&l){let n=()=>{e.subTree=iR(e),l(a,e.subTree,e,i,null)};h?t.type.__asyncLoader().then(()=>!e.isUnmounted&&n()):n()}else{let l=e.subTree=iR(e);k(null,l,n,r,e,i,s),t.el=l.el}if(d&&is(d,i),!h&&(o=c&&c.onVnodeMounted)){let e=t;is(()=>lc(o,p,e),i)}(256&t.shapeFlag||p&&nI(p.vnode)&&256&p.vnode.shapeFlag)&&e.a&&is(e.a,i),e.isMounted=!0,t=n=r=null}},c=e.effect=new eS(a,p,()=>ne(u),e.scope),u=e.update=()=>{c.dirty&&c.run()};u.i=e,u.id=e.uid,id(e,!0),u()},Q=(e,t,n)=>{t.component=e;let r=e.vnode.props;e.vnode=t,e.next=null,function(e,t,n,r){let{props:i,attrs:l,vnode:{patchFlag:s}}=e,o=tC(i),[a]=e.propsOptions,c=!1;if((r||s>0)&&!(16&s)){if(8&s){let n=e.vnode.dynamicProps;for(let r=0;r<n.length;r++){let s=n[r];if(iI(e.emitsOptions,s))continue;let u=t[s];if(a){if(_(l,s))u!==l[s]&&(l[s]=u,c=!0);else{let t=B(s);i[t]=rB(a,o,t,u,e,!1)}}else u!==l[s]&&(l[s]=u,c=!0)}}}else{let r;for(let s in rD(e,t,i,l)&&(c=!0),o)t&&(_(t,s)||(r=j(s))!==s&&_(t,r))||(a?n&&(void 0!==n[s]||void 0!==n[r])&&(i[s]=rB(a,o,s,void 0,e,!0)):delete i[s]);if(l!==o)for(let e in l)t&&_(t,e)||(delete l[e],c=!0)}c&&eU(e.attrs,"set","")}(e,t.props,r,n),rX(e,t.children,n),eI(),nr(e),eR()},Z=(e,t,n,r,i,l,s,o,a=!1)=>{let c=e&&e.children,u=e?e.shapeFlag:0,d=t.children,{patchFlag:p,shapeFlag:h}=t;if(p>0){if(128&p){ee(c,d,n,r,i,l,s,o,a);return}if(256&p){Y(c,d,n,r,i,l,s,o,a);return}}8&h?(16&u&&es(c,i,l),d!==c&&y(n,d)):16&u?16&h?ee(c,d,n,r,i,l,s,o,a):es(c,i,l,!0):(8&u&&y(n,""),16&h&&V(d,n,r,i,l,s,o,a))},Y=(e,t,n,r,i,l,s,o,a)=>{let c;e=e||d,t=t||d;let u=e.length,p=t.length,h=Math.min(u,p);for(c=0;c<h;c++){let r=t[c]=a?ls(t[c]):ll(t[c]);k(e[c],r,n,null,i,l,s,o,a)}u>p?es(e,i,l,!0,!1,h):V(t,n,r,i,l,s,o,a,h)},ee=(e,t,n,r,i,l,s,o,a)=>{let c=0,u=t.length,p=e.length-1,h=u-1;for(;c<=p&&c<=h;){let r=e[c],u=t[c]=a?ls(t[c]):ll(t[c]);if(i6(r,u))k(r,u,n,null,i,l,s,o,a);else break;c++}for(;c<=p&&c<=h;){let r=e[p],c=t[h]=a?ls(t[h]):ll(t[h]);if(i6(r,c))k(r,c,n,null,i,l,s,o,a);else break;p--,h--}if(c>p){if(c<=h){let e=h+1,d=e<u?t[e].el:r;for(;c<=h;)k(null,t[c]=a?ls(t[c]):ll(t[c]),n,d,i,l,s,o,a),c++}}else if(c>h)for(;c<=p;)en(e[c],i,l,!0),c++;else{let f;let m=c,g=c,y=new Map;for(c=g;c<=h;c++){let e=t[c]=a?ls(t[c]):ll(t[c]);null!=e.key&&y.set(e.key,c)}let b=0,_=h-g+1,S=!1,x=0,C=Array(_);for(c=0;c<_;c++)C[c]=0;for(c=m;c<=p;c++){let r;let u=e[c];if(b>=_){en(u,i,l,!0);continue}if(null!=u.key)r=y.get(u.key);else for(f=g;f<=h;f++)if(0===C[f-g]&&i6(u,t[f])){r=f;break}void 0===r?en(u,i,l,!0):(C[r-g]=c+1,r>=x?x=r:S=!0,k(u,t[r],n,null,i,l,s,o,a),b++)}let T=S?function(e){let t,n,r,i,l;let s=e.slice(),o=[0],a=e.length;for(t=0;t<a;t++){let a=e[t];if(0!==a){if(e[n=o[o.length-1]]<a){s[t]=n,o.push(t);continue}for(r=0,i=o.length-1;r<i;)e[o[l=r+i>>1]]<a?r=l+1:i=l;a<e[o[r]]&&(r>0&&(s[t]=o[r-1]),o[r]=t)}}for(r=o.length,i=o[r-1];r-- >0;)o[r]=i,i=s[i];return o}(C):d;for(f=T.length-1,c=_-1;c>=0;c--){let e=g+c,d=t[e],p=e+1<u?t[e+1].el:r;0===C[c]?k(null,d,n,p,i,l,s,o,a):S&&(f<0||c!==T[f]?et(d,n,p,2):f--)}}},et=(e,t,n,r,i=null)=>{let{el:l,type:o,transition:a,children:c,shapeFlag:u}=e;if(6&u){et(e.component.subTree,t,n,r);return}if(128&u){e.suspense.move(t,n,r);return}if(64&u){o.move(e,t,n,eu);return}if(o===iq){s(l,t,n);for(let e=0;e<c.length;e++)et(c[e],t,n,r);s(e.anchor,t,n);return}if(o===iz){O(e,t,n);return}if(2!==r&&1&u&&a){if(0===r)a.beforeEnter(l),s(l,t,n),is(()=>a.enter(l),i);else{let{leave:e,delayLeave:r,afterLeave:i}=a,o=()=>s(l,t,n),c=()=>{e(l,()=>{o(),i&&i()})};r?r(l,o,c):c()}}else s(l,t,n)},en=(e,t,n,r=!1,i=!1)=>{let l;let{type:s,props:o,ref:a,children:c,dynamicChildren:u,shapeFlag:d,patchFlag:p,dirs:h,cacheIndex:f}=e;if(-2===p&&(i=!1),null!=a&&rQ(a,null,n,e,!0),null!=f&&(t.renderCache[f]=void 0),256&d){t.ctx.deactivate(e);return}let m=1&d&&h,g=!nI(e);if(g&&(l=o&&o.onVnodeBeforeUnmount)&&lc(l,t,e),6&d)el(e.component,n,r);else{if(128&d){e.suspense.unmount(n,r);return}m&&nm(e,null,t,"beforeUnmount"),64&d?e.type.remove(e,t,n,eu,r):u&&!u.hasOnce&&(s!==iq||p>0&&64&p)?es(u,t,n,!1,!0):(s===iq&&384&p||!i&&16&d)&&es(c,t,n),r&&er(e)}(g&&(l=o&&o.onVnodeUnmounted)||m)&&is(()=>{l&&lc(l,t,e),m&&nm(e,null,t,"unmounted")},n)},er=e=>{let{type:t,el:n,anchor:r,transition:i}=e;if(t===iq){ei(n,r);return}if(t===iz){M(e);return}let l=()=>{o(n),i&&!i.persisted&&i.afterLeave&&i.afterLeave()};if(1&e.shapeFlag&&i&&!i.persisted){let{leave:t,delayLeave:r}=i,s=()=>t(n,l);r?r(e.el,l,s):s()}else l()},ei=(e,t)=>{let n;for(;e!==t;)n=x(e),o(e),e=n;o(t)},el=(e,t,n)=>{let{bum:r,scope:i,update:l,subTree:s,um:o,m:a,a:c}=e;im(a),im(c),r&&K(r),i.stop(),l&&(l.active=!1,en(s,e,t,n)),o&&is(o,t),is(()=>{e.isUnmounted=!0},t),t&&t.pendingBranch&&!t.isUnmounted&&e.asyncDep&&!e.asyncResolved&&e.suspenseId===t.pendingId&&(t.deps--,0===t.deps&&t.resolve())},es=(e,t,n,r=!1,i=!1,l=0)=>{for(let s=l;s<e.length;s++)en(e[s],t,n,r,i)},eo=e=>{if(6&e.shapeFlag)return eo(e.component.subTree);if(128&e.shapeFlag)return e.suspense.next();let t=x(e.anchor||e.el),n=t&&t[rZ];return n?x(n):t},ea=!1,ec=(e,t,n)=>{null==e?t._vnode&&en(t._vnode,null,null,!0):k(t._vnode||null,e,t,null,null,null,n),t._vnode=e,ea||(ea=!0,nr(),ni(),ea=!1)},eu={p:k,um:en,m:et,r:er,mt:z,mc:V,pc:Z,pbc:U,n:eo,o:e};return t&&([r,l]=t(eu)),{render:ec,hydrate:r,createApp:(n=r,function(e,t=null){w(e)||(e=g({},e)),null==t||N(t)||(t=null);let r=rI(),i=new WeakSet,l=!1,s=r.app={_uid:rR++,_component:e,_props:t,_container:null,_context:r,_instance:null,version:lR,get config(){return r.config},set config(v){},use:(e,...t)=>(i.has(e)||(e&&w(e.install)?(i.add(e),e.install(s,...t)):w(e)&&(i.add(e),e(s,...t))),s),mixin:e=>(r.mixins.includes(e)||r.mixins.push(e),s),component:(e,t)=>t?(r.components[e]=t,s):r.components[e],directive:(e,t)=>t?(r.directives[e]=t,s):r.directives[e],mount(i,o,a){if(!l){let c=i7(e,t);return c.appContext=r,!0===a?a="svg":!1===a&&(a=void 0),o&&n?n(c,i):ec(c,i,a),l=!0,s._container=i,i.__vue_app__=s,lT(c.component)}},unmount(){l&&(ec(null,s._container),delete s._container.__vue_app__)},provide:(e,t)=>(r.provides[e]=t,s),runWithContext(e){let t=rO;rO=s;try{return e()}finally{rO=t}}};return s})}}function iu({type:e,props:t},n){return"svg"===n&&"foreignObject"===e||"mathml"===n&&"annotation-xml"===e&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function id({effect:e,update:t},n){e.allowRecurse=t.allowRecurse=n}function ip(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function ih(e,t,n=!1){let r=e.children,i=t.children;if(S(r)&&S(i))for(let e=0;e<r.length;e++){let t=r[e],l=i[e];!(1&l.shapeFlag)||l.dynamicChildren||((l.patchFlag<=0||32===l.patchFlag)&&((l=i[e]=ls(i[e])).el=t.el),n||-2===l.patchFlag||ih(t,l)),l.type===iW&&(l.el=t.el)}}function im(e){if(e)for(let t=0;t<e.length;t++)e[t].active=!1}let ig=Symbol.for("v-scx"),iy=()=>rL(ig);function iv(e,t){return iC(e,null,t)}function ib(e,t){return iC(e,null,{flush:"post"})}function i_(e,t){return iC(e,null,{flush:"sync"})}let iS={};function ix(e,t,n){return iC(e,t,n)}function iC(e,t,{immediate:n,deep:r,flush:i,once:l,onTrack:s,onTrigger:o}=u){let a,c,d;if(t&&l){let e=t;t=(...t)=>{e(...t),k()}}let h=lp,f=e=>!0===r?e:iw(e,!1===r?1:void 0),m=!1,g=!1;if(tI(e)?(a=()=>e.value,m=tS(e)):tb(e)?(a=()=>f(e),m=!0):S(e)?(g=!0,m=e.some(e=>tb(e)||tS(e)),a=()=>e.map(e=>tI(e)?e.value:tb(e)?f(e):w(e)?tQ(e,h,2):void 0)):a=w(e)?t?()=>tQ(e,h,2):()=>(c&&c(),tZ(e,h,3,[b])):p,t&&r){let e=a;a=()=>iw(e())}let b=e=>{c=C.onStop=()=>{tQ(e,h,4),c=C.onStop=void 0}},_=g?Array(e.length).fill(iS):iS,x=()=>{if(C.active&&C.dirty){if(t){let e=C.run();(r||m||(g?e.some((e,t)=>W(e,_[t])):W(e,_)))&&(c&&c(),tZ(t,h,3,[e,_===iS?void 0:g&&_[0]===iS?[]:_,b]),_=e)}else C.run()}};x.allowRecurse=!!t,"sync"===i?d=x:"post"===i?d=()=>is(x,h&&h.suspense):(x.pre=!0,h&&(x.id=h.uid),d=()=>ne(x));let C=new eS(a,p,d),T=eb(),k=()=>{C.stop(),T&&y(T.effects,C)};return t?n?x():_=C.run():"post"===i?is(C.run.bind(C),h&&h.suspense):C.run(),k}function iT(e,t,n){let r;let i=this.proxy,l=E(e)?e.includes(".")?ik(i,e):()=>i[e]:e.bind(i,i);w(t)?r=t:(r=t.handler,n=t);let s=lf(this),o=iC(l,r.bind(i),n);return s(),o}function ik(e,t){let n=t.split(".");return()=>{let t=e;for(let e=0;e<n.length&&t;e++)t=t[n[e]];return t}}function iw(e,t=1/0,n){if(t<=0||!N(e)||e.__v_skip||(n=n||new Set).has(e))return e;if(n.add(e),t--,tI(e))iw(e.value,t,n);else if(S(e))for(let r=0;r<e.length;r++)iw(e[r],t,n);else if(C(e)||x(e))e.forEach(e=>{iw(e,t,n)});else if(L(e)){for(let r in e)iw(e[r],t,n);for(let r of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,r)&&iw(e[r],t,n)}return e}function iE(e,t,n=u){let r=lh(),i=B(t),l=j(t),s=iA(e,t),o=tU((s,o)=>{let a,c;let d=u;return i_(()=>{let n=e[t];W(a,n)&&(a=n,o())}),{get:()=>(s(),n.get?n.get(a):a),set(e){let s=n.set?n.set(e):e;if(!W(s,a)&&!(d!==u&&W(e,d)))return;let p=r.vnode.props;p&&(t in p||i in p||l in p)&&(`onUpdate:${t}` in p||`onUpdate:${i}` in p||`onUpdate:${l}` in p)||(a=e,o()),r.emit(`update:${t}`,s),W(e,s)&&W(e,d)&&!W(s,c)&&o(),d=e,c=s}}});return o[Symbol.iterator]=()=>{let e=0;return{next:()=>e<2?{value:e++?s||u:o,done:!1}:{done:!0}}},o}let iA=(e,t)=>"modelValue"===t||"model-value"===t?e.modelModifiers:e[`${t}Modifiers`]||e[`${B(t)}Modifiers`]||e[`${j(t)}Modifiers`];function iN(e,t,...n){let r;if(e.isUnmounted)return;let i=e.vnode.props||u,l=n,s=t.startsWith("update:"),o=s&&iA(i,t.slice(7));o&&(o.trim&&(l=n.map(e=>E(e)?e.trim():e)),o.number&&(l=n.map(G)));let a=i[r=q(t)]||i[r=q(B(t))];!a&&s&&(a=i[r=q(j(t))]),a&&tZ(a,e,6,l);let c=i[r+"Once"];if(c){if(e.emitted){if(e.emitted[r])return}else e.emitted={};e.emitted[r]=!0,tZ(c,e,6,l)}}function iI(e,t){return!!(e&&f(t))&&(_(e,(t=t.slice(2).replace(/Once$/,""))[0].toLowerCase()+t.slice(1))||_(e,j(t))||_(e,t))}function iR(e){let t,n;let{type:r,vnode:i,proxy:l,withProxy:s,propsOptions:[o],slots:a,attrs:c,emit:u,render:d,renderCache:p,props:h,data:f,setupState:g,ctx:y,inheritAttrs:b}=e,_=nc(e);try{if(4&i.shapeFlag){let e=s||l;t=ll(d.call(e,e,p,h,g,f,y)),n=c}else t=ll(r.length>1?r(h,{attrs:c,slots:a,emit:u}):r(h,null)),n=r.props?c:iO(c)}catch(n){iG.length=0,tY(n,e,1),t=i7(iK)}let S=t;if(n&&!1!==b){let e=Object.keys(n),{shapeFlag:t}=S;e.length&&7&t&&(o&&e.some(m)&&(n=iM(n,o)),S=lt(S,n,!1,!0))}return i.dirs&&((S=lt(S,null,!1,!0)).dirs=S.dirs?S.dirs.concat(i.dirs):i.dirs),i.transition&&(S.transition=i.transition),t=S,nc(_),t}let iO=e=>{let t;for(let n in e)("class"===n||"style"===n||f(n))&&((t||(t={}))[n]=e[n]);return t},iM=(e,t)=>{let n={};for(let r in e)m(r)&&r.slice(9) in t||(n[r]=e[r]);return n};function iL(e,t,n){let r=Object.keys(t);if(r.length!==Object.keys(e).length)return!0;for(let i=0;i<r.length;i++){let l=r[i];if(t[l]!==e[l]&&!iI(n,l))return!0}return!1}function iP({vnode:e,parent:t},n){for(;t;){let r=t.subTree;if(r.suspense&&r.suspense.activeBranch===e&&(r.el=e.el),r===e)(e=t.vnode).el=n,t=t.parent;else break}}let i$=e=>e.__isSuspense,iF=0,iV={name:"Suspense",__isSuspense:!0,process(e,t,n,r,i,l,s,o,a,c){if(null==e)!function(e,t,n,r,i,l,s,o,a){let{p:c,o:{createElement:u}}=a,d=u("div"),p=e.suspense=iB(e,i,r,t,d,n,l,s,o,a);c(null,p.pendingBranch=e.ssContent,d,null,r,p,l,s),p.deps>0?(iD(e,"onPending"),iD(e,"onFallback"),c(null,e.ssFallback,t,n,r,null,l,s),iH(p,e.ssFallback)):p.resolve(!1,!0)}(t,n,r,i,l,s,o,a,c);else{if(l&&l.deps>0&&!e.suspense.isInFallback){t.suspense=e.suspense,t.suspense.vnode=t,t.el=e.el;return}!function(e,t,n,r,i,l,s,o,{p:a,um:c,o:{createElement:u}}){let d=t.suspense=e.suspense;d.vnode=t,t.el=e.el;let p=t.ssContent,h=t.ssFallback,{activeBranch:f,pendingBranch:m,isInFallback:g,isHydrating:y}=d;if(m)d.pendingBranch=p,i6(p,m)?(a(m,p,d.hiddenContainer,null,i,d,l,s,o),d.deps<=0?d.resolve():g&&!y&&(a(f,h,n,r,i,null,l,s,o),iH(d,h))):(d.pendingId=iF++,y?(d.isHydrating=!1,d.activeBranch=m):c(m,i,d),d.deps=0,d.effects.length=0,d.hiddenContainer=u("div"),g?(a(null,p,d.hiddenContainer,null,i,d,l,s,o),d.deps<=0?d.resolve():(a(f,h,n,r,i,null,l,s,o),iH(d,h))):f&&i6(p,f)?(a(f,p,n,r,i,d,l,s,o),d.resolve(!0)):(a(null,p,d.hiddenContainer,null,i,d,l,s,o),d.deps<=0&&d.resolve()));else if(f&&i6(p,f))a(f,p,n,r,i,d,l,s,o),iH(d,p);else if(iD(t,"onPending"),d.pendingBranch=p,512&p.shapeFlag?d.pendingId=p.component.suspenseId:d.pendingId=iF++,a(null,p,d.hiddenContainer,null,i,d,l,s,o),d.deps<=0)d.resolve();else{let{timeout:e,pendingId:t}=d;e>0?setTimeout(()=>{d.pendingId===t&&d.fallback(h)},e):0===e&&d.fallback(h)}}(e,t,n,r,i,s,o,a,c)}},hydrate:function(e,t,n,r,i,l,s,o,a){let c=t.suspense=iB(t,r,n,e.parentNode,document.createElement("div"),null,i,l,s,o,!0),u=a(e,c.pendingBranch=t.ssContent,n,c,l,s);return 0===c.deps&&c.resolve(!1,!0),u},normalize:function(e){let{shapeFlag:t,children:n}=e,r=32&t;e.ssContent=iU(r?n.default:n),e.ssFallback=r?iU(n.fallback):i7(iK)}};function iD(e,t){let n=e.props&&e.props[t];w(n)&&n()}function iB(e,t,n,r,i,l,s,o,a,c,u=!1){let d;let{p:p,m:h,um:f,n:m,o:{parentNode:g,remove:y}}=c,b=function(e){let t=e.props&&e.props.suspensible;return null!=t&&!1!==t}(e);b&&t&&t.pendingBranch&&(d=t.pendingId,t.deps++);let _=e.props?J(e.props.timeout):void 0,S=l,x={vnode:e,parent:t,parentComponent:n,namespace:s,container:r,hiddenContainer:i,deps:0,pendingId:iF++,timeout:"number"==typeof _?_:-1,activeBranch:null,pendingBranch:null,isInFallback:!u,isHydrating:u,isUnmounted:!1,effects:[],resolve(e=!1,n=!1){let{vnode:r,activeBranch:i,pendingBranch:s,pendingId:o,effects:a,parentComponent:c,container:u}=x,p=!1;x.isHydrating?x.isHydrating=!1:e||((p=i&&s.transition&&"out-in"===s.transition.mode)&&(i.transition.afterLeave=()=>{o===x.pendingId&&(h(s,u,l===S?m(i):l,0),nn(a))}),i&&(g(i.el)!==x.hiddenContainer&&(l=m(i)),f(i,c,x,!0)),p||h(s,u,l,0)),iH(x,s),x.pendingBranch=null,x.isInFallback=!1;let y=x.parent,_=!1;for(;y;){if(y.pendingBranch){y.effects.push(...a),_=!0;break}y=y.parent}_||p||nn(a),x.effects=[],b&&t&&t.pendingBranch&&d===t.pendingId&&(t.deps--,0!==t.deps||n||t.resolve()),iD(r,"onResolve")},fallback(e){if(!x.pendingBranch)return;let{vnode:t,activeBranch:n,parentComponent:r,container:i,namespace:l}=x;iD(t,"onFallback");let s=m(n),c=()=>{x.isInFallback&&(p(null,e,i,s,r,null,l,o,a),iH(x,e))},u=e.transition&&"out-in"===e.transition.mode;u&&(n.transition.afterLeave=c),x.isInFallback=!0,f(n,r,null,!0),u||c()},move(e,t,n){x.activeBranch&&h(x.activeBranch,e,t,n),x.container=e},next:()=>x.activeBranch&&m(x.activeBranch),registerDep(e,t,n){let r=!!x.pendingBranch;r&&x.deps++;let i=e.vnode.el;e.asyncDep.catch(t=>{tY(t,e,0)}).then(l=>{if(e.isUnmounted||x.isUnmounted||x.pendingId!==e.suspenseId)return;e.asyncResolved=!0;let{vnode:o}=e;lv(e,l,!1),i&&(o.el=i);let a=!i&&e.subTree.el;t(e,o,g(i||e.subTree.el),i?null:m(e.subTree),x,s,n),a&&y(a),iP(e,o.el),r&&0==--x.deps&&x.resolve()})},unmount(e,t){x.isUnmounted=!0,x.activeBranch&&f(x.activeBranch,n,e,t),x.pendingBranch&&f(x.pendingBranch,n,e,t)}};return x}function iU(e){let t;if(w(e)){let n=iZ&&e._c;n&&(e._d=!1,iX()),e=e(),n&&(e._d=!0,t=iJ,iQ())}return S(e)&&(e=function(e,t=!0){let n;for(let t=0;t<e.length;t++){let r=e[t];if(!i3(r))return;if(r.type!==iK||"v-if"===r.children){if(n)return;n=r}}return n}(e)),e=ll(e),t&&!e.dynamicChildren&&(e.dynamicChildren=t.filter(t=>t!==e)),e}function ij(e,t){t&&t.pendingBranch?S(e)?t.effects.push(...e):t.effects.push(e):nn(e)}function iH(e,t){e.activeBranch=t;let{vnode:n,parentComponent:r}=e,i=t.el;for(;!i&&t.component;)i=(t=t.component.subTree).el;n.el=i,r&&r.subTree===n&&(r.vnode.el=i,iP(r,i))}let iq=Symbol.for("v-fgt"),iW=Symbol.for("v-txt"),iK=Symbol.for("v-cmt"),iz=Symbol.for("v-stc"),iG=[],iJ=null;function iX(e=!1){iG.push(iJ=e?null:[])}function iQ(){iG.pop(),iJ=iG[iG.length-1]||null}let iZ=1;function iY(e){iZ+=e,e<0&&iJ&&(iJ.hasOnce=!0)}function i0(e){return e.dynamicChildren=iZ>0?iJ||d:null,iQ(),iZ>0&&iJ&&iJ.push(e),e}function i1(e,t,n,r,i,l){return i0(i9(e,t,n,r,i,l,!0))}function i2(e,t,n,r,i){return i0(i7(e,t,n,r,i,!0))}function i3(e){return!!e&&!0===e.__v_isVNode}function i6(e,t){return e.type===t.type&&e.key===t.key}function i4(e){}let i8=({key:e})=>null!=e?e:null,i5=({ref:e,ref_key:t,ref_for:n})=>("number"==typeof e&&(e=""+e),null!=e?E(e)||tI(e)||w(e)?{i:no,r:e,k:t,f:!!n}:e:null);function i9(e,t=null,n=null,r=0,i=null,l=e===iq?0:1,s=!1,o=!1){let a={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&i8(t),ref:t&&i5(t),scopeId:na,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:l,patchFlag:r,dynamicProps:i,dynamicChildren:null,appContext:null,ctx:no};return o?(lo(a,n),128&l&&e.normalize(a)):n&&(a.shapeFlag|=E(n)?8:16),iZ>0&&!s&&iJ&&(a.patchFlag>0||6&l)&&32!==a.patchFlag&&iJ.push(a),a}let i7=function(e,t=null,n=null,r=0,i=null,l=!1){var s;if(e&&e!==n1||(e=iK),i3(e)){let r=lt(e,t,!0);return n&&lo(r,n),iZ>0&&!l&&iJ&&(6&r.shapeFlag?iJ[iJ.indexOf(e)]=r:iJ.push(r)),r.patchFlag=-2,r}if(w(s=e)&&"__vccOpts"in s&&(e=e.__vccOpts),t){let{class:e,style:n}=t=le(t);e&&!E(e)&&(t.class=er(e)),N(n)&&(tx(n)&&!S(n)&&(n=g({},n)),t.style=Z(n))}let o=E(e)?1:i$(e)?128:rY(e)?64:N(e)?4:w(e)?2:0;return i9(e,t,n,r,i,o,l,!0)};function le(e){return e?tx(e)||rV(e)?g({},e):e:null}function lt(e,t,n=!1,r=!1){let{props:i,ref:l,patchFlag:s,children:o,transition:a}=e,c=t?la(i||{},t):i,u={__v_isVNode:!0,__v_skip:!0,type:e.type,props:c,key:c&&i8(c),ref:t&&t.ref?n&&l?S(l)?l.concat(i5(t)):[l,i5(t)]:i5(t):l,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:o,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==iq?-1===s?16:16|s:s,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:a,component:e.component,suspense:e.suspense,ssContent:e.ssContent&<(e.ssContent),ssFallback:e.ssFallback&<(e.ssFallback),el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return a&&r&&nE(u,a.clone(u)),u}function ln(e=" ",t=0){return i7(iW,null,e,t)}function lr(e,t){let n=i7(iz,null,e);return n.staticCount=t,n}function li(e="",t=!1){return t?(iX(),i2(iK,null,e)):i7(iK,null,e)}function ll(e){return null==e||"boolean"==typeof e?i7(iK):S(e)?i7(iq,null,e.slice()):"object"==typeof e?ls(e):i7(iW,null,String(e))}function ls(e){return null===e.el&&-1!==e.patchFlag||e.memo?e:lt(e)}function lo(e,t){let n=0,{shapeFlag:r}=e;if(null==t)t=null;else if(S(t))n=16;else if("object"==typeof t){if(65&r){let n=t.default;n&&(n._c&&(n._d=!1),lo(e,n()),n._c&&(n._d=!0));return}{n=32;let r=t._;r||rV(t)?3===r&&no&&(1===no.slots._?t._=1:(t._=2,e.patchFlag|=1024)):t._ctx=no}}else w(t)?(t={default:t,_ctx:no},n=32):(t=String(t),64&r?(n=16,t=[ln(t)]):n=8);e.children=t,e.shapeFlag|=n}function la(...e){let t={};for(let n=0;n<e.length;n++){let r=e[n];for(let e in r)if("class"===e)t.class!==r.class&&(t.class=er([t.class,r.class]));else if("style"===e)t.style=Z([t.style,r.style]);else if(f(e)){let n=t[e],i=r[e];i&&n!==i&&!(S(n)&&n.includes(i))&&(t[e]=n?[].concat(n,i):i)}else""!==e&&(t[e]=r[e])}return t}function lc(e,t,n,r=null){tZ(e,t,7,[n,r])}let lu=rI(),ld=0,lp=null,lh=()=>lp||no;r=e=>{lp=e},i=e=>{ly=e};let lf=e=>{let t=lp;return r(e),e.scope.on(),()=>{e.scope.off(),r(t)}},lm=()=>{lp&&lp.scope.off(),r(null)};function lg(e){return 4&e.vnode.shapeFlag}let ly=!1;function lv(e,t,n){w(t)?e.render=t:N(t)&&(e.setupState=tD(t)),lS(e,n)}function lb(e){l=e,s=e=>{e.render._rc&&(e.withProxy=new Proxy(e.ctx,ri))}}let l_=()=>!l;function lS(e,t,n){let r=e.type;if(!e.render){if(!t&&l&&!r.render){let t=r.template||rx(e).template;if(t){let{isCustomElement:n,compilerOptions:i}=e.appContext.config,{delimiters:s,compilerOptions:o}=r,a=g(g({isCustomElement:n,delimiters:s},i),o);r.render=l(t,a)}}e.render=r.render||p,s&&s(e)}{let t=lf(e);eI();try{!function(e){let t=rx(e),n=e.proxy,r=e.ctx;r_=!1,t.beforeCreate&&rS(t.beforeCreate,e,"bc");let{data:i,computed:l,methods:s,watch:o,provide:a,inject:c,created:u,beforeMount:d,mounted:h,beforeUpdate:f,updated:m,activated:g,deactivated:y,beforeDestroy:b,beforeUnmount:_,destroyed:x,unmounted:C,render:T,renderTracked:k,renderTriggered:A,errorCaptured:I,serverPrefetch:R,expose:O,inheritAttrs:M,components:L,directives:P,filters:$}=t;if(c&&function(e,t,n=p){for(let n in S(e)&&(e=rw(e)),e){let r;let i=e[n];tI(r=N(i)?"default"in i?rL(i.from||n,i.default,!0):rL(i.from||n):rL(i))?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>r.value,set:e=>r.value=e}):t[n]=r}}(c,r,null),s)for(let e in s){let t=s[e];w(t)&&(r[e]=t.bind(n))}if(i){let t=i.call(n,n);N(t)&&(e.data=tf(t))}if(r_=!0,l)for(let e in l){let t=l[e],i=w(t)?t.bind(n,n):w(t.get)?t.get.bind(n,n):p,s=lw({get:i,set:!w(t)&&w(t.set)?t.set.bind(n):p});Object.defineProperty(r,e,{enumerable:!0,configurable:!0,get:()=>s.value,set:e=>s.value=e})}if(o)for(let e in o)!function e(t,n,r,i){let l=i.includes(".")?ik(r,i):()=>r[i];if(E(t)){let e=n[t];w(e)&&ix(l,e)}else if(w(t))ix(l,t.bind(r));else if(N(t)){if(S(t))t.forEach(t=>e(t,n,r,i));else{let e=w(t.handler)?t.handler.bind(r):n[t.handler];w(e)&&ix(l,e,t)}}}(o[e],r,n,e);if(a){let e=w(a)?a.call(n):a;Reflect.ownKeys(e).forEach(t=>{rM(t,e[t])})}function F(e,t){S(t)?t.forEach(t=>e(t.bind(n))):t&&e(t.bind(n))}if(u&&rS(u,e,"c"),F(nH,d),F(nq,h),F(nW,f),F(nK,m),F(n$,g),F(nF,y),F(nZ,I),F(nQ,k),F(nX,A),F(nz,_),F(nG,C),F(nJ,R),S(O)){if(O.length){let t=e.exposed||(e.exposed={});O.forEach(e=>{Object.defineProperty(t,e,{get:()=>n[e],set:t=>n[e]=t})})}else e.exposed||(e.exposed={})}T&&e.render===p&&(e.render=T),null!=M&&(e.inheritAttrs=M),L&&(e.components=L),P&&(e.directives=P)}(e)}finally{eR(),t()}}}let lx={get:(e,t)=>(eB(e,"get",""),e[t])};function lC(e){return{attrs:new Proxy(e.attrs,lx),slots:e.slots,emit:e.emit,expose:t=>{e.exposed=t||{}}}}function lT(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(tD(tT(e.exposed)),{get:(t,n)=>n in t?t[n]:n in rt?rt[n](e):void 0,has:(e,t)=>t in e||t in rt})):e.proxy}function lk(e,t=!0){return w(e)?e.displayName||e.name:e.name||t&&e.__name}let lw=(e,t)=>(function(e,t,n=!1){let r,i;let l=w(e);return l?(r=e,i=p):(r=e.get,i=e.set),new tE(r,i,l||!i,n)})(e,0,ly);function lE(e,t,n){let r=arguments.length;return 2!==r?(r>3?n=Array.prototype.slice.call(arguments,2):3===r&&i3(n)&&(n=[n]),i7(e,t,n)):!N(t)||S(t)?i7(e,null,t):i3(t)?i7(e,null,[t]):i7(e,t)}function lA(){}function lN(e,t,n,r){let i=n[r];if(i&&lI(i,e))return i;let l=t();return l.memo=e.slice(),l.cacheIndex=r,n[r]=l}function lI(e,t){let n=e.memo;if(n.length!=t.length)return!1;for(let e=0;e<n.length;e++)if(W(n[e],t[e]))return!1;return iZ>0&&iJ&&iJ.push(e),!0}let lR="3.4.38",lO=p,lM=null,lL=void 0,lP=p,l$=null,lF=null,lV=null,lD=null,lB="undefined"!=typeof document?document:null,lU=lB&&lB.createElement("template"),lj="transition",lH="animation",lq=Symbol("_vtc"),lW=(e,{slots:t})=>lE(nx,lX(e),t);lW.displayName="Transition";let lK={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},lz=lW.props=g({},n_,lK),lG=(e,t=[])=>{S(e)?e.forEach(e=>e(...t)):e&&e(...t)},lJ=e=>!!e&&(S(e)?e.some(e=>e.length>1):e.length>1);function lX(e){let t={};for(let n in e)n in lK||(t[n]=e[n]);if(!1===e.css)return t;let{name:n="v",type:r,duration:i,enterFromClass:l=`${n}-enter-from`,enterActiveClass:s=`${n}-enter-active`,enterToClass:o=`${n}-enter-to`,appearFromClass:a=l,appearActiveClass:c=s,appearToClass:u=o,leaveFromClass:d=`${n}-leave-from`,leaveActiveClass:p=`${n}-leave-active`,leaveToClass:h=`${n}-leave-to`}=e,f=function(e){if(null==e)return null;if(N(e))return[J(e.enter),J(e.leave)];{let t=J(e);return[t,t]}}(i),m=f&&f[0],y=f&&f[1],{onBeforeEnter:b,onEnter:_,onEnterCancelled:S,onLeave:x,onLeaveCancelled:C,onBeforeAppear:T=b,onAppear:k=_,onAppearCancelled:w=S}=t,E=(e,t,n)=>{lZ(e,t?u:o),lZ(e,t?c:s),n&&n()},A=(e,t)=>{e._isLeaving=!1,lZ(e,d),lZ(e,h),lZ(e,p),t&&t()},I=e=>(t,n)=>{let i=e?k:_,s=()=>E(t,e,n);lG(i,[t,s]),lY(()=>{lZ(t,e?a:l),lQ(t,e?u:o),lJ(i)||l1(t,r,m,s)})};return g(t,{onBeforeEnter(e){lG(b,[e]),lQ(e,l),lQ(e,s)},onBeforeAppear(e){lG(T,[e]),lQ(e,a),lQ(e,c)},onEnter:I(!1),onAppear:I(!0),onLeave(e,t){e._isLeaving=!0;let n=()=>A(e,t);lQ(e,d),lQ(e,p),l4(),lY(()=>{e._isLeaving&&(lZ(e,d),lQ(e,h),lJ(x)||l1(e,r,y,n))}),lG(x,[e,n])},onEnterCancelled(e){E(e,!1),lG(S,[e])},onAppearCancelled(e){E(e,!0),lG(w,[e])},onLeaveCancelled(e){A(e),lG(C,[e])}})}function lQ(e,t){t.split(/\s+/).forEach(t=>t&&e.classList.add(t)),(e[lq]||(e[lq]=new Set)).add(t)}function lZ(e,t){t.split(/\s+/).forEach(t=>t&&e.classList.remove(t));let n=e[lq];n&&(n.delete(t),n.size||(e[lq]=void 0))}function lY(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let l0=0;function l1(e,t,n,r){let i=e._endId=++l0,l=()=>{i===e._endId&&r()};if(n)return setTimeout(l,n);let{type:s,timeout:o,propCount:a}=l2(e,t);if(!s)return r();let c=s+"end",u=0,d=()=>{e.removeEventListener(c,p),l()},p=t=>{t.target===e&&++u>=a&&d()};setTimeout(()=>{u<a&&d()},o+1),e.addEventListener(c,p)}function l2(e,t){let n=window.getComputedStyle(e),r=e=>(n[e]||"").split(", "),i=r(`${lj}Delay`),l=r(`${lj}Duration`),s=l3(i,l),o=r(`${lH}Delay`),a=r(`${lH}Duration`),c=l3(o,a),u=null,d=0,p=0;t===lj?s>0&&(u=lj,d=s,p=l.length):t===lH?c>0&&(u=lH,d=c,p=a.length):p=(u=(d=Math.max(s,c))>0?s>c?lj:lH:null)?u===lj?l.length:a.length:0;let h=u===lj&&/\b(transform|all)(,|$)/.test(r(`${lj}Property`).toString());return{type:u,timeout:d,propCount:p,hasTransform:h}}function l3(e,t){for(;e.length<t.length;)e=e.concat(e);return Math.max(...t.map((t,n)=>l6(t)+l6(e[n])))}function l6(e){return"auto"===e?0:1e3*Number(e.slice(0,-1).replace(",","."))}function l4(){return document.body.offsetHeight}let l8=Symbol("_vod"),l5=Symbol("_vsh"),l9={beforeMount(e,{value:t},{transition:n}){e[l8]="none"===e.style.display?"":e.style.display,n&&t?n.beforeEnter(e):l7(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:r}){!t!=!n&&(r?t?(r.beforeEnter(e),l7(e,!0),r.enter(e)):r.leave(e,()=>{l7(e,!1)}):l7(e,t))},beforeUnmount(e,{value:t}){l7(e,t)}};function l7(e,t){e.style.display=t?e[l8]:"none",e[l5]=!t}let se=Symbol("");function st(e){let t=lh();if(!t)return;let n=t.ut=(n=e(t.proxy))=>{Array.from(document.querySelectorAll(`[data-v-owner="${t.uid}"]`)).forEach(e=>sn(e,n))},r=()=>{let r=e(t.proxy);(function e(t,n){if(128&t.shapeFlag){let r=t.suspense;t=r.activeBranch,r.pendingBranch&&!r.isHydrating&&r.effects.push(()=>{e(r.activeBranch,n)})}for(;t.component;)t=t.component.subTree;if(1&t.shapeFlag&&t.el)sn(t.el,n);else if(t.type===iq)t.children.forEach(t=>e(t,n));else if(t.type===iz){let{el:e,anchor:r}=t;for(;e&&(sn(e,n),e!==r);)e=e.nextSibling}})(t.subTree,r),n(r)};nH(()=>{ib(r)}),nq(()=>{let e=new MutationObserver(r);e.observe(t.subTree.el.parentNode,{childList:!0}),nG(()=>e.disconnect())})}function sn(e,t){if(1===e.nodeType){let n=e.style,r="";for(let e in t)n.setProperty(`--${e}`,t[e]),r+=`--${e}: ${t[e]};`;n[se]=r}}let sr=/(^|;)\s*display\s*:/,si=/\s*!important$/;function sl(e,t,n){if(S(n))n.forEach(n=>sl(e,t,n));else if(null==n&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{let r=function(e,t){let n=so[t];if(n)return n;let r=B(t);if("filter"!==r&&r in e)return so[t]=r;r=H(r);for(let n=0;n<ss.length;n++){let i=ss[n]+r;if(i in e)return so[t]=i}return t}(e,t);si.test(n)?e.setProperty(j(r),n.replace(si,""),"important"):e[r]=n}}let ss=["Webkit","Moz","ms"],so={},sa="http://www.w3.org/1999/xlink";function sc(e,t,n,r,i,l=ec(t)){r&&t.startsWith("xlink:")?null==n?e.removeAttributeNS(sa,t.slice(6,t.length)):e.setAttributeNS(sa,t,n):null==n||l&&!(n||""===n)?e.removeAttribute(t):e.setAttribute(t,l?"":A(n)?String(n):n)}function su(e,t,n,r){e.addEventListener(t,n,r)}let sd=Symbol("_vei"),sp=/(?:Once|Passive|Capture)$/,sh=0,sf=Promise.resolve(),sm=()=>sh||(sf.then(()=>sh=0),sh=Date.now()),sg=e=>111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&e.charCodeAt(2)>96&&123>e.charCodeAt(2);/*! #__NO_SIDE_EFFECTS__ */function sy(e,t,n){let r=nN(e,t);class i extends s_{constructor(e){super(r,e,n)}}return i.def=r,i}/*! #__NO_SIDE_EFFECTS__ */let sv=(e,t)=>sy(e,t,s0),sb="undefined"!=typeof HTMLElement?HTMLElement:class{};class s_ extends sb{constructor(e,t={},n){super(),this._def=e,this._props=t,this._instance=null,this._connected=!1,this._resolved=!1,this._numberProps=null,this._ob=null,this.shadowRoot&&n?n(this._createVNode(),this.shadowRoot):(this.attachShadow({mode:"open"}),this._def.__asyncLoader||this._resolveProps(this._def))}connectedCallback(){this._connected=!0,this._instance||(this._resolved?this._update():this._resolveDef())}disconnectedCallback(){this._connected=!1,t7(()=>{this._connected||(this._ob&&(this._ob.disconnect(),this._ob=null),sY(null,this.shadowRoot),this._instance=null)})}_resolveDef(){this._resolved=!0;for(let e=0;e<this.attributes.length;e++)this._setAttr(this.attributes[e].name);this._ob=new MutationObserver(e=>{for(let t of e)this._setAttr(t.attributeName)}),this._ob.observe(this,{attributes:!0});let e=(e,t=!1)=>{let n;let{props:r,styles:i}=e;if(r&&!S(r))for(let e in r){let t=r[e];(t===Number||t&&t.type===Number)&&(e in this._props&&(this._props[e]=J(this._props[e])),(n||(n=Object.create(null)))[B(e)]=!0)}this._numberProps=n,t&&this._resolveProps(e),this._applyStyles(i),this._update()},t=this._def.__asyncLoader;t?t().then(t=>e(t,!0)):e(this._def)}_resolveProps(e){let{props:t}=e,n=S(t)?t:Object.keys(t||{});for(let e of Object.keys(this))"_"!==e[0]&&n.includes(e)&&this._setProp(e,this[e],!0,!1);for(let e of n.map(B))Object.defineProperty(this,e,{get(){return this._getProp(e)},set(t){this._setProp(e,t)}})}_setAttr(e){let t=this.hasAttribute(e)?this.getAttribute(e):void 0,n=B(e);this._numberProps&&this._numberProps[n]&&(t=J(t)),this._setProp(n,t,!1)}_getProp(e){return this._props[e]}_setProp(e,t,n=!0,r=!0){t!==this._props[e]&&(this._props[e]=t,r&&this._instance&&this._update(),n&&(!0===t?this.setAttribute(j(e),""):"string"==typeof t||"number"==typeof t?this.setAttribute(j(e),t+""):t||this.removeAttribute(j(e))))}_update(){sY(this._createVNode(),this.shadowRoot)}_createVNode(){let e=i7(this._def,g({},this._props));return this._instance||(e.ce=e=>{this._instance=e,e.isCE=!0;let t=(e,t)=>{this.dispatchEvent(new CustomEvent(e,{detail:t}))};e.emit=(e,...n)=>{t(e,n),j(e)!==e&&t(j(e),n)};let n=this;for(;n=n&&(n.parentNode||n.host);)if(n instanceof s_){e.parent=n._instance,e.provides=n._instance.provides;break}}),e}_applyStyles(e){e&&e.forEach(e=>{let t=document.createElement("style");t.textContent=e,this.shadowRoot.appendChild(t)})}}function sS(e="$style"){{let t=lh();if(!t)return u;let n=t.type.__cssModules;return n&&n[e]||u}}let sx=new WeakMap,sC=new WeakMap,sT=Symbol("_moveCb"),sk=Symbol("_enterCb"),sw={name:"TransitionGroup",props:g({},lz,{tag:String,moveClass:String}),setup(e,{slots:t}){let n,r;let i=lh(),l=nv();return nK(()=>{if(!n.length)return;let t=e.moveClass||`${e.name||"v"}-move`;if(!function(e,t,n){let r=e.cloneNode(),i=e[lq];i&&i.forEach(e=>{e.split(/\s+/).forEach(e=>e&&r.classList.remove(e))}),n.split(/\s+/).forEach(e=>e&&r.classList.add(e)),r.style.display="none";let l=1===t.nodeType?t:t.parentNode;l.appendChild(r);let{hasTransform:s}=l2(r);return l.removeChild(r),s}(n[0].el,i.vnode.el,t))return;n.forEach(sA),n.forEach(sN);let r=n.filter(sI);l4(),r.forEach(e=>{let n=e.el,r=n.style;lQ(n,t),r.transform=r.webkitTransform=r.transitionDuration="";let i=n[sT]=e=>{(!e||e.target===n)&&(!e||/transform$/.test(e.propertyName))&&(n.removeEventListener("transitionend",i),n[sT]=null,lZ(n,t))};n.addEventListener("transitionend",i)})}),()=>{let s=tC(e),o=lX(s),a=s.tag||iq;if(n=[],r)for(let e=0;e<r.length;e++){let t=r[e];t.el&&t.el instanceof Element&&(n.push(t),nE(t,nT(t,o,l,i)),sx.set(t,t.el.getBoundingClientRect()))}r=t.default?nA(t.default()):[];for(let e=0;e<r.length;e++){let t=r[e];null!=t.key&&nE(t,nT(t,o,l,i))}return i7(a,null,r)}}};sw.props;let sE=sw;function sA(e){let t=e.el;t[sT]&&t[sT](),t[sk]&&t[sk]()}function sN(e){sC.set(e,e.el.getBoundingClientRect())}function sI(e){let t=sx.get(e),n=sC.get(e),r=t.left-n.left,i=t.top-n.top;if(r||i){let t=e.el.style;return t.transform=t.webkitTransform=`translate(${r}px,${i}px)`,t.transitionDuration="0s",e}}let sR=e=>{let t=e.props["onUpdate:modelValue"]||!1;return S(t)?e=>K(t,e):t};function sO(e){e.target.composing=!0}function sM(e){let t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}let sL=Symbol("_assign"),sP={created(e,{modifiers:{lazy:t,trim:n,number:r}},i){e[sL]=sR(i);let l=r||i.props&&"number"===i.props.type;su(e,t?"change":"input",t=>{if(t.target.composing)return;let r=e.value;n&&(r=r.trim()),l&&(r=G(r)),e[sL](r)}),n&&su(e,"change",()=>{e.value=e.value.trim()}),t||(su(e,"compositionstart",sO),su(e,"compositionend",sM),su(e,"change",sM))},mounted(e,{value:t}){e.value=null==t?"":t},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:r,trim:i,number:l}},s){if(e[sL]=sR(s),e.composing)return;let o=(l||"number"===e.type)&&!/^0\d/.test(e.value)?G(e.value):e.value,a=null==t?"":t;o===a||document.activeElement===e&&"range"!==e.type&&(r&&t===n||i&&e.value.trim()===a)||(e.value=a)}},s$={deep:!0,created(e,t,n){e[sL]=sR(n),su(e,"change",()=>{let t=e._modelValue,n=sU(e),r=e.checked,i=e[sL];if(S(t)){let e=ed(t,n),l=-1!==e;if(r&&!l)i(t.concat(n));else if(!r&&l){let n=[...t];n.splice(e,1),i(n)}}else if(C(t)){let e=new Set(t);r?e.add(n):e.delete(n),i(e)}else i(sj(e,r))})},mounted:sF,beforeUpdate(e,t,n){e[sL]=sR(n),sF(e,t,n)}};function sF(e,{value:t,oldValue:n},r){e._modelValue=t,S(t)?e.checked=ed(t,r.props.value)>-1:C(t)?e.checked=t.has(r.props.value):t!==n&&(e.checked=eu(t,sj(e,!0)))}let sV={created(e,{value:t},n){e.checked=eu(t,n.props.value),e[sL]=sR(n),su(e,"change",()=>{e[sL](sU(e))})},beforeUpdate(e,{value:t,oldValue:n},r){e[sL]=sR(r),t!==n&&(e.checked=eu(t,r.props.value))}},sD={deep:!0,created(e,{value:t,modifiers:{number:n}},r){let i=C(t);su(e,"change",()=>{let t=Array.prototype.filter.call(e.options,e=>e.selected).map(e=>n?G(sU(e)):sU(e));e[sL](e.multiple?i?new Set(t):t:t[0]),e._assigning=!0,t7(()=>{e._assigning=!1})}),e[sL]=sR(r)},mounted(e,{value:t}){sB(e,t)},beforeUpdate(e,t,n){e[sL]=sR(n)},updated(e,{value:t}){e._assigning||sB(e,t)}};function sB(e,t,n){let r=e.multiple,i=S(t);if(!r||i||C(t)){for(let n=0,l=e.options.length;n<l;n++){let l=e.options[n],s=sU(l);if(r){if(i){let e=typeof s;"string"===e||"number"===e?l.selected=t.some(e=>String(e)===String(s)):l.selected=ed(t,s)>-1}else l.selected=t.has(s)}else if(eu(sU(l),t)){e.selectedIndex!==n&&(e.selectedIndex=n);return}}r||-1===e.selectedIndex||(e.selectedIndex=-1)}}function sU(e){return"_value"in e?e._value:e.value}function sj(e,t){let n=t?"_trueValue":"_falseValue";return n in e?e[n]:t}let sH={created(e,t,n){sq(e,t,n,null,"created")},mounted(e,t,n){sq(e,t,n,null,"mounted")},beforeUpdate(e,t,n,r){sq(e,t,n,r,"beforeUpdate")},updated(e,t,n,r){sq(e,t,n,r,"updated")}};function sq(e,t,n,r,i){let l=function(e,t){switch(e){case"SELECT":return sD;case"TEXTAREA":return sP;default:switch(t){case"checkbox":return s$;case"radio":return sV;default:return sP}}}(e.tagName,n.props&&n.props.type)[i];l&&l(e,t,n,r)}let sW=["ctrl","shift","alt","meta"],sK={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&0!==e.button,middle:e=>"button"in e&&1!==e.button,right:e=>"button"in e&&2!==e.button,exact:(e,t)=>sW.some(n=>e[`${n}Key`]&&!t.includes(n))},sz=(e,t)=>{let n=e._withMods||(e._withMods={}),r=t.join(".");return n[r]||(n[r]=(n,...r)=>{for(let e=0;e<t.length;e++){let r=sK[t[e]];if(r&&r(n,t))return}return e(n,...r)})},sG={esc:"escape",space:" ",up:"arrow-up",left:"arrow-left",right:"arrow-right",down:"arrow-down",delete:"backspace"},sJ=(e,t)=>{let n=e._withKeys||(e._withKeys={}),r=t.join(".");return n[r]||(n[r]=n=>{if(!("key"in n))return;let r=j(n.key);if(t.some(e=>e===r||sG[e]===r))return e(n)})},sX=g({patchProp:(e,t,n,r,i,l)=>{let s="svg"===i;"class"===t?function(e,t,n){let r=e[lq];r&&(t=(t?[t,...r]:[...r]).join(" ")),null==t?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}(e,r,s):"style"===t?function(e,t,n){let r=e.style,i=E(n),l=!1;if(n&&!i){if(t){if(E(t))for(let e of t.split(";")){let t=e.slice(0,e.indexOf(":")).trim();null==n[t]&&sl(r,t,"")}else for(let e in t)null==n[e]&&sl(r,e,"")}for(let e in n)"display"===e&&(l=!0),sl(r,e,n[e])}else if(i){if(t!==n){let e=r[se];e&&(n+=";"+e),r.cssText=n,l=sr.test(n)}}else t&&e.removeAttribute("style");l8 in e&&(e[l8]=l?r.display:"",e[l5]&&(r.display="none"))}(e,n,r):f(t)?m(t)||function(e,t,n,r,i=null){let l=e[sd]||(e[sd]={}),s=l[t];if(r&&s)s.value=r;else{let[n,o]=function(e){let t;if(sp.test(e)){let n;for(t={};n=e.match(sp);)e=e.slice(0,e.length-n[0].length),t[n[0].toLowerCase()]=!0}return[":"===e[2]?e.slice(3):j(e.slice(2)),t]}(t);r?su(e,n,l[t]=function(e,t){let n=e=>{if(e._vts){if(e._vts<=n.attached)return}else e._vts=Date.now();tZ(function(e,t){if(!S(t))return t;{let n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(e=>t=>!t._stopped&&e&&e(t))}}(e,n.value),t,5,[e])};return n.value=e,n.attached=sm(),n}(r,i),o):s&&(!function(e,t,n,r){e.removeEventListener(t,n,r)}(e,n,s,o),l[t]=void 0)}}(e,t,0,r,l):("."===t[0]?(t=t.slice(1),0):"^"===t[0]?(t=t.slice(1),1):!function(e,t,n,r){if(r)return!!("innerHTML"===t||"textContent"===t||t in e&&sg(t)&&w(n));if("spellcheck"===t||"draggable"===t||"translate"===t||"form"===t||"list"===t&&"INPUT"===e.tagName||"type"===t&&"TEXTAREA"===e.tagName)return!1;if("width"===t||"height"===t){let t=e.tagName;if("IMG"===t||"VIDEO"===t||"CANVAS"===t||"SOURCE"===t)return!1}return!(sg(t)&&E(n))&&t in e}(e,t,r,s))?("true-value"===t?e._trueValue=r:"false-value"===t&&(e._falseValue=r),sc(e,t,r,s)):(!function(e,t,n,r){if("innerHTML"===t||"textContent"===t){if(null==n)return;e[t]=n;return}let i=e.tagName;if("value"===t&&"PROGRESS"!==i&&!i.includes("-")){let r="OPTION"===i?e.getAttribute("value")||"":e.value,l=null==n?"":String(n);r===l&&"_value"in e||(e.value=l),null==n&&e.removeAttribute(t),e._value=n;return}let l=!1;if(""===n||null==n){let r=typeof e[t];if("boolean"===r){var s;n=!!(s=n)||""===s}else null==n&&"string"===r?(n="",l=!0):"number"===r&&(n=0,l=!0)}try{e[t]=n}catch(e){}l&&e.removeAttribute(t)}(e,t,r),e.tagName.includes("-")||"value"!==t&&"checked"!==t&&"selected"!==t||sc(e,t,r,s,l,"value"!==t))}},{insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{let t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,r)=>{let i="svg"===t?lB.createElementNS("http://www.w3.org/2000/svg",e):"mathml"===t?lB.createElementNS("http://www.w3.org/1998/Math/MathML",e):n?lB.createElement(e,{is:n}):lB.createElement(e);return"select"===e&&r&&null!=r.multiple&&i.setAttribute("multiple",r.multiple),i},createText:e=>lB.createTextNode(e),createComment:e=>lB.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>lB.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,r,i,l){let s=n?n.previousSibling:t.lastChild;if(i&&(i===l||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),n),i!==l&&(i=i.nextSibling););else{lU.innerHTML="svg"===r?`<svg>${e}</svg>`:"mathml"===r?`<math>${e}</math>`:e;let i=lU.content;if("svg"===r||"mathml"===r){let e=i.firstChild;for(;e.firstChild;)i.appendChild(e.firstChild);i.removeChild(e)}t.insertBefore(i,n)}return[s?s.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}}),sQ=!1;function sZ(){return o=sQ?o:ia(sX),sQ=!0,o}let sY=(...e)=>{(o||(o=io(sX))).render(...e)},s0=(...e)=>{sZ().hydrate(...e)},s1=(...e)=>{let t=(o||(o=io(sX))).createApp(...e),{mount:n}=t;return t.mount=e=>{let r=s6(e);if(!r)return;let i=t._component;w(i)||i.render||i.template||(i.template=r.innerHTML),r.innerHTML="";let l=n(r,!1,s3(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),l},t},s2=(...e)=>{let t=sZ().createApp(...e),{mount:n}=t;return t.mount=e=>{let t=s6(e);if(t)return n(t,!0,s3(t))},t};function s3(e){return e instanceof SVGElement?"svg":"function"==typeof MathMLElement&&e instanceof MathMLElement?"mathml":void 0}function s6(e){return E(e)?document.querySelector(e):e}let s4=p;var s8=Object.freeze({__proto__:null,BaseTransition:nx,BaseTransitionPropsValidators:n_,Comment:iK,DeprecationTypes:lD,EffectScope:eg,ErrorCodes:tX,ErrorTypeStrings:lM,Fragment:iq,KeepAlive:nL,ReactiveEffect:eS,Static:iz,Suspense:iV,Teleport:r4,Text:iW,TrackOpTypes:tz,Transition:lW,TransitionGroup:sE,TriggerOpTypes:tG,VueElement:s_,assertNumber:tJ,callWithAsyncErrorHandling:tZ,callWithErrorHandling:tQ,camelize:B,capitalize:H,cloneVNode:lt,compatUtils:lV,computed:lw,createApp:s1,createBlock:i2,createCommentVNode:li,createElementBlock:i1,createElementVNode:i9,createHydrationRenderer:ia,createPropsRestProxy:rv,createRenderer:io,createSSRApp:s2,createSlots:n5,createStaticVNode:lr,createTextVNode:ln,createVNode:i7,customRef:tU,defineAsyncComponent:nR,defineComponent:nN,defineCustomElement:sy,defineEmits:rs,defineExpose:ro,defineModel:ru,defineOptions:ra,defineProps:rl,defineSSRCustomElement:sv,defineSlots:rc,devtools:lL,effect:ek,effectScope:ey,getCurrentInstance:lh,getCurrentScope:eb,getTransitionRawChildren:nA,guardReactiveProps:le,h:lE,handleError:tY,hasInjectionContext:rP,hydrate:s0,initCustomFormatter:lA,initDirectivesForSSR:s4,inject:rL,isMemoSame:lI,isProxy:tx,isReactive:tb,isReadonly:t_,isRef:tI,isRuntimeOnly:l_,isShallow:tS,isVNode:i3,markRaw:tT,mergeDefaults:rg,mergeModels:ry,mergeProps:la,nextTick:t7,normalizeClass:er,normalizeProps:ei,normalizeStyle:Z,onActivated:n$,onBeforeMount:nH,onBeforeUnmount:nz,onBeforeUpdate:nW,onDeactivated:nF,onErrorCaptured:nZ,onMounted:nq,onRenderTracked:nQ,onRenderTriggered:nX,onScopeDispose:e_,onServerPrefetch:nJ,onUnmounted:nG,onUpdated:nK,openBlock:iX,popScopeId:nd,provide:rM,proxyRefs:tD,pushScopeId:nu,queuePostFlushCb:nn,reactive:tf,readonly:tg,ref:tR,registerRuntimeCompiler:lb,render:sY,renderList:n8,renderSlot:n9,resolveComponent:n0,resolveDirective:n3,resolveDynamicComponent:n2,resolveFilter:lF,resolveTransitionHooks:nT,setBlockTracking:iY,setDevtoolsHook:lP,setTransitionHooks:nE,shallowReactive:tm,shallowReadonly:ty,shallowRef:tO,ssrContextKey:ig,ssrUtils:l$,stop:ew,toDisplayString:eh,toHandlerKey:q,toHandlers:n7,toRaw:tC,toRef:tW,toRefs:tj,toValue:tF,transformVNodeArgs:i4,triggerRef:tP,unref:t$,useAttrs:rh,useCssModule:sS,useCssVars:st,useModel:iE,useSSRContext:iy,useSlots:rp,useTransitionState:nv,vModelCheckbox:s$,vModelDynamic:sH,vModelRadio:sV,vModelSelect:sD,vModelText:sP,vShow:l9,version:lR,warn:lO,watch:ix,watchEffect:iv,watchPostEffect:ib,watchSyncEffect:i_,withAsyncContext:rb,withCtx:nh,withDefaults:rd,withDirectives:nf,withKeys:sJ,withMemo:lN,withModifiers:sz,withScopeId:np});let s5=Symbol(""),s9=Symbol(""),s7=Symbol(""),oe=Symbol(""),ot=Symbol(""),on=Symbol(""),or=Symbol(""),oi=Symbol(""),ol=Symbol(""),os=Symbol(""),oo=Symbol(""),oa=Symbol(""),oc=Symbol(""),ou=Symbol(""),od=Symbol(""),op=Symbol(""),oh=Symbol(""),of=Symbol(""),om=Symbol(""),og=Symbol(""),oy=Symbol(""),ov=Symbol(""),ob=Symbol(""),o_=Symbol(""),oS=Symbol(""),ox=Symbol(""),oC=Symbol(""),oT=Symbol(""),ok=Symbol(""),ow=Symbol(""),oE=Symbol(""),oA=Symbol(""),oN=Symbol(""),oI=Symbol(""),oR=Symbol(""),oO=Symbol(""),oM=Symbol(""),oL=Symbol(""),oP=Symbol(""),o$={[s5]:"Fragment",[s9]:"Teleport",[s7]:"Suspense",[oe]:"KeepAlive",[ot]:"BaseTransition",[on]:"openBlock",[or]:"createBlock",[oi]:"createElementBlock",[ol]:"createVNode",[os]:"createElementVNode",[oo]:"createCommentVNode",[oa]:"createTextVNode",[oc]:"createStaticVNode",[ou]:"resolveComponent",[od]:"resolveDynamicComponent",[op]:"resolveDirective",[oh]:"resolveFilter",[of]:"withDirectives",[om]:"renderList",[og]:"renderSlot",[oy]:"createSlots",[ov]:"toDisplayString",[ob]:"mergeProps",[o_]:"normalizeClass",[oS]:"normalizeStyle",[ox]:"normalizeProps",[oC]:"guardReactiveProps",[oT]:"toHandlers",[ok]:"camelize",[ow]:"capitalize",[oE]:"toHandlerKey",[oA]:"setBlockTracking",[oN]:"pushScopeId",[oI]:"popScopeId",[oR]:"withCtx",[oO]:"unref",[oM]:"isRef",[oL]:"withMemo",[oP]:"isMemoSame"},oF={start:{line:1,column:1,offset:0},end:{line:1,column:1,offset:0},source:""};function oV(e,t,n,r,i,l,s,o=!1,a=!1,c=!1,u=oF){return e&&(o?(e.helper(on),e.helper(e.inSSR||c?or:oi)):e.helper(e.inSSR||c?ol:os),s&&e.helper(of)),{type:13,tag:t,props:n,children:r,patchFlag:i,dynamicProps:l,directives:s,isBlock:o,disableTracking:a,isComponent:c,loc:u}}function oD(e,t=oF){return{type:17,loc:t,elements:e}}function oB(e,t=oF){return{type:15,loc:t,properties:e}}function oU(e,t){return{type:16,loc:oF,key:E(e)?oj(e,!0):e,value:t}}function oj(e,t=!1,n=oF,r=0){return{type:4,loc:n,content:e,isStatic:t,constType:t?3:r}}function oH(e,t=oF){return{type:8,loc:t,children:e}}function oq(e,t=[],n=oF){return{type:14,loc:n,callee:e,arguments:t}}function oW(e,t,n=!1,r=!1,i=oF){return{type:18,params:e,returns:t,newline:n,isSlot:r,loc:i}}function oK(e,t,n,r=!0){return{type:19,test:e,consequent:t,alternate:n,newline:r,loc:oF}}function oz(e,{helper:t,removeHelper:n,inSSR:r}){if(!e.isBlock){var i,l;e.isBlock=!0,n((i=e.isComponent,r||i?ol:os)),t(on),t((l=e.isComponent,r||l?or:oi))}}let oG=new Uint8Array([123,123]),oJ=new Uint8Array([125,125]);function oX(e){return e>=97&&e<=122||e>=65&&e<=90}function oQ(e){return 32===e||10===e||9===e||12===e||13===e}function oZ(e){return 47===e||62===e||oQ(e)}function oY(e){let t=new Uint8Array(e.length);for(let n=0;n<e.length;n++)t[n]=e.charCodeAt(n);return t}let o0={Cdata:new Uint8Array([67,68,65,84,65,91]),CdataEnd:new Uint8Array([93,93,62]),CommentEnd:new Uint8Array([45,45,62]),ScriptEnd:new Uint8Array([60,47,115,99,114,105,112,116]),StyleEnd:new Uint8Array([60,47,115,116,121,108,101]),TitleEnd:new Uint8Array([60,47,116,105,116,108,101]),TextareaEnd:new Uint8Array([60,47,116,101,120,116,97,114,101,97])};function o1(e){throw e}function o2(e){}function o3(e,t,n,r){let i=SyntaxError(String(`https://vuejs.org/error-reference/#compiler-${e}`));return i.code=e,i.loc=t,i}let o6=e=>4===e.type&&e.isStatic;function o4(e){switch(e){case"Teleport":case"teleport":return s9;case"Suspense":case"suspense":return s7;case"KeepAlive":case"keep-alive":return oe;case"BaseTransition":case"base-transition":return ot}}let o8=/^\d|[^\$\w\xA0-\uFFFF]/,o5=e=>!o8.test(e),o9=/[A-Za-z_$\xA0-\uFFFF]/,o7=/[\.\?\w$\xA0-\uFFFF]/,ae=/\s+[.[]\s*|\s*[.[]\s+/g,at=e=>4===e.type?e.content:e.loc.source,an=e=>{let t=at(e).trim().replace(ae,e=>e.trim()),n=0,r=[],i=0,l=0,s=null;for(let e=0;e<t.length;e++){let o=t.charAt(e);switch(n){case 0:if("["===o)r.push(n),n=1,i++;else if("("===o)r.push(n),n=2,l++;else if(!(0===e?o9:o7).test(o))return!1;break;case 1:"'"===o||'"'===o||"`"===o?(r.push(n),n=3,s=o):"["===o?i++:"]"!==o||--i||(n=r.pop());break;case 2:if("'"===o||'"'===o||"`"===o)r.push(n),n=3,s=o;else if("("===o)l++;else if(")"===o){if(e===t.length-1)return!1;--l||(n=r.pop())}break;case 3:o===s&&(n=r.pop(),s=null)}}return!i&&!l},ar=/^\s*(async\s*)?(\([^)]*?\)|[\w$_]+)\s*(:[^=]+)?=>|^\s*(async\s+)?function(?:\s+[\w$]+)?\s*\(/,ai=e=>ar.test(at(e));function al(e,t,n=!1){for(let r=0;r<e.props.length;r++){let i=e.props[r];if(7===i.type&&(n||i.exp)&&(E(t)?i.name===t:t.test(i.name)))return i}}function as(e,t,n=!1,r=!1){for(let i=0;i<e.props.length;i++){let l=e.props[i];if(6===l.type){if(n)continue;if(l.name===t&&(l.value||r))return l}else if("bind"===l.name&&(l.exp||r)&&ao(l.arg,t))return l}}function ao(e,t){return!!(e&&o6(e)&&e.content===t)}function aa(e){return 5===e.type||2===e.type}function ac(e){return 7===e.type&&"slot"===e.name}function au(e){return 1===e.type&&3===e.tagType}function ad(e){return 1===e.type&&2===e.tagType}let ap=new Set([ox,oC]);function ah(e,t,n){let r,i;let l=13===e.type?e.props:e.arguments[2],s=[];if(l&&!E(l)&&14===l.type){let e=function e(t,n=[]){if(t&&!E(t)&&14===t.type){let r=t.callee;if(!E(r)&&ap.has(r))return e(t.arguments[0],n.concat(t))}return[t,n]}(l);l=e[0],i=(s=e[1])[s.length-1]}if(null==l||E(l))r=oB([t]);else if(14===l.type){let e=l.arguments[0];E(e)||15!==e.type?l.callee===oT?r=oq(n.helper(ob),[oB([t]),l]):l.arguments.unshift(oB([t])):af(t,e)||e.properties.unshift(t),r||(r=l)}else 15===l.type?(af(t,l)||l.properties.unshift(t),r=l):(r=oq(n.helper(ob),[oB([t]),l]),i&&i.callee===oC&&(i=s[s.length-2]));13===e.type?i?i.arguments[0]=r:e.props=r:i?i.arguments[0]=r:e.arguments[2]=r}function af(e,t){let n=!1;if(4===e.key.type){let r=e.key.content;n=t.properties.some(e=>4===e.key.type&&e.key.content===r)}return n}function am(e,t){return`_${t}_${e.replace(/[^\w]/g,(t,n)=>"-"===t?"_":e.charCodeAt(n).toString())}`}let ag=/([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/,ay={parseMode:"base",ns:0,delimiters:["{{","}}"],getNamespace:()=>0,isVoidTag:h,isPreTag:h,isCustomElement:h,onError:o1,onWarn:o2,comments:!1,prefixIdentifiers:!1},av=ay,ab=null,a_="",aS=null,ax=null,aC="",aT=-1,ak=-1,aw=0,aE=!1,aA=null,aN=[],aI=new class{constructor(e,t){this.stack=e,this.cbs=t,this.state=1,this.buffer="",this.sectionStart=0,this.index=0,this.entityStart=0,this.baseState=1,this.inRCDATA=!1,this.inXML=!1,this.inVPre=!1,this.newlines=[],this.mode=0,this.delimiterOpen=oG,this.delimiterClose=oJ,this.delimiterIndex=-1,this.currentSequence=void 0,this.sequenceIndex=0}get inSFCRoot(){return 2===this.mode&&0===this.stack.length}reset(){this.state=1,this.mode=0,this.buffer="",this.sectionStart=0,this.index=0,this.baseState=1,this.inRCDATA=!1,this.currentSequence=void 0,this.newlines.length=0,this.delimiterOpen=oG,this.delimiterClose=oJ}getPos(e){let t=1,n=e+1;for(let r=this.newlines.length-1;r>=0;r--){let i=this.newlines[r];if(e>i){t=r+2,n=e-i;break}}return{column:n,line:t,offset:e}}peek(){return this.buffer.charCodeAt(this.index+1)}stateText(e){60===e?(this.index>this.sectionStart&&this.cbs.ontext(this.sectionStart,this.index),this.state=5,this.sectionStart=this.index):this.inVPre||e!==this.delimiterOpen[0]||(this.state=2,this.delimiterIndex=0,this.stateInterpolationOpen(e))}stateInterpolationOpen(e){if(e===this.delimiterOpen[this.delimiterIndex]){if(this.delimiterIndex===this.delimiterOpen.length-1){let e=this.index+1-this.delimiterOpen.length;e>this.sectionStart&&this.cbs.ontext(this.sectionStart,e),this.state=3,this.sectionStart=e}else this.delimiterIndex++}else this.inRCDATA?(this.state=32,this.stateInRCDATA(e)):(this.state=1,this.stateText(e))}stateInterpolation(e){e===this.delimiterClose[0]&&(this.state=4,this.delimiterIndex=0,this.stateInterpolationClose(e))}stateInterpolationClose(e){e===this.delimiterClose[this.delimiterIndex]?this.delimiterIndex===this.delimiterClose.length-1?(this.cbs.oninterpolation(this.sectionStart,this.index+1),this.inRCDATA?this.state=32:this.state=1,this.sectionStart=this.index+1):this.delimiterIndex++:(this.state=3,this.stateInterpolation(e))}stateSpecialStartSequence(e){let t=this.sequenceIndex===this.currentSequence.length;if(t?oZ(e):(32|e)===this.currentSequence[this.sequenceIndex]){if(!t){this.sequenceIndex++;return}}else this.inRCDATA=!1;this.sequenceIndex=0,this.state=6,this.stateInTagName(e)}stateInRCDATA(e){if(this.sequenceIndex===this.currentSequence.length){if(62===e||oQ(e)){let t=this.index-this.currentSequence.length;if(this.sectionStart<t){let e=this.index;this.index=t,this.cbs.ontext(this.sectionStart,t),this.index=e}this.sectionStart=t+2,this.stateInClosingTagName(e),this.inRCDATA=!1;return}this.sequenceIndex=0}(32|e)===this.currentSequence[this.sequenceIndex]?this.sequenceIndex+=1:0===this.sequenceIndex?this.currentSequence!==o0.TitleEnd&&(this.currentSequence!==o0.TextareaEnd||this.inSFCRoot)?this.fastForwardTo(60)&&(this.sequenceIndex=1):e===this.delimiterOpen[0]&&(this.state=2,this.delimiterIndex=0,this.stateInterpolationOpen(e)):this.sequenceIndex=Number(60===e)}stateCDATASequence(e){e===o0.Cdata[this.sequenceIndex]?++this.sequenceIndex===o0.Cdata.length&&(this.state=28,this.currentSequence=o0.CdataEnd,this.sequenceIndex=0,this.sectionStart=this.index+1):(this.sequenceIndex=0,this.state=23,this.stateInDeclaration(e))}fastForwardTo(e){for(;++this.index<this.buffer.length;){let t=this.buffer.charCodeAt(this.index);if(10===t&&this.newlines.push(this.index),t===e)return!0}return this.index=this.buffer.length-1,!1}stateInCommentLike(e){e===this.currentSequence[this.sequenceIndex]?++this.sequenceIndex===this.currentSequence.length&&(this.currentSequence===o0.CdataEnd?this.cbs.oncdata(this.sectionStart,this.index-2):this.cbs.oncomment(this.sectionStart,this.index-2),this.sequenceIndex=0,this.sectionStart=this.index+1,this.state=1):0===this.sequenceIndex?this.fastForwardTo(this.currentSequence[0])&&(this.sequenceIndex=1):e!==this.currentSequence[this.sequenceIndex-1]&&(this.sequenceIndex=0)}startSpecial(e,t){this.enterRCDATA(e,t),this.state=31}enterRCDATA(e,t){this.inRCDATA=!0,this.currentSequence=e,this.sequenceIndex=t}stateBeforeTagName(e){33===e?(this.state=22,this.sectionStart=this.index+1):63===e?(this.state=24,this.sectionStart=this.index+1):oX(e)?(this.sectionStart=this.index,0===this.mode?this.state=6:this.inSFCRoot?this.state=34:this.inXML?this.state=6:116===e?this.state=30:this.state=115===e?29:6):47===e?this.state=8:(this.state=1,this.stateText(e))}stateInTagName(e){oZ(e)&&this.handleTagName(e)}stateInSFCRootTagName(e){if(oZ(e)){let t=this.buffer.slice(this.sectionStart,this.index);"template"!==t&&this.enterRCDATA(oY("</"+t),0),this.handleTagName(e)}}handleTagName(e){this.cbs.onopentagname(this.sectionStart,this.index),this.sectionStart=-1,this.state=11,this.stateBeforeAttrName(e)}stateBeforeClosingTagName(e){oQ(e)||(62===e?(this.state=1,this.sectionStart=this.index+1):(this.state=oX(e)?9:27,this.sectionStart=this.index))}stateInClosingTagName(e){(62===e||oQ(e))&&(this.cbs.onclosetag(this.sectionStart,this.index),this.sectionStart=-1,this.state=10,this.stateAfterClosingTagName(e))}stateAfterClosingTagName(e){62===e&&(this.state=1,this.sectionStart=this.index+1)}stateBeforeAttrName(e){62===e?(this.cbs.onopentagend(this.index),this.inRCDATA?this.state=32:this.state=1,this.sectionStart=this.index+1):47===e?this.state=7:60===e&&47===this.peek()?(this.cbs.onopentagend(this.index),this.state=5,this.sectionStart=this.index):oQ(e)||this.handleAttrStart(e)}handleAttrStart(e){118===e&&45===this.peek()?(this.state=13,this.sectionStart=this.index):46===e||58===e||64===e||35===e?(this.cbs.ondirname(this.index,this.index+1),this.state=14,this.sectionStart=this.index+1):(this.state=12,this.sectionStart=this.index)}stateInSelfClosingTag(e){62===e?(this.cbs.onselfclosingtag(this.index),this.state=1,this.sectionStart=this.index+1,this.inRCDATA=!1):oQ(e)||(this.state=11,this.stateBeforeAttrName(e))}stateInAttrName(e){(61===e||oZ(e))&&(this.cbs.onattribname(this.sectionStart,this.index),this.handleAttrNameEnd(e))}stateInDirName(e){61===e||oZ(e)?(this.cbs.ondirname(this.sectionStart,this.index),this.handleAttrNameEnd(e)):58===e?(this.cbs.ondirname(this.sectionStart,this.index),this.state=14,this.sectionStart=this.index+1):46===e&&(this.cbs.ondirname(this.sectionStart,this.index),this.state=16,this.sectionStart=this.index+1)}stateInDirArg(e){61===e||oZ(e)?(this.cbs.ondirarg(this.sectionStart,this.index),this.handleAttrNameEnd(e)):91===e?this.state=15:46===e&&(this.cbs.ondirarg(this.sectionStart,this.index),this.state=16,this.sectionStart=this.index+1)}stateInDynamicDirArg(e){93===e?this.state=14:(61===e||oZ(e))&&(this.cbs.ondirarg(this.sectionStart,this.index+1),this.handleAttrNameEnd(e))}stateInDirModifier(e){61===e||oZ(e)?(this.cbs.ondirmodifier(this.sectionStart,this.index),this.handleAttrNameEnd(e)):46===e&&(this.cbs.ondirmodifier(this.sectionStart,this.index),this.sectionStart=this.index+1)}handleAttrNameEnd(e){this.sectionStart=this.index,this.state=17,this.cbs.onattribnameend(this.index),this.stateAfterAttrName(e)}stateAfterAttrName(e){61===e?this.state=18:47===e||62===e?(this.cbs.onattribend(0,this.sectionStart),this.sectionStart=-1,this.state=11,this.stateBeforeAttrName(e)):oQ(e)||(this.cbs.onattribend(0,this.sectionStart),this.handleAttrStart(e))}stateBeforeAttrValue(e){34===e?(this.state=19,this.sectionStart=this.index+1):39===e?(this.state=20,this.sectionStart=this.index+1):oQ(e)||(this.sectionStart=this.index,this.state=21,this.stateInAttrValueNoQuotes(e))}handleInAttrValue(e,t){(e===t||this.fastForwardTo(t))&&(this.cbs.onattribdata(this.sectionStart,this.index),this.sectionStart=-1,this.cbs.onattribend(34===t?3:2,this.index+1),this.state=11)}stateInAttrValueDoubleQuotes(e){this.handleInAttrValue(e,34)}stateInAttrValueSingleQuotes(e){this.handleInAttrValue(e,39)}stateInAttrValueNoQuotes(e){oQ(e)||62===e?(this.cbs.onattribdata(this.sectionStart,this.index),this.sectionStart=-1,this.cbs.onattribend(1,this.index),this.state=11,this.stateBeforeAttrName(e)):(39===e||60===e||61===e||96===e)&&this.cbs.onerr(18,this.index)}stateBeforeDeclaration(e){91===e?(this.state=26,this.sequenceIndex=0):this.state=45===e?25:23}stateInDeclaration(e){(62===e||this.fastForwardTo(62))&&(this.state=1,this.sectionStart=this.index+1)}stateInProcessingInstruction(e){(62===e||this.fastForwardTo(62))&&(this.cbs.onprocessinginstruction(this.sectionStart,this.index),this.state=1,this.sectionStart=this.index+1)}stateBeforeComment(e){45===e?(this.state=28,this.currentSequence=o0.CommentEnd,this.sequenceIndex=2,this.sectionStart=this.index+1):this.state=23}stateInSpecialComment(e){(62===e||this.fastForwardTo(62))&&(this.cbs.oncomment(this.sectionStart,this.index),this.state=1,this.sectionStart=this.index+1)}stateBeforeSpecialS(e){e===o0.ScriptEnd[3]?this.startSpecial(o0.ScriptEnd,4):e===o0.StyleEnd[3]?this.startSpecial(o0.StyleEnd,4):(this.state=6,this.stateInTagName(e))}stateBeforeSpecialT(e){e===o0.TitleEnd[3]?this.startSpecial(o0.TitleEnd,4):e===o0.TextareaEnd[3]?this.startSpecial(o0.TextareaEnd,4):(this.state=6,this.stateInTagName(e))}startEntity(){}stateInEntity(){}parse(e){for(this.buffer=e;this.index<this.buffer.length;){let e=this.buffer.charCodeAt(this.index);switch(10===e&&this.newlines.push(this.index),this.state){case 1:this.stateText(e);break;case 2:this.stateInterpolationOpen(e);break;case 3:this.stateInterpolation(e);break;case 4:this.stateInterpolationClose(e);break;case 31:this.stateSpecialStartSequence(e);break;case 32:this.stateInRCDATA(e);break;case 26:this.stateCDATASequence(e);break;case 19:this.stateInAttrValueDoubleQuotes(e);break;case 12:this.stateInAttrName(e);break;case 13:this.stateInDirName(e);break;case 14:this.stateInDirArg(e);break;case 15:this.stateInDynamicDirArg(e);break;case 16:this.stateInDirModifier(e);break;case 28:this.stateInCommentLike(e);break;case 27:this.stateInSpecialComment(e);break;case 11:this.stateBeforeAttrName(e);break;case 6:this.stateInTagName(e);break;case 34:this.stateInSFCRootTagName(e);break;case 9:this.stateInClosingTagName(e);break;case 5:this.stateBeforeTagName(e);break;case 17:this.stateAfterAttrName(e);break;case 20:this.stateInAttrValueSingleQuotes(e);break;case 18:this.stateBeforeAttrValue(e);break;case 8:this.stateBeforeClosingTagName(e);break;case 10:this.stateAfterClosingTagName(e);break;case 29:this.stateBeforeSpecialS(e);break;case 30:this.stateBeforeSpecialT(e);break;case 21:this.stateInAttrValueNoQuotes(e);break;case 7:this.stateInSelfClosingTag(e);break;case 23:this.stateInDeclaration(e);break;case 22:this.stateBeforeDeclaration(e);break;case 25:this.stateBeforeComment(e);break;case 24:this.stateInProcessingInstruction(e);break;case 33:this.stateInEntity()}this.index++}this.cleanup(),this.finish()}cleanup(){this.sectionStart!==this.index&&(1===this.state||32===this.state&&0===this.sequenceIndex?(this.cbs.ontext(this.sectionStart,this.index),this.sectionStart=this.index):(19===this.state||20===this.state||21===this.state)&&(this.cbs.onattribdata(this.sectionStart,this.index),this.sectionStart=this.index))}finish(){this.handleTrailingData(),this.cbs.onend()}handleTrailingData(){let e=this.buffer.length;this.sectionStart>=e||(28===this.state?this.currentSequence===o0.CdataEnd?this.cbs.oncdata(this.sectionStart,e):this.cbs.oncomment(this.sectionStart,e):6===this.state||11===this.state||18===this.state||17===this.state||12===this.state||13===this.state||14===this.state||15===this.state||16===this.state||20===this.state||19===this.state||21===this.state||9===this.state||this.cbs.ontext(this.sectionStart,e))}emitCodePoint(e,t){}}(aN,{onerr:aK,ontext(e,t){aP(aM(e,t),e,t)},ontextentity(e,t,n){aP(e,t,n)},oninterpolation(e,t){if(aE)return aP(aM(e,t),e,t);let n=e+aI.delimiterOpen.length,r=t-aI.delimiterClose.length;for(;oQ(a_.charCodeAt(n));)n++;for(;oQ(a_.charCodeAt(r-1));)r--;let i=aM(n,r);i.includes("&")&&(i=av.decodeEntities(i,!1)),aj({type:5,content:aW(i,!1,aH(n,r)),loc:aH(e,t)})},onopentagname(e,t){let n=aM(e,t);aS={type:1,tag:n,ns:av.getNamespace(n,aN[0],av.ns),tagType:0,props:[],children:[],loc:aH(e-1,t),codegenNode:void 0}},onopentagend(e){aL(e)},onclosetag(e,t){let n=aM(e,t);if(!av.isVoidTag(n)){let r=!1;for(let e=0;e<aN.length;e++)if(aN[e].tag.toLowerCase()===n.toLowerCase()){r=!0,e>0&&aN[0].loc.start.offset;for(let n=0;n<=e;n++)a$(aN.shift(),t,n<e);break}r||aF(e,60)}},onselfclosingtag(e){let t=aS.tag;aS.isSelfClosing=!0,aL(e),aN[0]&&aN[0].tag===t&&a$(aN.shift(),e)},onattribname(e,t){ax={type:6,name:aM(e,t),nameLoc:aH(e,t),value:void 0,loc:aH(e)}},ondirname(e,t){let n=aM(e,t),r="."===n||":"===n?"bind":"@"===n?"on":"#"===n?"slot":n.slice(2);if(aE||""===r)ax={type:6,name:n,nameLoc:aH(e,t),value:void 0,loc:aH(e)};else if(ax={type:7,name:r,rawName:n,exp:void 0,arg:void 0,modifiers:"."===n?["prop"]:[],loc:aH(e)},"pre"===r){aE=aI.inVPre=!0,aA=aS;let e=aS.props;for(let t=0;t<e.length;t++)7===e[t].type&&(e[t]=function(e){let t={type:6,name:e.rawName,nameLoc:aH(e.loc.start.offset,e.loc.start.offset+e.rawName.length),value:void 0,loc:e.loc};if(e.exp){let n=e.exp.loc;n.end.offset<e.loc.end.offset&&(n.start.offset--,n.start.column--,n.end.offset++,n.end.column++),t.value={type:2,content:e.exp.content,loc:n}}return t}(e[t]))}},ondirarg(e,t){if(e===t)return;let n=aM(e,t);if(aE)ax.name+=n,aq(ax.nameLoc,t);else{let r="["!==n[0];ax.arg=aW(r?n:n.slice(1,-1),r,aH(e,t),r?3:0)}},ondirmodifier(e,t){let n=aM(e,t);if(aE)ax.name+="."+n,aq(ax.nameLoc,t);else if("slot"===ax.name){let e=ax.arg;e&&(e.content+="."+n,aq(e.loc,t))}else ax.modifiers.push(n)},onattribdata(e,t){aC+=aM(e,t),aT<0&&(aT=e),ak=t},onattribentity(e,t,n){aC+=e,aT<0&&(aT=t),ak=n},onattribnameend(e){let t=aM(ax.loc.start.offset,e);7===ax.type&&(ax.rawName=t),aS.props.some(e=>(7===e.type?e.rawName:e.name)===t)},onattribend(e,t){aS&&ax&&(aq(ax.loc,t),0!==e&&(aC.includes("&")&&(aC=av.decodeEntities(aC,!0)),6===ax.type?("class"===ax.name&&(aC=aU(aC).trim()),ax.value={type:2,content:aC,loc:1===e?aH(aT,ak):aH(aT-1,ak+1)},aI.inSFCRoot&&"template"===aS.tag&&"lang"===ax.name&&aC&&"html"!==aC&&aI.enterRCDATA(oY("</template"),0)):(ax.exp=aW(aC,!1,aH(aT,ak),0,0),"for"===ax.name&&(ax.forParseResult=function(e){let t=e.loc,n=e.content,r=n.match(ag);if(!r)return;let[,i,l]=r,s=(e,n,r=!1)=>{let i=t.start.offset+n,l=i+e.length;return aW(e,!1,aH(i,l),0,r?1:0)},o={source:s(l.trim(),n.indexOf(l,i.length)),value:void 0,key:void 0,index:void 0,finalized:!1},a=i.trim().replace(aO,"").trim(),c=i.indexOf(a),u=a.match(aR);if(u){let e;a=a.replace(aR,"").trim();let t=u[1].trim();if(t&&(e=n.indexOf(t,c+a.length),o.key=s(t,e,!0)),u[2]){let r=u[2].trim();r&&(o.index=s(r,n.indexOf(r,o.key?e+t.length:c+a.length),!0))}}return a&&(o.value=s(a,c,!0)),o}(ax.exp)))),(7!==ax.type||"pre"!==ax.name)&&aS.props.push(ax)),aC="",aT=ak=-1},oncomment(e,t){av.comments&&aj({type:3,content:aM(e,t),loc:aH(e-4,t+3)})},onend(){let e=a_.length;for(let t=0;t<aN.length;t++)a$(aN[t],e-1),aN[t].loc.start.offset},oncdata(e,t){0!==aN[0].ns&&aP(aM(e,t),e,t)},onprocessinginstruction(e){(aN[0]?aN[0].ns:av.ns)===0&&aK(21,e-1)}}),aR=/,([^,\}\]]*)(?:,([^,\}\]]*))?$/,aO=/^\(|\)$/g;function aM(e,t){return a_.slice(e,t)}function aL(e){aI.inSFCRoot&&(aS.innerLoc=aH(e+1,e+1)),aj(aS);let{tag:t,ns:n}=aS;0===n&&av.isPreTag(t)&&aw++,av.isVoidTag(t)?a$(aS,e):(aN.unshift(aS),(1===n||2===n)&&(aI.inXML=!0)),aS=null}function aP(e,t,n){{let t=aN[0]&&aN[0].tag;"script"!==t&&"style"!==t&&e.includes("&")&&(e=av.decodeEntities(e,!1))}let r=aN[0]||ab,i=r.children[r.children.length-1];i&&2===i.type?(i.content+=e,aq(i.loc,n)):r.children.push({type:2,content:e,loc:aH(t,n)})}function a$(e,t,n=!1){n?aq(e.loc,aF(t,60)):aq(e.loc,function(e,t){let n=e;for(;62!==a_.charCodeAt(n)&&n<a_.length-1;)n++;return n}(t,0)+1),aI.inSFCRoot&&(e.children.length?e.innerLoc.end=g({},e.children[e.children.length-1].loc.end):e.innerLoc.end=g({},e.innerLoc.start),e.innerLoc.source=aM(e.innerLoc.start.offset,e.innerLoc.end.offset));let{tag:r,ns:i}=e;!aE&&("slot"===r?e.tagType=2:function({tag:e,props:t}){if("template"===e){for(let e=0;e<t.length;e++)if(7===t[e].type&&aV.has(t[e].name))return!0}return!1}(e)?e.tagType=3:function({tag:e,props:t}){var n;if(av.isCustomElement(e))return!1;if("component"===e||(n=e.charCodeAt(0))>64&&n<91||o4(e)||av.isBuiltInComponent&&av.isBuiltInComponent(e)||av.isNativeTag&&!av.isNativeTag(e))return!0;for(let e=0;e<t.length;e++){let n=t[e];if(6===n.type&&"is"===n.name&&n.value&&n.value.content.startsWith("vue:"))return!0}return!1}(e)&&(e.tagType=1)),aI.inRCDATA||(e.children=aB(e.children,e.tag)),0===i&&av.isPreTag(r)&&aw--,aA===e&&(aE=aI.inVPre=!1,aA=null),aI.inXML&&(aN[0]?aN[0].ns:av.ns)===0&&(aI.inXML=!1)}function aF(e,t){let n=e;for(;a_.charCodeAt(n)!==t&&n>=0;)n--;return n}let aV=new Set(["if","else","else-if","for","slot"]),aD=/\r\n/g;function aB(e,t){let n="preserve"!==av.whitespace,r=!1;for(let t=0;t<e.length;t++){let i=e[t];if(2===i.type){if(aw)i.content=i.content.replace(aD,"\n");else if(function(e){for(let t=0;t<e.length;t++)if(!oQ(e.charCodeAt(t)))return!1;return!0}(i.content)){let l=e[t-1]&&e[t-1].type,s=e[t+1]&&e[t+1].type;!l||!s||n&&(3===l&&(3===s||1===s)||1===l&&(3===s||1===s&&function(e){for(let t=0;t<e.length;t++){let n=e.charCodeAt(t);if(10===n||13===n)return!0}return!1}(i.content)))?(r=!0,e[t]=null):i.content=" "}else n&&(i.content=aU(i.content))}}if(aw&&t&&av.isPreTag(t)){let t=e[0];t&&2===t.type&&(t.content=t.content.replace(/^\r?\n/,""))}return r?e.filter(Boolean):e}function aU(e){let t="",n=!1;for(let r=0;r<e.length;r++)oQ(e.charCodeAt(r))?n||(t+=" ",n=!0):(t+=e[r],n=!1);return t}function aj(e){(aN[0]||ab).children.push(e)}function aH(e,t){return{start:aI.getPos(e),end:null==t?t:aI.getPos(t),source:null==t?t:aM(e,t)}}function aq(e,t){e.end=aI.getPos(t),e.source=aM(e.start.offset,t)}function aW(e,t=!1,n,r=0,i=0){return oj(e,t,n,r)}function aK(e,t,n){av.onError(o3(e,aH(t,t)))}function az(e,t){let{children:n}=e;return 1===n.length&&1===t.type&&!ad(t)}function aG(e,t){let{constantCache:n}=t;switch(e.type){case 1:if(0!==e.tagType)return 0;let r=n.get(e);if(void 0!==r)return r;let i=e.codegenNode;if(13!==i.type||i.isBlock&&"svg"!==e.tag&&"foreignObject"!==e.tag&&"math"!==e.tag)return 0;if(void 0!==i.patchFlag)return n.set(e,0),0;{let r=3,c=aX(e,t);if(0===c)return n.set(e,0),0;c<r&&(r=c);for(let i=0;i<e.children.length;i++){let l=aG(e.children[i],t);if(0===l)return n.set(e,0),0;l<r&&(r=l)}if(r>1)for(let i=0;i<e.props.length;i++){let l=e.props[i];if(7===l.type&&"bind"===l.name&&l.exp){let i=aG(l.exp,t);if(0===i)return n.set(e,0),0;i<r&&(r=i)}}if(i.isBlock){var l,s,o,a;for(let t=0;t<e.props.length;t++)if(7===e.props[t].type)return n.set(e,0),0;t.removeHelper(on),t.removeHelper((l=t.inSSR,s=i.isComponent,l||s?or:oi)),i.isBlock=!1,t.helper((o=t.inSSR,a=i.isComponent,o||a?ol:os))}return n.set(e,r),r}case 2:case 3:return 3;case 9:case 11:case 10:default:return 0;case 5:case 12:return aG(e.content,t);case 4:return e.constType;case 8:let c=3;for(let n=0;n<e.children.length;n++){let r=e.children[n];if(E(r)||A(r))continue;let i=aG(r,t);if(0===i)return 0;i<c&&(c=i)}return c}}let aJ=new Set([o_,oS,ox,oC]);function aX(e,t){let n=3,r=aQ(e);if(r&&15===r.type){let{properties:e}=r;for(let r=0;r<e.length;r++){let i;let{key:l,value:s}=e[r],o=aG(l,t);if(0===o)return o;if(o<n&&(n=o),0===(i=4===s.type?aG(s,t):14===s.type?function e(t,n){if(14===t.type&&!E(t.callee)&&aJ.has(t.callee)){let r=t.arguments[0];if(4===r.type)return aG(r,n);if(14===r.type)return e(r,n)}return 0}(s,t):0))return i;i<n&&(n=i)}}return n}function aQ(e){let t=e.codegenNode;if(13===t.type)return t.props}function aZ(e,t){t.currentNode=e;let{nodeTransforms:n}=t,r=[];for(let i=0;i<n.length;i++){let l=n[i](e,t);if(l&&(S(l)?r.push(...l):r.push(l)),!t.currentNode)return;e=t.currentNode}switch(e.type){case 3:t.ssr||t.helper(oo);break;case 5:t.ssr||t.helper(ov);break;case 9:for(let n=0;n<e.branches.length;n++)aZ(e.branches[n],t);break;case 10:case 11:case 1:case 0:!function(e,t){let n=0,r=()=>{n--};for(;n<e.children.length;n++){let i=e.children[n];E(i)||(t.grandParent=t.parent,t.parent=e,t.childIndex=n,t.onNodeRemoved=r,aZ(i,t))}}(e,t)}t.currentNode=e;let i=r.length;for(;i--;)r[i]()}function aY(e,t){let n=E(e)?t=>t===e:t=>e.test(t);return(e,r)=>{if(1===e.type){let{props:i}=e;if(3===e.tagType&&i.some(ac))return;let l=[];for(let s=0;s<i.length;s++){let o=i[s];if(7===o.type&&n(o.name)){i.splice(s,1),s--;let n=t(e,o,r);n&&l.push(n)}}return l}}}let a0="/*#__PURE__*/",a1=e=>`${o$[e]}: _${o$[e]}`;function a2(e,t,{helper:n,push:r,newline:i,isTS:l}){let s=n("component"===t?ou:op);for(let n=0;n<e.length;n++){let o=e[n],a=o.endsWith("__self");a&&(o=o.slice(0,-6)),r(`const ${am(o,t)} = ${s}(${JSON.stringify(o)}${a?", true":""})${l?"!":""}`),n<e.length-1&&i()}}function a3(e,t){let n=e.length>3;t.push("["),n&&t.indent(),a6(e,t,n),n&&t.deindent(),t.push("]")}function a6(e,t,n=!1,r=!0){let{push:i,newline:l}=t;for(let s=0;s<e.length;s++){let o=e[s];E(o)?i(o,-3):S(o)?a3(o,t):a4(o,t),s<e.length-1&&(n?(r&&i(","),l()):r&&i(", "))}}function a4(e,t){if(E(e)){t.push(e,-3);return}if(A(e)){t.push(t.helper(e));return}switch(e.type){case 1:case 9:case 11:case 12:a4(e.codegenNode,t);break;case 2:!function(e,t){t.push(JSON.stringify(e.content),-3,e)}(e,t);break;case 4:a8(e,t);break;case 5:!function(e,t){let{push:n,helper:r,pure:i}=t;i&&n(a0),n(`${r(ov)}(`),a4(e.content,t),n(")")}(e,t);break;case 8:a5(e,t);break;case 3:!function(e,t){let{push:n,helper:r,pure:i}=t;i&&n(a0),n(`${r(oo)}(${JSON.stringify(e.content)})`,-3,e)}(e,t);break;case 13:!function(e,t){let n;let{push:r,helper:i,pure:l}=t,{tag:s,props:o,children:a,patchFlag:c,dynamicProps:u,directives:d,isBlock:p,disableTracking:h,isComponent:f}=e;c&&(n=String(c)),d&&r(i(of)+"("),p&&r(`(${i(on)}(${h?"true":""}), `),l&&r(a0),r(i(p?t.inSSR||f?or:oi:t.inSSR||f?ol:os)+"(",-2,e),a6(function(e){let t=e.length;for(;t--&&null==e[t];);return e.slice(0,t+1).map(e=>e||"null")}([s,o,a,n,u]),t),r(")"),p&&r(")"),d&&(r(", "),a4(d,t),r(")"))}(e,t);break;case 14:!function(e,t){let{push:n,helper:r,pure:i}=t,l=E(e.callee)?e.callee:r(e.callee);i&&n(a0),n(l+"(",-2,e),a6(e.arguments,t),n(")")}(e,t);break;case 15:!function(e,t){let{push:n,indent:r,deindent:i,newline:l}=t,{properties:s}=e;if(!s.length){n("{}",-2,e);return}let o=s.length>1;n(o?"{":"{ "),o&&r();for(let e=0;e<s.length;e++){let{key:r,value:i}=s[e];!function(e,t){let{push:n}=t;8===e.type?(n("["),a5(e,t),n("]")):e.isStatic?n(o5(e.content)?e.content:JSON.stringify(e.content),-2,e):n(`[${e.content}]`,-3,e)}(r,t),n(": "),a4(i,t),e<s.length-1&&(n(","),l())}o&&i(),n(o?"}":" }")}(e,t);break;case 17:a3(e.elements,t);break;case 18:!function(e,t){let{push:n,indent:r,deindent:i}=t,{params:l,returns:s,body:o,newline:a,isSlot:c}=e;c&&n(`_${o$[oR]}(`),n("(",-2,e),S(l)?a6(l,t):l&&a4(l,t),n(") => "),(a||o)&&(n("{"),r()),s?(a&&n("return "),S(s)?a3(s,t):a4(s,t)):o&&a4(o,t),(a||o)&&(i(),n("}")),c&&n(")")}(e,t);break;case 19:!function(e,t){let{test:n,consequent:r,alternate:i,newline:l}=e,{push:s,indent:o,deindent:a,newline:c}=t;if(4===n.type){let e=!o5(n.content);e&&s("("),a8(n,t),e&&s(")")}else s("("),a4(n,t),s(")");l&&o(),t.indentLevel++,l||s(" "),s("? "),a4(r,t),t.indentLevel--,l&&c(),l||s(" "),s(": ");let u=19===i.type;!u&&t.indentLevel++,a4(i,t),!u&&t.indentLevel--,l&&a(!0)}(e,t);break;case 20:!function(e,t){let{push:n,helper:r,indent:i,deindent:l,newline:s}=t;n(`_cache[${e.index}] || (`),e.isVOnce&&(i(),n(`${r(oA)}(-1),`),s(),n("(")),n(`_cache[${e.index}] = `),a4(e.value,t),e.isVOnce&&(n(`).cacheIndex = ${e.index},`),s(),n(`${r(oA)}(1),`),s(),n(`_cache[${e.index}]`),l()),n(")")}(e,t);break;case 21:a6(e.body,t,!0,!1)}}function a8(e,t){let{content:n,isStatic:r}=e;t.push(r?JSON.stringify(n):n,-3,e)}function a5(e,t){for(let n=0;n<e.children.length;n++){let r=e.children[n];E(r)?t.push(r,-3):a4(r,t)}}let a9=aY(/^(if|else|else-if)$/,(e,t,n)=>(function(e,t,n,r){if("else"!==t.name&&(!t.exp||!t.exp.content.trim())){let r=t.exp?t.exp.loc:e.loc;n.onError(o3(28,t.loc)),t.exp=oj("true",!1,r)}if("if"===t.name){let i=a7(e,t),l={type:9,loc:e.loc,branches:[i]};if(n.replaceNode(l),r)return r(l,i,!0)}else{let i=n.parent.children,l=i.indexOf(e);for(;l-- >=-1;){let s=i[l];if(s&&3===s.type||s&&2===s.type&&!s.content.trim().length){n.removeNode(s);continue}if(s&&9===s.type){"else-if"===t.name&&void 0===s.branches[s.branches.length-1].condition&&n.onError(o3(30,e.loc)),n.removeNode();let i=a7(e,t);s.branches.push(i);let l=r&&r(s,i,!1);aZ(i,n),l&&l(),n.currentNode=null}else n.onError(o3(30,e.loc));break}}})(e,t,n,(e,t,r)=>{let i=n.parent.children,l=i.indexOf(e),s=0;for(;l-- >=0;){let e=i[l];e&&9===e.type&&(s+=e.branches.length)}return()=>{r?e.codegenNode=ce(t,s,n):function(e){for(;;)if(19===e.type){if(19!==e.alternate.type)return e;e=e.alternate}else 20===e.type&&(e=e.value)}(e.codegenNode).alternate=ce(t,s+e.branches.length-1,n)}}));function a7(e,t){let n=3===e.tagType;return{type:10,loc:e.loc,condition:"else"===t.name?void 0:t.exp,children:n&&!al(e,"for")?e.children:[e],userKey:as(e,"key"),isTemplateIf:n}}function ce(e,t,n){return e.condition?oK(e.condition,ct(e,t,n),oq(n.helper(oo),['""',"true"])):ct(e,t,n)}function ct(e,t,n){let{helper:r}=n,i=oU("key",oj(`${t}`,!1,oF,2)),{children:l}=e,s=l[0];if(1!==l.length||1!==s.type){if(1!==l.length||11!==s.type)return oV(n,r(s5),oB([i]),l,64,void 0,void 0,!0,!1,!1,e.loc);{let e=s.codegenNode;return ah(e,i,n),e}}{let e=s.codegenNode,t=14===e.type&&e.callee===oL?e.arguments[1].returns:e;return 13===t.type&&oz(t,n),ah(t,i,n),e}}let cn=(e,t,n)=>{let{modifiers:r,loc:i}=e,l=e.arg,{exp:s}=e;if(s&&4===s.type&&!s.content.trim()&&(s=void 0),!s){if(4!==l.type||!l.isStatic)return n.onError(o3(52,l.loc)),{props:[oU(l,oj("",!0,i))]};cr(e),s=e.exp}return 4!==l.type?(l.children.unshift("("),l.children.push(') || ""')):l.isStatic||(l.content=`${l.content} || ""`),r.includes("camel")&&(4===l.type?l.isStatic?l.content=B(l.content):l.content=`${n.helperString(ok)}(${l.content})`:(l.children.unshift(`${n.helperString(ok)}(`),l.children.push(")"))),!n.inSSR&&(r.includes("prop")&&ci(l,"."),r.includes("attr")&&ci(l,"^")),{props:[oU(l,s)]}},cr=(e,t)=>{let n=e.arg,r=B(n.content);e.exp=oj(r,!1,n.loc)},ci=(e,t)=>{4===e.type?e.isStatic?e.content=t+e.content:e.content=`\`${t}\${${e.content}}\``:(e.children.unshift(`'${t}' + (`),e.children.push(")"))},cl=aY("for",(e,t,n)=>{let{helper:r,removeHelper:i}=n;return function(e,t,n,r){if(!t.exp){n.onError(o3(31,t.loc));return}let i=t.forParseResult;if(!i){n.onError(o3(32,t.loc));return}cs(i);let{addIdentifiers:l,removeIdentifiers:s,scopes:o}=n,{source:a,value:c,key:u,index:d}=i,p={type:11,loc:t.loc,source:a,valueAlias:c,keyAlias:u,objectIndexAlias:d,parseResult:i,children:au(e)?e.children:[e]};n.replaceNode(p),o.vFor++;let h=r&&r(p);return()=>{o.vFor--,h&&h()}}(e,t,n,t=>{let l=oq(r(om),[t.source]),s=au(e),o=al(e,"memo"),a=as(e,"key",!1,!0);a&&7===a.type&&!a.exp&&cr(a);let c=a&&(6===a.type?a.value?oj(a.value.content,!0):void 0:a.exp),u=a&&c?oU("key",c):null,d=4===t.source.type&&t.source.constType>0,p=d?64:a?128:256;return t.codegenNode=oV(n,r(s5),void 0,l,p,void 0,void 0,!0,!d,!1,e.loc),()=>{let a;let{children:p}=t,h=1!==p.length||1!==p[0].type,f=ad(e)?e:s&&1===e.children.length&&ad(e.children[0])?e.children[0]:null;if(f)a=f.codegenNode,s&&u&&ah(a,u,n);else if(h)a=oV(n,r(s5),u?oB([u]):void 0,e.children,64,void 0,void 0,!0,void 0,!1);else{var m,g,y,b,_,S,x,C;a=p[0].codegenNode,s&&u&&ah(a,u,n),!d!==a.isBlock&&(a.isBlock?(i(on),i((m=n.inSSR,g=a.isComponent,m||g?or:oi))):i((y=n.inSSR,b=a.isComponent,y||b?ol:os))),(a.isBlock=!d,a.isBlock)?(r(on),r((_=n.inSSR,S=a.isComponent,_||S?or:oi))):r((x=n.inSSR,C=a.isComponent,x||C?ol:os))}if(o){let e=oW(co(t.parseResult,[oj("_cached")]));e.body={type:21,body:[oH(["const _memo = (",o.exp,")"]),oH(["if (_cached",...c?[" && _cached.key === ",c]:[],` && ${n.helperString(oP)}(_cached, _memo)) return _cached`]),oH(["const _item = ",a]),oj("_item.memo = _memo"),oj("return _item")],loc:oF},l.arguments.push(e,oj("_cache"),oj(String(n.cached++)))}else l.arguments.push(oW(co(t.parseResult),a,!0))}})});function cs(e,t){e.finalized||(e.finalized=!0)}function co({value:e,key:t,index:n},r=[]){return function(e){let t=e.length;for(;t--&&!e[t];);return e.slice(0,t+1).map((e,t)=>e||oj("_".repeat(t+1),!1))}([e,t,n,...r])}let ca=oj("undefined",!1),cc=(e,t)=>{if(1===e.type&&(1===e.tagType||3===e.tagType)){let n=al(e,"slot");if(n)return n.exp,t.scopes.vSlot++,()=>{t.scopes.vSlot--}}},cu=(e,t,n,r)=>oW(e,n,!1,!0,n.length?n[0].loc:r);function cd(e,t,n){let r=[oU("name",e),oU("fn",t)];return null!=n&&r.push(oU("key",oj(String(n),!0))),oB(r)}let cp=new WeakMap,ch=(e,t)=>function(){let n,r,i,l,s;if(!(1===(e=t.currentNode).type&&(0===e.tagType||1===e.tagType)))return;let{tag:o,props:a}=e,c=1===e.tagType,u=c?function(e,t,n=!1){let{tag:r}=e,i=cg(r),l=as(e,"is",!1,!0);if(l){if(i){let e;if(6===l.type?e=l.value&&oj(l.value.content,!0):(e=l.exp)||(e=oj("is",!1,l.arg.loc)),e)return oq(t.helper(od),[e])}else 6===l.type&&l.value.content.startsWith("vue:")&&(r=l.value.content.slice(4))}let s=o4(r)||t.isBuiltInComponent(r);return s?(n||t.helper(s),s):(t.helper(ou),t.components.add(r),am(r,"component"))}(e,t):`"${o}"`,d=N(u)&&u.callee===od,p=0,h=d||u===s9||u===s7||!c&&("svg"===o||"foreignObject"===o||"math"===o);if(a.length>0){let r=cf(e,t,void 0,c,d);n=r.props,p=r.patchFlag,l=r.dynamicPropNames;let i=r.directives;s=i&&i.length?oD(i.map(e=>(function(e,t){let n=[],r=cp.get(e);r?n.push(t.helperString(r)):(t.helper(op),t.directives.add(e.name),n.push(am(e.name,"directive")));let{loc:i}=e;if(e.exp&&n.push(e.exp),e.arg&&(e.exp||n.push("void 0"),n.push(e.arg)),Object.keys(e.modifiers).length){e.arg||(e.exp||n.push("void 0"),n.push("void 0"));let t=oj("true",!1,i);n.push(oB(e.modifiers.map(e=>oU(e,t)),i))}return oD(n,e.loc)})(e,t))):void 0,r.shouldUseBlock&&(h=!0)}if(e.children.length>0){if(u===oe&&(h=!0,p|=1024),c&&u!==s9&&u!==oe){let{slots:n,hasDynamicSlots:i}=function(e,t,n=cu){t.helper(oR);let{children:r,loc:i}=e,l=[],s=[],o=t.scopes.vSlot>0||t.scopes.vFor>0,a=al(e,"slot",!0);if(a){let{arg:e,exp:t}=a;e&&!o6(e)&&(o=!0),l.push(oU(e||oj("default",!0),n(t,void 0,r,i)))}let c=!1,u=!1,d=[],p=new Set,h=0;for(let e=0;e<r.length;e++){let i,f,m,g;let y=r[e];if(!au(y)||!(i=al(y,"slot",!0))){3!==y.type&&d.push(y);continue}if(a){t.onError(o3(37,i.loc));break}c=!0;let{children:b,loc:_}=y,{arg:S=oj("default",!0),exp:x,loc:C}=i;o6(S)?f=S?S.content:"default":o=!0;let T=al(y,"for"),k=n(x,T,b,_);if(m=al(y,"if"))o=!0,s.push(oK(m.exp,cd(S,k,h++),ca));else if(g=al(y,/^else(-if)?$/,!0)){let n,i=e;for(;i--&&3===(n=r[i]).type;);if(n&&au(n)&&al(n,/^(else-)?if$/)){let e=s[s.length-1];for(;19===e.alternate.type;)e=e.alternate;e.alternate=g.exp?oK(g.exp,cd(S,k,h++),ca):cd(S,k,h++)}else t.onError(o3(30,g.loc))}else if(T){o=!0;let e=T.forParseResult;e?(cs(e),s.push(oq(t.helper(om),[e.source,oW(co(e),cd(S,k),!0)]))):t.onError(o3(32,T.loc))}else{if(f){if(p.has(f)){t.onError(o3(38,C));continue}p.add(f),"default"===f&&(u=!0)}l.push(oU(S,k))}}if(!a){let e=(e,t)=>oU("default",n(e,void 0,t,i));c?d.length&&d.some(e=>(function e(t){return 2!==t.type&&12!==t.type||(2===t.type?!!t.content.trim():e(t.content))})(e))&&(u?t.onError(o3(39,d[0].loc)):l.push(e(void 0,d))):l.push(e(void 0,r))}let f=o?2:!function e(t){for(let n=0;n<t.length;n++){let r=t[n];switch(r.type){case 1:if(2===r.tagType||e(r.children))return!0;break;case 9:if(e(r.branches))return!0;break;case 10:case 11:if(e(r.children))return!0}}return!1}(e.children)?1:3,m=oB(l.concat(oU("_",oj(f+"",!1))),i);return s.length&&(m=oq(t.helper(oy),[m,oD(s)])),{slots:m,hasDynamicSlots:o}}(e,t);r=n,i&&(p|=1024)}else if(1===e.children.length&&u!==s9){let n=e.children[0],i=n.type,l=5===i||8===i;l&&0===aG(n,t)&&(p|=1),r=l||2===i?n:e.children}else r=e.children}l&&l.length&&(i=function(e){let t="[";for(let n=0,r=e.length;n<r;n++)t+=JSON.stringify(e[n]),n<r-1&&(t+=", ");return t+"]"}(l)),e.codegenNode=oV(t,u,n,r,0===p?void 0:p,i,s,!!h,!1,c,e.loc)};function cf(e,t,n=e.props,r,i,l=!1){let s;let{tag:o,loc:a,children:c}=e,u=[],d=[],p=[],h=c.length>0,m=!1,g=0,y=!1,b=!1,_=!1,S=!1,x=!1,C=!1,T=[],k=e=>{u.length&&(d.push(oB(cm(u),a)),u=[]),e&&d.push(e)},w=()=>{t.scopes.vFor>0&&u.push(oU(oj("ref_for",!0),oj("true")))},E=({key:e,value:n})=>{if(o6(e)){let l=e.content,s=f(l);s&&(!r||i)&&"onclick"!==l.toLowerCase()&&"onUpdate:modelValue"!==l&&!$(l)&&(S=!0),s&&$(l)&&(C=!0),s&&14===n.type&&(n=n.arguments[0]),20===n.type||(4===n.type||8===n.type)&&aG(n,t)>0||("ref"===l?y=!0:"class"===l?b=!0:"style"===l?_=!0:"key"===l||T.includes(l)||T.push(l),r&&("class"===l||"style"===l)&&!T.includes(l)&&T.push(l))}else x=!0};for(let i=0;i<n.length;i++){let s=n[i];if(6===s.type){let{loc:e,name:t,nameLoc:n,value:r}=s;if("ref"===t&&(y=!0,w()),"is"===t&&(cg(o)||r&&r.content.startsWith("vue:")))continue;u.push(oU(oj(t,!0,n),oj(r?r.content:"",!0,r?r.loc:e)))}else{let{name:n,arg:i,exp:c,loc:f,modifiers:y}=s,b="bind"===n,_="on"===n;if("slot"===n){r||t.onError(o3(40,f));continue}if("once"===n||"memo"===n||"is"===n||b&&ao(i,"is")&&cg(o)||_&&l)continue;if((b&&ao(i,"key")||_&&h&&ao(i,"vue:before-update"))&&(m=!0),b&&ao(i,"ref")&&w(),!i&&(b||_)){x=!0,c?b?(w(),k(),d.push(c)):k({type:14,loc:f,callee:t.helper(oT),arguments:r?[c]:[c,"true"]}):t.onError(o3(b?34:35,f));continue}b&&y.includes("prop")&&(g|=32);let S=t.directiveTransforms[n];if(S){let{props:n,needRuntime:r}=S(s,e,t);l||n.forEach(E),_&&i&&!o6(i)?k(oB(n,a)):u.push(...n),r&&(p.push(s),A(r)&&cp.set(s,r))}else!F(n)&&(p.push(s),h&&(m=!0))}}if(d.length?(k(),s=d.length>1?oq(t.helper(ob),d,a):d[0]):u.length&&(s=oB(cm(u),a)),x?g|=16:(b&&!r&&(g|=2),_&&!r&&(g|=4),T.length&&(g|=8),S&&(g|=32)),!m&&(0===g||32===g)&&(y||C||p.length>0)&&(g|=512),!t.inSSR&&s)switch(s.type){case 15:let N=-1,I=-1,R=!1;for(let e=0;e<s.properties.length;e++){let t=s.properties[e].key;o6(t)?"class"===t.content?N=e:"style"===t.content&&(I=e):t.isHandlerKey||(R=!0)}let O=s.properties[N],M=s.properties[I];R?s=oq(t.helper(ox),[s]):(O&&!o6(O.value)&&(O.value=oq(t.helper(o_),[O.value])),M&&(_||4===M.value.type&&"["===M.value.content.trim()[0]||17===M.value.type)&&(M.value=oq(t.helper(oS),[M.value])));break;case 14:break;default:s=oq(t.helper(ox),[oq(t.helper(oC),[s])])}return{props:s,directives:p,patchFlag:g,dynamicPropNames:T,shouldUseBlock:m}}function cm(e){let t=new Map,n=[];for(let r=0;r<e.length;r++){let i=e[r];if(8===i.key.type||!i.key.isStatic){n.push(i);continue}let l=i.key.content,s=t.get(l);s?("style"===l||"class"===l||f(l))&&(17===s.value.type?s.value.elements.push(i.value):s.value=oD([s.value,i.value],s.loc)):(t.set(l,i),n.push(i))}return n}function cg(e){return"component"===e||"Component"===e}let cy=(e,t)=>{if(ad(e)){let{children:n,loc:r}=e,{slotName:i,slotProps:l}=function(e,t){let n,r='"default"',i=[];for(let t=0;t<e.props.length;t++){let n=e.props[t];if(6===n.type)n.value&&("name"===n.name?r=JSON.stringify(n.value.content):(n.name=B(n.name),i.push(n)));else if("bind"===n.name&&ao(n.arg,"name")){if(n.exp)r=n.exp;else if(n.arg&&4===n.arg.type){let e=B(n.arg.content);r=n.exp=oj(e,!1,n.arg.loc)}}else"bind"===n.name&&n.arg&&o6(n.arg)&&(n.arg.content=B(n.arg.content)),i.push(n)}if(i.length>0){let{props:r,directives:l}=cf(e,t,i,!1,!1);n=r,l.length&&t.onError(o3(36,l[0].loc))}return{slotName:r,slotProps:n}}(e,t),s=[t.prefixIdentifiers?"_ctx.$slots":"$slots",i,"{}","undefined","true"],o=2;l&&(s[2]=l,o=3),n.length&&(s[3]=oW([],n,!1,!1,r),o=4),t.scopeId&&!t.slotted&&(o=5),s.splice(o),e.codegenNode=oq(t.helper(og),s,r)}},cv=(e,t,n,r)=>{let i;let{loc:l,modifiers:s,arg:o}=e;if(e.exp||s.length,4===o.type){if(o.isStatic){let e=o.content;e.startsWith("vue:")&&(e=`vnode-${e.slice(4)}`),i=oj(0!==t.tagType||e.startsWith("vnode")||!/[A-Z]/.test(e)?q(B(e)):`on:${e}`,!0,o.loc)}else i=oH([`${n.helperString(oE)}(`,o,")"])}else(i=o).children.unshift(`${n.helperString(oE)}(`),i.children.push(")");let a=e.exp;a&&!a.content.trim()&&(a=void 0);let c=n.cacheHandlers&&!a&&!n.inVOnce;if(a){let e=an(a),t=!(e||ai(a)),n=a.content.includes(";");(t||c&&e)&&(a=oH([`${t?"$event":"(...args)"} => ${n?"{":"("}`,a,n?"}":")"]))}let u={props:[oU(i,a||oj("() => {}",!1,l))]};return r&&(u=r(u)),c&&(u.props[0].value=n.cache(u.props[0].value)),u.props.forEach(e=>e.key.isHandlerKey=!0),u},cb=(e,t)=>{if(0===e.type||1===e.type||11===e.type||10===e.type)return()=>{let n;let r=e.children,i=!1;for(let e=0;e<r.length;e++){let t=r[e];if(aa(t)){i=!0;for(let i=e+1;i<r.length;i++){let l=r[i];if(aa(l))n||(n=r[e]=oH([t],t.loc)),n.children.push(" + ",l),r.splice(i,1),i--;else{n=void 0;break}}}}if(i&&(1!==r.length||0!==e.type&&(1!==e.type||0!==e.tagType||e.props.find(e=>7===e.type&&!t.directiveTransforms[e.name]))))for(let e=0;e<r.length;e++){let n=r[e];if(aa(n)||8===n.type){let i=[];(2!==n.type||" "!==n.content)&&i.push(n),t.ssr||0!==aG(n,t)||i.push("1"),r[e]={type:12,content:n,loc:n.loc,codegenNode:oq(t.helper(oa),i)}}}}},c_=new WeakSet,cS=(e,t)=>{if(1===e.type&&al(e,"once",!0)&&!c_.has(e)&&!t.inVOnce&&!t.inSSR)return c_.add(e),t.inVOnce=!0,t.helper(oA),()=>{t.inVOnce=!1;let e=t.currentNode;e.codegenNode&&(e.codegenNode=t.cache(e.codegenNode,!0))}},cx=(e,t,n)=>{let r;let{exp:i,arg:l}=e;if(!i)return n.onError(o3(41,e.loc)),cC();let s=i.loc.source,o=4===i.type?i.content:s,a=n.bindingMetadata[s];if("props"===a||"props-aliased"===a)return i.loc,cC();if(!o.trim()||!an(i))return n.onError(o3(42,i.loc)),cC();let c=l||oj("modelValue",!0),u=l?o6(l)?`onUpdate:${B(l.content)}`:oH(['"onUpdate:" + ',l]):"onUpdate:modelValue",d=n.isTS?"($event: any)":"$event";r=oH([`${d} => ((`,i,") = $event)"]);let p=[oU(c,e.exp),oU(u,r)];if(e.modifiers.length&&1===t.tagType){let t=e.modifiers.map(e=>(o5(e)?e:JSON.stringify(e))+": true").join(", "),n=l?o6(l)?`${l.content}Modifiers`:oH([l,' + "Modifiers"']):"modelModifiers";p.push(oU(n,oj(`{ ${t} }`,!1,e.loc,2)))}return cC(p)};function cC(e=[]){return{props:e}}let cT=new WeakSet,ck=(e,t)=>{if(1===e.type){let n=al(e,"memo");if(!(!n||cT.has(e)))return cT.add(e),()=>{let r=e.codegenNode||t.currentNode.codegenNode;r&&13===r.type&&(1!==e.tagType&&oz(r,t),e.codegenNode=oq(t.helper(oL),[n.exp,oW(void 0,r),"_cache",String(t.cached++)]))}}},cw=Symbol(""),cE=Symbol(""),cA=Symbol(""),cN=Symbol(""),cI=Symbol(""),cR=Symbol(""),cO=Symbol(""),cM=Symbol(""),cL=Symbol(""),cP=Symbol("");!function(e){Object.getOwnPropertySymbols(e).forEach(t=>{o$[t]=e[t]})}({[cw]:"vModelRadio",[cE]:"vModelCheckbox",[cA]:"vModelText",[cN]:"vModelSelect",[cI]:"vModelDynamic",[cR]:"withModifiers",[cO]:"withKeys",[cM]:"vShow",[cL]:"Transition",[cP]:"TransitionGroup"});let c$={parseMode:"html",isVoidTag:ea,isNativeTag:e=>el(e)||es(e)||eo(e),isPreTag:e=>"pre"===e,decodeEntities:function(e,t=!1){return(a||(a=document.createElement("div")),t)?(a.innerHTML=`<div foo="${e.replace(/"/g,""")}">`,a.children[0].getAttribute("foo")):(a.innerHTML=e,a.textContent)},isBuiltInComponent:e=>"Transition"===e||"transition"===e?cL:"TransitionGroup"===e||"transition-group"===e?cP:void 0,getNamespace(e,t,n){let r=t?t.ns:n;if(t&&2===r){if("annotation-xml"===t.tag){if("svg"===e)return 1;t.props.some(e=>6===e.type&&"encoding"===e.name&&null!=e.value&&("text/html"===e.value.content||"application/xhtml+xml"===e.value.content))&&(r=0)}else/^m(?:[ions]|text)$/.test(t.tag)&&"mglyph"!==e&&"malignmark"!==e&&(r=0)}else t&&1===r&&("foreignObject"===t.tag||"desc"===t.tag||"title"===t.tag)&&(r=0);if(0===r){if("svg"===e)return 1;if("math"===e)return 2}return r}},cF=(e,t)=>oj(JSON.stringify(en(e)),!1,t,3),cV=c("passive,once,capture"),cD=c("stop,prevent,self,ctrl,shift,alt,meta,exact,middle"),cB=c("left,right"),cU=c("onkeyup,onkeydown,onkeypress",!0),cj=(e,t,n,r)=>{let i=[],l=[],s=[];for(let n=0;n<t.length;n++){let r=t[n];cV(r)?s.push(r):cB(r)?o6(e)?cU(e.content)?i.push(r):l.push(r):(i.push(r),l.push(r)):cD(r)?l.push(r):i.push(r)}return{keyModifiers:i,nonKeyModifiers:l,eventOptionModifiers:s}},cH=(e,t)=>o6(e)&&"onclick"===e.content.toLowerCase()?oj(t,!0):4!==e.type?oH(["(",e,`) === "onClick" ? "${t}" : (`,e,")"]):e,cq=(e,t)=>{1===e.type&&0===e.tagType&&("script"===e.tag||"style"===e.tag)&&t.removeNode()},cW=[e=>{1===e.type&&e.props.forEach((t,n)=>{6===t.type&&"style"===t.name&&t.value&&(e.props[n]={type:7,name:"bind",arg:oj("style",!0,t.loc),exp:cF(t.value.content,t.loc),modifiers:[],loc:t.loc})})}],cK={cloak:()=>({props:[]}),html:(e,t,n)=>{let{exp:r,loc:i}=e;return r||n.onError(o3(53,i)),t.children.length&&(n.onError(o3(54,i)),t.children.length=0),{props:[oU(oj("innerHTML",!0,i),r||oj("",!0))]}},text:(e,t,n)=>{let{exp:r,loc:i}=e;return r||n.onError(o3(55,i)),t.children.length&&(n.onError(o3(56,i)),t.children.length=0),{props:[oU(oj("textContent",!0),r?aG(r,n)>0?r:oq(n.helperString(ov),[r],i):oj("",!0))]}},model:(e,t,n)=>{let r=cx(e,t,n);if(!r.props.length||1===t.tagType)return r;e.arg&&n.onError(o3(58,e.arg.loc));let{tag:i}=t,l=n.isCustomElement(i);if("input"===i||"textarea"===i||"select"===i||l){let s=cA,o=!1;if("input"===i||l){let r=as(t,"type");if(r){if(7===r.type)s=cI;else if(r.value)switch(r.value.content){case"radio":s=cw;break;case"checkbox":s=cE;break;case"file":o=!0,n.onError(o3(59,e.loc))}}else t.props.some(e=>7===e.type&&"bind"===e.name&&(!e.arg||4!==e.arg.type||!e.arg.isStatic))&&(s=cI)}else"select"===i&&(s=cN);o||(r.needRuntime=n.helper(s))}else n.onError(o3(57,e.loc));return r.props=r.props.filter(e=>!(4===e.key.type&&"modelValue"===e.key.content)),r},on:(e,t,n)=>cv(e,t,n,t=>{let{modifiers:r}=e;if(!r.length)return t;let{key:i,value:l}=t.props[0],{keyModifiers:s,nonKeyModifiers:o,eventOptionModifiers:a}=cj(i,r,n,e.loc);if(o.includes("right")&&(i=cH(i,"onContextmenu")),o.includes("middle")&&(i=cH(i,"onMouseup")),o.length&&(l=oq(n.helper(cR),[l,JSON.stringify(o)])),s.length&&(!o6(i)||cU(i.content))&&(l=oq(n.helper(cO),[l,JSON.stringify(s)])),a.length){let e=a.map(H).join("");i=o6(i)?oj(`${i.content}${e}`,!0):oH(["(",i,`) + "${e}"`])}return{props:[oU(i,l)]}}),show:(e,t,n)=>{let{exp:r,loc:i}=e;return!r&&n.onError(o3(61,i)),{props:[],needRuntime:n.helper(cM)}}},cz=new WeakMap;function cG(e,t){let n;if(!E(e)){if(!e.nodeType)return p;e=e.innerHTML}let r=e,i=((n=cz.get(null!=t?t:u))||(n=Object.create(null),cz.set(null!=t?t:u,n)),n),l=i[r];if(l)return l;if("#"===e[0]){let t=document.querySelector(e);e=t?t.innerHTML:""}let s=g({hoistStatic:!0,onError:void 0,onWarn:p},t);s.isCustomElement||"undefined"==typeof customElements||(s.isCustomElement=e=>!!customElements.get(e));let{code:o}=function(e,t={}){return function(e,t={}){let n=t.onError||o1,r="module"===t.mode;!0===t.prefixIdentifiers?n(o3(47)):r&&n(o3(48)),t.cacheHandlers&&n(o3(49)),t.scopeId&&!r&&n(o3(50));let i=g({},t,{prefixIdentifiers:!1}),l=E(e)?function(e,t){if(aI.reset(),aS=null,ax=null,aC="",aT=-1,ak=-1,aN.length=0,a_=e,av=g({},ay),t){let e;for(e in t)null!=t[e]&&(av[e]=t[e])}aI.mode="html"===av.parseMode?1:"sfc"===av.parseMode?2:0,aI.inXML=1===av.ns||2===av.ns;let n=t&&t.delimiters;n&&(aI.delimiterOpen=oY(n[0]),aI.delimiterClose=oY(n[1]));let r=ab=function(e,t=""){return{type:0,source:t,children:e,helpers:new Set,components:[],directives:[],hoists:[],imports:[],cached:0,temps:0,codegenNode:void 0,loc:oF}}([],e);return aI.parse(a_),r.loc=aH(0,e.length),r.children=aB(r.children),ab=null,r}(e,i):e,[s,o]=[[cS,a9,ck,cl,cy,ch,cc,cb],{on:cv,bind:cn,model:cx}];return!function(e,t){let n=function(e,{filename:t="",prefixIdentifiers:n=!1,hoistStatic:r=!1,hmr:i=!1,cacheHandlers:l=!1,nodeTransforms:s=[],directiveTransforms:o={},transformHoist:a=null,isBuiltInComponent:c=p,isCustomElement:d=p,expressionPlugins:h=[],scopeId:f=null,slotted:m=!0,ssr:g=!1,inSSR:y=!1,ssrCssVars:b="",bindingMetadata:_=u,inline:S=!1,isTS:x=!1,onError:C=o1,onWarn:T=o2,compatConfig:k}){let w=t.replace(/\?.*$/,"").match(/([^/\\]+)\.\w+$/),A={filename:t,selfName:w&&H(B(w[1])),prefixIdentifiers:n,hoistStatic:r,hmr:i,cacheHandlers:l,nodeTransforms:s,directiveTransforms:o,transformHoist:a,isBuiltInComponent:c,isCustomElement:d,expressionPlugins:h,scopeId:f,slotted:m,ssr:g,inSSR:y,ssrCssVars:b,bindingMetadata:_,inline:S,isTS:x,onError:C,onWarn:T,compatConfig:k,root:e,helpers:new Map,components:new Set,directives:new Set,hoists:[],imports:[],constantCache:new WeakMap,temps:0,cached:0,identifiers:Object.create(null),scopes:{vFor:0,vSlot:0,vPre:0,vOnce:0},parent:null,grandParent:null,currentNode:e,childIndex:0,inVOnce:!1,helper(e){let t=A.helpers.get(e)||0;return A.helpers.set(e,t+1),e},removeHelper(e){let t=A.helpers.get(e);if(t){let n=t-1;n?A.helpers.set(e,n):A.helpers.delete(e)}},helperString:e=>`_${o$[A.helper(e)]}`,replaceNode(e){A.parent.children[A.childIndex]=A.currentNode=e},removeNode(e){let t=A.parent.children,n=e?t.indexOf(e):A.currentNode?A.childIndex:-1;e&&e!==A.currentNode?A.childIndex>n&&(A.childIndex--,A.onNodeRemoved()):(A.currentNode=null,A.onNodeRemoved()),A.parent.children.splice(n,1)},onNodeRemoved:p,addIdentifiers(e){},removeIdentifiers(e){},hoist(e){E(e)&&(e=oj(e)),A.hoists.push(e);let t=oj(`_hoisted_${A.hoists.length}`,!1,e.loc,2);return t.hoisted=e,t},cache:(e,t=!1)=>(function(e,t,n=!1){return{type:20,index:e,value:t,isVOnce:n,loc:oF}})(A.cached++,e,t)};return A}(e,t);aZ(e,n),t.hoistStatic&&function e(t,n,r=!1){let{children:i}=t,l=i.length,s=0;for(let t=0;t<i.length;t++){let l=i[t];if(1===l.type&&0===l.tagType){let e=r?0:aG(l,n);if(e>0){if(e>=2){l.codegenNode.patchFlag=-1,l.codegenNode=n.hoist(l.codegenNode),s++;continue}}else{let e=l.codegenNode;if(13===e.type){let t=e.patchFlag;if((void 0===t||512===t||1===t)&&aX(l,n)>=2){let t=aQ(l);t&&(e.props=n.hoist(t))}e.dynamicProps&&(e.dynamicProps=n.hoist(e.dynamicProps))}}}if(1===l.type){let t=1===l.tagType;t&&n.scopes.vSlot++,e(l,n),t&&n.scopes.vSlot--}else if(11===l.type)e(l,n,1===l.children.length);else if(9===l.type)for(let t=0;t<l.branches.length;t++)e(l.branches[t],n,1===l.branches[t].children.length)}if(s&&n.transformHoist&&n.transformHoist(i,n,t),s&&s===l&&1===t.type&&0===t.tagType&&t.codegenNode&&13===t.codegenNode.type&&S(t.codegenNode.children)){let e=n.hoist(oD(t.codegenNode.children));n.hmr&&(e.content=`[...${e.content}]`),t.codegenNode.children=e}}(e,n,az(e,e.children[0])),t.ssr||function(e,t){let{helper:n}=t,{children:r}=e;if(1===r.length){let n=r[0];if(az(e,n)&&n.codegenNode){let r=n.codegenNode;13===r.type&&oz(r,t),e.codegenNode=r}else e.codegenNode=n}else r.length>1&&(e.codegenNode=oV(t,n(s5),void 0,e.children,64,void 0,void 0,!0,void 0,!1))}(e,n),e.helpers=new Set([...n.helpers.keys()]),e.components=[...n.components],e.directives=[...n.directives],e.imports=n.imports,e.hoists=n.hoists,e.temps=n.temps,e.cached=n.cached,e.transformed=!0}(l,g({},i,{nodeTransforms:[...s,...t.nodeTransforms||[]],directiveTransforms:g({},o,t.directiveTransforms||{})})),function(e,t={}){let n=function(e,{mode:t="function",prefixIdentifiers:n="module"===t,sourceMap:r=!1,filename:i="template.vue.html",scopeId:l=null,optimizeImports:s=!1,runtimeGlobalName:o="Vue",runtimeModuleName:a="vue",ssrRuntimeModuleName:c="vue/server-renderer",ssr:u=!1,isTS:d=!1,inSSR:p=!1}){let h={mode:t,prefixIdentifiers:n,sourceMap:r,filename:i,scopeId:l,optimizeImports:s,runtimeGlobalName:o,runtimeModuleName:a,ssrRuntimeModuleName:c,ssr:u,isTS:d,inSSR:p,source:e.source,code:"",column:1,line:1,offset:0,indentLevel:0,pure:!1,map:void 0,helper:e=>`_${o$[e]}`,push(e,t=-2,n){h.code+=e},indent(){f(++h.indentLevel)},deindent(e=!1){e?--h.indentLevel:f(--h.indentLevel)},newline(){f(h.indentLevel)}};function f(e){h.push("\n"+" ".repeat(e),0)}return h}(e,t);t.onContextCreated&&t.onContextCreated(n);let{mode:r,push:i,prefixIdentifiers:l,indent:s,deindent:o,newline:a,scopeId:c,ssr:u}=n,d=Array.from(e.helpers),p=d.length>0,h=!l&&"module"!==r;(function(e,t){let{ssr:n,prefixIdentifiers:r,push:i,newline:l,runtimeModuleName:s,runtimeGlobalName:o,ssrRuntimeModuleName:a}=t,c=Array.from(e.helpers);if(c.length>0&&(i(`const _Vue = ${o} +`,-1),e.hoists.length)){let e=[ol,os,oo,oa,oc].filter(e=>c.includes(e)).map(a1).join(", ");i(`const { ${e} } = _Vue +`,-1)}(function(e,t){if(!e.length)return;t.pure=!0;let{push:n,newline:r,helper:i,scopeId:l,mode:s}=t;r();for(let i=0;i<e.length;i++){let l=e[i];l&&(n(`const _hoisted_${i+1} = `),a4(l,t),r())}t.pure=!1})(e.hoists,t),l(),i("return ")})(e,n);let f=(u?["_ctx","_push","_parent","_attrs"]:["_ctx","_cache"]).join(", ");if(i(`function ${u?"ssrRender":"render"}(${f}) {`),s(),h&&(i("with (_ctx) {"),s(),p&&(i(`const { ${d.map(a1).join(", ")} } = _Vue +`,-1),a())),e.components.length&&(a2(e.components,"component",n),(e.directives.length||e.temps>0)&&a()),e.directives.length&&(a2(e.directives,"directive",n),e.temps>0&&a()),e.temps>0){i("let ");for(let t=0;t<e.temps;t++)i(`${t>0?", ":""}_temp${t}`)}return(e.components.length||e.directives.length||e.temps)&&(i(` +`,0),a()),u||i("return "),e.codegenNode?a4(e.codegenNode,n):i("null"),h&&(o(),i("}")),o(),i("}"),{ast:e,code:n.code,preamble:"",map:n.map?n.map.toJSON():void 0}}(l,i)}(e,g({},c$,t,{nodeTransforms:[cq,...cW,...t.nodeTransforms||[]],directiveTransforms:g({},cK,t.directiveTransforms||{}),transformHoist:null}))}(e,s),a=Function("Vue",o)(s8);return a._rc=!0,i[r]=a}lb(cG);export{nx as BaseTransition,n_ as BaseTransitionPropsValidators,iK as Comment,lD as DeprecationTypes,eg as EffectScope,tX as ErrorCodes,lM as ErrorTypeStrings,iq as Fragment,nL as KeepAlive,eS as ReactiveEffect,iz as Static,iV as Suspense,r4 as Teleport,iW as Text,tz as TrackOpTypes,lW as Transition,sE as TransitionGroup,tG as TriggerOpTypes,s_ as VueElement,tJ as assertNumber,tZ as callWithAsyncErrorHandling,tQ as callWithErrorHandling,B as camelize,H as capitalize,lt as cloneVNode,lV as compatUtils,cG as compile,lw as computed,s1 as createApp,i2 as createBlock,li as createCommentVNode,i1 as createElementBlock,i9 as createElementVNode,ia as createHydrationRenderer,rv as createPropsRestProxy,io as createRenderer,s2 as createSSRApp,n5 as createSlots,lr as createStaticVNode,ln as createTextVNode,i7 as createVNode,tU as customRef,nR as defineAsyncComponent,nN as defineComponent,sy as defineCustomElement,rs as defineEmits,ro as defineExpose,ru as defineModel,ra as defineOptions,rl as defineProps,sv as defineSSRCustomElement,rc as defineSlots,lL as devtools,ek as effect,ey as effectScope,lh as getCurrentInstance,eb as getCurrentScope,nA as getTransitionRawChildren,le as guardReactiveProps,lE as h,tY as handleError,rP as hasInjectionContext,s0 as hydrate,lA as initCustomFormatter,s4 as initDirectivesForSSR,rL as inject,lI as isMemoSame,tx as isProxy,tb as isReactive,t_ as isReadonly,tI as isRef,l_ as isRuntimeOnly,tS as isShallow,i3 as isVNode,tT as markRaw,rg as mergeDefaults,ry as mergeModels,la as mergeProps,t7 as nextTick,er as normalizeClass,ei as normalizeProps,Z as normalizeStyle,n$ as onActivated,nH as onBeforeMount,nz as onBeforeUnmount,nW as onBeforeUpdate,nF as onDeactivated,nZ as onErrorCaptured,nq as onMounted,nQ as onRenderTracked,nX as onRenderTriggered,e_ as onScopeDispose,nJ as onServerPrefetch,nG as onUnmounted,nK as onUpdated,iX as openBlock,nd as popScopeId,rM as provide,tD as proxyRefs,nu as pushScopeId,nn as queuePostFlushCb,tf as reactive,tg as readonly,tR as ref,lb as registerRuntimeCompiler,sY as render,n8 as renderList,n9 as renderSlot,n0 as resolveComponent,n3 as resolveDirective,n2 as resolveDynamicComponent,lF as resolveFilter,nT as resolveTransitionHooks,iY as setBlockTracking,lP as setDevtoolsHook,nE as setTransitionHooks,tm as shallowReactive,ty as shallowReadonly,tO as shallowRef,ig as ssrContextKey,l$ as ssrUtils,ew as stop,eh as toDisplayString,q as toHandlerKey,n7 as toHandlers,tC as toRaw,tW as toRef,tj as toRefs,tF as toValue,i4 as transformVNodeArgs,tP as triggerRef,t$ as unref,rh as useAttrs,sS as useCssModule,st as useCssVars,iE as useModel,iy as useSSRContext,rp as useSlots,nv as useTransitionState,s$ as vModelCheckbox,sH as vModelDynamic,sV as vModelRadio,sD as vModelSelect,sP as vModelText,l9 as vShow,lR as version,lO as warn,ix as watch,iv as watchEffect,ib as watchPostEffect,i_ as watchSyncEffect,rb as withAsyncContext,nh as withCtx,rd as withDefaults,nf as withDirectives,sJ as withKeys,lN as withMemo,sz as withModifiers,np as withScopeId}; diff --git a/loop_memory/serve/static/js/main.js b/loop_memory/serve/static/js/main.js new file mode 100644 index 0000000..9facd47 --- /dev/null +++ b/loop_memory/serve/static/js/main.js @@ -0,0 +1,20 @@ +/** + * Entry point. Boots Vue 3 and mounts the App component. + * + * Vue 3 is loaded from a CDN (unpkg). We use the production ESM build + * to keep the runtime tiny. No build step is required — every component + * is a self-contained ES module that the browser imports directly. + * + * If a future iteration wants SFC + HMR, swap the CDN for the Vite + * dev server; the component sources are already structured to fit. + */ +import { createApp } from './lib/vue.esm-browser.prod.js'; +import { App } from './App.js'; + +const root = document.getElementById('app'); +if (root) { + createApp(App).mount(root); +} else { + // Should never happen; helps debugging if the host page is misconfigured. + console.error('loop-memory: #app root not found'); +} diff --git a/loop_memory/serve/static/js/store.js b/loop_memory/serve/static/js/store.js new file mode 100644 index 0000000..9b3f730 --- /dev/null +++ b/loop_memory/serve/static/js/store.js @@ -0,0 +1,288 @@ +/** + * Reactive global state for the loop-memory dashboard. + * + * Uses Vue 3 reactivity (no Vuex / Pinia) — this app is small enough that + * a plain reactive() object is easier to maintain than a state library. + * Components import { store, persistPrefs } and read / mutate. + */ +import { reactive, watch, computed } from './lib/vue.esm-browser.prod.js'; + +const STORE_KEY = 'loop_memory_prefs_v1'; + +function loadPrefs() { + try { + const raw = localStorage.getItem(STORE_KEY); + if (raw) return JSON.parse(raw); + } catch (e) { /* ignore */ } + return {}; +} + +function savePrefs(p) { + try { localStorage.setItem(STORE_KEY, JSON.stringify(p)); } catch (e) { /* ignore */ } +} + +const initial = loadPrefs(); + +function _detectInitialLang(stored) { + if (stored) return stored; // explicit user choice — respect it + if (typeof navigator !== 'undefined') { + const nav = (navigator.language || (navigator.languages && navigator.languages[0]) || '').toLowerCase(); + if (nav.startsWith('zh')) return 'zh'; + // Anything else (en, ja, fr, etc.) defaults to zh only as a last resort. + // The user can always switch via the kebab menu — their selection will stick. + if (nav) return nav.startsWith('en') ? 'en' : 'zh'; + } + return 'zh'; +} + +// Reactive global state. The shape is documented in applyDefaults(). +export const store = reactive({ + // ---- user preferences (persisted) ---- + // Default picks Chinese for zh-* browser locales, English for en-* locales, + // and Chinese as the final fallback. Stored value (explicit user choice) wins. + lang: _detectInitialLang(initial.lang), + theme: initial.theme || 'auto', // 'auto' | 'light' | 'dark' + showZh: initial.showZh ?? true, // mixed-lang UI helper + + // ---- runtime state (NOT persisted) ---- + activeTab: 'timeline', // 'timeline' | 'dashboard' | 'wiki' | 'graph' + ready: false, // true after first i18n + theme apply + toast: null, // { msg, ts } + toastTimer: null, + runStatus: { is_running: false, progress: { current: 0, total: 0, message: '' } }, + stats: { memories: 0, sessions: 0, wiki_pages: 0, avg_score: 0, graph: '0/0', dbPath: '' }, + modelInfo: { provider: 'rules', model: 'rules', api_key_set: false, key_len: 0, + // Reachability: 'unset' | 'ok' | 'stale' | 'fail' + // unset — no key configured + // ok — key set AND last test (or live run) succeeded recently + // stale — key set, no recent test (treat as 'configured but unverified') + // fail — key set, last test/error was a provider failure + reachability: 'unset', last_test_ok: null, last_test_at: null, last_test_message: '' }, + stripDismissed: false, + lastRunId: null, +}); + +// ---- shared actions ---- +// Lightweight event-bus: App.js registers the implementation that opens +// drawers (settings / diagnostic). Other components call these helpers +// without having to bubble events through the Vue tree. +const _actions = { + openSettings: () => { /* set by App.js */ }, + openDiag: () => { /* set by App.js */ }, + llmRun: () => { /* set by App.js — kicks off an LLM consolidation run */ }, +}; +export function registerActions(map) { Object.assign(_actions, map); } +export function callAction(name, ...args) { + const fn = _actions[name]; + if (typeof fn === 'function') { + try { return fn(...args); } catch (_e) { return undefined; } + } + return undefined; +} + +// Persist prefs whenever they change. +watch(() => [store.lang, store.theme, store.showZh], () => { + savePrefs({ lang: store.lang, theme: store.theme, showZh: store.showZh }); +}); + +// ---- i18n ---- +// _i18n is a reactive proxy so any component template / computed that calls +// `t()` automatically re-renders when the dictionaries finish loading. Without +// this, the first render flashes raw keys like `tab.wiki` until something else +// forces a re-render (see commit a34de38 → a plain object's keys aren't tracked). +const _i18n = reactive({ en: {}, zh: {} }); +let _i18nLoaded = false; + +/** + * Read the inline i18n JSON that the server injects into ``<script + * type="application/json" id="loop-i18n-en">`` / ``id="loop-i18n-zh"`` + * (see ``serve/app.py`` index route). Reading these SYNCHRONOUSLY at + * module init means Vue's first render already has the strings — no + * flash of raw keys like ``tab.wiki`` while the JSON fetch is in + * flight, which the user reported as "页面先变英文再转中文". + * + * Returns true if at least one dict was populated inline. + */ +function _readInlineI18n() { + if (typeof document === 'undefined') return false; + let ok = false; + for (const lang of ['en', 'zh']) { + const tag = document.getElementById('loop-i18n-' + lang); + if (!tag) continue; + try { + const dict = JSON.parse(tag.textContent || '{}'); + Object.assign(_i18n[lang], dict); + ok = true; + } catch (_e) { /* ignore parse errors — fetch fallback below */ } + } + return ok; +} + +// Synchronously hydrate from inline JSON before Vue mounts. This is the +// critical line that prevents the "flash of English keys" the user +// complained about on hard refresh. +if (_readInlineI18n()) { + _i18nLoaded = true; +} + +export async function loadI18n() { + if (_i18nLoaded) return _i18n; + // Fetch the JSON files for any keys that weren't inlined (e.g. when + // the page is opened from a different host / dev mode). ``cache: + // 'no-store'`` bypasses the browser HTTP cache so that a user who + // just edited an i18n string and hit reload actually sees the new + // copy — Safari in particular is aggressive about caching small + // JSON files served with ``Cache-Control: max-age=3600``. + const [en, zh] = await Promise.all([ + fetch('static/i18n/en.json', { cache: 'no-store' }).then(r => r.ok ? r.json() : {}).catch(() => ({})), + fetch('static/i18n/zh.json', { cache: 'no-store' }).then(r => r.ok ? r.json() : {}).catch(() => ({})), + ]); + // Merge on top of any inline dicts (the inline version usually wins + // since it's what the server served for this exact render, but if + // the user is in a stale browser cache and we got fresher JSON from + // the network, that's the better source). + Object.assign(_i18n.en, en); + Object.assign(_i18n.zh, zh); + _i18nLoaded = true; + return _i18n; +} + +export function t(key, vars) { + const dict = _i18n[store.lang] || _i18n.zh || {}; + let s = dict[key] ?? _i18n.en[key] ?? key; + if (vars) { + for (const k of Object.keys(vars)) s = s.replace('{' + k + '}', vars[k]); + } + return s; +} + + +export function tOrKey(key) { + return t(key, undefined); +} + +export const lang = computed(() => store.lang); +export const theme = computed(() => store.theme); + +// ---- theme ---- +function _systemPrefersDark() { + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +export function effectiveTheme() { + if (store.theme === 'auto') return _systemPrefersDark() ? 'dark' : 'light'; + return store.theme; +} + +export function applyTheme() { + document.documentElement.setAttribute('data-theme', effectiveTheme()); +} + +export function applyLang() { + document.documentElement.setAttribute('data-lang', store.lang); + // Also update the standard ``lang`` attribute so screen readers, + // browser translation prompts, and search-engine hints reflect the + // actual content language. ``data-lang`` is kept for CSS selectors + // that key off it. + const htmlLang = store.lang === 'zh' ? 'zh-CN' : 'en'; + document.documentElement.setAttribute('lang', htmlLang); +} + +// Apply theme/lang to <html> reactively. +watch(() => store.theme, () => applyTheme()); +watch(() => store.lang, () => applyLang()); +if (window.matchMedia) { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (store.theme === 'auto') applyTheme(); + }); +} + +// ---- toast ---- +export function toast(msg, ms = 2200) { + store.toast = { msg, ts: Date.now() }; + if (store.toastTimer) clearTimeout(store.toastTimer); + store.toastTimer = setTimeout(() => { store.toast = null; }, ms); +} + +// ---- prefs patch ---- +export function patchPrefs(patch) { + Object.assign(store, patch); + savePrefs({ + lang: store.lang, theme: store.theme, showZh: store.showZh, + }); +} + +// ---- formatting helpers ---- +export function escapeHtml(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +// ---- HTML sanitizer for v-html (XSS protection) ---- +// Minimal allowlist sanitizer — only permits safe Markdown-rendered tags. +// Strips all event handlers, javascript: URLs, and dangerous attributes. +const _SANITIZE_RE = /<(\/?)(script|style|iframe|object|embed|form|input|button|select|textarea|a\s+[^>]*href\s*=\s*["']?javascript:[^>]*|svg\s+[^>]*>|math\s+[^>]*>)|(\s+on\w+\s*=|javascript:)|(<\/?[a-z][^>]*\s+[^>]*>)/gi; + +export function sanitizeHtml(dirty) { + if (dirty == null) return ''; + let s = String(dirty); + // Step 1: Remove script, style, iframe, object, embed, form tags entirely + s = s.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ''); + s = s.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ''); + s = s.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, ''); + s = s.replace(/<object[^>]*>[\s\S]*?<\/object>/gi, ''); + s = s.replace(/<embed[^>]*>/gi, ''); + s = s.replace(/<form[^>]*>[\s\S]*?<\/form>/gi, ''); + // Step 2: Remove on* event handlers and javascript: URLs from tags + s = s.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, ''); + s = s.replace(/href\s*=\s*["']?\s*javascript:[^"'\s>]+/gi, 'href="#"'); + // Step 3: Remove svg/math elements with event handlers (common XSS vector) + s = s.replace(/<(svg|math)[^>]*>[\s\S]*?<\/\1>/gi, ''); + // Step 4: Remove data: URLs except for safe image types + s = s.replace(/src\s*=\s*["']?\s*data:(?!image\/(png|jpeg|jpg|gif|webp)):[^"'\s>]+/gi, 'src="#"'); + return s; +} + +export function timeAgo(ts) { + if (!ts) return '—'; + const d = Date.now() / 1000 - Number(ts); + if (d < 60) return Math.max(0, Math.floor(d)) + 's'; + if (d < 3600) return Math.floor(d / 60) + 'm'; + if (d < 86400) return Math.floor(d / 3600) + 'h'; + if (d < 86400 * 30) return Math.floor(d / 86400) + 'd'; + return Math.floor(d / 86400 / 30) + 'mo'; +} + +export function fmtTime(ts) { + if (!ts) return '—'; + const d = new Date(Number(ts) * 1000); + return d.toLocaleString(); +} + +// ---- shared numeric / string formatting helpers ---- +// Moved here from Dashboard.js so both Dashboard and Timeline (and any +// future component) can share one implementation. +export function fmtNum(v) { return Number(v || 0).toLocaleString(); } + +export function truncate(s, n = 28) { + if (!s) return ''; + return s.length > n ? s.slice(0, n - 1) + '…' : s; +} + +export function shortenPath(s, max = 22) { + if (!s) return ''; + return s.length > max ? '…' + s.slice(-(max - 1)) : s; +} + +// Format a duration in seconds as "Nd Nh" / "Nh Nm" / "Nm". +export function fmtDuration(sec) { + sec = Math.max(0, Math.floor(sec || 0)); + const d = Math.floor(sec / 86400); + const h = Math.floor((sec % 86400) / 3600); + const m = Math.floor((sec % 3600) / 60); + if (d > 0) return `${d}d ${h}h`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} diff --git a/loop_memory/serve/watcher.py b/loop_memory/serve/watcher.py index 98d6dae..25f0b1e 100644 --- a/loop_memory/serve/watcher.py +++ b/loop_memory/serve/watcher.py @@ -4,12 +4,18 @@ written by Codex CLI / Claude Code / Hermes and ingests **only when a transcript is "done"**: - * it has not been modified for ``idle_seconds`` (default 60s), and - * the size is stable across the same idle window. + * its **byte size** has not grown for ``idle_seconds`` (default 60s). + +We intentionally do NOT key on mtime alone: Codex desktop (and similar +agents) refresh the file mtime on background metadata flushes even +when no new content is being written. Treating those as "still being +written" would prevent an ingest from ever firing for long, active +sessions. Size-stable-for-N-seconds is the correct signal. This means a 30-minute chat that just ended is picked up ~60 seconds after the user (or the CLI's auto-save) finished writing. Active -typing that mutates the file every few seconds is **not** picked up. +typing that grows the file size every few seconds is **not** picked up, +but pure metadata flushes on an idle file **are**. Already-ingested files are tracked in a small JSON ledger so re-runs don't double-write. @@ -19,13 +25,26 @@ import json import logging +import os import time from pathlib import Path +from typing import Any, Optional from ..ingest.loader import BaseLoader from ..ingest.pipeline import MemoryPipeline log = logging.getLogger("loop_memory.watcher") +# Make ``watching ...`` / ``watcher settings reloaded: ...`` lines +# visible in the hook process log without requiring every caller to +# configure logging first. ``basicConfig`` is a no-op once the root +# logger already has a handler, so importing this module from the +# serve app or from tests doesn't disturb their log formatting. +if not logging.getLogger().handlers: + _level_name = os.environ.get("LOOP_MEMORY_LOG_LEVEL", "INFO").upper() + logging.basicConfig( + level=getattr(logging, _level_name, logging.INFO), + format="%(asctime)s [%(name)s] %(levelname)s %(message)s", + ) def _ledger_path(watch_dir: Path) -> Path: @@ -48,14 +67,49 @@ def _save_ledger(path: Path, ledger: dict) -> None: log.exception("failed to persist ingest ledger at %s", path) +# Default knobs when the user has no persisted ingest settings yet. +# We default to 5 minutes (300s) of size-stable idle before ingesting: +# shorter intervals fragment long conversations into multiple partial +# memories, longer intervals delay recall. The user can dial this up +# or down from Settings → 采集频率. ``poll_seconds`` defaults to 5 +# (vs the previous 2.0) to cut down on stat() churn on the watched +# directory — stat is cheap on SSD but very cheap on the order of +# seconds; not the order of milliseconds. The user can always tune +# both via the Settings drawer. +DEFAULT_IDLE_SECONDS = 300.0 +DEFAULT_POLL_SECONDS = 5.0 + + +def _read_ingest_settings(store) -> tuple[float, float]: + """Pull ``ingest.idle_seconds`` / ``ingest.poll_seconds`` from the + settings store. Missing keys fall back to module defaults. + + Returning floats (not e.g. ints) keeps the math inside the loop + predictable: ``time.sleep(poll_seconds)`` and the idle comparison + both treat the value as a wall-clock duration in seconds. + + A failure here is logged and falls back to defaults rather than + crashing the watcher — the watcher is a long-lived background + process and a transient DB hiccup must not kill it. + """ + try: + cfg = store.get_setting("ingest", {}) if store is not None else {} + except Exception: + cfg = {} + idle = float(cfg.get("idle_seconds", DEFAULT_IDLE_SECONDS)) + poll = float(cfg.get("poll_seconds", DEFAULT_POLL_SECONDS)) + return idle, poll + + def run_watcher( loader: BaseLoader, watch_dir: Path, pipeline: MemoryPipeline, - poll_seconds: float = 2.0, - idle_seconds: float = 60.0, - ledger: Optional[dict] = None, - on_ingest: Optional[callable] = None, + poll_seconds: float | None = None, + idle_seconds: float | None = None, + ledger: dict | None = None, + on_ingest: callable | None = None, + store: Any = None, ) -> None: """Watch a directory and ingest each transcript once it has been idle for ``idle_seconds``. @@ -67,6 +121,14 @@ def run_watcher( ``on_ingest`` is an optional callable invoked with no arguments after a successful ingest. The serve layer hooks this to a consolidator scheduler so ``realtime`` mode can fire. + + ``store`` is an optional :class:`MemoryStore`. When provided, the + watcher reads ``ingest.idle_seconds`` / ``ingest.poll_seconds`` + from the settings table at every iteration so the user can dial + ingest frequency from the Settings drawer WITHOUT restarting the + launchd watcher process. Reads are throttled to once every + ``SETTINGS_RELOAD_EVERY`` ticks (cheap SQLite SELECT) so we + don't add noticeable overhead even at a 5-second poll cadence. """ watch_dir = Path(watch_dir).expanduser() watch_dir.mkdir(parents=True, exist_ok=True) @@ -74,6 +136,20 @@ def run_watcher( if ledger is None: ledger = _load_ledger(ledger_path) + # Resolve initial values from the settings store if available, + # otherwise fall back to the kwarg / module defaults. + store_idle, store_poll = _read_ingest_settings(store) + if idle_seconds is None: + idle_seconds = store_idle + if poll_seconds is None: + poll_seconds = store_poll + + # Re-read settings on a wall-clock cadence instead of per-tick, + # so reload latency doesn't grow with ``poll_seconds``. We default + # to 30s: short enough that a user dialing the slider sees the + # effect promptly, long enough that we don't hammer SQLite. + SETTINGS_RELOAD_SECONDS = 30.0 + log.info( "watching %s for %s transcripts (idle>=%.0fs, poll=%.1fs)", watch_dir, loader.source, idle_seconds, poll_seconds, @@ -82,8 +158,27 @@ def run_watcher( def persist(): _save_ledger(ledger_path, ledger) + last_reload_at = 0.0 try: while True: + # Throttled settings reload on a wall-clock cadence so + # the reload interval is stable regardless of poll_seconds. + if store is not None: + now_mono = time.monotonic() + if now_mono - last_reload_at >= SETTINGS_RELOAD_SECONDS: + last_reload_at = now_mono + try: + new_idle, new_poll = _read_ingest_settings(store) + if new_idle != idle_seconds or new_poll != poll_seconds: + log.info( + "watcher settings reloaded: idle=%.0fs poll=%.1fs", + new_idle, new_poll, + ) + idle_seconds = new_idle + poll_seconds = new_poll + except Exception: + log.exception("settings reload failed (using current values)") + try: files = list(loader.discover(watch_dir)) except FileNotFoundError: @@ -106,16 +201,50 @@ def persist(): # Already-ingested with same signature → skip. if prev and prev.get("sig") == list(sig): continue - # Already-ingested but file changed → treat as a new - # session appended to the same file. Reset idle timer. + + # Already-ingested but file changed. + # + # v2 fix (size-stable idle, not mtime-stable idle): + # Previously any mtime refresh — including background + # metadata flushes from Codex desktop that do not add + # any new content — would reset the idle timer, which + # meant a long-running active session would never + # trigger an ingest: every keystroke flushed the file + # mtime and we kept waiting. + # + # The real signal of "still being written" is *content + # growth* (size increasing). mtime alone is unreliable. + # We now track ``last_size_change_at`` and only treat a + # file as active when its size is actually growing. if prev and prev.get("ingested_at"): - ledger[key] = { - "sig": list(sig), - "first_seen": now, - "last_mtime": st.st_mtime, - "size": st.st_size, - "ingested_at": None, - } + prev_size = prev.get("size", -1) + if st.st_size > prev_size: + # Real content growth → bump idle timestamp. + ledger[key] = { + "sig": list(sig), + "first_seen": prev.get("first_seen", now), + "last_mtime": st.st_mtime, + "size": st.st_size, + "last_size_change_at": now, + "ingested_at": None, + } + else: + # Only mtime refreshed, no new bytes. Keep the + # idle clock running — do NOT reset it. + ledger[key] = { + "sig": list(sig), + "first_seen": prev.get("first_seen", now), + "last_mtime": st.st_mtime, + "size": st.st_size, + # Fall back to the previous bump time so + # legacy ledgers without the field keep + # working. + "last_size_change_at": prev.get( + "last_size_change_at", + prev.get("first_seen", now), + ), + "ingested_at": None, + } continue # First observation: stamp it. @@ -125,14 +254,23 @@ def persist(): "first_seen": now, "last_mtime": st.st_mtime, "size": st.st_size, + "last_size_change_at": now, "ingested_at": None, } persist() continue # Subsequent observation: only proceed if the file has - # been idle for ``idle_seconds``. - if (now - st.st_mtime) < idle_seconds: + # been size-stable (not just mtime-stable) for + # ``idle_seconds``. Codex desktop touches mtime on + # every flush but the size only grows when new + # conversation content lands — that's the signal we + # care about. + last_change = ledger[key].get( + "last_size_change_at", + ledger[key].get("first_seen", now), + ) + if (now - last_change) < idle_seconds: continue # Stable and idle → ingest once. @@ -170,3 +308,144 @@ def persist(): except KeyboardInterrupt: log.info("watcher exiting") persist() + + + +def run_once( + loader: BaseLoader, + watch_dir: Path, + pipeline: MemoryPipeline, + poll_seconds: float = 2.0, + idle_seconds: float = 0.0, + ledger: dict | None = None, + on_ingest: callable | None = None, +) -> dict[str, Any]: + """Run a single ingest pass over ``watch_dir`` and return a summary. + + Unlike :func:`run_watcher` this does NOT loop — it scans once, + ingests any file whose size has grown since the last successful + ingest (or that has been size-stable for ``idle_seconds``), and + returns. Used by the server-side force-ingest endpoint so a UI + button can trigger one batch without spawning a long-lived + watcher process. + + Returns a dict with:: + + { + "scanned": int, # number of files seen + "ingested": int, # number of files successfully ingested + "skipped": int, # unchanged or already-ingested + "errors": int, # files that failed to load + "files": [ # per-file detail + {"path": str, "status": "ingested"|"skipped"|"error", + "summary_items": int, "error": str?} + ], + } + """ + watch_dir = Path(watch_dir).expanduser() + watch_dir.mkdir(parents=True, exist_ok=True) + ledger_path = _ledger_path(watch_dir) + if ledger is None: + ledger = _load_ledger(ledger_path) + + def _persist(): + _save_ledger(ledger_path, ledger) + + result: dict[str, Any] = { + "scanned": 0, + "ingested": 0, + "skipped": 0, + "errors": 0, + "files": [], + } + try: + files = list(loader.discover(watch_dir)) + except FileNotFoundError: + files = [] + + now = time.time() + for path in files: + result["scanned"] += 1 + key = str(path) + try: + st = path.stat() + except FileNotFoundError: + continue + if path.name == ".loop_memory_seen.json": + continue + + prev = ledger.get(key) + prev_size = (prev or {}).get("size", -1) + prev_ingested = (prev or {}).get("ingested_at") + + # If the file is identical to what we last ingested, skip. + if prev_ingested and st.st_size == prev_size: + result["skipped"] += 1 + result["files"].append({"path": key, "status": "skipped"}) + continue + + # Optional idle gate: when idle_seconds > 0, only ingest if + # the file's size has been stable for at least that long. + # When idle_seconds == 0 (the default for run_once), ingest + # immediately as long as new content exists. + if idle_seconds > 0: + last_change = (prev or {}).get( + "last_size_change_at", + (prev or {}).get("first_seen", now), + ) + if (now - last_change) < idle_seconds: + result["skipped"] += 1 + result["files"].append({ + "path": key, "status": "skipped", + "reason": "not_idle_long_enough", + }) + continue + + # Try to load + ingest. + try: + session = loader.load_one(path) + except Exception as e: + log.exception("loader failed on %s", path) + result["errors"] += 1 + result["files"].append({ + "path": key, "status": "error", + "error": f"{type(e).__name__}: {e}", + }) + continue + + if session is None: + result["skipped"] += 1 + result["files"].append({"path": key, "status": "skipped"}) + continue + + try: + pipe_result = pipeline.run(session) + n_items = len(pipe_result.summary_items) + result["ingested"] += 1 + result["files"].append({ + "path": key, "status": "ingested", + "summary_items": n_items, + }) + ledger[key] = { + "sig": [st.st_mtime, st.st_size], + "first_seen": (prev or {}).get("first_seen", now), + "last_mtime": st.st_mtime, + "size": st.st_size, + "last_size_change_at": now, + "ingested_at": now, + } + _persist() + if on_ingest is not None: + try: + on_ingest() + except Exception: + log.exception("on_ingest callback failed") + except Exception as e: + log.exception("pipeline failed on %s", path) + result["errors"] += 1 + result["files"].append({ + "path": key, "status": "error", + "error": f"{type(e).__name__}: {e}", + }) + + return result diff --git a/loop_memory/storage/retrieval.py b/loop_memory/storage/retrieval.py new file mode 100644 index 0000000..96051ab --- /dev/null +++ b/loop_memory/storage/retrieval.py @@ -0,0 +1,366 @@ +""" +Hybrid retrieval primitives. + +Two building blocks used by ``MemoryStore.recall_hybrid``: + +* ``bm25_search`` — runs an FTS5 MATCH against the memories or wiki + mirror, returning ranked candidates with their native BM25 score. + +* ``fuse_rrf`` — Reciprocal Rank Fusion across multiple ranked lists. + Each list contributes ``1 / (k + rank_i(d))`` per document; + the document's fused score is the sum across lists. RRF is the + standard 2020+ recipe for combining heterogeneous rankers + (semantic, keyword, entity) without needing to align their raw + score distributions, which is exactly the problem we have here. + +No external deps. SQLite FTS5 ships with the stdlib ``sqlite3`` module. +""" +from __future__ import annotations + +from typing import Any, Iterable + +# RRF constant. 60 is the value used in the original Cormack et al. +# 2009 paper and matches what Mem0/Graphiti use in 2026. +DEFAULT_RRF_K = 60 + + +def _escape_fts(query: str) -> str: + """Wrap the query for FTS5's MATCH expression. + + The pipeline is query-type-aware: + + * ASCII/Latin tokens (``vue``, ``codex``) → wrapped in double + quotes so ``vue.js`` does NOT collapse into one exact-phrase. + * CJK runs (``知识图谱``, ``记忆系统``) → decomposed into character + trigrams (``知识图``, ``识图谱``) joined by ``AND``. The trigram + tokenizer otherwise needs each trigram to be a separate FTS5 + token, and quoting the whole run as an exact phrase matches + only documents that contain the literal byte sequence — which + nothing does for arbitrary text. + + Returns ``""`` when there is no usable content. + """ + import re + + s = (query or "").lower() + if not s.strip(): + return "" + + # 1. Split into "Latin word" tokens and "CJK runs" tokens. Any + # contiguous run of CJK ideographs is treated as one chunk. + cjk_re = re.compile(r"[一-鿿]+") + lat_re = re.compile(r"[\w]+") + + parts: list[str] = [] + + # Walk through the string preserving order; we don't actually need + # FTS5 to honour order (RRF rescues it), so a flat list is fine. + for cjk_run in cjk_re.findall(s): + if len(cjk_run) < 3: + # too short for trigrams; quote and fall back to LIKE + parts.append(f'"{cjk_run}"') + continue + grams = [cjk_run[i:i + 3] for i in range(len(cjk_run) - 2)] + if len(grams) <= 2: + # 3-4 chars: emit them all (precise enough) + parts.extend(grams) + else: + # 5+ chars: AND only the boundary trigrams (first + last). + # The middle grams are usually present whenever the + # boundary grams are, so omitting them makes the query + # more recall-friendly without losing precision. + parts.append(grams[0]) + parts.append(grams[-1]) + + # Also pull out Latin words (skip CJK runs). + cleaned = cjk_re.sub(" ", s) + for w in lat_re.findall(cleaned): + if len(w) >= 1: + parts.append(f'"{w}"') + + if not parts: + return "" + # Default FTS5 operator between terms is AND; that's what we want. + return " ".join(parts) + + +def bm25_search( + store: Any, + query: str, + kind: str = "memories", + limit: int = 50, + source_filter: str | None = None, +) -> list[dict]: + """Run an FTS5 query and return a list of ``{"id": ..., "_score": bm25}``. + + ``kind`` is "memories" or "wiki". The native BM25 score from + SQLite's ``bm25(memories_fts)`` is negative (lower = better), so + we negate it for the RRF caller which expects positive scores. + + For ``wiki``, the FTS query is restricted to rows whose scope + allows the caller's ``source_filter`` (when provided). + + Fallback: the FTS5 trigram tokenizer requires >=3 contiguous + characters to create any token. For inputs that are all-CJK + AND total length <3, FTS5 returns nothing; we then issue a + ``LIKE '%<q>%'`` over the source table as a backstop. For our + scale (<10k memories, <1k wiki) LIKE is well under 5 ms. + """ + import re as _re + fts_q = _escape_fts(query) + is_short_cjk = bool( + _re.fullmatch(r"[一-鿿]+", (query or "").strip() or "") + and len((query or "").strip()) < 3 + ) + rows: list = [] + if fts_q and not is_short_cjk: + table = "memories_fts" if kind == "memories" else "wiki_fts" + score_col = f"bm25({table})" + with store._conn() as c: + if kind == "memories": + # Join back to memories so we can return the actual + # UUID `id` column (the FTS5 rowid is just a sqlite + # rowid, not the memory's primary key). + sql = ( + f"SELECT m.id AS id, {score_col} AS s " + f"FROM {table} t " + f"JOIN memories m ON m.rowid = t.rowid " + f"WHERE {table} MATCH ? " + f"ORDER BY s LIMIT ?" + ) + rows = c.execute(sql, (fts_q, limit)).fetchall() + else: + # Wiki: also join back to wiki_pages so we can apply the + # per-source scope filter at the SQL layer. We negate + # the bm25 score so the RRF caller sees positive values. + sql = ( + f"SELECT w.id AS id, {score_col} AS s, w.scope AS scope " + f"FROM {table} t " + f"JOIN wiki_pages w ON w.rowid = t.rowid " + f"WHERE {table} MATCH ? " + f"ORDER BY s LIMIT ?" + ) + rows = c.execute(sql, (fts_q, limit * 3)).fetchall() + # Apply scope filter in Python so we can use the same + # token-matching convention as the rest of the system. + rows = _filter_wiki_scope(rows, source_filter)[:limit] + # Always run LIKE as a backstop. Even when FTS5 returns hits, + # LIKE may surface documents the trigram tokenizer can't see + # (short CJK, OCR noise, mixed code identifiers, ...). The two + # lists are merged by id; LIKE rows get a synthetic importance + # score so the RRF caller can rank them alongside BM25. + like_rows = _like_fallback(store, query, kind=kind, limit=limit, + source_filter=source_filter) + merged: dict[str, dict] = {} + for r in (rows or []): + merged[r["id"]] = {"id": r["id"], "_score": -float(r["s"])} + for r in like_rows: + imp = r["importance"] if "importance" in r.keys() else 0.5 + if r["id"] not in merged: + merged[r["id"]] = {"id": r["id"], "_score": float(imp or 0.5)} + return list(merged.values()) + + +def _like_fallback( + store: Any, + query: str, + kind: str, + limit: int, + source_filter: str | None = None, +) -> list: + """Brute-force LIKE fallback used when FTS5 trigram can't index + short CJK queries. Searches <substr> against the source table + directly. Returns rows in the same shape as bm25_search. + """ + pat = f"%{(query or '').strip()}%" + if not query.strip(): + return [] + with store._conn() as c: + if kind == "memories": + sql = ( + "SELECT m.id AS id, m.importance AS importance " + "FROM memories m WHERE LOWER(text) LIKE LOWER(?) " + "ORDER BY m.importance DESC, m.created_at DESC LIMIT ?" + ) + return [dict(r) for r in c.execute(sql, (pat, limit)).fetchall()] + sql = ( + "SELECT w.id AS id, w.importance AS importance, w.scope AS scope " + "FROM wiki_pages w " + "WHERE LOWER(w.title) LIKE LOWER(?) OR LOWER(w.body) LIKE LOWER(?) " + "ORDER BY w.importance DESC, w.updated_at DESC LIMIT ?" + ) + rows = list(c.execute(sql, (pat, pat, limit * 3)).fetchall()) + return _filter_wiki_scope(rows, source_filter)[:limit] + + +def _filter_wiki_scope(rows: Iterable, source: str | None) -> list: + if not source: + return list(rows) + tok = (source or "").strip().lower() + out = [] + for r in rows: + scope = (r["scope"] if "scope" in r.keys() else "global") or "global" + if scope == "global": + out.append(r) + else: + # scope is "global" or a comma-list like "codex,claude" + allowed = {s.strip() for s in scope.split(",") if s.strip()} + if tok in allowed: + out.append(r) + return out + + +def fuse_rrf( + ranked_lists: list[list[dict]], + k: int = DEFAULT_RRF_K, +) -> list[dict]: + """Reciprocal Rank Fusion. + + Each input list is a list of ``{"id": ..., "_score": ...}``, + already sorted by descending score (best first). Documents not + present in a list contribute 0 from that list. + + The fused score is:: + + fused(d) = sum over lists i of 1 / (k + rank_i(d)) + + where ``rank_i(d)`` is 1-based and ``None`` (= not in list) is + treated as 0. + + Returns a list of ``{"id": ..., "_rrf": ...}`` sorted by fused + score descending. The ``_score`` field from each input is ignored + (RRF operates on ranks, not raw scores). + """ + fused: dict[str, float] = {} + for lst in ranked_lists: + for rank, item in enumerate(lst, start=1): + fused[item["id"]] = fused.get(item["id"], 0.0) + 1.0 / (k + rank) + return [{"id": i, "_rrf": s} for i, s in sorted(fused.items(), key=lambda kv: -kv[1])] + + +# ---------------------------------------------------------------------- +# Temporal reasoning layer +# ---------------------------------------------------------------------- +# Mem0 (April 2026) showed that explicitly modelling the *temporal intent* +# of the query + reranking by date relevance contributes about 27 points +# on LongMemEval (94.4 vs 67.8 baseline). The intuition is simple: +# queries that say "what is the current X" should prefer the *most recent* +# memory that covers X, even if an older one is semantically closer. +# Conversely, "the project I shipped last week" should prefer the dated +# memory from that week, even if a fresher one exists. Without a temporal +# pass, RRF will happily return a newer-but-irrelevant memory. +# +# This module exposes two primitives. The store wires them in below +# the RRF fusion, so the change is opt-in for callers that already +# use ``recall_hybrid``. +# ---------------------------------------------------------------------- + +# Lightweight Chinese + English lexicons. We keep this in a plain +# constant (not an LLM call) so the latency cost of adding temporal +# reasoning is one regex pass per query. +_TEMPORAL_CURRENT = ( + "current", "currently", "now", "today", "latest", "recent", + "现在", "当前", "目前", "今天", "此刻", "现在的", "最新的", "现在的", +) +_TEMPORAL_PAST = ( + "previous", "previously", "before", "last", "ago", "yesterday", + "earlier", "originally", "initially", "at that time", "back then", + "之前", "上次", "上次", "上次", "曾经", "过去", "原来的", "当初", + "之前", "前几天", "上次", "已经", "之前", +) +_TEMPORAL_FUTURE = ( + "tomorrow", "upcoming", "next", "will", "plan to", "going to", + "future", "scheduled", + "明天", "下次", "未来", "即将", "之后", "将要", "计划", "打算", +) + + +def detect_temporal_intent(query: str) -> tuple[str, float]: + """Return (intent, confidence) where intent is one of + 'current', 'past', 'future', 'any'. + + Confidence is the rough lexical overlap with the matching lexicon, + capped at 1.0. 'any' always has confidence 0.0 (no signal). + """ + import re as _re + + q = (query or "").lower() + if not q.strip(): + return ("any", 0.0) + + def _hit(lex): + hits = 0 + for w in lex: + # use a simple substring match; we deliberately avoid a + # tokenizer here because the query is short (≤ a few + # sentences) and Jinja-style tokenization would slow us + # down without measurable benefit. + if w in q: + hits += 1 + return hits + + n_cur = _hit(_TEMPORAL_CURRENT) + n_past = _hit(_TEMPORAL_PAST) + n_fut = _hit(_TEMPORAL_FUTURE) + counts = {"current": n_cur, "past": n_past, "future": n_fut} + intent = max(counts, key=counts.get) # type: ignore[arg-type] + total = n_cur + n_past + n_fut + if total == 0: + return ("any", 0.0) + # If the winning intent only wins by 1 over the runner-up, treat + # as 'any' (too ambiguous to do anything useful). + sorted_counts = sorted(counts.values(), reverse=True) + if sorted_counts[0] - sorted_counts[1] < 1: + return ("any", 0.0) + confidence = min(1.0, counts[intent] / 3.0) # 3 hits = full confidence + return (intent, confidence) + + +def temporal_score( + *, + created_at: float, + updated_at: float | None, + intent: str, + now: float, + confidence: float, +) -> float: + """Return a multiplier in roughly [0.5, 1.5] for how well a memory's + date matches the query intent. + + - intent='current': more recent = better; 30d-half-life decay. + - intent='past': memories close to "now - small_delta" get a small + boost; very recent memories are penalised so dated history wins. + - intent='future': memories with created/updated_at > now get a + strong boost (they're "upcoming plans"). We also accept memories + whose text mentions future intent (caller decides via flag). + - intent='any': returns 1.0 (no opinion). + + A confidence < 1.0 softens the effect; with confidence 0.0 the + function returns 1.0 regardless of intent. + """ + if intent == "any" or confidence <= 0.0: + return 1.0 + import math as _math + + dt = float(updated_at or created_at or now) + age_days = max(0.0, (now - dt) / 86400.0) + if intent == "current": + # 30-day half-life: fresh ≈1.5, 30d ≈1.0, 180d ≈0.65, 1y ≈0.5 + base = 1.25 + 0.25 * _math.exp(-age_days / 30.0) + elif intent == "past": + # Sigmoid centred on "1 week ago" — slight penalty for very + # recent memories (they're probably the new state, not the past + # state the user asked about). Cross-over at ~7 days old. + x = (age_days - 7.0) / 7.0 + base = 1.25 + 0.25 * (_math.tanh(x)) + elif intent == "future": + if dt >= now: + base = 1.4 # upcoming / planned + else: + base = 0.7 # not future-dated + else: + base = 1.0 + # Blend toward 1.0 by (1 - confidence) so we don't blow up a + # borderline match into a hard rule. + return 1.0 + (base - 1.0) * confidence + diff --git a/loop_memory/storage/sqlite_store.py b/loop_memory/storage/sqlite_store.py index fe3d645..ef40369 100644 --- a/loop_memory/storage/sqlite_store.py +++ b/loop_memory/storage/sqlite_store.py @@ -27,6 +27,11 @@ import struct import time import uuid + +# Local imports are deferred inside recall_hybrid() to avoid a circular +# dependency on .retrieval during package import; the helpers used by +# _hydrate_* are imported here for the same reason. +from .retrieval import temporal_score # noqa: E402 from collections.abc import Iterator from contextlib import contextmanager from dataclasses import dataclass @@ -60,13 +65,89 @@ score REAL NOT NULL DEFAULT 0.5, ttl REAL, tags TEXT, - embedding BLOB + embedding BLOB, + agent_id TEXT, + user_id TEXT, + external_id TEXT ); CREATE INDEX IF NOT EXISTS idx_mem_session ON memories(session_id); CREATE INDEX IF NOT EXISTS idx_mem_created ON memories(created_at); CREATE INDEX IF NOT EXISTS idx_mem_score ON memories(score); CREATE INDEX IF NOT EXISTS idx_mem_kind ON memories(kind); +-- Per-agent (agent_id, user_id, external_id) indexes are created +-- in _init_schema *after* the ALTER TABLE that adds the columns, +-- so opening an old DB doesn't fail with "no such column: agent_id". +-- See _init_schema for the migration block. + +-- FTS5 mirror of memories.text + tags. We keep it in sync via triggers +-- (see end of this schema block) so every INSERT/UPDATE/DELETE on +-- memories propagates to memories_fts without app-level code. +-- The bm25() ranking is the kernel-side OK API; downstream callers +-- fuse this with the existing semantic score via Reciprocal Rank +-- Fusion (see recall_hybrid below). +-- FTS5 mirror of memories.text + tags. The trigram tokenizer +-- (SQLite ≥ 3.34) gives us substring search, which is the only +-- thing that works for CJK text without an external ICU build. +-- Trade-off vs unicode61: trigram produces larger indexes and +-- "word boundary" semantics are looser (e.g. "javascript" matches +-- "java"). For our use case (mixed Chinese/English with code +-- snippets) trigram wins decisively. The mirrors are kept in +-- sync via triggers so every INSERT/UPDATE/DELETE on memories +-- propagates automatically. +CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( + text, + tags, + source, + tokenize = 'trigram' +); + +-- FTS5 mirror of wiki_pages. Same trigger pattern. +CREATE VIRTUAL TABLE IF NOT EXISTS wiki_fts USING fts5( + title, + body, + summary, + tags, + tokenize = 'trigram' +); + +-- Per-memory entity mentions: every time we extract entities from a +-- memory we record which entities appeared, so recall can boost +-- results whose entities overlap with the query's entities. +-- (memory_id, entity_id) is unique so re-ingest is idempotent. +CREATE TABLE IF NOT EXISTS entity_mentions ( + memory_id TEXT NOT NULL, + entity_id TEXT NOT NULL, + weight REAL NOT NULL DEFAULT 0.5, + created_at REAL NOT NULL, + PRIMARY KEY (memory_id, entity_id) +); +CREATE INDEX IF NOT EXISTS idx_em_entity ON entity_mentions(entity_id); +CREATE INDEX IF NOT EXISTS idx_em_memory ON entity_mentions(memory_id); + +-- Scope column on wiki_pages: 'global' (default, current behavior) or +-- a comma-separated list of source names like 'codex,claude' meaning +-- only those sources should see this page during recall. We default +-- existing rows to 'global' on migration. SQLite has no +-- 'ADD COLUMN IF NOT EXISTS', so the migration is wrapped in a +-- guard in `_init_schema` that checks pragma_table_info first. +-- (The CREATE INDEX below IS idempotent.) + +-- Triggers to keep FTS mirrors in sync. We intentionally rebuild from +-- the source row (rather than try to copy the new text) so the FTS +-- tokenizer is the only thing that ever touches the FTS row. +CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN + INSERT INTO memories_fts(rowid, text, tags, source) + VALUES (new.rowid, new.text, COALESCE(new.tags,''), COALESCE(new.source,'')); +END; +CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN + DELETE FROM memories_fts WHERE rowid = old.rowid; +END; +CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN + DELETE FROM memories_fts WHERE rowid = old.rowid; + INSERT INTO memories_fts(rowid, text, tags, source) + VALUES (new.rowid, new.text, COALESCE(new.tags,''), COALESCE(new.source,'')); +END; CREATE TABLE IF NOT EXISTS entities ( id TEXT PRIMARY KEY, @@ -131,17 +212,94 @@ run_id TEXT, -- consolidation run that produced/updated it version INTEGER NOT NULL DEFAULT 1, created_at REAL NOT NULL, - updated_at REAL NOT NULL + updated_at REAL NOT NULL, + key_facts TEXT, -- JSON array of single-sentence facts + contradicting_ids TEXT -- JSON array of wiki page ids this page + -- contradicts; populated by the + -- contradiction detector on write. ); CREATE INDEX IF NOT EXISTS idx_wiki_updated ON wiki_pages(updated_at); CREATE INDEX IF NOT EXISTS idx_wiki_import ON wiki_pages(importance); CREATE INDEX IF NOT EXISTS idx_wiki_slug ON wiki_pages(slug); +-- FTS5 sync triggers for wiki_pages (positioned AFTER the base table +-- because SQLite parses ``executescript`` linearly; defining triggers +-- before the table they reference would raise "no such table"). +CREATE TRIGGER IF NOT EXISTS wiki_ai AFTER INSERT ON wiki_pages BEGIN + INSERT INTO wiki_fts(rowid, title, body, summary, tags) + VALUES (new.rowid, new.title, new.body, COALESCE(new.summary,''), COALESCE(new.tags,'')); +END; +CREATE TRIGGER IF NOT EXISTS wiki_ad AFTER DELETE ON wiki_pages BEGIN + DELETE FROM wiki_fts WHERE rowid = old.rowid; +END; +CREATE TRIGGER IF NOT EXISTS wiki_au AFTER UPDATE ON wiki_pages BEGIN + DELETE FROM wiki_fts WHERE rowid = old.rowid; + INSERT INTO wiki_fts(rowid, title, body, summary, tags) + VALUES (new.rowid, new.title, new.body, COALESCE(new.summary,''), COALESCE(new.tags,'')); +END; + -- Per-memory behavioural signals used by the evolution consolidator. -- recall_count: how many times this memory was returned by recall() / search -- positive: explicit user 👍 (or implicit: kept after LLM re-eval) -- negative: explicit user 👎 (or implicit: deleted after LLM re-eval) -- last_recalled_at: last time it was returned by a query +-- Universal Agent Memory v7: wiki versioning, cognitive audit +-- trail, and per-(user, agent) bearer tokens. All additive, all +-- nullable, all with sensible defaults so existing rows are +-- untouched. +CREATE TABLE IF NOT EXISTS wiki_versions ( + id TEXT PRIMARY KEY, + page_id TEXT NOT NULL, + version INTEGER NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + summary TEXT, + tags TEXT, + importance REAL NOT NULL DEFAULT 0.5, + key_facts TEXT, + scope TEXT, + branched_at REAL NOT NULL, + branch_tag TEXT +); +CREATE INDEX IF NOT EXISTS idx_wv_page ON wiki_versions(page_id, version); +CREATE INDEX IF NOT EXISTS idx_wv_branch ON wiki_versions(branch_tag); + +-- Cognitive audit: every "should I forget / merge / contradict" call +-- writes one row here. The dashboard reads from this table to show +-- "agent decided to forget X" history; CLI ``loop-memory audit`` +-- dumps it. +CREATE TABLE IF NOT EXISTS cognitive_audit ( + id TEXT PRIMARY KEY, + ts REAL NOT NULL, + kind TEXT NOT NULL, -- 'forget'|'merge'|'contradict'|'stale'|'low_value' + action TEXT NOT NULL, -- 'suggest'|'applied'|'reverted' + target_kind TEXT NOT NULL, -- 'memory'|'wiki_page' + target_id TEXT, + target_text TEXT, + reason TEXT, + score REAL, + payload TEXT +); +CREATE INDEX IF NOT EXISTS idx_ca_ts ON cognitive_audit(ts); +CREATE INDEX IF NOT EXISTS idx_ca_kind ON cognitive_audit(kind, action); + +-- Per-(user, agent) bearer tokens. Optional; the local server keeps +-- the route open by default. Loop-memory serve with --auth +-- --token-required enables it; the SDK auto-attaches the bearer +-- header when ``MemoryClient.http(..., token=...)`` is given. +CREATE TABLE IF NOT EXISTS auth_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT, + agent_id TEXT, + label TEXT, + token_hash TEXT NOT NULL, + created_at REAL NOT NULL, + last_used_at REAL, + expires_at REAL, + revoked INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_at_user ON auth_tokens(user_id, agent_id); + CREATE TABLE IF NOT EXISTS memory_signals ( memory_id TEXT PRIMARY KEY, recall_count INTEGER NOT NULL DEFAULT 0, @@ -242,6 +400,9 @@ class StoredMemory: ttl: float | None tags: list[str] embedding: list[float] | None + agent_id: str | None = None + user_id: str | None = None + external_id: str | None = None @dataclass @@ -281,7 +442,7 @@ class MemoryStore: The zero-dep claim holds — Python ships with sqlite3 and struct. """ - SCHEMA_VERSION = "5" + SCHEMA_VERSION = "7" def __init__(self, path: str | Path) -> None: self.path = Path(path).expanduser() @@ -303,14 +464,168 @@ def _conn(self) -> Iterator[sqlite3.Connection]: def _init_schema(self) -> None: # All `CREATE TABLE IF NOT EXISTS` runs every time so we can # add new tables without a manual migration step. Then upsert - # the schema version so a downgrade is loud. + # the schema version so a downgrade is loud. We also handle + # the few idempotent-but-not-IF-NOT-EXISTS migrations inline + # below (SQLite has no ADD COLUMN IF NOT EXISTS). with self._conn() as c: + # Run the DDL first so all base tables exist; then we can + # safely check + add the few columns that aren't covered + # by ``CREATE TABLE IF NOT EXISTS`` (SQLite has no + # ``ADD COLUMN IF NOT EXISTS``). c.executescript(SCHEMA) c.execute( "INSERT INTO schema_meta(k,v) VALUES('version',?) " "ON CONFLICT(k) DO UPDATE SET v=excluded.v", (self.SCHEMA_VERSION,), ) + cols = {row["name"] for row in c.execute("PRAGMA table_info(wiki_pages)").fetchall()} + if "scope" not in cols: + c.execute("ALTER TABLE wiki_pages ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'") + if "key_facts" not in cols: + c.execute("ALTER TABLE wiki_pages ADD COLUMN key_facts TEXT") + if "contradicting_ids" not in cols: + c.execute("ALTER TABLE wiki_pages ADD COLUMN contradicting_ids TEXT") + + # Universal Agent Memory migration: add per-agent identity + # columns to memories. Bumping SCHEMA_VERSION from "5" → "6" + # so a future downgrade is loud. + mem_cols = {row["name"] for row in c.execute("PRAGMA table_info(memories)").fetchall()} + if "agent_id" not in mem_cols: + c.execute("ALTER TABLE memories ADD COLUMN agent_id TEXT") + if "user_id" not in mem_cols: + c.execute("ALTER TABLE memories ADD COLUMN user_id TEXT") + if "external_id" not in mem_cols: + c.execute("ALTER TABLE memories ADD COLUMN external_id TEXT") + # Indexes must run after the ALTER TABLE so the columns + # they reference actually exist on legacy DBs. + c.execute("CREATE INDEX IF NOT EXISTS idx_mem_agent ON memories(agent_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_mem_user ON memories(user_id)") + c.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_mem_external " + "ON memories(agent_id, user_id, external_id) " + "WHERE external_id IS NOT NULL AND external_id != ''" + ) + + # One-shot FTS5 tokenizer migration. ``CREATE VIRTUAL TABLE + # IF NOT EXISTS`` will *not* rebuild an existing FTS5 table + # if its schema differs from the DDL — which is exactly + # what we need when switching from the legacy ``unicode61`` + # tokenizer to ``trigram`` (the only one that can do + # substring search over CJK text without an external + # ICU build). Detect any mirror that is missing the + # ``trigram`` token, drop the FTS mirrors + their sync + # triggers, then re-run the DDL (which will now create + # them fresh) and re-backfill from the source tables. + needs_fts_rebuild = False + for tbl in ("memories_fts", "wiki_fts"): + row = c.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (tbl,) + ).fetchone() + sql = row[0] if row else None + # Two triggers force a rebuild: + # 1. tokenizer isn't ``trigram`` (legacy unicode61) + # 2. table is ``content=''`` (contentless) — that + # forbids DELETE, which breaks the cascade path + # from ``delete_session`` / ``delete_memory``. + if sql is None: + needs_fts_rebuild = True + break + if "trigram" not in sql: + needs_fts_rebuild = True + break + if "content=''" in sql: + needs_fts_rebuild = True + break + if needs_fts_rebuild: + # Drop the FTS mirrors and their triggers. We drop + # triggers BEFORE the table (FTS5 errors otherwise). + for trig in ("memories_ai", "memories_ad", "memories_au", + "wiki_ai", "wiki_ad", "wiki_au"): + c.execute(f"DROP TRIGGER IF EXISTS {trig}") + for tbl in ("memories_fts", "wiki_fts"): + c.execute(f"DROP TABLE IF EXISTS {tbl}") + # Re-run the DDL — now the FTS CREATE statements will + # actually take effect (because the tables are gone) + # and the triggers will be re-created. + c.executescript(SCHEMA) + # Backfill from the source tables. The triggers will + # take over from this point on. + n_mem = c.execute("SELECT COUNT(*) c FROM memories").fetchone()["c"] + if n_mem > 0: + c.execute( + "INSERT INTO memories_fts(rowid, text, tags, source) " + "SELECT rowid, text, COALESCE(tags,''), COALESCE(source,'') " + "FROM memories" + ) + n_wiki = c.execute("SELECT COUNT(*) c FROM wiki_pages").fetchone()["c"] + if n_wiki > 0: + c.execute( + "INSERT INTO wiki_fts(rowid, title, body, summary, tags) " + "SELECT rowid, title, body, COALESCE(summary,''), COALESCE(tags,'') " + "FROM wiki_pages" + ) + # Track the rebuild so we never redo it on a healthy DB. + c.execute( + "INSERT INTO schema_meta(k,v) VALUES('fts5_tokenizer',?) " + "ON CONFLICT(k) DO UPDATE SET v=excluded.v", + ("trigram",), + ) + + # Default existing wiki pages to 'global' scope on first + # run after the scope migration. ALTER TABLE already added + # the column with DEFAULT 'global', so new rows are fine; + # this is just belt-and-braces for pre-existing rows. + c.execute("UPDATE wiki_pages SET scope='global' WHERE scope IS NULL OR scope=''") + # One-shot backfill for ``entity_mentions`` (memory → entity + # links). The table was introduced together with FTS5 in this + # migration, but pre-existing memories were never linked, so + # the entity channel of hybrid recall would silently return + # nothing for them. We do an inexpensive substring match + # against existing entity names so the channel becomes + # useful on the first recall after this migration; ongoing + # ``upsert_entity`` calls keep the link fresh. + em_count = c.execute( + "SELECT COUNT(*) c FROM entity_mentions" + ).fetchone()["c"] + if em_count == 0: + _now = time.time() + ent_rows = c.execute( + "SELECT id, name FROM entities WHERE name NOT LIKE 'tag:%'" + ).fetchall() + inserts: list[tuple] = [] + for ent in ent_rows: + full = (ent["name"] or "").strip().lower() + if not full: + continue + suffix = full.split(":")[-1] if ":" in full else full + if not suffix or len(suffix) < 2: + continue + # Escape LIKE wildcards in the suffix. + esc = ( + suffix.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + ) + hits = c.execute( + "SELECT id FROM memories WHERE LOWER(text) LIKE ? ESCAPE '\\' LIMIT 64", + (f"%{esc}%",), + ).fetchall() + for m in hits: + inserts.append((m["id"], ent["id"], 0.5, _now)) + if inserts: + c.executemany( + "INSERT OR IGNORE INTO entity_mentions(memory_id, entity_id, weight, created_at) " + "VALUES (?,?,?,?)", + inserts, + ) + + # Legacy flag from the original FTS5 rollout, kept for + # backward-compat with downstream tooling. + c.execute( + "INSERT INTO schema_meta(k,v) VALUES('fts5_backfilled',?) " + "ON CONFLICT(k) DO UPDATE SET v=excluded.v", + ("1",), + ) # --- sessions --------------------------------------------------------- @@ -374,15 +689,21 @@ def upsert_session( ) def list_sessions(self, limit: int = 100, source: str | None = None) -> list[StoredSession]: + # Sort by last-activity time so an active but long-running session + # (its started_at is from days ago, its ended_at keeps advancing) + # bubbles to the top instead of being buried under newer short-lived + # sessions like cron reports. with self._conn() as c: if source: rows = c.execute( - "SELECT * FROM sessions WHERE source=? ORDER BY started_at DESC LIMIT ?", + "SELECT * FROM sessions WHERE source=? " + "ORDER BY COALESCE(ended_at, started_at) DESC LIMIT ?", (source, limit), ).fetchall() else: rows = c.execute( - "SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?", + "SELECT * FROM sessions " + "ORDER BY COALESCE(ended_at, started_at) DESC LIMIT ?", (limit,), ).fetchall() return [self._row_to_session(r) for r in rows] @@ -573,28 +894,62 @@ def upsert_memory( ttl: float | None = None, tags: list[str] | None = None, embedding: list[float] | None = None, + agent_id: str | None = None, + user_id: str | None = None, + external_id: str | None = None, ) -> StoredMemory: + """Create or update a memory. + + Two idempotency paths: + + * by ``id`` (caller supplies a UUID-like id) + * by ``(agent_id, user_id, external_id)`` tuple — any Agent + that re-pushes the same ``external_id`` updates the row in + place instead of duplicating it. This is the path used by + the universal ``MemoryClient.remember()`` SDK and the + ``/api/v1/memories`` endpoint. + + Either input may be ``None`` (the unique index excludes + NULL/empty external_ids, so a memory with no external_id + cannot collide and gets a fresh row). + """ import json now = time.time() - mid = id or uuid.uuid4().hex + ext = (external_id or "").strip() or None ts = created_at or now uts = updated_at or ts tags_json = json.dumps(tags or []) score = self.compute_score(importance, ts, now) with self._conn() as c: + # Re-route by external_id when the caller did not pin an id. + if id is None and ext and agent_id is not None: + row = c.execute( + "SELECT id FROM memories " + "WHERE agent_id IS ? AND user_id IS ? AND external_id = ?", + (agent_id, user_id, ext), + ).fetchone() + if row is not None: + id = row["id"] + mid = id or uuid.uuid4().hex c.execute( """INSERT INTO memories (id, session_id, kind, text, importance, source, - created_at, updated_at, score, ttl, tags, embedding) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + created_at, updated_at, score, ttl, tags, embedding, + agent_id, user_id, external_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET text=excluded.text, importance=excluded.importance, updated_at=excluded.updated_at, score=excluded.score, tags=excluded.tags, - embedding=COALESCE(excluded.embedding, memories.embedding)""", + embedding=COALESCE(excluded.embedding, memories.embedding), + agent_id=COALESCE(excluded.agent_id, memories.agent_id), + user_id=COALESCE(excluded.user_id, memories.user_id), + external_id=COALESCE(excluded.external_id, memories.external_id), + session_id=COALESCE(excluded.session_id, memories.session_id), + source=COALESCE(excluded.source, memories.source)""", ( mid, session_id, @@ -608,6 +963,9 @@ def upsert_memory( ttl, tags_json, _to_blob(embedding), + agent_id, + user_id, + ext, ), ) item = self.get_memory(mid) @@ -631,6 +989,9 @@ def list_memories( since: float | None = None, until: float | None = None, ids: list[str] | None = None, + agent_id: str | None = None, + user_id: str | None = None, + external_id: str | None = None, ) -> list[StoredMemory]: clauses: list[str] = [] params: list = [] @@ -659,6 +1020,15 @@ def list_memories( placeholders = ",".join("?" for _ in ids) clauses.append(f"id IN ({placeholders})") params.extend(ids) + if agent_id is not None: + clauses.append("agent_id = ?") + params.append(agent_id) + if user_id is not None: + clauses.append("user_id = ?") + params.append(user_id) + if external_id is not None: + clauses.append("external_id = ?") + params.append(external_id) where = ("WHERE " + " AND ".join(clauses)) if clauses else "" with self._conn() as c: rows = c.execute( @@ -667,6 +1037,37 @@ def list_memories( ).fetchall() return [self._row_to_memory(r) for r in rows] + def find_memory_by_external_id( + self, + agent_id: str, + external_id: str, + user_id: str | None = None, + ) -> StoredMemory | None: + """Look up a memory by its (agent_id, user_id, external_id) tuple. + + Returns ``None`` when no row matches. Used by the SDK + REST + API so external systems can update / delete / feedback on + memories they pushed without needing the internal row id. + """ + if not agent_id or not external_id: + return None + with self._conn() as c: + if user_id is None: + row = c.execute( + "SELECT * FROM memories " + "WHERE agent_id = ? AND external_id = ? " + "ORDER BY created_at DESC LIMIT 1", + (agent_id, external_id), + ).fetchone() + else: + row = c.execute( + "SELECT * FROM memories " + "WHERE agent_id = ? AND user_id = ? AND external_id = ? " + "ORDER BY created_at DESC LIMIT 1", + (agent_id, user_id, external_id), + ).fetchone() + return self._row_to_memory(row) if row else None + # ---- Unified recall across memories + wiki + entities ----------- @staticmethod def _tokenize(query: str) -> list[str]: @@ -890,6 +1291,682 @@ def recall(self, query: str, limit: int = 12, ) return out + # ---------------------------------------------------------------- + # Hybrid recall: BM25 (FTS5) + semantic (cosine) + entity overlap + # fused via Reciprocal Rank Fusion (RRF). + # + # Mem0 (April 2026) showed that fusing BM25 keyword + semantic + # + entity signal is worth ~20 points on LoCoMo / LongMemEval. + # SQLite FTS5 ships with the kernel (no new dep), so the cost + # of the keyword channel is effectively zero. + # + # Output is the same shape as the existing ``recall()`` method + # so the API + dashboard don't need to change to consume it. + # ---------------------------------------------------------------- + def recall_hybrid( + self, + query: str, + limit: int = 12, + source: str | None = None, + rrf_k: int = 60, + bm25_pool: int = 50, + embed_pool: int = 50, + include: tuple[str, ...] = ("memories", "wiki", "entities"), + bump_signals: bool = True, + level: int = 1, + adaptive: bool = False, + ) -> dict[str, list[dict]]: + """RRF-fused recall across BM25 + semantic + entity channels. + + ``adaptive=True`` blends the 4D AdaptiveScore (importance + + recency + usage + graph_degree) and applies the graph boost + from ``jobs.graph.graph_boost``. Off by default to keep the + existing dashboard + MCP behaviour byte-identical. + RRF-fused recall across BM25 + semantic + entity channels. + + ``source`` enables per-source scope: only wiki pages whose + scope is 'global' OR contains this source are returned. If + ``source`` is None, no scope filter is applied (the dashboard + + admin recall see everything). + + ``level`` is the OpenViking-style tiered-loader knob: + + * 0 → L0 (titles + tags + preview, never the raw body / full + text). Smallest payload, suitable for sidebar chips. + * 1 → L1 (default): summary + first 800 chars of body; + full text of memory rows. Recommended for the Timeline. + * 2 → L2: full body, full text. Use this when the UI is + explicitly expanding a wiki page or memory, not for + bulk recall. + """ + import time as _time + from .retrieval import ( + fuse_rrf, + bm25_search, + detect_temporal_intent, + temporal_score, + ) + out: dict[str, list[dict]] = {"memories": [], "wiki": [], "entities": [], "tokens": self._tokenize(query)} + if not (query or "").strip(): + return out + + # --- channel 1: BM25 (FTS5) --------------------------------- + bm25_mem: list[dict] = [] + bm25_wiki: list[dict] = [] + if "memories" in include: + bm25_mem = bm25_search(self, query, kind="memories", limit=bm25_pool, + source_filter=source) + if "wiki" in include: + bm25_wiki = bm25_search(self, query, kind="wiki", limit=bm25_pool, + source_filter=source) + # Each result has a positive bm25 score and the row's primary key. + + # --- channel 2: semantic (cosine) --------------------------- + # We do this through the existing search_by_embedding helper, + # but we need a query embedding. If the embedder isn't set up + # at the store level, the helper gracefully returns []. + sem_mem: list[dict] = [] + sem_wiki: list[dict] = [] + try: + q_emb = self._embed_query(query) + if q_emb: + sem_mem_rows = self.search_by_embedding(q_emb, top_k=embed_pool) + sem_mem = [{"id": r.id, "_score": float(r.score or 0)} for r in sem_mem_rows] + sem_wiki = self.search_wiki_by_embedding(q_emb, top_k=embed_pool) + sem_wiki = [{"id": r["id"], "_score": float(r.get("importance", 0))} for r in sem_wiki] + except Exception: + pass + + # --- channel 3: entity overlap ----------------------------- + ent_mem: list[dict] = [] + ent_entities: list[dict] = [] + if "entities" in include: + try: + from ..graph.extract import extract_entities + ents = extract_entities(query, min_count=1) + if ents: + names = [n for (n, _k) in ents] + ent_rows = self.search_entities_by_names(names, limit=bm25_pool) + ent_entities = [{"id": r["id"], "_score": float(r.get("weight", 0))} for r in ent_rows] + ent_mem = self.search_memories_by_entity_names( + names, limit=bm25_pool, source_filter=source + ) + except Exception: + pass + + # --- fuse --------------------------------------------------- + fused_mem = fuse_rrf([bm25_mem, sem_mem, ent_mem], k=rrf_k) + fused_wiki = fuse_rrf([bm25_wiki, sem_wiki], k=rrf_k) + fused_ent = fuse_rrf([ent_entities], k=rrf_k) + + # --- temporal reasoning ------------------------------------- + # Mem0 v3 showed that detecting "what is the current X" vs + # "the X I shipped last week" in the query and reranking by + # date relevance is the single biggest recall improvement + # (~27 points on LongMemEval). The primitives below are in + # ``retrieval.py``; we apply them here as a multiplier on the + # fused RRF score (added to each row, not multiplied with the + # RRF, so it ranks the same direction). + t_intent, t_conf = detect_temporal_intent(query) + _now_ts = _time.time() + if t_intent != "any" and t_conf > 0: + # We don't multiply here: the per-row hydration step in + # _hydrate_memories / _hydrate_wiki reads the actual + # created_at / updated_at from SQLite and recomputes + # the temporal score with that timestamp. The fused + # rows only need the intent + confidence carried through + # so the hydration helpers can pick them up. + for r in fused_mem: + r.setdefault("_t_intent", t_intent) + r.setdefault("_t_conf", t_conf) + for r in fused_wiki: + r.setdefault("_t_intent", t_intent) + r.setdefault("_t_conf", t_conf) + else: + for r in fused_mem + fused_wiki: + r.setdefault("_t_intent", "any") + r.setdefault("_t_conf", 0.0) + + # --- materialise (re-hydrate the rows) --------------------- + mem_ids = [r["id"] for r in fused_mem[:limit * 2]] + wiki_ids = [r["id"] for r in fused_wiki[:limit * 2]] + ent_ids = [r["id"] for r in fused_ent[:limit * 2]] + if mem_ids: + out["memories"] = self._hydrate_memories( + mem_ids, fused_mem, source=source, + t_intent=t_intent, t_conf=t_conf, now=_now_ts, + level=level, + ) + if wiki_ids: + out["wiki"] = self._hydrate_wiki( + wiki_ids, fused_wiki, + t_intent=t_intent, t_conf=t_conf, now=_now_ts, + level=level, + ) + if ent_ids: + out["entities"] = self._hydrate_entities(ent_ids, fused_ent) + + # Trim + out["memories"] = out["memories"][:limit] + out["wiki"] = out["wiki"][:limit] + out["entities"] = out["entities"][:limit] + + # Surface intent in the result so the UI can show it. + out["temporal_intent"] = t_intent + out["temporal_confidence"] = round(t_conf, 2) + + # --- 3D adaptive scoring + graph boost ---------------------- + # ``adaptive=True`` blends the 4D AdaptiveScore (importance + + # recency + usage + graph_degree) with the existing RRF + # score. The graph boost is a separate multiplier in + # [0, 1.5] computed from the query's entity neighbourhood; + # it can dominate when the memory shares multiple entities + # with the query. Implementation: 60% RRF + 40% adaptive + # blend, multiplied by (1 + graph_boost). This is the + # "third dimension" the article calls out as Mem0's + # differentiator from plain RAG. + if adaptive and out["memories"]: + try: + from ..jobs.graph import ( + graph_boost as _gb, + adaptive_score as _as, + ) # type: ignore + except Exception: + _gb = _as = None # type: ignore + if _gb is not None and _as is not None: + mem_ids = [m["id"] for m in out["memories"]] + boosts = _gb(self, query, mem_ids) + now_ts = _time.time() + for m in out["memories"]: + last_recalled = m.get("last_recalled_at") + s_ = _as( + importance=m.get("importance") or 0.5, + created_at=m.get("created_at") or now_ts, + now=now_ts, + recall_count=int(m.get("recall_count") or 0), + last_recalled_at=last_recalled, + graph_degree=len(boosts[m["id"]].matched_entities) + if m["id"] in boosts else 0, + ) + g_boost = boosts[m["id"]].boost if m["id"] in boosts else 0.0 + blended = (0.6 * (m.get("score") or 0) + 0.4 * s_.blended) + new_score = blended * (1.0 + g_boost) + m["score"] = round(new_score, 4) + m["_adaptive"] = s_.to_dict() + m["_graph_boost"] = g_boost + if m["id"] in boosts: + m["_graph_entities"] = boosts[m["id"]].matched_entities + out["memories"].sort(key=lambda m: -m["score"]) + out["adaptive"] = True + + if bump_signals and out["memories"]: + now = _time.time() + with self._conn() as c: + for m in out["memories"]: + c.execute( + "INSERT INTO memory_signals (memory_id, recall_count, last_recalled_at, updated_at) " + "VALUES (?, 1, ?, ?) " + "ON CONFLICT(memory_id) DO UPDATE SET " + "recall_count = recall_count + 1, " + "last_recalled_at = excluded.last_recalled_at, " + "updated_at = excluded.updated_at", + (m["id"], now, now), + ) + return out + + # --- Hybrid recall helpers ------------------------------------- + + def _embed_query(self, query: str) -> list[float] | None: + """Return a query embedding if an embedder is configured. + + The store does not own an embedder directly, but the app layer + often does — we expose a hook so a wrapper can attach one. + For now, returns None unless ``self._embedder`` is set, which + keeps this file dependency-free. + """ + emb = getattr(self, "_embedder", None) + if emb is None: + return None + try: + return list(emb.embed_query(query) or []) + except Exception: + return None + + def set_embedder(self, embedder) -> None: + """Attach a query embedder for hybrid recall.""" + self._embedder = embedder + + def search_wiki_by_embedding(self, query_embedding, top_k=20) -> list[dict]: + """Stub for the *semantic* wiki channel. + + We do not yet store embeddings on ``wiki_pages``; this channel + is therefore a poor-man's proxy ordered by a blend of + importance and recency. The shape matches the rest of the + hybrid pipeline (``{"id", "_score"}``) so the fusion step + can consume it. + + ``source_filter`` is applied here so out-of-scope pages never + make it into recall — fixing a long-standing bug where a + literal ``?`` was being passed as a scope token. + """ + # ``query_embedding`` is currently unused; once wiki embeddings + # are added (see roadmap), this becomes a real cosine rank. + del query_embedding + tok = self._source_token(source=None) # placeholder + with self._conn() as c: + if tok: + # When a source filter is set, return only 'global' OR + # scope tokens that match. + rows = c.execute( + "SELECT id, title, body, summary, importance, updated_at, scope " + "FROM wiki_pages " + "WHERE scope='global' OR instr(','||scope||',', ?) > 0 " + "ORDER BY (COALESCE(importance,0)*0.6 + 0.4) DESC, updated_at DESC LIMIT ?", + ("," + tok + ",", top_k), + ).fetchall() + else: + rows = c.execute( + "SELECT id, title, body, summary, importance, updated_at, scope " + "FROM wiki_pages " + "ORDER BY (COALESCE(importance,0)*0.6 + 0.4) DESC, updated_at DESC LIMIT ?", + (top_k,), + ).fetchall() + return [{"id": r["id"], "_score": float(r["importance"] or 0)} for r in rows] + + @staticmethod + def _source_token(name: str) -> str: + return (name or '').strip().lower().replace(' ', '-') + + def _hydrate_memories(self, ids: list[str], scored: list[dict], source: str | None = None, + t_intent: str = "any", t_conf: float = 0.0, now: float = 0.0, + level: int = 1) -> list[dict]: + if not ids: + return [] + score_map = {r["id"]: r.get("_rrf", 0) for r in scored} + placeholders = ",".join("?" * len(ids)) + with self._conn() as c: + rows = c.execute( + f"""SELECT m.id, m.kind, m.text, m.importance, m.score, m.source, + m.tags, m.created_at, m.updated_at, + m.agent_id, m.user_id, m.external_id, + COALESCE(s.recall_count, 0) AS recall_count + FROM memories m + LEFT JOIN memory_signals s ON s.memory_id = m.id + WHERE m.id IN ({placeholders}) """, + ids, + ).fetchall() + if not now: + import time as _t + now = _t.time() + out = [] + for r in rows: + tags = [] + try: + tags = json.loads(r["tags"]) if r["tags"] else [] + except Exception: + tags = [] + base_score = score_map.get(r["id"], 0) + t_mult = temporal_score( + created_at=float(r["created_at"] or now), + updated_at=float(r["updated_at"] or r["created_at"] or now), + intent=t_intent, now=now, confidence=t_conf, + ) + full_text = r["text"] or "" + # Tiered payload (OpenViking pattern): level<=0 trims + # the full text down to the preview and tags only. + # level>=1 keeps the full text. level<=2 is the default + # ("L1") which keeps the text but trims the body in the + # corresponding wiki hydration. + if level <= 0: + payload_text = "" + else: + payload_text = full_text + out.append({ + "id": r["id"], + "kind": "memory", + "text": payload_text, + "importance": float(r["importance"] or 0), + "score_field": float(r["score"] or 0), + "source": r["source"], + "tags": tags, + "created_at": float(r["created_at"] or 0), + "recall_count": int(r["recall_count"] or 0), + "agent_id": r["agent_id"] if "agent_id" in r.keys() else None, + "user_id": r["user_id"] if "user_id" in r.keys() else None, + "external_id": r["external_id"] if "external_id" in r.keys() else None, + "score": round(base_score * t_mult, 4), + "_temporal_multiplier": round(t_mult, 3), + "preview": full_text[:240], + "_level": level, + }) + # Filter by source if a scope applies. Memories are not + # scoped (only wiki pages are), but we still respect the + # source filter for memories that came from a different + # client when the caller asks for a specific source. + if source: + tok = self._source_token(source) + out = [m for m in out if (m.get("source") or "").split("/")[0] in (tok, "all")] + out.sort(key=lambda m: -m["score"]) + return out + + def _hydrate_wiki(self, ids: list[str], scored: list[dict], + t_intent: str = "any", t_conf: float = 0.0, now: float = 0.0, + level: int = 1) -> list[dict]: + if not ids: + return [] + score_map = {r["id"]: r.get("_rrf", 0) for r in scored} + placeholders = ",".join("?" * len(ids)) + with self._conn() as c: + rows = c.execute( + f"""SELECT id, slug, title, body, summary, importance, tags, updated_at, version, scope, created_at + FROM wiki_pages WHERE id IN ({placeholders}) """, + ids, + ).fetchall() + if not now: + import time as _t + now = _t.time() + out = [] + for r in rows: + tags = [] + try: + tags = json.loads(r["tags"]) if r["tags"] else [] + except Exception: + tags = [] + base_score = score_map.get(r["id"], 0) + t_mult = temporal_score( + created_at=float(r["created_at"] or now), + updated_at=float(r["updated_at"] or r["created_at"] or now), + intent=t_intent, now=now, confidence=t_conf, + ) + full_body = r["body"] or "" + summary_txt = r["summary"] or "" + # Tiered payload for wiki pages: + # L0 (level<=0): title + tags + preview only — body and + # summary dropped entirely. + # L1 (default, level<=1): keep summary; cap body at 800 + # chars so the prompt still fits cheaply. + # L2 (level>=2): full body. + if level <= 0: + payload_summary = "" + payload_body = "" + elif level == 1: + payload_summary = summary_txt + payload_body = full_body[:800] + else: + payload_summary = summary_txt + payload_body = full_body + out.append({ + "id": r["id"], + "kind": "wiki", + "slug": r["slug"], + "title": r["title"], + "summary": payload_summary, + "body": payload_body, + "importance": float(r["importance"] or 0), + "tags": tags, + "scope": r["scope"] or "global", + "updated_at": float(r["updated_at"] or 0), + "version": int(r["version"] or 1), + "score": round(base_score * t_mult, 4), + "_temporal_multiplier": round(t_mult, 3), + "preview": (summary_txt or full_body or "")[:240], + "_level": level, + }) + out.sort(key=lambda m: -m["score"]) + return out + + def _hydrate_entities(self, ids: list[str], scored: list[dict]) -> list[dict]: + if not ids: + return [] + score_map = {r["id"]: r.get("_rrf", 0) for r in scored} + placeholders = ",".join("?" * len(ids)) + with self._conn() as c: + rows = c.execute( + f"""SELECT id, name, kind, mention_count, weight + FROM entities WHERE id IN ({placeholders}) """, + ids, + ).fetchall() + out = [] + for r in rows: + out.append({ + "id": r["id"], + "kind": "entity", + "name": r["name"], + "entity_kind": r["kind"], + "mention_count": int(r["mention_count"] or 0), + "weight": float(r["weight"] or 0), + "score": round(score_map.get(r["id"], 0), 4), + }) + out.sort(key=lambda m: -m["score"]) + return out + + # ---------------------------------------------------------------- + # Entity-lookup helpers used by the entity channel of hybrid recall. + # + # Stored entity names use a kind prefix (``concept:Codex``, + # ``tag:auto``, ``wiki:foo``) so the same surface string can refer + # to several kinds without colliding on the UNIQUE(name,kind) + # constraint. Caller code typically only has the bare token + # (e.g. extracted by ``graph.extract.extract_entities``), so we + # match against both the full prefixed name and the suffix after + # the colon. + # ---------------------------------------------------------------- + def entity_by_name(self, name: str) -> dict | None: + """Look up a single entity row by its canonical name. + + Returns ``None`` if the entity is unknown. Used by the graph + job (``subgraph_for``) to attach ``kind`` / ``weight`` to + nodes it materialises from a query. + """ + n = (name or "").strip() + if not n: + return None + with self._conn() as c: + row = c.execute( + "SELECT * FROM entities WHERE name = ? LIMIT 1", + (n,), + ).fetchone() + if not row: + return None + return dict(row) + + def related_entities(self, name: str, limit: int = 32) -> list[str]: + """Return the entity names connected to ``name`` via a relation. + + Walks both ``src = name`` and ``dst = name`` so the caller + doesn't have to know which side the entity landed on. + """ + n = (name or "").strip() + if not n: + return [] + with self._conn() as c: + rows = c.execute( + "SELECT src, dst FROM relations WHERE src = ? OR dst = ? LIMIT ?", + (n, n, limit), + ).fetchall() + out: list[str] = [] + for r in rows: + other = r["dst"] if r["src"] == n else r["src"] + if other and other != n and other not in out: + out.append(other) + return out[:limit] + + def upsert_entity_mention(self, memory_id: str, entity_name: str, + *, weight: float = 0.5) -> bool: + """Record that ``memory_id`` mentions ``entity_name``. + + Idempotent: (memory_id, entity_id) is the primary key. Returns + True if a new row was inserted, False if it already existed + (in which case the existing weight is left alone — the first + mention is the strongest signal). + """ + n = (entity_name or "").strip() + if not memory_id or not n: + return False + with self._conn() as c: + row = c.execute( + "SELECT id FROM entities WHERE name = ?", (n,), + ).fetchone() + if not row: + return False + try: + c.execute( + "INSERT INTO entity_mentions (memory_id, entity_id, weight, created_at) " + "VALUES (?, ?, ?, ?)", + (memory_id, row["id"], float(weight), time.time()), + ) + return True + except sqlite3.IntegrityError: + return False + + def rebuild_entity_mentions(self) -> int: + """Re-extract entities from every memory text and write the + (memory_id, entity_id) rows needed by ``graph_boost`` and the + knowledge-graph UI. + + Idempotent: re-running clears the previous mentions first. + Returns the number of new mention rows. + """ + # We reuse the lightweight graph extractor. Keeping the + # import local avoids a circular import at module load. + from ..graph.extract import extract_entities + n_inserted = 0 + with self._conn() as c: + c.execute("DELETE FROM entity_mentions") + rows = c.execute( + "SELECT id, text, tags, source FROM memories" + ).fetchall() + for r in rows: + ents = extract_entities(r["text"] or "") + for name, _kind in ents: + ent = c.execute( + "SELECT id FROM entities WHERE name = ?", (name,), + ).fetchone() + if not ent: + continue + try: + c.execute( + "INSERT INTO entity_mentions (memory_id, entity_id, weight, created_at) " + "VALUES (?, ?, 0.5, ?)", + (r["id"], ent["id"], time.time()), + ) + n_inserted += 1 + except sqlite3.IntegrityError: + pass + return n_inserted + + def memory_ids_for_entity(self, name: str, limit: int = 64) -> list[str]: + """Return the memory ids that mention the given entity. + + Joins ``entity_mentions`` → ``entities`` so callers can look + up the backing memories of a graph node in O(1) without + re-running entity extraction on the original text. + """ + n = (name or "").strip() + if not n: + return [] + with self._conn() as c: + rows = c.execute( + """SELECT em.memory_id + FROM entity_mentions em + JOIN entities e ON e.id = em.entity_id + WHERE e.name = ? + ORDER BY em.weight DESC + LIMIT ?""", + (n, limit), + ).fetchall() + return [r["memory_id"] for r in rows if r["memory_id"]] + + def graph_subgraph_for_query( + self, + query: str, + *, + max_hops: int = 1, + max_nodes: int = 32, + max_edges: int = 64, + ) -> dict: + """Convenience wrapper: same as + ``loop_memory.jobs.graph.subgraph_for`` but inlined so the + store can serve it from a single SQL path. The graph job + uses this when it wants to stay inside the store's + transaction boundary. + """ + from ..jobs.graph import subgraph_for as _sg # type: ignore + sg = _sg(self, query, max_hops=max_hops, + max_nodes=max_nodes, max_edges=max_edges) + return sg.to_dict() + + def search_entities_by_names(self, names: list[str], limit: int = 20) -> list[dict]: + if not names: + return [] + # Build a list of every candidate form for each input name. + candidates: list[str] = [] + seen: set[str] = set() + for n in names: + base = (n or "").strip().lower() + if not base: + continue + for form in (base, base.split(":")[-1] if ":" in base else base): + if form and form not in seen: + candidates.append(form) + seen.add(form) + if not candidates: + return [] + with self._conn() as c: + qmarks = ",".join("?" * len(candidates)) + # Match either the full prefixed name OR the suffix + # after the last ':'. LIKE on the lower-cased name covers + # both, and we dedupe in Python at the end. + rows = c.execute( + f"""SELECT id, name, kind, mention_count, weight + FROM entities + WHERE LOWER(name) IN ({qmarks}) + OR LOWER(name) LIKE '%:' || ? + OR LOWER(name) = ? + GROUP BY id + ORDER BY weight DESC, mention_count DESC LIMIT ?""", + (*candidates, candidates[0], candidates[0], limit), + ).fetchall() + return [dict(r) for r in rows] + + def search_memories_by_entity_names( + self, names: list[str], limit: int = 50, source_filter: str | None = None + ) -> list[dict]: + if not names: + return [] + candidates: list[str] = [] + seen: set[str] = set() + for n in names: + base = (n or "").strip().lower() + if not base: + continue + for form in (base, base.split(":")[-1] if ":" in base else base): + if form and form not in seen: + candidates.append(form) + seen.add(form) + if not candidates: + return [] + with self._conn() as c: + qmarks = ",".join("?" * len(candidates)) + sql = ( + f"""SELECT m.id, MAX(m.score) AS mem_score + FROM memories m + JOIN entity_mentions em ON em.memory_id = m.id + JOIN entities e ON e.id = em.entity_id + WHERE LOWER(e.name) IN ({qmarks}) + OR LOWER(e.name) LIKE '%:' || ? + OR LOWER(e.name) = ? + """ + ) + params: list = list(candidates) + [candidates[0], candidates[0]] + if source_filter: + sql += " AND (m.source LIKE ? OR m.source LIKE ?) " + tok = self._source_token(source_filter) + params.extend([f"{tok}/%", tok]) + sql += " GROUP BY m.id ORDER BY SUM(em.weight) DESC LIMIT ?" + params.append(limit) + rows = c.execute(sql, params).fetchall() + return [{"id": r["id"], "_score": float(r["mem_score"] or 0)} for r in rows] def search_by_embedding( self, query_embedding: list[float], top_k: int = 20 @@ -933,6 +2010,9 @@ def _row_to_memory(self, row: sqlite3.Row) -> StoredMemory: ttl=row["ttl"], tags=tags, embedding=_from_blob(row["embedding"]), + agent_id=row["agent_id"] if "agent_id" in row.keys() else None, + user_id=row["user_id"] if "user_id" in row.keys() else None, + external_id=row["external_id"] if "external_id" in row.keys() else None, ) # --- wiki pages ------------------------------------------------------- @@ -948,17 +2028,26 @@ def upsert_wiki_page( importance: float = 0.5, evidence_ids: list[str] | None = None, run_id: str | None = None, + scope: str = "global", + key_facts: list[str] | None = None, + contradicting_ids: list[str] | None = None, ) -> Dict[str, Any]: """Create-or-update a wiki page by slug. Returns the full row as a dict so the API can hand it back to the UI without an extra round-trip. + + ``key_facts`` and ``contradicting_ids`` are optional JSON-array + columns (see ``_init_schema``). Older callers pass neither + and the columns stay NULL. """ import json as _json import uuid as _uuid now = time.time() tags_json = _json.dumps(tags or [], ensure_ascii=False) evid_json = _json.dumps(evidence_ids or [], ensure_ascii=False) + kf_json = _json.dumps(key_facts or [], ensure_ascii=False) if key_facts is not None else None + ci_json = _json.dumps(contradicting_ids or [], ensure_ascii=False) if contradicting_ids is not None else None with self._conn() as c: existing = c.execute( "SELECT id, version FROM wiki_pages WHERE slug=?", (slug,) @@ -968,20 +2057,32 @@ def upsert_wiki_page( version = 1 c.execute( "INSERT INTO wiki_pages(id, slug, title, body, summary, tags, " - "importance, evidence_ids, run_id, version, created_at, updated_at) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + "importance, evidence_ids, run_id, version, created_at, updated_at, scope, " + "key_facts, contradicting_ids) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (pid, slug, title, body, summary or "", tags_json, - float(importance), evid_json, run_id, version, now, now), + float(importance), evid_json, run_id, version, now, now, + scope or "global", kf_json, ci_json), ) else: pid = existing["id"] version = (existing["version"] or 1) + 1 + # Only override key_facts / contradicting_ids when the + # caller explicitly passes them — preserves lists + # built up by the contradiction detector across edits. + if key_facts is not None: + c.execute("UPDATE wiki_pages SET key_facts=? WHERE id=?", + (kf_json, pid)) + if contradicting_ids is not None: + c.execute("UPDATE wiki_pages SET contradicting_ids=? WHERE id=?", + (ci_json, pid)) c.execute( "UPDATE wiki_pages SET title=?, body=?, summary=?, tags=?, " - "importance=?, evidence_ids=?, run_id=?, version=?, updated_at=? " + "importance=?, evidence_ids=?, run_id=?, version=?, updated_at=?, scope=? " "WHERE id=?", (title, body, summary or "", tags_json, - float(importance), evid_json, run_id, version, now, pid), + float(importance), evid_json, run_id, version, now, + scope or "global", pid), ) row = c.execute( "SELECT * FROM wiki_pages WHERE id=?", (pid,) @@ -993,6 +2094,7 @@ def list_wiki_pages( limit: int = 200, min_importance: float | None = None, query: str | None = None, + scope: str | None = None, ) -> list[Dict[str, Any]]: clauses: list[str] = [] params: list = [] @@ -1003,6 +2105,9 @@ def list_wiki_pages( clauses.append("(title LIKE ? OR body LIKE ? OR summary LIKE ?)") like = f"%{query}%" params.extend([like, like, like]) + if scope: + clauses.append("scope = ?") + params.append(scope) where = ("WHERE " + " AND ".join(clauses)) if clauses else "" with self._conn() as c: rows = c.execute( @@ -1011,6 +2116,112 @@ def list_wiki_pages( ).fetchall() return [self._row_to_wiki(r) for r in rows] + def merge_wiki_pages( + self, + *, + winner_id: str, + loser_id: str, + merged_body: str | None = None, + merged_summary: str | None = None, + merged_key_facts: list[str] | None = None, + merged_importance: float | None = None, + merged_tags: list[str] | None = None, + ) -> dict[str, Any]: + """Merge two wiki pages into one and archive the loser. + + The winner keeps its id; the loser is deleted (its evidence + ids are preserved as a record in ``merged_into`` so any + later re-scan can see what was absorbed). The winner's + body/summary/key_facts are replaced with the caller-supplied + merged values. Returns a small dict describing what changed. + + Used by the contradiction UI: the user previews a side-by-side + diff, edits a merged body, and posts it here. The loser is + gone in the same transaction so the UI can refresh once. + """ + if winner_id == loser_id: + raise ValueError("merge_wiki_pages needs two distinct ids") + winner = self.get_wiki_page(winner_id) + loser = self.get_wiki_page(loser_id) + if not winner or not loser: + raise ValueError("both pages must exist") + # Carry the loser's evidence_ids forward — they're a record + # of which raw memories contributed to the merged topic. + winner_evidence = list(winner.get("evidence_ids") or []) + winner_evidence.extend(loser.get("evidence_ids") or []) + # Dedup but keep order. + seen = set() + merged_evidence = [] + for x in winner_evidence: + if x in seen: + continue + seen.add(x) + merged_evidence.append(x) + body = merged_body if merged_body is not None else winner.get("body") or "" + summary = merged_summary if merged_summary is not None else winner.get("summary") or "" + facts = merged_key_facts if merged_key_facts is not None else winner.get("key_facts") or [] + tags = merged_tags if merged_tags is not None else winner.get("tags") or [] + imp = merged_importance if merged_importance is not None else max( + float(winner.get("importance") or 0), + float(loser.get("importance") or 0), + ) + # Clear the winner's contradicting_ids — once merged, there's + # nothing left to flag. + with self._conn() as c: + c.execute( + "UPDATE wiki_pages SET body=?, summary=?, key_facts=?, " + "tags=?, importance=?, evidence_ids=?, contradicting_ids=?, " + "updated_at=? WHERE id=?", + ( + body, + summary, + json.dumps(facts, ensure_ascii=False) if facts is not None else None, + json.dumps(tags, ensure_ascii=False), + imp, + json.dumps(merged_evidence, ensure_ascii=False), + json.dumps([], ensure_ascii=False), + time.time(), + winner_id, + ), + ) + c.execute("DELETE FROM wiki_pages WHERE id=?", (loser_id,)) + # Also drop the loser from any other page's contradicting_ids + c.execute( + "UPDATE wiki_pages SET contradicting_ids=" + "REPLACE(REPLACE(contradicting_ids, ?, ''), ?, '') " + "WHERE contradicting_ids LIKE ?", + ( + f'"{loser_id}"', + f',"{loser_id}"', + f'%{loser_id}%', + ), + ) + return { + "winner_id": winner_id, + "loser_id": loser_id, + "winner_title": winner.get("title") or "", + "loser_title": loser.get("title") or "", + "merged": { + "body_len": len(body), + "summary_len": len(summary), + "facts": len(facts), + "evidence_ids": len(merged_evidence), + "importance": imp, + }, + } + + def resolve_contradiction(self, page_id: str) -> bool: + """Clear a page's ``contradicting_ids`` so it disappears from + the contradiction list. Use when the user inspects and + decides there is no real conflict (e.g. the two pages are + about different facets of the same topic).""" + with self._conn() as c: + cur = c.execute( + "UPDATE wiki_pages SET contradicting_ids=? WHERE id=?", + (json.dumps([], ensure_ascii=False), page_id), + ) + return cur.rowcount > 0 + def get_wiki_page(self, page_id: str) -> Dict[str, Any] | None: with self._conn() as c: row = c.execute( @@ -1058,21 +2269,38 @@ def count_wiki_pages(self) -> int: return int(row["n"] or 0) if row else 0 def _row_to_wiki(self, row: sqlite3.Row) -> Dict[str, Any]: - import json as _json tags = [] if row["tags"]: try: - tags = _json.loads(row["tags"]) + tags = json.loads(row["tags"]) except (ValueError, TypeError): logger.warning("corrupt tags for relation %s; resetting", row["id"]) tags = [] evidence = [] if row["evidence_ids"]: try: - evidence = _json.loads(row["evidence_ids"]) + evidence = json.loads(row["evidence_ids"]) except (ValueError, TypeError): logger.warning("corrupt evidence_ids for relation %s; resetting", row["id"]) evidence = [] + key_facts: list[str] = [] + if row["key_facts"]: + try: + parsed = json.loads(row["key_facts"]) + if isinstance(parsed, list): + key_facts = [str(x) for x in parsed if x] + except (ValueError, TypeError): + logger.warning("corrupt key_facts for wiki page %s; resetting", row["id"]) + key_facts = [] + contradicting_ids: list[str] = [] + if row["contradicting_ids"]: + try: + parsed = json.loads(row["contradicting_ids"]) + if isinstance(parsed, list): + contradicting_ids = [str(x) for x in parsed if x] + except (ValueError, TypeError): + logger.warning("corrupt contradicting_ids for wiki page %s; resetting", row["id"]) + contradicting_ids = [] return { "id": row["id"], "slug": row["slug"], @@ -1086,6 +2314,9 @@ def _row_to_wiki(self, row: sqlite3.Row) -> Dict[str, Any]: "version": int(row["version"] or 1), "created_at": float(row["created_at"] or 0.0), "updated_at": float(row["updated_at"] or 0.0), + "key_facts": key_facts, + "contradicting_ids": contradicting_ids, + "scope": row["scope"] or "global", } # --- scoring ---------------------------------------------------------- @@ -1356,6 +2587,355 @@ def delete_graph(self) -> int: c.execute("DELETE FROM relations") return r1 + # --- Universal Agent Memory v7 ---------------------------------- + # Methods for the three new tables (wiki_versions, cognitive_audit, + # auth_tokens). All keep the same return-shape conventions as the + # rest of the file: dataclasses for memory-shaped rows, dicts for + # raw query results. + + # ----- wiki_versions ---------------------------------------------- + + def snapshot_wiki_version( + self, + page_id: str, + *, + branch_tag: str | None = None, + ) -> dict | None: + """Snapshot the current state of a wiki page into ``wiki_versions``. + + Called on every ``upsert_wiki_page`` and whenever the user + runs ``MemoryClient.fork(branch_tag=...)``. Returns the new + version row, or ``None`` if the page doesn't exist. + """ + page = self.get_wiki_page(page_id) + if page is None: + return None + import json + with self._conn() as c: + row = c.execute( + "SELECT COALESCE(MAX(version), 0) AS v FROM wiki_versions WHERE page_id=?", + (page_id,), + ).fetchone() + next_v = int(row["v"] or 0) + 1 + wid = uuid.uuid4().hex + c.execute( + """INSERT INTO wiki_versions + (id, page_id, version, title, body, summary, tags, + importance, key_facts, scope, branched_at, branch_tag) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + wid, + page_id, + next_v, + page.get("title") or "", + page.get("body") or "", + page.get("summary") or "", + json.dumps(page.get("tags") or []), + float(page.get("importance") or 0.5), + json.dumps(page.get("key_facts") or []), + page.get("scope") or "global", + time.time(), + branch_tag, + ), + ) + return { + "id": wid, + "page_id": page_id, + "version": next_v, + "title": page.get("title") or "", + "summary": page.get("summary") or "", + "tags": page.get("tags") or [], + "importance": float(page.get("importance") or 0.5), + "scope": page.get("scope") or "global", + "branch_tag": branch_tag, + } + + def list_wiki_versions( + self, + page_id: str | None = None, + *, + branch_tag: str | None = None, + limit: int = 200, + ) -> list[dict]: + """Return version history, newest first.""" + clauses: list[str] = [] + params: list = [] + if page_id is not None: + clauses.append("page_id = ?") + params.append(page_id) + if branch_tag is not None: + clauses.append("branch_tag = ?") + params.append(branch_tag) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + import json + with self._conn() as c: + rows = c.execute( + f"SELECT * FROM wiki_versions {where} " + "ORDER BY branched_at DESC LIMIT ?", + (*params, limit), + ).fetchall() + out: list[dict] = [] + for r in rows: + try: + tags = json.loads(r["tags"]) if r["tags"] else [] + except Exception: + tags = [] + try: + kf = json.loads(r["key_facts"]) if r["key_facts"] else [] + except Exception: + kf = [] + out.append({ + "id": r["id"], + "page_id": r["page_id"], + "version": int(r["version"] or 1), + "title": r["title"] or "", + "summary": r["summary"] or "", + "tags": tags, + "importance": float(r["importance"] or 0.5), + "key_facts": kf, + "scope": r["scope"] or "global", + "branched_at": float(r["branched_at"] or 0), + "branch_tag": r["branch_tag"], + }) + return out + + def get_wiki_version(self, version_id: str) -> dict | None: + import json + with self._conn() as c: + r = c.execute( + "SELECT * FROM wiki_versions WHERE id=?", (version_id,), + ).fetchone() + if not r: + return None + try: + tags = json.loads(r["tags"]) if r["tags"] else [] + except Exception: + tags = [] + try: + kf = json.loads(r["key_facts"]) if r["key_facts"] else [] + except Exception: + kf = [] + return { + "id": r["id"], + "page_id": r["page_id"], + "version": int(r["version"] or 1), + "title": r["title"] or "", + "body": r["body"] or "", + "summary": r["summary"] or "", + "tags": tags, + "importance": float(r["importance"] or 0.5), + "key_facts": kf, + "scope": r["scope"] or "global", + "branched_at": float(r["branched_at"] or 0), + "branch_tag": r["branch_tag"], + } + + # ----- cognitive_audit ------------------------------------------- + + def record_audit( + self, + *, + kind: str, + action: str, + target_kind: str, + target_id: str | None = None, + target_text: str | None = None, + reason: str | None = None, + score: float | None = None, + payload: dict | None = None, + ) -> dict: + """Append one row to ``cognitive_audit``. + + ``kind`` is the trigger category — ``forget``, ``merge``, + ``contradict``, ``stale``, ``low_value``. ``action`` is the + disposition — ``suggest`` (we proposed it but didn't touch + data), ``applied`` (the SDK / CLI ran the cleanup), or + ``reverted`` (the user undid it). + """ + import json + aid = uuid.uuid4().hex + ts = time.time() + with self._conn() as c: + c.execute( + """INSERT INTO cognitive_audit + (id, ts, kind, action, target_kind, target_id, + target_text, reason, score, payload) + VALUES (?,?,?,?,?,?,?,?,?,?)""", + ( + aid, + ts, + kind, + action, + target_kind, + target_id, + target_text, + reason, + score, + json.dumps(payload or {}), + ), + ) + return { + "id": aid, + "ts": ts, + "kind": kind, + "action": action, + "target_kind": target_kind, + "target_id": target_id, + "target_text": target_text, + "reason": reason, + "score": score, + "payload": payload or {}, + } + + def list_audit( + self, + *, + kind: str | None = None, + action: str | None = None, + limit: int = 200, + ) -> list[dict]: + clauses: list[str] = [] + params: list = [] + if kind: + clauses.append("kind = ?") + params.append(kind) + if action: + clauses.append("action = ?") + params.append(action) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + import json + with self._conn() as c: + rows = c.execute( + f"SELECT * FROM cognitive_audit {where} " + "ORDER BY ts DESC LIMIT ?", + (*params, limit), + ).fetchall() + out: list[dict] = [] + for r in rows: + try: + pj = json.loads(r["payload"]) if r["payload"] else {} + except Exception: + pj = {} + out.append({ + "id": r["id"], + "ts": float(r["ts"] or 0), + "kind": r["kind"], + "action": r["action"], + "target_kind": r["target_kind"], + "target_id": r["target_id"], + "target_text": r["target_text"], + "reason": r["reason"], + "score": r["score"], + "payload": pj, + }) + return out + + # ----- auth_tokens ----------------------------------------------- + + def issue_token( + self, + *, + user_id: str | None = None, + agent_id: str | None = None, + label: str | None = None, + expires_in: float | None = None, + ) -> dict: + """Mint a bearer token scoped to ``(user_id, agent_id)``. + + Returns ``{"id", "token", "user_id", "agent_id", "label", + "created_at", "expires_at"}``. The token is only available + at issue time — the store keeps a SHA-256 hash so it can be + verified but never recovered. + """ + import hashlib + import secrets + token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() + tid = uuid.uuid4().hex + now = time.time() + exp = (now + expires_in) if expires_in else None + with self._conn() as c: + c.execute( + """INSERT INTO auth_tokens + (id, user_id, agent_id, label, token_hash, + created_at, expires_at, revoked) + VALUES (?,?,?,?,?,?,?,0)""", + (tid, user_id, agent_id, label, token_hash, now, exp), + ) + return { + "id": tid, + "token": token, + "user_id": user_id, + "agent_id": agent_id, + "label": label, + "created_at": now, + "expires_at": exp, + } + + def verify_token(self, token: str) -> dict | None: + """Return the token row if ``token`` is valid, else ``None``.""" + import hashlib + if not token: + return None + h = hashlib.sha256(token.encode("utf-8")).hexdigest() + now = time.time() + with self._conn() as c: + r = c.execute( + "SELECT * FROM auth_tokens WHERE token_hash=? AND revoked=0", + (h,), + ).fetchone() + if not r: + return None + if r["expires_at"] and float(r["expires_at"]) < now: + return None + # Best-effort: bump last_used_at. We don't fail if it errors + # — verification is the contract. + try: + with self._conn() as c: + c.execute( + "UPDATE auth_tokens SET last_used_at=? WHERE id=?", + (now, r["id"]), + ) + except Exception: + pass + return { + "id": r["id"], + "user_id": r["user_id"], + "agent_id": r["agent_id"], + "label": r["label"], + "created_at": float(r["created_at"] or 0), + "expires_at": r["expires_at"], + } + + def revoke_token(self, token_id: str) -> bool: + with self._conn() as c: + cur = c.execute( + "UPDATE auth_tokens SET revoked=1 WHERE id=? AND revoked=0", + (token_id,), + ) + return cur.rowcount > 0 + + def list_tokens(self) -> list[dict]: + with self._conn() as c: + rows = c.execute( + "SELECT id, user_id, agent_id, label, created_at, " + "last_used_at, expires_at, revoked FROM auth_tokens " + "ORDER BY created_at DESC" + ).fetchall() + return [ + { + "id": r["id"], + "user_id": r["user_id"], + "agent_id": r["agent_id"], + "label": r["label"], + "created_at": float(r["created_at"] or 0), + "last_used_at": r["last_used_at"], + "expires_at": r["expires_at"], + "revoked": bool(r["revoked"]), + } + for r in rows + ] + # --- maintenance ------------------------------------------------------ def delete_memory(self, mid: str) -> int: @@ -1363,6 +2943,95 @@ def delete_memory(self, mid: str) -> int: cur = c.execute("DELETE FROM memories WHERE id=?", (mid,)) return cur.rowcount + def merge_memories(self, a_id: str, b_id: str) -> dict: + """True memory-pair merge. + + Behaviour: + - The higher-scored memory wins (ties go to ``a_id``). + - The loser's text is appended to the winner's text (de-duplicated + if the loser's text is already a substring of the winner's). + - The winner's ``importance`` and ``score`` are bumped to the max + of the two so the fused memory keeps the strongest signal. + - The loser is deleted in the same transaction. + - The pair is recorded in ``contradiction_ignored`` so the pulse + does not surface it again. + + Returns a small dict describing what changed so the API layer can + report it back to the UI (UI then shows a 'merged' toast). + """ + a_id = str(a_id or "") + b_id = str(b_id or "") + if not a_id or not b_id or a_id == b_id: + raise ValueError("merge_memories needs two distinct ids") + now = time.time() + with self._conn() as c: + a_row = c.execute( + "SELECT id, text, importance, score FROM memories WHERE id=?", + (a_id,), + ).fetchone() + b_row = c.execute( + "SELECT id, text, importance, score FROM memories WHERE id=?", + (b_id,), + ).fetchone() + if a_row is None and b_row is None: + return {"merged": False, "reason": "neither_exists"} + if a_row is None: + # Only B exists — silently delete the missing A and keep B. + c.execute("DELETE FROM memories WHERE id=?", (a_id,)) + return {"merged": False, "kept": b_id, "lost": a_id, "reason": "a_missing"} + if b_row is None: + c.execute("DELETE FROM memories WHERE id=?", (b_id,)) + return {"merged": False, "kept": a_id, "lost": b_id, "reason": "b_missing"} + + # Pick winner = higher score; ties go to a_id. + a_score = a_row["score"] or 0.0 + b_score = b_row["score"] or 0.0 + winner_is_a = a_score >= b_score + winner_id = a_id if winner_is_a else b_id + loser_id = b_id if winner_is_a else a_id + winner_text = (a_row["text"] if winner_is_a else b_row["text"]) or "" + loser_text = (b_row["text"] if winner_is_a else a_row["text"]) or "" + importance_max = max(a_row["importance"] or 0.0, b_row["importance"] or 0.0) + score_max = max(a_score, b_score) + + # Decide whether the loser's content needs to be appended. If + # the winner already contains it (string containment is fine for + # the plain-text payloads we have here), skip the append. + needs_append = bool(loser_text.strip()) and loser_text.strip() not in winner_text + if needs_append: + # Triple-dash rule marks the boundary between the two + # original sources of a merged memory. + sep = "\n\n---\n\n" + merged_text = winner_text.rstrip() + sep + loser_text.strip() + else: + merged_text = winner_text + + c.execute( + "UPDATE memories " + "SET text=?, importance=?, score=?, updated_at=? " + "WHERE id=?", + (merged_text, importance_max, score_max, now, winner_id), + ) + c.execute("DELETE FROM memories WHERE id=?", (loser_id,)) + + # Suppress the pair so the pulse does not resurface it. + lo, hi = sorted([a_id, b_id]) + key = f"{lo}|{hi}" + c.execute( + "INSERT INTO contradiction_ignored(pair_key, ignored_at) VALUES(?, ?) " + "ON CONFLICT(pair_key) DO NOTHING", + (key, now), + ) + + return { + "merged": True, + "kept": winner_id, + "lost": loser_id, + "appended": needs_append, + "winner_was_a": winner_is_a, + "new_length": len(merged_text), + } + def delete_session(self, session_id: str) -> int: with self._conn() as c: cur = c.execute("DELETE FROM memories WHERE session_id=?", (session_id,)) @@ -1383,17 +3052,75 @@ def stats(self) -> dict: n_mem = c.execute("SELECT COUNT(*) c FROM memories").fetchone()["c"] n_ses = c.execute("SELECT COUNT(*) c FROM sessions").fetchone()["c"] n_wiki = c.execute("SELECT COUNT(*) c FROM wiki_pages").fetchone()["c"] + n_entities = c.execute("SELECT COUNT(*) c FROM entities").fetchone()["c"] + n_relations = c.execute("SELECT COUNT(*) c FROM relations").fetchone()["c"] avg = c.execute("SELECT AVG(score) a FROM memories").fetchone()["a"] or 0.0 wiki_avg = c.execute("SELECT AVG(importance) a FROM wiki_pages").fetchone()["a"] or 0.0 return { "memories": n_mem, "sessions": n_ses, "wiki_pages": n_wiki, + "entities": n_entities, + "relations": n_relations, "wiki_avg_importance": round(float(wiki_avg), 4), "avg_score": round(avg, 4), "path": str(self.path), + "db_size_bytes": self.db_size_bytes(), } + def db_size_bytes(self) -> int: + """Return the on-disk size of the SQLite file in bytes. + + Cheap (one stat call) so the dashboard can poll it freely. + """ + try: + return int(self.path.stat().st_size) + except OSError: + return 0 + + def list_low_value_memories(self, limit: int = 500) -> list[StoredMemory]: + """Memories ranked by *combined* importance × score, ascending. + + Used by the compactor to drop the least-useful rows when the + store exceeds its hard ceiling. Excludes rows that have ever + been recalled — we never evict demonstrably-useful memories + without an explicit user action. + """ + with self._conn() as c: + rows = c.execute( + """ + SELECT m.* + FROM memories m + LEFT JOIN memory_signals s ON s.memory_id = m.id + WHERE COALESCE(s.recall_count, 0) = 0 + AND m.kind != 'digest' + ORDER BY (COALESCE(m.score, 0) * COALESCE(m.importance, 0)) ASC, + COALESCE(m.created_at, 0) ASC + LIMIT ? + """, + (limit,), + ).fetchall() + return [self._row_to_memory(r) for r in rows] + + def storage_breakdown(self) -> dict[str, int]: + """Per-table row counts and approximate bytes-on-disk. + + The on-disk byte estimate is from SQLite's ``dbstat`` virtual + table when available; falls back to a row-count × avg-size + heuristic otherwise. + """ + out: dict[str, int] = {} + with self._conn() as c: + for tbl in ("memories", "sessions", "wiki_pages", "entities", "relations", + "memory_signals", "contradiction_pairs", "drops", "settings"): + try: + row = c.execute(f"SELECT COUNT(*) c FROM {tbl}").fetchone() + out[tbl] = int(row["c"] or 0) + except sqlite3.OperationalError: + out[tbl] = 0 + out["db_size_bytes"] = self.db_size_bytes() + return out + # --- settings --------------------------------------------------------- @@ -1561,7 +3288,7 @@ def record( import uuid try: aid = uuid.uuid4().hex - prompt_hash = hashlib.sha1((prompt or "").encode("utf-8")).hexdigest()[:16] + prompt_hash = hashlib.sha1((prompt or "").encode("utf-8"), usedforsecurity=False).hexdigest()[:16] total = (prompt_tokens or 0) + (completion_tokens or 0) with self._store._conn() as c: c.execute( diff --git a/pyproject.toml b/pyproject.toml index 02c3222..8b2e01a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,13 +5,13 @@ build-backend = "setuptools.build_meta" [project] name = "loop-memory" version = "0.3.0" -description = "A Loop Engineering memory system for large language models, with a local web UI and auto-ingest from Codex / Claude / Hermes transcripts." +description = "A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI." readme = "README.md" requires-python = ">=3.10" license = "MIT" license-files = ["LICENSE"] authors = [{ name = "Loop Memory contributors <loop-memory@users.noreply.github.com>" }] -keywords = ["llm", "memory", "agent", "rag", "long-term-memory", "loop-engineering"] +keywords = ["llm", "memory", "agent", "agents", "rag", "long-term-memory", "memory-system", "codex", "claude", "hermes", "openclaw", "clawx"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -55,7 +55,7 @@ target-version = "py310" [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP"] -ignore = ["E501", "E701", "E702", "F821", "F401", "B007", "B904", "UP035", "I001"] +ignore = ["E501", "E701", "E702", "F821", "F401", "B007", "B904", "UP035", "UP045", "I001"] [tool.ruff.format] quote-style = "double" diff --git a/scripts/codex_config_tune.py b/scripts/codex_config_tune.py new file mode 100755 index 0000000..624d596 --- /dev/null +++ b/scripts/codex_config_tune.py @@ -0,0 +1,64 @@ +"""Apply safe context-limits to ~/.codex/config.toml. + +Without these, long Codex sessions grow unbounded and the app process +eats gigabytes of RAM because every tool output is replayed into the +model context on every turn. The defaults below keep a healthy headroom +for the current 258k context window; raise them only if your model +supports a larger window. +""" +from __future__ import annotations +import sys +import shutil +import datetime as dt +from pathlib import Path + +CFG = Path.home() / ".codex" / "config.toml" +BAK = CFG.with_suffix(f".toml.bak.pre-tune-{int(dt.datetime.now().timestamp())}") + +# Tunables. Conservative so they don't surprise users on first apply; +# any one of these alone would have prevented the 48 GB session. +PATCH = { + "model_auto_compact_token_limit": 80000, + "model_auto_compact_token_limit_scope": "conversation", + "tool_output_token_limit": 4000, + "project_doc_max_bytes": 50000, +} + +def main(apply: bool = False) -> int: + if not CFG.exists(): + print(f"❌ {CFG} not found", file=sys.stderr) + return 1 + original = CFG.read_text() + # If a value already exists, do not touch it. This script is idempotent. + new_lines = [] + existing_keys = {k for k in PATCH if f"{k} =" in original} + for k, v in PATCH.items(): + if k in existing_keys: + print(f" · {k} already set — leaving alone") + continue + new_lines.append(f"{k} = {_toml(v)}") + if not new_lines: + print("✓ All tunables already present, no changes needed") + return 0 + if not apply: + print("Dry-run. Re-run with --apply to write:") + for ln in new_lines: + print(f" + {ln}") + return 0 + shutil.copy2(CFG, BAK) + CFG.write_text(original.rstrip() + "\n\n# Added by loop_memory codex-config-tune on " + + dt.date.today().isoformat() + "\n" + "\n".join(new_lines) + "\n") + print(f"✓ Patched {CFG}") + print(f" Backup: {BAK}") + return 0 + +def _toml(v): + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, str): + return f'"{v}"' + return str(v) + +if __name__ == "__main__": + apply = "--apply" in sys.argv + sys.exit(main(apply=apply)) diff --git a/scripts/codex_session_audit.py b/scripts/codex_session_audit.py new file mode 100644 index 0000000..e09a72b --- /dev/null +++ b/scripts/codex_session_audit.py @@ -0,0 +1,136 @@ +"""Audit Codex session files and warn before they bloat the host. + +Each Codex session is a JSONL append-only log under +``~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<...>.jsonl``. Long +sessions grow into the hundreds of MB because every tool output is +replayed into the model context on every turn. This script reads the +``token_count`` events Codex already emits and prints a per-session +report so the user can decide which ones to ``/compact`` or archive. + +Usage:: + + python3 -m loop_memory.scripts.codex_session_audit # all sessions + python3 -m loop_memory.scripts.codex_session_audit --top 10 # top 10 by peak tokens + python3 -m loop_memory.scripts.codex_session_audit --json # machine-readable +""" +from __future__ import annotations +import argparse +import json +import os +import sys +from pathlib import Path +from collections import defaultdict + +DEFAULT_ROOT = Path.home() / ".codex" / "sessions" +CTX_WINDOW_DEFAULT = 258_400 # tokens, matches the current MiniMax-M3 window + +def iter_sessions(root: Path): + if not root.exists(): + return + yield from sorted(root.rglob("rollout-*.jsonl")) + +def audit_file(path: Path) -> dict: + """Read a session JSONL once and pull token_count peaks + size. + + We avoid loading the full file into memory — the line iterator + only carries the line we just parsed. + """ + info = { + "path": str(path), + "size_bytes": path.stat().st_size, + "lines": 0, + "peak_input_tokens": 0, + "peak_total_tokens": 0, + "final_input_tokens": 0, + "first_ts": None, + "last_ts": None, + "tool_output_count": 0, + "tool_output_bytes": 0, + } + peak_inp = 0 + peak_tot = 0 + final_inp = 0 + with path.open("r", encoding="utf-8", errors="replace") as f: + for line in f: + info["lines"] += 1 + try: + o = json.loads(line) + except Exception: + continue + ts = o.get("timestamp") + if ts: + if not info["first_ts"]: + info["first_ts"] = ts + info["last_ts"] = ts + p = o.get("payload", {}) + if o.get("type") == "response_item" and p.get("type") == "function_call_output": + info["tool_output_count"] += 1 + # Approximate: line bytes minus JSON envelope + info["tool_output_bytes"] += len(line) + if p.get("type") == "token_count": + tu = p.get("info", {}).get("total_token_usage", {}) + inp = int(tu.get("input_tokens", 0) or 0) + tot = int(tu.get("total_tokens", 0) or 0) + if inp > peak_inp: + peak_inp = inp + if tot > peak_tot: + peak_tot = tot + final_inp = inp + info["peak_input_tokens"] = peak_inp + info["peak_total_tokens"] = peak_tot + info["final_input_tokens"] = final_inp + info["size_mb"] = round(info["size_bytes"] / (1024 * 1024), 2) + return info + +def severity(info: dict, ctx: int) -> str: + peak = info.get("peak_input_tokens", 0) or 0 + if peak > ctx * 5: + return "critical" # 5x overflow → process memory stress + if peak > ctx * 2: + return "high" + if peak > ctx: + return "warning" + return "ok" + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--root", type=Path, default=DEFAULT_ROOT, help="codex sessions root") + ap.add_argument("--top", type=int, default=20, help="show top N sessions by peak tokens") + ap.add_argument("--json", action="store_true", help="emit JSON instead of a table") + ap.add_argument("--ctx-window", type=int, default=CTX_WINDOW_DEFAULT, + help="model context window in tokens (default: 258400)") + args = ap.parse_args(argv) + + rows = [audit_file(p) for p in iter_sessions(args.root)] + if not rows: + print(f"No sessions found under {args.root}") + return 0 + rows.sort(key=lambda r: r.get("peak_input_tokens", 0), reverse=True) + + if args.json: + json.dump(rows[: args.top], sys.stdout, indent=2, ensure_ascii=False) + sys.stdout.write("\n") + return 0 + + print(f"Audited {len(rows)} sessions under {args.root} (context window = {args.ctx_window:,} tokens)\n") + header = f"{'STATUS':<10} {'PEAK_IN':>14} {'SIZE_MB':>9} {'LINES':>7} {'TOOL_OUT':>9} PATH" + print(header) + print("-" * len(header)) + for r in rows[: args.top]: + sev = severity(r, args.ctx_window) + print(f"{sev:<10} {r['peak_input_tokens']:>14,} " + f"{r['size_mb']:>9.1f} {r['lines']:>7,} " + f"{r['tool_output_count']:>9,} {r['path']}") + crit = [r for r in rows if severity(r, args.ctx_window) in ("critical", "high")] + if crit: + print("\n⚠️ Critical / high sessions should be handled immediately:") + print(" • Run `/compact` in the Codex UI for each affected session, or") + print(" • Run `codex archive <session-id>` to retire a session, or") + print(" • Open a fresh session and inject the loop_memory digest instead:") + print(" `python3 -m loop_memory.cli.main memory-digest --out ~/.codex/AGENTS.md`") + print(" • Set `model_auto_compact_token_limit` in ~/.codex/config.toml") + print(" (use `python3 -m loop_memory.scripts.codex_config_tune --apply`)") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/install_weekly_research_launchd.sh b/scripts/install_weekly_research_launchd.sh new file mode 100755 index 0000000..138f0e6 --- /dev/null +++ b/scripts/install_weekly_research_launchd.sh @@ -0,0 +1,63 @@ +#!/bin/zsh +set -euo pipefail + +SCRIPT_DIR=${0:A:h} +REPO_ROOT=${SCRIPT_DIR:h} +WEEKDAY=${1:-1} +HOUR=${2:-3} +MINUTE=${3:-0} +LABEL=com.loopmemory.weekly-research +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" +RUNNER="$REPO_ROOT/scripts/weekly_research_update.sh" + +if [[ ! "$WEEKDAY" =~ '^[0-7]$' ]] || (( HOUR < 0 || HOUR > 23 || MINUTE < 0 || MINUTE > 59 )); then + echo "Usage: $0 [weekday 0-7] [hour 0-23] [minute 0-59]" >&2 + echo "Weekday 1 is Monday; 0 or 7 is Sunday." >&2 + exit 2 +fi + +mkdir -p "$HOME/Library/LaunchAgents" "$HOME/.loop_memory/automation/logs" +chmod 700 "$HOME/.loop_memory" "$HOME/.loop_memory/automation" \ + "$HOME/.loop_memory/automation/logs" 2>/dev/null || true + +cat > "$PLIST" <<EOF +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>$LABEL</string> + <key>ProgramArguments</key> + <array> + <string>$RUNNER</string> + </array> + <key>WorkingDirectory</key> + <string>$REPO_ROOT</string> + <key>EnvironmentVariables</key> + <dict> + <key>PATH</key> + <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string> + </dict> + <key>StartCalendarInterval</key> + <dict> + <key>Weekday</key> + <integer>$WEEKDAY</integer> + <key>Hour</key> + <integer>$HOUR</integer> + <key>Minute</key> + <integer>$MINUTE</integer> + </dict> + <key>ProcessType</key> + <string>Background</string> +</dict> +</plist> +EOF +chmod 600 "$PLIST" + +launchctl bootout "gui/$UID/$LABEL" 2>/dev/null || true +launchctl bootstrap "gui/$UID" "$PLIST" +launchctl enable "gui/$UID/$LABEL" + +echo "Installed $LABEL for weekday $WEEKDAY at $(printf '%02d:%02d' "$HOUR" "$MINUTE") local time." +echo "Logs: $HOME/.loop_memory/automation/logs" +echo "Inspect: launchctl print gui/$UID/$LABEL" diff --git a/scripts/scan_secrets.py b/scripts/scan_secrets.py new file mode 100755 index 0000000..70ec6c8 --- /dev/null +++ b/scripts/scan_secrets.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Fail safely when repository files appear to contain private credentials.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + + +MAX_FILE_BYTES = 2 * 1024 * 1024 +PATTERNS = { + "private key": re.compile(r"-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----"), + "OpenAI-style API key": re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), + "GitHub token": re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"), + "Anthropic API key": re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b"), + "Google API key": re.compile(r"\bAIza[0-9A-Za-z_-]{30,}\b"), + "AWS access key": re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"), + "Slack token": re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b"), +} +ASSIGNMENT_PATTERN = re.compile( + r"(?i)\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|password)\b" + r"\s*[:=]\s*['\"]([^'\"\s]{16,})['\"]" +) +PLACEHOLDER_MARKERS = ( + "example", + "placeholder", + "replace_me", + "replace-with", + "your_", + "your-", + "dummy", + "fake", + "test", + "xxxx", + "${", + "{{", +) + + +def repository_files(root: Path, tracked_only: bool) -> list[Path]: + command = ["git", "ls-files", "-z"] + if not tracked_only: + command = ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"] + result = subprocess.run(command, cwd=root, check=True, capture_output=True) + return [root / item.decode() for item in result.stdout.split(b"\0") if item] + + +def looks_like_placeholder(value: str) -> bool: + lowered = value.lower() + return any(marker in lowered for marker in PLACEHOLDER_MARKERS) + + +def scan_file(path: Path, root: Path) -> list[tuple[str, int]]: + try: + if not path.is_file() or path.stat().st_size > MAX_FILE_BYTES: + return [] + raw = path.read_bytes() + except OSError: + return [] + if b"\0" in raw: + return [] + + findings: list[tuple[str, int]] = [] + text = raw.decode("utf-8", errors="replace") + for line_number, line in enumerate(text.splitlines(), start=1): + for label, pattern in PATTERNS.items(): + match = pattern.search(line) + if match and not looks_like_placeholder(match.group(0)): + findings.append((label, line_number)) + assignment = ASSIGNMENT_PATTERN.search(line) + if assignment and not looks_like_placeholder(assignment.group(1)): + findings.append(("credential-like assignment", line_number)) + return findings + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--tracked-only", + action="store_true", + help="scan only files already tracked by Git", + ) + args = parser.parse_args() + + root_result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], check=True, capture_output=True, text=True + ) + root = Path(root_result.stdout.strip()) + findings: list[tuple[Path, str, int]] = [] + for path in repository_files(root, args.tracked_only): + for label, line_number in scan_file(path, root): + findings.append((path.relative_to(root), label, line_number)) + + if findings: + print("Secret scan failed. Potential private data found:", file=sys.stderr) + for path, label, line_number in findings: + print(f" {path}:{line_number}: {label}", file=sys.stderr) + print("Values are intentionally redacted. Remove or rotate them before pushing.", file=sys.stderr) + return 1 + + scope = "tracked files" if args.tracked_only else "tracked and untracked repository files" + print(f"Secret scan passed ({scope}).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/weekly_research_update.sh b/scripts/weekly_research_update.sh new file mode 100755 index 0000000..a662404 --- /dev/null +++ b/scripts/weekly_research_update.sh @@ -0,0 +1,148 @@ +#!/bin/zsh +set -euo pipefail + +SCRIPT_DIR=${0:A:h} +REPO_ROOT=${LOOP_MEMORY_REPO:-${SCRIPT_DIR:h}} +STATE_DIR=${LOOP_MEMORY_AUTOMATION_STATE_DIR:-$HOME/.loop_memory/automation} +LOG_DIR="$STATE_DIR/logs" +LOCK_DIR="$STATE_DIR/weekly-research.lock" +PRIVATE_ENV_FILE="$STATE_DIR/env" +RUN_DATE=$(date +%F) +BRANCH="automation/weekly-research-$RUN_DATE" +CODEX_BIN=${CODEX_BIN:-/Applications/ChatGPT.app/Contents/Resources/codex} + +umask 077 +mkdir -p "$LOG_DIR" +exec > >(tee -a "$LOG_DIR/weekly-research-$RUN_DATE.log") 2>&1 + +if [[ -f "$PRIVATE_ENV_FILE" ]]; then + ENV_MODE=$(stat -f '%Lp' "$PRIVATE_ENV_FILE") + if (( (8#$ENV_MODE & 8#077) != 0 )); then + echo "$PRIVATE_ENV_FILE must not be readable by group or others." >&2 + exit 1 + fi + set -a + source "$PRIVATE_ENV_FILE" + set +a +fi + +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "Another weekly research run is active; exiting." + exit 0 +fi +trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT + +echo "[$(date -Iseconds)] Starting Loop Memory weekly research" +cd "$REPO_ROOT" + +for command in git gh python3; do + command -v "$command" >/dev/null || { + echo "Required command not found: $command" >&2 + exit 1 + } +done +[[ -x "$CODEX_BIN" ]] || { + echo "Codex executable not found: $CODEX_BIN" >&2 + exit 1 +} + +if [[ -n "$(git status --porcelain)" ]]; then + echo "Repository has local changes. Skipping to avoid mixing private or unfinished work." + exit 0 +fi +if [[ "$(git branch --show-current)" != "main" ]]; then + echo "Repository is not on main. Skipping safely." + exit 0 +fi + +git fetch origin --prune +LOCAL_HEAD=$(git rev-parse HEAD) +REMOTE_HEAD=$(git rev-parse origin/main) +if [[ "$LOCAL_HEAD" != "$REMOTE_HEAD" ]]; then + echo "Local main and origin/main differ. Resolve them before the next run." + exit 1 +fi + +if git show-ref --verify --quiet "refs/heads/$BRANCH"; then + echo "Branch $BRANCH already exists; refusing to overwrite it." + exit 1 +fi +git switch -c "$BRANCH" + +PROMPT=$(cat <<'EOF' +You are running the weekly open-source research and improvement cycle for Loop Memory. + +Research first: +1. Search GitHub releases, changelogs, issues, papers, engineering blogs, and other public web sources from roughly the last seven days. +2. Compare relevant agent-memory and long-term-memory projects, including Mem0, Letta, Zep/Graphiti, LangMem, OpenMemory, and newly discovered peers. +3. Prefer primary sources. Record source URLs, publication/update dates, licenses, notable changes, applicability, and explicit adopt/defer/reject decisions. +4. Never copy incompatible code. Reimplement only general ideas that fit this MIT project and add attribution when required. + +Update the repository: +1. Create docs/research/YYYY-MM-DD.md with a concise evidence-based report, even if no code change is justified. +2. Implement only small, high-confidence improvements that materially benefit Loop Memory and fit its architecture. Avoid speculative rewrites and dependency growth. +3. Add or update focused tests and user documentation for every behavior change. +4. Run focused validation while iterating. + +Safety rules: +- Do not read ~/.loop_memory/secrets.json, ~/.codex/auth.json, shell history, .env files, keychains, or any credential store. +- Do not include local transcripts, databases, logs, user paths, personal data, tokens, API keys, or credentials in repository files. +- Do not commit, push, open pull requests, merge, publish packages, or modify Git configuration. +- Do not weaken security checks, CI, secret scanning, or tests. +- Leave the working tree with only the intended research report and justified project changes. +EOF +) + +"$CODEX_BIN" exec \ + --ephemeral \ + --cd "$REPO_ROOT" \ + --sandbox workspace-write \ + -c 'approval_policy="never"' \ + -c 'sandbox_workspace_write.network_access=true' \ + "$PROMPT" + +if [[ -z "$(git status --porcelain)" ]]; then + echo "Codex produced no repository changes." + git switch main + git branch -D "$BRANCH" + exit 0 +fi + +python3 scripts/scan_secrets.py +python3 -m py_compile scripts/scan_secrets.py + +if [[ -x .venv/bin/ruff ]]; then + .venv/bin/ruff check loop_memory tests +else + python3 -m ruff check loop_memory tests +fi +if [[ -x .venv/bin/pytest ]]; then + .venv/bin/pytest -q +else + python3 -m pytest -q +fi + +git add -A +python3 scripts/scan_secrets.py --tracked-only +git diff --cached --check +git commit -m "chore(research): weekly ecosystem update $RUN_DATE" +git push --set-upstream origin "$BRANCH" + +PR_URL=$(gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "chore(research): weekly ecosystem update $RUN_DATE" \ + --body "Automated weekly ecosystem research and high-confidence improvements. Local secret scan, lint, and tests passed before push. CI must pass before automatic squash merge.") +echo "Created $PR_URL" + +if gh pr checks "$PR_URL" --watch --fail-fast --interval 30; then + gh pr merge "$PR_URL" --squash --delete-branch + git switch main + git pull --ff-only origin main + echo "Merged and synchronized $PR_URL" +else + echo "CI failed or was cancelled. The remote branch and PR remain for review." >&2 + exit 1 +fi + +echo "[$(date -Iseconds)] Weekly research completed" diff --git a/skills/loop-memory-context/SKILL.md b/skills/loop-memory-context/SKILL.md new file mode 100644 index 0000000..49c614f --- /dev/null +++ b/skills/loop-memory-context/SKILL.md @@ -0,0 +1,69 @@ +--- +name: "loop-memory-context" +description: "Use when the user starts a long-running Codex task, asks for context carry-over, requests previous-session knowledge, or after any tool call that hints at a long chat history. Loads a compact, distilled knowledge digest from the local Loop Memory service so the assistant can reference prior work without paying the cost of replaying every turn into context. Use this skill whenever the user asks 'what did we do before', 'continue from last time', 'do you remember', or after the session has visibly accumulated many turns." +--- + +# Loop Memory — Compact Context Digest + +Long Codex sessions accumulate a huge history of tool calls, function outputs, and reasoning traces. Replaying all of it on every turn explodes the model's context window and eventually the host's RAM. This skill sidesteps the problem by pulling a *distilled* digest from the user's local Loop Memory service (~6 KB / 1500 tokens) and prepending it as background context. + +The digest is auto-generated by the Loop Memory LLM consolidator — it contains the user's important project facts, decisions, and preferences — not raw transcripts. It is small enough to live in the system prompt permanently without inflating token cost per turn. + +## When to use + +- At the **start of any new task** that might reference prior work (user says "remember last time", "we discussed X", "the same project", etc.). +- When the user asks the assistant to **continue a previous session** or to pick up where they left off. +- When you detect the conversation has grown long enough that `wc -c` on the session JSONL exceeds ~5 MB. +- **Never** use to replace reading the actual files the user is asking about — the digest is a *summary*, not a substitute for source code. + +## When NOT to use + +- When the user explicitly asks for raw history (`/history`, "show me the previous messages"). +- When Loop Memory is not running (`curl localhost:7767/api/stats` returns connection refused). Fall back to the user's `~/.codex/AGENTS.md` if it exists. + +## Steps + +1. **Probe the service.** Run: + ```bash + curl -sf http://127.0.0.1:7767/api/stats > /dev/null || { + echo "Loop Memory is not running. Try: python3 -m loop_memory.cli.main serve --port 7767" + return 1 + } + ``` + +2. **Pull the digest.** If the user's repo has a project-local digest (typically `AGENTS.md` or `LOOP_MEMORY.md`), prefer it. Otherwise fetch: + ```bash + python3 -m loop_memory.cli.main digest --out ~/.loop_memory/AGENTS.md --max-chars 8000 + ``` + This is idempotent — running it again just refreshes the file. Default budget is ~1500 tokens; raise `--max-chars 16000` (~4000 tokens) when the user has a complex multi-project setup. + +3. **Read it.** Open the file with `view_file` or `cat`. It contains the user's distilled wiki pages, ordered by importance. + +4. **Acknowledge briefly.** One line: "Loaded N pages of distilled memory (M chars). I see you previously decided X — I'll work with that." + +5. **Stop.** Do **not** dump the digest into chat. The user can see the file. The point is that the *model* now has it in context, not the user. + +## What the digest contains + +Each entry in the digest is a wiki page with: +- Title and 1-3 sentence summary +- `Key facts` — machine-readable single-sentence bullets used for retrieval and contradiction detection +- Body — polished narrative form (markdown), truncated to 800 chars per page +- Tags and importance score + +These are produced by the Loop Memory `EvolutionConsolidator`, which runs LLM-driven distillation on the raw memory store. The digest is therefore **higher signal** than the underlying memories. + +## Refresh cadence + +- Auto-refresh: Loop Memory re-distills in the background on a schedule the user configured. The digest is **not** auto-updated; rerun step 2 manually when the user adds a major new decision. +- Manual refresh: from the web UI, Settings → "Recompile digest" button. + +## Verifying it's working + +After loading the digest, check `GET /api/admin/llm/runs?limit=1` to see when the last distillation ran. If the wiki is empty (`curl -s localhost:7767/api/wiki | jq '. | length'`), the user has not yet had anything distilled — suggest they trigger one with `loop-memory consolidate-now` or the dashboard's "Run evolution" button. + +## Caveats + +- The digest is **a snapshot**, not live. If the user just made a critical decision two minutes ago, that decision may not yet be reflected. Use `curl 'localhost:7767/api/recall?q=...'` to fetch fresh memories on demand. +- The digest does **not** carry code context. If the user asks "what did I write in foo.py yesterday", still read the file. +- If the user has multiple machines, the digest lives wherever the Loop Memory DB lives. Cross-machine sync is out of scope; mention this to the user if they ask. diff --git a/tests/test_agent_memory_api.py b/tests/test_agent_memory_api.py new file mode 100644 index 0000000..0c443db --- /dev/null +++ b/tests/test_agent_memory_api.py @@ -0,0 +1,163 @@ +"""HTTP-level tests for the /api/v1/memories surface. + +These run against an in-process FastAPI app via ``TestClient`` so no +network access is required and the live database is never touched. +""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from fastapi.testclient import TestClient + +from loop_memory.serve.app import create_app +from loop_memory.storage.sqlite_store import MemoryStore + + +def _new_client() -> tuple[TestClient, Path]: + tmp = Path(tempfile.mkdtemp(prefix="loop_api_")) + db = tmp / "api.db" + store = MemoryStore(db) + app = create_app(store, static_dir=None) + return TestClient(app), db + + +class V1MemoriesCreateTests(unittest.TestCase): + def setUp(self) -> None: + self.c, self.db = _new_client() + self.body = { + "text": "the orders service uses Postgres", + "kind": "fact", + "importance": 0.7, + "tags": ["infra", "db"], + "agent_id": "team-bot", + "user_id": "alice", + "external_id": "orders-pg", + } + + def test_post_creates_memory(self) -> None: + r = self.c.post("/api/v1/memories", json=self.body) + self.assertEqual(r.status_code, 200) + j = r.json() + self.assertEqual(j["external_id"], "orders-pg") + self.assertEqual(j["agent_id"], "team-bot") + self.assertEqual(j["user_id"], "alice") + self.assertIn("infra", j["tags"]) + + def test_post_is_idempotent_on_external_id(self) -> None: + a = self.c.post("/api/v1/memories", json=self.body).json() + b = self.c.post("/api/v1/memories", json={ + **self.body, "text": "now uses Postgres + Redis", + }).json() + self.assertEqual(a["id"], b["id"]) + self.assertIn("Redis", b["text"]) + + def test_post_rejects_empty_text(self) -> None: + r = self.c.post("/api/v1/memories", json={**self.body, "text": " "}) + self.assertEqual(r.status_code, 400) + + def test_post_clamps_importance(self) -> None: + r = self.c.post("/api/v1/memories", json={ + **self.body, "external_id": "x", "importance": 5.0, + }) + self.assertEqual(r.status_code, 200) + self.assertLessEqual(r.json()["importance"], 1.0) + + +class V1MemoriesBatchTests(unittest.TestCase): + def test_batch_returns_per_item_result(self) -> None: + c, _ = _new_client() + r = c.post("/api/v1/memories:batch", json={"items": [ + {"text": "a", "agent_id": "x", "external_id": "a"}, + {"text": "b", "agent_id": "x", "external_id": "b"}, + {"text": "", "agent_id": "x"}, # malformed + "not-an-object", + ]}) + self.assertEqual(r.status_code, 200) + items = r.json()["items"] + self.assertEqual(len(items), 4) + self.assertTrue(items[0]["external_id"] == "a") + self.assertIn("error", items[2]) + self.assertIn("error", items[3]) + + def test_batch_caps_size(self) -> None: + c, _ = _new_client() + items = [{"text": f"x{i}"} for i in range(501)] + r = c.post("/api/v1/memories:batch", json={"items": items}) + self.assertEqual(r.status_code, 400) + + +class V1RecallAndFeedbackTests(unittest.TestCase): + def setUp(self) -> None: + self.c, _ = _new_client() + for ext, text in [ + ("a1", "team uses Postgres for orders"), + ("a2", "team uses Redis for cache"), + ]: + self.c.post("/api/v1/memories", json={ + "text": text, "kind": "fact", "importance": 0.7, + "agent_id": "team-bot", "user_id": "alice", + "external_id": ext, + }) + + def test_recall_finds_memory(self) -> None: + r = self.c.get("/api/v1/recall", params={"q": "Postgres", "limit": 5}) + self.assertEqual(r.status_code, 200) + j = r.json() + self.assertTrue(j["memories"]) + self.assertEqual(j["memories"][0]["external_id"], "a1") + + def test_recall_namespace_filter_excludes_other_agents(self) -> None: + r = self.c.get("/api/v1/recall", params={ + "q": "Postgres", "agent_id": "other-bot", "limit": 5, + }) + self.assertFalse(r.json()["memories"]) + + def test_feedback_by_external_then_delete(self) -> None: + r = self.c.post("/api/v1/memories/feedback", json={ + "value": "up", "external_id": "a2", "agent_id": "team-bot", "user_id": "alice", + }) + self.assertEqual(r.status_code, 200) + r = self.c.delete("/api/v1/memories", params={ + "external_id": "a2", "agent_id": "team-bot", "user_id": "alice", + }) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()["deleted"], 1) + # second delete -> 404 + r = self.c.delete("/api/v1/memories", params={ + "external_id": "a2", "agent_id": "team-bot", "user_id": "alice", + }) + self.assertEqual(r.status_code, 404) + + def test_feedback_by_id(self) -> None: + r = self.c.get("/api/v1/memories", params={"external_id": "a1"}) + mid = r.json()["memories"][0]["id"] + r = self.c.post(f"/api/v1/memories/{mid}/feedback", json={"value": "down"}) + self.assertEqual(r.status_code, 200) + + def test_feedback_unknown_external_returns_404(self) -> None: + r = self.c.post("/api/v1/memories/feedback", json={ + "value": "up", "external_id": "nope", "agent_id": "x", + }) + self.assertEqual(r.status_code, 404) + + +class V1ListTests(unittest.TestCase): + def test_list_filtered_by_agent(self) -> None: + c, _ = _new_client() + c.post("/api/v1/memories", json={ + "text": "x", "agent_id": "a", "external_id": "1", + }) + c.post("/api/v1/memories", json={ + "text": "y", "agent_id": "b", "external_id": "1", + }) + r = c.get("/api/v1/memories", params={"agent_id": "a"}) + rows = r.json()["memories"] + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["agent_id"], "a") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_agent_memory_sdk.py b/tests/test_agent_memory_sdk.py new file mode 100644 index 0000000..c19306f --- /dev/null +++ b/tests/test_agent_memory_sdk.py @@ -0,0 +1,184 @@ +"""Tests for the universal Agent Memory SDK and the underlying +``(agent_id, user_id, external_id)`` storage contract. + +These tests cover the in-process backend; the HTTP backend is +exercised by ``test_agent_memory_api.py`` and the MCP write tools +by ``test_mcp.py::McpWriteToolTests``. +""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from loop_memory.sdk import MemoryClient, MemoryClientError +from loop_memory.storage.sqlite_store import MemoryStore + + +class AgentMemorySdkTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.mkdtemp(prefix="loop_sdk_") + self.db = Path(self.tmp) / "sdk.db" + self.store = MemoryStore(self.db) + self.client = MemoryClient.memory( + self.store, agent_id="alpha", user_id="u-1", + ) + + def tearDown(self) -> None: + import shutil + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_remember_creates_memory_with_agent_triple(self) -> None: + m = self.client.remember( + "user prefers dark mode", + kind="preference", importance=0.8, + tags=["ui"], external_id="pref-dark", + ) + self.assertEqual(m.agent_id, "alpha") + self.assertEqual(m.user_id, "u-1") + self.assertEqual(m.external_id, "pref-dark") + self.assertIn("ui", m.tags) + + def test_remember_is_idempotent_via_external_id(self) -> None: + a = self.client.remember("v1", external_id="k1", importance=0.5) + b = self.client.remember("v2 updated", external_id="k1", importance=0.7) + self.assertEqual(a.id, b.id) + self.assertEqual(b.text, "v2 updated") + self.assertGreaterEqual(b.importance, 0.7 - 1e-9) + + def test_remember_without_external_id_creates_new_row(self) -> None: + a = self.client.remember("first") + b = self.client.remember("second") + self.assertNotEqual(a.id, b.id) + + def test_recall_finds_just_written_memory(self) -> None: + self.client.remember("orders service runs on Postgres", + kind="fact", external_id="orders-db", + tags=["infra"]) + r = self.client.recall("orders Postgres", limit=5) + self.assertTrue(r.memories) + # The recalled memory must be the one we just wrote. + self.assertEqual(r.memories[0].external_id, "orders-db") + self.assertIn("Postgres", r.memories[0].text) + + def test_recall_namespace_filter_drops_other_agents(self) -> None: + self.client.remember("alpha note", external_id="a1") + # Switch the client's identity; the alpha-only memory must + # not leak into a different agent's recall. + other = MemoryClient.memory(self.store, agent_id="beta", user_id="u-1") + # beta did not set LOOP_MEMORY_*; the store call is filtered + # only when agent_id is explicitly passed. + r = other.recall("alpha note", limit=10, agent_id="beta") + self.assertFalse(r.memories) + + def test_feedback_up_then_ignore_deletes(self) -> None: + self.client.remember("ephemeral", external_id="eph-1") + self.assertTrue(self.client.feedback(external_id="eph-1", value="up")) + self.assertTrue(self.client.feedback(external_id="eph-1", value="ignore")) + # now forget should be a no-op + self.assertEqual(self.client.forget(external_id="eph-1"), 0) + + def test_forget_requires_external_id_or_memory_id(self) -> None: + with self.assertRaises(ValueError): + self.client.forget() # type: ignore[call-arg] + + def test_forget_unknown_returns_zero(self) -> None: + self.assertEqual(self.client.forget(external_id="never"), 0) + + def test_list_filters_by_agent_and_user(self) -> None: + self.client.remember("alpha note 1", external_id="a1") + self.client.remember("alpha note 2", external_id="a2") + other = MemoryClient.memory(self.store, agent_id="gamma", user_id="u-2") + other.remember("gamma note", external_id="g1") + rows = self.client.list(limit=50) + ext = {m.external_id for m in rows} + self.assertSetEqual(ext, {"a1", "a2"}) + + def test_remember_rejects_empty_text(self) -> None: + with self.assertRaises(ValueError): + self.client.remember(" ") + + def test_remember_batch_returns_one_per_item(self) -> None: + rows = self.client.remember_batch([ + {"text": "x1", "external_id": "b1"}, + {"text": "x2", "external_id": "b2"}, + ]) + self.assertEqual(len(rows), 2) + self.assertEqual({m.external_id for m in rows}, {"b1", "b2"}) + + def test_feedback_rejects_unknown_value(self) -> None: + m = self.client.remember("x", external_id="v1") + with self.assertRaises(ValueError): + self.client.feedback(memory_id=m.id, value="thumb") + + +class HttpBackendTests(unittest.TestCase): + """Smoke test the HTTP backend wiring without spinning a server. + + We monkey-patch ``urllib.request.urlopen`` so the SDK can run in + CI without ``loop-memory serve`` running, and we verify the JSON + payloads / paths / verbs match the API contract. + """ + + def setUp(self) -> None: + from loop_memory.sdk import _HttpClient + self.client = _HttpClient(base_url="http://example.invalid") + self.calls: list[tuple[str, str, dict | None]] = [] + # Override the request method to capture (method, path, body) + def _fake(method, path, body=None): + self.calls.append((method, path, body)) + if method == "POST" and path == "/api/v1/memories": + return { + "id": "m-1", "text": body["text"], "kind": body.get("kind", "fact"), + "importance": body.get("importance", 0.5), "score": 0.5, + "source": body.get("source"), "session_id": body.get("session_id"), + "agent_id": body.get("agent_id"), "user_id": body.get("user_id"), + "external_id": body.get("external_id"), + "tags": body.get("tags", []), "created_at": 0.0, "updated_at": 0.0, + } + if method == "GET" and path.startswith("/api/v1/recall"): + return {"memories": [], "wiki": [], "entities": []} + if method == "DELETE": + return {"deleted": 1, "memory_id": "m-1"} + if method == "POST" and path.endswith("/feedback"): + return {"ok": True} + return {} + self.client._request = _fake # type: ignore[assignment] + + def test_remember_uses_post_v1(self) -> None: + m = self.client.remember("hi", external_id="x", agent_id="a", user_id="u") + self.assertEqual(m.id, "m-1") + method, path, body = self.calls[-1] + self.assertEqual(method, "POST") + self.assertEqual(path, "/api/v1/memories") + self.assertEqual(body["external_id"], "x") + self.assertEqual(body["agent_id"], "a") + + def test_recall_uses_get_v1(self) -> None: + self.client.recall("foo", limit=4, agent_id="a") + method, path, _ = self.calls[-1] + self.assertEqual(method, "GET") + self.assertTrue(path.startswith("/api/v1/recall?")) + self.assertIn("q=foo", path) + self.assertIn("agent_id=a", path) + self.assertIn("limit=4", path) + + def test_forget_by_external_uses_query_string(self) -> None: + self.client.forget(external_id="x", agent_id="a") + method, path, _ = self.calls[-1] + self.assertEqual(method, "DELETE") + self.assertIn("external_id=x", path) + self.assertIn("agent_id=a", path) + + def test_feedback_by_external_uses_post(self) -> None: + self.client.feedback(external_id="x", value="up", agent_id="a") + method, path, body = self.calls[-1] + self.assertEqual(method, "POST") + self.assertEqual(path, "/api/v1/memories/feedback") + self.assertEqual(body["external_id"], "x") + self.assertEqual(body["value"], "up") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_v7.py b/tests/test_cli_v7.py new file mode 100644 index 0000000..dcf7475 --- /dev/null +++ b/tests/test_cli_v7.py @@ -0,0 +1,56 @@ +"""CLI compatibility tests for the Universal Agent Memory v7 commands.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + +from loop_memory.cli.main import main +from loop_memory.storage.sqlite_store import MemoryStore + + +class CliExportCompatibilityTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory(prefix="loop_cli_v7_") + self.root = Path(self.tmp.name) + self.db = self.root / "memory.db" + self.previous_db = os.environ.get("LOOP_MEMORY_DB") + os.environ["LOOP_MEMORY_DB"] = str(self.db) + MemoryStore(self.db).upsert_wiki_page( + slug="cli-export", + title="CLI export", + body="The CLI export contract is backward compatible.", + summary="Export compatibility", + tags=["cli"], + importance=0.8, + ) + + def tearDown(self) -> None: + if self.previous_db is None: + os.environ.pop("LOOP_MEMORY_DB", None) + else: + os.environ["LOOP_MEMORY_DB"] = self.previous_db + self.tmp.cleanup() + + def test_legacy_export_keeps_markdown_file_contract(self) -> None: + output = self.root / "legacy.md" + self.assertEqual(main(["export", "--out", str(output)]), 0) + self.assertIn("# Loop Memory — Distilled Knowledge", output.read_text()) + self.assertIn("CLI export", output.read_text()) + + def test_positional_export_writes_v7_bundle(self) -> None: + output = self.root / "bundle" + self.assertEqual(main(["export", str(output)]), 0) + self.assertTrue((output / "MEMORY.md").exists()) + self.assertTrue((output / "pages" / "cli-export.md").exists()) + + def test_export_bundle_alias_writes_v7_bundle(self) -> None: + output = self.root / "bundle-alias" + self.assertEqual(main(["export-bundle", str(output)]), 0) + self.assertTrue((output / "MEMORY.md").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_contradictions.py b/tests/test_contradictions.py index c62833e..f54f03b 100644 --- a/tests/test_contradictions.py +++ b/tests/test_contradictions.py @@ -72,22 +72,88 @@ def test_resolve_endpoint_keepA_deletes_B(self): self.assertIsNone(self.store.get_memory(self.b.id)) self.assertTrue(self.store.is_contradiction_ignored(self.a.id, self.b.id)) - def test_resolve_endpoint_merge_keeps_higher_scored(self): + def test_resolve_endpoint_merge_fuses_text_into_winner(self): + """``merge`` should fuse the two memories into one: loser's text is + appended onto the winner (with a separator), score/importance are + bumped to the max, and the loser is deleted. The pair must also + be marked ignored so it does not resurface in the pulse.""" from fastapi.testclient import TestClient from loop_memory.serve.app import create_app - # Force A to have higher score than B so the merge keeps A. + # Force A (winner) higher than B (loser) on score. with self.store._conn() as conn: conn.execute("UPDATE memories SET score=? WHERE id=?", (0.95, self.a.id)) conn.execute("UPDATE memories SET score=? WHERE id=?", (0.20, self.b.id)) + conn.execute("UPDATE memories SET importance=? WHERE id=?", (0.55, self.a.id)) + conn.execute("UPDATE memories SET importance=? WHERE id=?", (0.40, self.b.id)) app = create_app(self.store) with TestClient(app) as c: r = c.post(f"/api/contradictions/resolve?a={self.a.id}&b={self.b.id}&action=merge") self.assertEqual(r.status_code, 200, r.text) data = r.json() + # Action reports the new semantics, not the old 'deleted' array. self.assertEqual(data["action"], "merge") - self.assertEqual(len(data["deleted"]), 1) - self.assertEqual(data["deleted"][0]["kept"], self.a.id) - self.assertEqual(data["deleted"][0]["id"], self.b.id) + self.assertTrue(data.get("merged")) + self.assertEqual(data["winner"], self.a.id) + self.assertEqual(data["loser"], self.b.id) + self.assertTrue(data["appended"]) + + # Winner still exists, now with appended loser's text. + winner = self.store.get_memory(self.a.id) + self.assertIsNotNone(winner) + a_text = winner.text + # The winner's original text + the separator + loser's text. + self.assertIn(self.b.text, a_text) + self.assertIn("---", a_text) + # Loser is gone. + self.assertIsNone(self.store.get_memory(self.b.id)) + # Importance / score are the max of the pair. + self.assertAlmostEqual(winner.importance, 0.55, places=4) + self.assertAlmostEqual(winner.score, 0.95, places=4) + # The pair is now hidden so the pulse will not resurface it. + self.assertTrue(self.store.is_contradiction_ignored(self.a.id, self.b.id)) + + def test_resolve_endpoint_merge_skips_append_when_subset(self): + """If the loser's text is already contained in the winner's, the + merge must not duplicate text; it should still delete the loser.""" + from fastapi.testclient import TestClient + from loop_memory.serve.app import create_app + # Make B's text a strict substring of A's so the merge is a no-op + # for content even though it still deletes the loser row. + with self.store._conn() as conn: + conn.execute( + "UPDATE memories SET score=?, text=? WHERE id=?", + (0.9, "User preferences collected so far: " + self.b.text, self.a.id), + ) + conn.execute( + "UPDATE memories SET score=? WHERE id=?", + (0.1, self.b.id), + ) + app = create_app(self.store) + with TestClient(app) as c: + r = c.post(f"/api/contradictions/resolve?a={self.a.id}&b={self.b.id}&action=merge") + self.assertEqual(r.status_code, 200, r.text) + data = r.json() + self.assertTrue(data["merged"]) + self.assertFalse(data["appended"]) + winner = self.store.get_memory(self.a.id) + self.assertIsNotNone(winner) + self.assertEqual(winner.text.count("---"), 0) + self.assertIsNone(self.store.get_memory(self.b.id)) + + def test_resolve_endpoint_merge_tie_keeps_a(self): + """Ties should resolve to side A; new semantics still report winner.""" + from fastapi.testclient import TestClient + from loop_memory.serve.app import create_app + with self.store._conn() as conn: + conn.execute("UPDATE memories SET score=? WHERE id=?", (0.5, self.a.id)) + conn.execute("UPDATE memories SET score=? WHERE id=?", (0.5, self.b.id)) + app = create_app(self.store) + with TestClient(app) as c: + r = c.post(f"/api/contradictions/resolve?a={self.a.id}&b={self.b.id}&action=merge") + self.assertEqual(r.status_code, 200, r.text) + data = r.json() + self.assertEqual(data["winner"], self.a.id) + self.assertEqual(data["loser"], self.b.id) def test_resolve_endpoint_ignore_keeps_both_but_hides_pair(self): from fastapi.testclient import TestClient @@ -181,7 +247,10 @@ def test_no_provider_returns_no_provider_hint(self): self.store.set_setting("llm_consolidator", {"provider": "echo", "model": "rules", "api_key_set": False}) app = create_app(self.store) with TestClient(app) as c: - r = c.get("/api/weekly-report?days=7") + # force=true bypasses the per-ISO-week cache so the test + # actually exercises the no-provider path; otherwise a stale + # cache hit would mask the error classification. + r = c.get("/api/weekly-report?days=7&force=true") self.assertEqual(r.status_code, 200) data = r.json() self.assertIn("llm_error_kind", data) diff --git a/tests/test_export_ask.py b/tests/test_export_ask.py index ced4395..de7c40d 100644 --- a/tests/test_export_ask.py +++ b/tests/test_export_ask.py @@ -119,6 +119,86 @@ def test_ask_requires_q(self): self.assertEqual(r.status_code, 400) +class WikiImportExportRoundTrip(unittest.TestCase): + """Verify JSON export → import is a loss-less round-trip + and markdown import parses ## sections correctly.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.db = os.path.join(self.tmp, "test.db") + self.store = MemoryStore(self.db) + _seed_wiki(self.store) + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_export_json_returns_all_records(self): + from fastapi.testclient import TestClient + from loop_memory.serve.app import create_app + app = create_app(self.store) + with TestClient(app) as c: + r = c.get("/api/wiki/export", params={"format": "json"}) + self.assertEqual(r.status_code, 200) + data = r.json() + self.assertEqual(data["format"], "json") + self.assertEqual(data["count"], 2) + + def test_import_json_creates_and_updates(self): + from fastapi.testclient import TestClient + from loop_memory.serve.app import create_app + app = create_app(self.store) + with TestClient(app) as c: + # First import: two new pages + r1 = c.post("/api/wiki/import", json={ + "format": "json", + "pages": [ + {"slug": "imported-a", "title": "Imported A", "body": "Body A", "importance": 0.6}, + {"slug": "imported-b", "title": "Imported B", "body": "Body B", "summary": "s"}, + ], + }) + self.assertEqual(r1.status_code, 200) + self.assertEqual(r1.json()["created"], 2) + self.assertEqual(r1.json()["updated"], 0) + # Re-import with same slug updates + r2 = c.post("/api/wiki/import", json={ + "format": "json", + "pages": [{"slug": "imported-a", "title": "Imported A v2", "body": "Body A v2"}], + }) + self.assertEqual(r2.status_code, 200) + self.assertEqual(r2.json()["updated"], 1) + self.assertEqual(r2.json()["created"], 0) + # Round-trip: re-export and confirm 4 pages + r3 = c.get("/api/wiki/export", params={"format": "json"}) + self.assertEqual(r3.json()["count"], 4) + + def test_import_markdown_parses_h2_sections(self): + from fastapi.testclient import TestClient + from loop_memory.serve.app import create_app + app = create_app(self.store) + md = ( + "## Page Alpha\n\n" + "Body of alpha.\n\n" + "## Page Beta\n\n" + "Body of beta with more text.\n\n" + "## 中文测试\n\n" + "中文正文。\n" + ) + with TestClient(app) as c: + r = c.post("/api/wiki/import", json={"format": "markdown", "markdown": md}) + self.assertEqual(r.status_code, 200) + data = r.json() + self.assertEqual(data["created"], 3) + self.assertEqual(data["updated"], 0) + + def test_import_rejects_unknown_format(self): + from fastapi.testclient import TestClient + from loop_memory.serve.app import create_app + app = create_app(self.store) + with TestClient(app) as c: + r = c.post("/api/wiki/import", json={"format": "xml", "pages": []}) + self.assertEqual(r.status_code, 400) + + class ConsolidateNowTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp() diff --git a/tests/test_llm_providers.py b/tests/test_llm_providers.py index aa8dede..d338489 100644 --- a/tests/test_llm_providers.py +++ b/tests/test_llm_providers.py @@ -195,7 +195,9 @@ def test_clamps_temperature_and_tokens(self) -> None: "behaviour": {"temperature": 5.0, "max_output_tokens": 9999, "batch_size": -1}, }) self.assertLessEqual(cfg["behaviour"]["temperature"], 2.0) - self.assertLessEqual(cfg["behaviour"]["max_output_tokens"], 4096) + # Validator ceiling raised 4096 -> 8192 in v2 to match the + # "completeness over compactness" distillation policy. + self.assertLessEqual(cfg["behaviour"]["max_output_tokens"], 8192) self.assertEqual(cfg["behaviour"]["batch_size"], 1) def test_warns_on_unknown_provider(self) -> None: diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 1e09627..cdeacd1 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -236,3 +236,151 @@ def test_inject_emits_wiki_block(self): self.assertIn("A short summary.", out) # The body should be used when summary is empty self.assertIn("B body.", out) + + + +class McpWriteToolTests(unittest.TestCase): + """Cover the write surface added to the stdio MCP server so any + MCP-aware client (Codex / Claude / Hermes / …) can remember, + forget, and feedback via the same JSON-RPC transport as the + existing read-only tools. + """ + + def setUp(self) -> None: + import os + import tempfile + from pathlib import Path + from loop_memory.storage.sqlite_store import MemoryStore + self.tmp = Path(tempfile.mkdtemp(prefix="loop_mcp_w_")) + self.db = self.tmp / "mcp_w.db" + self.prev_db = os.environ.get("LOOP_MEMORY_DB") + self.prev_agent = os.environ.get("LOOP_MEMORY_AGENT_ID") + os.environ["LOOP_MEMORY_DB"] = str(self.db) + os.environ["LOOP_MEMORY_AGENT_ID"] = "mcp-test-agent" + # Seed a page so recall has something to fetch + s = MemoryStore(self.db) + s.upsert_wiki_page( + slug="mcp-test-knowledge", title="MCP Test Knowledge", + body="body of a test wiki page", summary="summary of mcp test knowledge", + tags=["mcp"], importance=0.6, + ) + + def tearDown(self) -> None: + import shutil + if self.prev_db is None: + import os + os.environ.pop("LOOP_MEMORY_DB", None) + else: + import os + os.environ["LOOP_MEMORY_DB"] = self.prev_db + if self.prev_agent is None: + import os + os.environ.pop("LOOP_MEMORY_AGENT_ID", None) + else: + import os + os.environ["LOOP_MEMORY_AGENT_ID"] = self.prev_agent + shutil.rmtree(self.tmp, ignore_errors=True) + + def _call(self, name, arguments): + import json + from loop_memory.mcp import TOOL_DISPATCH + result = TOOL_DISPATCH[name](arguments) + self.assertEqual(len(result), 1) + return result[0]["text"] + + def test_tools_list_contains_writes(self): + from loop_memory.mcp import TOOLS + names = {t["name"] for t in TOOLS} + self.assertIn("remember", names) + self.assertIn("forget", names) + self.assertIn("feedback", names) + + def test_remember_round_trip(self): + out = self._call("remember", { + "text": "the deploys run on Fridays at 17:00 UTC", + "kind": "fact", + "importance": 0.7, + "tags": ["ops"], + "external_id": "deploy-window", + }) + self.assertIn("remembered", out) + # The row exists with the right external_id and is findable + from loop_memory.storage.sqlite_store import MemoryStore + s = MemoryStore(self.db) + row = s.find_memory_by_external_id("mcp-test-agent", "deploy-window") + self.assertIsNotNone(row) + self.assertEqual(row.importance, 0.7) + self.assertIn("ops", row.tags) + + def test_remember_is_idempotent(self): + a = self._call("remember", {"text": "v1", "external_id": "k"}) + b = self._call("remember", {"text": "v2", "external_id": "k"}) + self.assertIn("remembered", a) + self.assertIn("remembered", b) + from loop_memory.storage.sqlite_store import MemoryStore + s = MemoryStore(self.db) + rows = s.list_memories(external_id="k") + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].text, "v2") + + def test_remember_rejects_empty_text(self): + out = self._call("remember", {"text": " "}) + self.assertIn("missing", out) + + def test_forget_by_external_id(self): + self._call("remember", {"text": "x", "external_id": "to-forget"}) + out = self._call("forget", {"external_id": "to-forget"}) + self.assertIn("deleted=1", out) + out = self._call("forget", {"external_id": "to-forget"}) + self.assertIn("No memory matches", out) + + def test_feedback_up_then_ignore(self): + self._call("remember", {"text": "y", "external_id": "fb"}) + out = self._call("feedback", {"value": "up", "external_id": "fb"}) + self.assertIn("feedback(up)", out) + out = self._call("feedback", {"value": "ignore", "external_id": "fb"}) + self.assertIn("deleted=1", out) + + def test_feedback_rejects_unknown_value(self): + self._call("remember", {"text": "z", "external_id": "fb2"}) + out = self._call("feedback", {"value": "thumb", "external_id": "fb2"}) + self.assertIn("up|down|ignore", out) + + def test_recall_after_remember(self): + self._call("remember", { + "text": "we use Postgres for orders", + "kind": "fact", + "tags": ["infra"], + "external_id": "orders-db", + }) + from loop_memory.mcp import TOOL_DISPATCH + out = TOOL_DISPATCH["recall"]({"query": "Postgres", "limit": 5}) + self.assertEqual(len(out), 1) + self.assertIn("Postgres", out[0]["text"]) + + def test_serve_stdio_includes_remember(self): + """End-to-end: pipe a remember() through the stdio server.""" + import io + import json + import sys + + from loop_memory.mcp import serve_stdio + msgs = [ + json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize"}), + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "remember", "arguments": { + "text": "End-to-end MCP remember", "external_id": "e2e-1", + }}}), + json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}), + ] + stdin_bak, stdout_bak = sys.stdin, sys.stdout + sys.stdin = io.StringIO("\n".join(msgs) + "\n") + sys.stdout = io.StringIO() + try: + serve_stdio() + out = sys.stdout.getvalue() + finally: + sys.stdin, sys.stdout = stdin_bak, stdout_bak + responses = [json.loads(ln) for ln in out.splitlines() if ln.strip()] + self.assertEqual(len(responses), 2) + self.assertIn("remembered", responses[1]["result"]["content"][0]["text"]) diff --git a/tests/test_serve_app.py b/tests/test_serve_app.py index 588cfcf..1cd6609 100644 --- a/tests/test_serve_app.py +++ b/tests/test_serve_app.py @@ -1,5 +1,8 @@ from __future__ import annotations +import re +import shutil +import subprocess import tempfile import unittest from pathlib import Path @@ -37,6 +40,19 @@ def test_memories_endpoint_empty(self) -> None: self.assertEqual(r.status_code, 200) self.assertEqual(r.json(), []) + def test_csp_allows_self_hosted_vue_template_compiler(self) -> None: + r = self.client.get("/") + self.assertEqual(r.status_code, 200) + csp = r.headers["content-security-policy"] + self.assertIn("script-src 'self' 'unsafe-eval'", csp) + self.assertNotIn("unpkg.com", csp) + self.assertNotIn("'unsafe-inline'", csp.split("style-src", 1)[0]) + + def test_static_javascript_always_revalidates(self) -> None: + r = self.client.get("/static/js/api.js") + self.assertEqual(r.status_code, 200) + self.assertEqual(r.headers["cache-control"], "no-cache, must-revalidate") + def test_admin_rescore_returns_updated(self) -> None: r = self.client.post("/api/admin/rescore") self.assertEqual(r.status_code, 200) @@ -155,6 +171,48 @@ def test_pipeline_endpoint_returns_stage_array(self) -> None: class IndexRouteTests(unittest.TestCase): + @unittest.skipUnless(shutil.which("node"), "Node.js is required for static module parsing") + def test_static_javascript_modules_parse_as_esm(self) -> None: + static_js = Path(__file__).parents[1] / "loop_memory" / "serve" / "static" / "js" + with tempfile.TemporaryDirectory(prefix="loop-memory-js-") as tmp: + for module in static_js.rglob("*.js"): + check_file = Path(tmp) / f"{module.stem}-{abs(hash(module))}.mjs" + check_file.write_text(module.read_text(encoding="utf-8"), encoding="utf-8") + result = subprocess.run( + ["node", "--check", str(check_file)], capture_output=True, text=True + ) + self.assertEqual(result.returncode, 0, f"{module}: {result.stderr}") + + def test_static_modules_do_not_reexport_direct_exports(self) -> None: + static_js = Path(__file__).parents[1] / "loop_memory" / "serve" / "static" / "js" + direct_pattern = re.compile( + r"\bexport\s+(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)" + ) + list_pattern = re.compile(r"\bexport\s*\{([^}]*)\}") + for module in static_js.rglob("*.js"): + source = module.read_text(encoding="utf-8") + direct = set(direct_pattern.findall(source)) + listed: set[str] = set() + for group in list_pattern.findall(source): + listed.update( + item.strip().split(" as ", 1)[0].strip() + for item in group.split(",") + if item.strip() + ) + self.assertFalse(direct & listed, f"duplicate exports in {module}: {direct & listed}") + + def test_async_components_resolve_named_exports(self) -> None: + static_js = Path(__file__).parents[1] / "loop_memory" / "serve" / "static" / "js" + unresolved = re.compile( + r"defineAsyncComponent\(\(\)\s*=>\s*import\(['\"][^'\"]+['\"]\)\s*\)" + ) + for module in static_js.rglob("*.js"): + source = module.read_text(encoding="utf-8") + self.assertIsNone( + unresolved.search(source), + f"async component in {module} must resolve its named export", + ) + def test_root_returns_index_or_404_when_no_static(self) -> None: store, tmp = _store() try: @@ -167,5 +225,75 @@ def test_root_returns_index_or_404_when_no_static(self) -> None: shutil.rmtree(tmp, ignore_errors=True) +class SecurityHeadersTests(ServeAppSmokeTests): + """Covers the security headers and CSRF guard in + ``loop_memory/serve/app.py``. The CSP and Bearer-token tests live + elsewhere; this class focuses on what is easy to break by accident.""" + + def test_csp_includes_object_and_base_uri(self) -> None: + r = self.client.get("/") + csp = r.headers.get("Content-Security-Policy", "") + self.assertIn("object-src 'none'", csp) + self.assertIn("base-uri 'self'", csp) + self.assertNotIn("img-src 'self' data: https:", csp, + "img-src must NOT include https: to block remote images") + + def test_x_content_type_options_nosniff(self) -> None: + r = self.client.get("/api/stats") + self.assertEqual(r.headers.get("X-Content-Type-Options"), "nosniff") + self.assertEqual(r.headers.get("X-Frame-Options"), "DENY") + self.assertEqual(r.headers.get("Referrer-Policy"), "no-referrer") + + def test_cors_preflight_rejects_unknown_origin(self) -> None: + r = self.client.options( + "/api/admin/auth/token", + headers={ + "Origin": "https://attacker.example", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "authorization,content-type", + }, + ) + # No Access-Control-Allow-Origin should be returned for a + # foreign origin, because we mount an allow_origins=[] policy. + self.assertNotIn("access-control-allow-origin", {k.lower() for k in r.headers}) + + def test_csrf_blocks_cross_origin_post(self) -> None: + r = self.client.post( + "/api/admin/auth/token", + headers={"Origin": "https://attacker.example"}, + ) + self.assertEqual(r.status_code, 403) + self.assertIn("Cross-origin", r.json()["error"]) + + def test_csrf_allows_same_origin_post(self) -> None: + # Build a same-origin request; even if the route is missing or + # returns 404/405, the CSRF guard should let it through (so + # we only assert non-403). + r = self.client.post( + "/api/admin/auth/token", + headers={"Origin": "http://testserver"}, + ) + self.assertNotEqual(r.status_code, 403) + + def test_csrf_rejects_post_with_cross_site_sec_fetch(self) -> None: + r = self.client.post( + "/api/admin/auth/token", + headers={"Sec-Fetch-Site": "cross-site"}, + ) + # Either CSRF rejection (403) or 404/405 is acceptable; the + # important thing is that we don't 200 with auth bypass. + self.assertIn(r.status_code, (403, 404, 405)) + + def test_auth_token_disabled_by_default(self) -> None: + # When no token is configured, the /api/admin/auth/token POST + # is still allowed (it's the public "create a token" endpoint + # itself). What we want to verify is that the middleware does + # NOT 401 the request just because there's no Authorization + # header — token enforcement is opt-in. + r = self.client.post("/api/admin/auth/token") + self.assertNotEqual(r.status_code, 401, + "auth middleware should be a no-op when no token is set") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_session_order.py b/tests/test_session_order.py new file mode 100644 index 0000000..9a8f3ba --- /dev/null +++ b/tests/test_session_order.py @@ -0,0 +1,75 @@ +"""Regression test: list_sessions() orders by last-activity time, not +started_at, so a long-running session that started days ago still rises +to the top once it receives new turns.""" +import os +import tempfile +import time +import unittest + +from loop_memory.storage.sqlite_store import MemoryStore + + +class ListSessionsActivityOrderTests(unittest.TestCase): + def setUp(self): + fd, self.db_path = tempfile.mkstemp(suffix=".sqlite") + os.close(fd) + self.store = MemoryStore(self.db_path) + + def tearDown(self): + try: + self.store.close() + except Exception: + pass + try: + os.unlink(self.db_path) + except Exception: + pass + + def test_active_session_rises_to_top_over_newer_short_lived(self): + # 1) Insert an "old" session that started 9 days ago and only + # just got a new turn. + now = time.time() + old_started = now - 9 * 86400 + old_sid = self.store.upsert_session( + source="codex", + external_id="old-conv", + title="long-running conversation", + started_at=old_started, + ended_at=now, # last activity = now + message_count=3165, + metadata={"kind": "summary"}, + ) + + # 2) Insert several "fresh" sessions that started recently but + # haven't been touched for hours. These should sort BELOW + # the active long-running one. + for i in range(3): + self.store.upsert_session( + source="openclaw", + external_id=f"cron-{i}", + title=f"cron report #{i}", + started_at=now - 3600, # 1h ago + ended_at=now - 1800, # last activity 30m ago + message_count=50, + metadata={"kind": "summary"}, + ) + + listed = self.store.list_sessions(limit=10) + ids = [s.id for s in listed] + + # The long-running active session must be at position 0 + self.assertEqual( + ids[0], old_sid.id, + "active session with stale started_at was buried; " + f"got order: {[(s.external_id, s.started_at, s.ended_at) for s in listed]}", + ) + + # And the three cron sessions must come AFTER it + cron_ids = {s.id for s in listed if s.external_id and s.external_id.startswith("cron-")} + self.assertEqual(len(cron_ids), 3) + for cron_id in cron_ids: + self.assertGreater(ids.index(cron_id), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_store.py b/tests/test_store.py index 6026ae7..ddec5f3 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -95,10 +95,241 @@ def test_stats_counts(self) -> None: s = self.store.upsert_session(source="codex", external_id="z", message_count=0) self.store.upsert_memory(kind="turn", text="x", session_id=s.id) self.store.upsert_memory(kind="turn", text="y", session_id=s.id) + self.store.upsert_entity("Codex") + self.store.upsert_entity("Claude") + self.store.upsert_relation("Codex", "Claude") stats = self.store.stats() self.assertEqual(stats["memories"], 2) self.assertEqual(stats["sessions"], 1) + self.assertEqual(stats["entities"], 2) + self.assertEqual(stats["relations"], 1) if __name__ == "__main__": unittest.main() + + +class HybridRecallTests(unittest.TestCase): + """Covers the BM25 + semantic + entity-fused recall_hybrid pipeline + and the FTS5 tokenizer migration.""" + + def setUp(self) -> None: + self.path = Path("/tmp/test_loop_hybrid.db") + self.path.unlink(missing_ok=True) + self.store = MemoryStore(self.path) + + def tearDown(self) -> None: + self.path.unlink(missing_ok=True) + + def test_fts5_uses_trigram_tokenizer(self) -> None: + import sqlite3 + c = sqlite3.connect(self.path) + for tbl in ("memories_fts", "wiki_fts"): + row = c.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (tbl,) + ).fetchone() + self.assertIsNotNone(row, f"{tbl} missing") + self.assertIn("trigram", row[0], f"{tbl} not using trigram tokenizer") + c.close() + + def test_recall_hybrid_returns_memories_and_wiki(self) -> None: + # Seed: 1 wiki page with a unique-token title, 1 memory with + # an overlapping keyword + entity mention. + self.store.upsert_memory( + kind="fact", text="the cache buster middleware handles cache", + importance=0.5, source="codex", + tags=["cache", "middleware"], + ) + self.store.upsert_wiki_page( + slug="cache-buster", title="Cache buster middleware", + body="we hash asset URLs to bust the cache on every deploy", + importance=0.7, scope="global", + ) + out = self.store.recall_hybrid("cache", limit=10) + # Both the memory and the wiki page should appear (they share + # the keyword "cache"). + self.assertTrue(out["memories"] or out["wiki"], "empty recall") + text_blob = " ".join( + (m.get("text", "") + " " + (m.get("title", "") or "")) + for m in out["memories"] + [{"title": w.get("title", "")} for w in out["wiki"]] + ) + self.assertIn("cache", text_blob.lower()) + + def test_recall_hybrid_handles_cjk(self) -> None: + # Trigram tokenizer is the only thing that makes CJK search + # usable. Seed a Chinese memory and ask for it by substring. + self.store.upsert_memory( + kind="fact", text="知识图谱应该可以点击节点跳转", + importance=0.6, source="codex", + ) + out = self.store.recall_hybrid("知识图谱", limit=5) + self.assertTrue(out["memories"], "CJK recall returned nothing") + + def test_fts_migration_is_one_shot(self) -> None: + # Second open of the same path should be a no-op for the FTS + # migration block (no exceptions, trigram remains installed). + MemoryStore(self.path) + import sqlite3 + c = sqlite3.connect(self.path) + row = c.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='memories_fts'" + ).fetchone() + self.assertIn("trigram", row[0]) + c.close() + + +class RetrievalPrimitivesTests(unittest.TestCase): + """Covers the BM25 + RRF primitives used by recall_hybrid.""" + + def test_fuse_rrf_basic(self) -> None: + from loop_memory.storage.retrieval import fuse_rrf + a = [{"id": "x", "_score": 1.0}, {"id": "y", "_score": 0.5}] + b = [{"id": "y", "_score": 0.9}, {"id": "z", "_score": 0.4}] + out = fuse_rrf([a, b]) + # x is rank1 in a only → 1/(60+1) ; y is rank2 in a + rank1 in b + # → 1/(60+2) + 1/(60+1) ; z is rank2 in b → 1/(60+2). y > z > x. + ids = [r["id"] for r in out] + self.assertEqual(ids[0], "y") + self.assertIn("x", ids) + self.assertIn("z", ids) + + def test_escape_fts_handles_punctuation_and_empty(self) -> None: + from loop_memory.storage.retrieval import _escape_fts + self.assertEqual(_escape_fts(""), "") + self.assertEqual(_escape_fts(" "), "") + # "vue.js" should NOT become one exact-phrase; must split. + out = _escape_fts("vue.js") + self.assertIn('"vue"', out) + self.assertIn('"js"', out) + + +class TieredLoadingTests(unittest.TestCase): + """L0/L1/L2 tiered loading on wiki/memory hydration (OpenViking).""" + + def setUp(self) -> None: + self.path = Path("/tmp/test_loop_tiered.db") + self.path.unlink(missing_ok=True) + self.store = MemoryStore(self.path) + # Seed a long wiki page so truncation shows up clearly. + self.store.upsert_wiki_page( + slug="long-page", title="A long distillation", + body="X" * 4000, # 4000 chars + summary="A brief summary", + importance=0.6, scope="global", + ) + self.wiki_id = self.store.list_wiki_pages()[0]["id"] + # Seed a memory. + self.store.upsert_memory( + kind="fact", text="Z" * 1000, # 1000 chars + importance=0.5, source="codex", + ) + + def tearDown(self) -> None: + self.path.unlink(missing_ok=True) + + def test_recall_hybrid_levels_trim_wiki_body(self) -> None: + # Use a query that hits BOTH the wiki and the memory so we + # can assert level-based trimming on both payloads. + out = self.store.recall_hybrid("ZZZ long", limit=5, level=0) + body_len = sum(len(r.get("body") or "") for r in out["wiki"]) + self.assertEqual(body_len, 0, "L0 should have no body") + if out["memories"]: + # At least one memory matched; verify L0 trimmed its text. + self.assertEqual(out["memories"][0]["text"], "") + for r in out["wiki"]: + self.assertEqual(r["_level"], 0) + # L0 has only preview and metadata. + self.assertIn("A long", r["preview"]) + + def test_recall_hybrid_l1_caps_wiki_body(self) -> None: + out = self.store.recall_hybrid("distillation", limit=5, level=1) + body_len = sum(len(r.get("body") or "") for r in out["wiki"]) + self.assertGreater(body_len, 0) + # body is capped at 800 chars when level=1 + for r in out["wiki"]: + self.assertLessEqual(len(r.get("body") or ""), 800) + self.assertEqual(r["_level"], 1) + + def test_recall_hybrid_l2_returns_full_body(self) -> None: + out = self.store.recall_hybrid("distillation", limit=5, level=2) + for r in out["wiki"]: + self.assertGreaterEqual(len(r.get("body") or ""), 3500, + "L2 should preserve the full 4000-char body") + self.assertEqual(r["_level"], 2) + + def test_memory_text_trimmed_at_l0(self) -> None: + out = self.store.recall_hybrid("Z", limit=5, level=0) + for m in out["memories"]: + self.assertEqual(m["text"], "", "L0 trims memory text") + self.assertGreater(len(m["preview"]), 0, "preview remains") + + +class WikiScopeTests(unittest.TestCase): + """Per-source scope on wiki_pages ('global' or comma-list of clients).""" + + def setUp(self) -> None: + self.path = Path("/tmp/test_wiki_scope.db") + self.path.unlink(missing_ok=True) + self.store = MemoryStore(self.path) + # Each body carries a unique token so tests can isolate them. + self.page_codex = self.store.upsert_wiki_page( + slug="codex-only", title="Codex专属", body="- markertoken-codex", + scope="codex", + ) + self.page_claude = self.store.upsert_wiki_page( + slug="claude-only", title="Claude专属", body="- markertoken-claude", + scope="claude", + ) + self.page_global = self.store.upsert_wiki_page( + slug="for-all", title="通用知识", body="- markertoken-global", + scope="global", + ) + + def tearDown(self) -> None: + self.path.unlink(missing_ok=True) + + def test_row_to_wiki_returns_scope(self) -> None: + # list_wiki_pages() searches title/body/summary, so query the + # unique markertoken each page's body contains. + for slug, want, body_token in ( + ("codex-only", "codex", "markertoken-codex"), + ("claude-only", "claude", "markertoken-claude"), + ("for-all", "global", "markertoken-global")): + results = self.store.list_wiki_pages(query=body_token) + self.assertEqual(len(results), 1, + f"expected exactly 1 row for token {body_token!r}, got {len(results)}") + page = results[0] + self.assertEqual(page["slug"], slug) + self.assertEqual(page["scope"], want) + + def test_recall_filters_wiki_by_source(self) -> None: + """When the caller passes source='codex', only pages whose + scope is 'global' or includes 'codex' should be returned. + + We query for the unique markertoken each page carries so + trigram/LIKE noise from other wiki pages can't pollute the + assertion.""" + out_codex = self.store.recall_hybrid("markertoken", limit=20, + source="codex", level=0) + titles_codex = {w["title"] for w in out_codex["wiki"]} + # codex client: sees Codex专属 (own scope) + 通用知识 (global) + self.assertIn("Codex专属", titles_codex) + self.assertIn("通用知识", titles_codex) + # codex client must NOT see Claude专属 + self.assertNotIn("Claude专属", titles_codex) + + out_claude = self.store.recall_hybrid("markertoken", limit=20, + source="claude", level=0) + titles_claude = {w["title"] for w in out_claude["wiki"]} + # claude client: sees Claude专属 + 通用知识, NOT Codex专属 + self.assertIn("Claude专属", titles_claude) + self.assertIn("通用知识", titles_claude) + self.assertNotIn("Codex专属", titles_claude) + + def test_recall_without_source_sees_everything(self) -> None: + out = self.store.recall_hybrid("markertoken", limit=20, level=0) + titles = {w["title"] for w in out["wiki"]} + # No source filter = admin view, sees all wiki pages. + self.assertIn("Codex专属", titles) + self.assertIn("Claude专属", titles) + self.assertIn("通用知识", titles) diff --git a/tests/test_universal_memory.py b/tests/test_universal_memory.py new file mode 100644 index 0000000..bdcb97d --- /dev/null +++ b/tests/test_universal_memory.py @@ -0,0 +1,444 @@ +"""Tests for the v7 Universal Agent Memory surface: + +* ``MemoryStore`` schema v7 (wiki_versions, cognitive_audit, auth_tokens) +* ``jobs.graph`` semantic edges, subgraph, adaptive scoring +* ``jobs.cognitive`` cognitive_sleep + audit +* ``export`` bundle round-trip + fork +* SDK namespace / graph / cognitive / export extensions +* HTTP /api/v1/* routes +* MCP v7 tools +""" + +from __future__ import annotations + +import json +import os +import tempfile +import time +import unittest +from pathlib import Path + +from fastapi.testclient import TestClient + +from loop_memory.export import export_bundle, fork_snapshot, import_bundle +from loop_memory.jobs.cognitive import cognitive_sleep +from loop_memory.jobs.graph import ( + adaptive_score, + graph_boost, + subgraph_for, + upsert_semantic_edge, +) +from loop_memory.sdk import MemoryClient +from loop_memory.serve.app import create_app +from loop_memory.storage.sqlite_store import MemoryStore + + +def _new_store() -> tuple[MemoryStore, Path]: + tmp = Path(tempfile.mkdtemp(prefix="loop_v7_")) + db = tmp / "v7.db" + return MemoryStore(db), db + + +# --------------------------------------------------------------------------- +# Schema v7 +# --------------------------------------------------------------------------- + + +class SchemaV7Tests(unittest.TestCase): + def setUp(self) -> None: + self.store, self.db = _new_store() + + def test_wiki_versions_crud(self) -> None: + pid = self.store.upsert_wiki_page(slug="x", title="X", body="b", + summary="s", tags=["t"], importance=0.7)["id"] + v1 = self.store.snapshot_wiki_version(pid) + self.assertEqual(v1["version"], 1) + self.store.upsert_wiki_page(slug="x", title="X2", body="b2", + summary="s", tags=["t"], importance=0.8) + v2 = self.store.snapshot_wiki_version(pid) + self.assertEqual(v2["version"], 2) + rows = self.store.list_wiki_versions(pid) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]["version"], 2) + + def test_cognitive_audit_crud(self) -> None: + a = self.store.record_audit(kind="forget", action="suggest", + target_kind="memory", target_id="m1", + reason="low_score", score=0.1) + self.assertEqual(a["kind"], "forget") + self.assertEqual(self.store.list_audit(kind="forget")[0]["id"], a["id"]) + + def test_auth_tokens_issue_verify_revoke(self) -> None: + t = self.store.issue_token(user_id="u1", agent_id="bot", label="x") + v = self.store.verify_token(t["token"]) + self.assertEqual(v["user_id"], "u1") + self.assertTrue(self.store.revoke_token(t["id"])) + self.assertIsNone(self.store.verify_token(t["token"])) + + def test_auth_tokens_expire(self) -> None: + t = self.store.issue_token(user_id="u1", expires_in=-1) + self.assertIsNone(self.store.verify_token(t["token"])) + + +# --------------------------------------------------------------------------- +# Graph + 3D adaptive scoring +# --------------------------------------------------------------------------- + + +class GraphJobTests(unittest.TestCase): + def setUp(self) -> None: + self.store, _ = _new_store() + + def test_upsert_semantic_edge(self) -> None: + upsert_semantic_edge(self.store, "Alice", "Hangzhou", + kind="lives_in", weight=0.9) + ents = self.store.entity_by_name("Alice") + self.assertIsNotNone(ents) + rels = self.store.related_entities("Alice") + self.assertIn("Hangzhou", rels) + + def test_upsert_semantic_edge_rejects_empty(self) -> None: + with self.assertRaises(ValueError): + upsert_semantic_edge(self.store, "", "x") + with self.assertRaises(ValueError): + upsert_semantic_edge(self.store, "x", "x") + + def test_subgraph_for_returns_related_entities(self) -> None: + self.store.upsert_memory(kind="fact", + text="Alice works on Project Atlas using Postgres", + importance=0.7, agent_id="bot") + self.store.rebuild_entity_mentions() + upsert_semantic_edge(self.store, "Alice", "Hangzhou", kind="lives_in", weight=0.9) + sg = subgraph_for(self.store, "Where does Alice live?") + names = [n["name"] for n in sg.nodes] + self.assertIn("Alice", names) + self.assertIn("Hangzhou", names) + + def test_adaptive_score_is_bounded(self) -> None: + for importance, recall, expected_blend in [ + (0.0, 0, 0.0), + (1.0, 100, 0.4 + 0.25 * 1.0 + 0.15 * 0.0), # max importance+usage + (0.5, 0, 0.2), + ]: + s = adaptive_score(importance=importance, created_at=0, now=1.0, + recall_count=recall, last_recalled_at=0) + self.assertGreaterEqual(s.blended, 0.0) + self.assertLessEqual(s.blended, 1.0) + + def test_graph_boost_after_rebuild(self) -> None: + from loop_memory.graph.build import KnowledgeGraph + self.store.upsert_memory(kind="fact", + text="Alice works on Atlas using Postgres", + importance=0.8, agent_id="bot", user_id="u1") + self.store.upsert_memory(kind="fact", + text="Atlas uses Postgres for orders", + importance=0.7, agent_id="bot", user_id="u1") + self.store.upsert_memory(kind="preference", + text="Alice prefers dark mode UI", + importance=0.5, agent_id="bot", user_id="u1") + # Rebuild BOTH relations (co-occurs_with) AND entity_mentions + # so the 1-hop graph in ``graph_boost`` has something to walk. + KnowledgeGraph(self.store).rebuild(clear=True) + self.store.rebuild_entity_mentions() + ids = [r.id for r in self.store.list_memories(limit=10)] + boosts = graph_boost(self.store, "Alice Atlas", ids) + # At least one of the Alice+Atlas memories should have a boost + self.assertTrue(boosts, "graph_boost should find at least one boost") + self.assertGreater(max(b.boost for b in boosts.values()), 0.0) + + def test_recall_hybrid_adaptive_keeps_dashboard_compatible(self) -> None: + self.store.upsert_memory(kind="fact", text="alpha alpha alpha", + importance=0.7, agent_id="bot", user_id="u1") + out_default = self.store.recall_hybrid("alpha", limit=5) + out_adaptive = self.store.recall_hybrid("alpha", limit=5, adaptive=True) + # Both must return the same shape; adaptive just adds scores. + self.assertIn("memories", out_default) + self.assertIn("memories", out_adaptive) + self.assertTrue(out_adaptive.get("adaptive")) + # The adaptive result should have _adaptive on the first hit + if out_adaptive["memories"]: + self.assertIn("_adaptive", out_adaptive["memories"][0]) + + +# --------------------------------------------------------------------------- +# Cognitive sleep +# --------------------------------------------------------------------------- + + +class CognitiveSleepTests(unittest.TestCase): + def setUp(self) -> None: + self.store, _ = _new_store() + + def test_dry_run_does_not_delete(self) -> None: + m = self.store.upsert_memory(kind="fact", text="old noise", + importance=0.1, agent_id="bot", + created_at=time.time() - 200 * 86400) + rpt = cognitive_sleep(self.store, apply=False, stale_days=90, + min_score=0.5, min_importance=0.3) + self.assertEqual(rpt.counts["stale"], 1) + # Memory still exists + self.assertIsNotNone(self.store.get_memory(m.id)) + + def test_apply_deletes_and_records_audit(self) -> None: + m = self.store.upsert_memory(kind="fact", text="old noise", + importance=0.1, agent_id="bot", + created_at=time.time() - 200 * 86400) + rpt = cognitive_sleep(self.store, apply=True, stale_days=90, + min_score=0.5, min_importance=0.3) + self.assertEqual(rpt.counts["forget"], 1) + self.assertIsNone(self.store.get_memory(m.id)) + audit = self.store.list_audit(kind="stale", action="applied") + self.assertEqual(len(audit), 1) + self.assertEqual(audit[0]["target_id"], m.id) + + def test_suggested_merge_for_near_duplicates(self) -> None: + self.store.upsert_memory(kind="fact", text="team uses Postgres for orders", + importance=0.7, agent_id="bot", user_id="u1") + self.store.upsert_memory(kind="fact", text="team uses Postgres for the orders table", + importance=0.6, agent_id="bot", user_id="u1") + rpt = cognitive_sleep(self.store, apply=False) + # Should find a merge candidate + self.assertGreaterEqual(rpt.counts["merge"], 1) + + +# --------------------------------------------------------------------------- +# Export / import / fork +# --------------------------------------------------------------------------- + + +class ExportImportForkTests(unittest.TestCase): + def setUp(self) -> None: + self.store, _ = _new_store() + + def test_export_writes_bundle(self) -> None: + self.store.upsert_wiki_page(slug="p", title="P", body="b", + summary="s", tags=["t"], importance=0.7) + self.store.upsert_memory(kind="fact", text="x", importance=0.5, + agent_id="bot", user_id="u1", + external_id="x-1") + with tempfile.TemporaryDirectory() as t: + out = Path(t) / "bundle" + r = export_bundle(self.store, out, agent_id="bot", user_id="u1") + self.assertEqual(r.memories, 1) + self.assertTrue((out / "MEMORY.md").exists()) + self.assertTrue((out / "memories.jsonl").exists()) + self.assertTrue((out / "graph.json").exists()) + self.assertTrue((out / "INDEX.md").exists()) + self.assertTrue((out / "meta.json").exists()) + self.assertTrue((out / "pages" / "p.md").exists()) + + def test_export_import_round_trip_is_idempotent(self) -> None: + self.store.upsert_wiki_page(slug="p", title="P", body="b", + summary="s", tags=["t"], importance=0.7, + key_facts=["p1"]) + self.store.upsert_memory(kind="fact", text="x", importance=0.5, + agent_id="bot", user_id="u1", + external_id="x-1") + with tempfile.TemporaryDirectory() as t: + out = Path(t) / "bundle" + export_bundle(self.store, out, agent_id="bot", user_id="u1") + s2 = MemoryStore(Path(t) / "v7b.db") + r1 = import_bundle(s2, out, agent_id="bot", user_id="u1") + self.assertEqual(r1.pages_upserted, 1) + self.assertEqual(r1.memories_upserted, 1) + # Re-import is idempotent + r2 = import_bundle(s2, out, agent_id="bot", user_id="u1") + self.assertEqual(r2.pages_upserted, 1) + self.assertEqual(r2.memories_upserted, 1) + self.assertEqual(len(s2.list_memories(limit=20)), 1) + + def test_fork_snapshots_every_page(self) -> None: + for slug in ("a", "b"): + self.store.upsert_wiki_page(slug=slug, title=slug.upper(), + body="x", summary="x", importance=0.5) + r = fork_snapshot(self.store, branch_tag="tag-1") + self.assertEqual(r["snapshotted"], 2) + versions = self.store.list_wiki_versions(branch_tag="tag-1") + self.assertEqual(len(versions), 2) + + +# --------------------------------------------------------------------------- +# SDK extensions +# --------------------------------------------------------------------------- + + +class SdkExtensionsTests(unittest.TestCase): + def setUp(self) -> None: + self.store, _ = _new_store() + self.client = MemoryClient.memory(self.store, agent_id="bot", user_id="alice") + + def test_namespace_sugar(self) -> None: + alice = self.client.for_user("alice") + m = alice.remember("alice prefers dark mode", external_id="pref-dark") + self.assertEqual(m.user_id, "alice") + m2 = self.client.for_agent("bot").remember("bot note", external_id="bot-1") + self.assertEqual(m2.agent_id, "bot") + + def test_recall_adaptive(self) -> None: + self.client.remember("Alice works on Atlas", external_id="alice-1") + r = self.client.recall_adaptive("Alice", limit=5) + self.assertIsNotNone(r) + + def test_cognitive_sleep_dry_run(self) -> None: + self.client.remember("old news", external_id="old-1", + importance=0.1, + created_at=time.time() - 200*86400) + rep = self.client.cognitive_sleep(apply=False, stale_days=90, + min_score=0.5, min_importance=0.3) + self.assertGreaterEqual(rep.counts["stale"], 1) + + def test_audit_round_trip(self) -> None: + self.client.remember("old news", external_id="old-2", + importance=0.1, + created_at=time.time() - 200*86400) + self.client.cognitive_sleep(apply=False, stale_days=90, + min_score=0.5, min_importance=0.3) + rows = self.client.audit(kind="stale", limit=10) + self.assertGreaterEqual(len(rows), 1) + + def test_export_import(self) -> None: + self.client.remember("x", external_id="x-1") + with tempfile.TemporaryDirectory() as t: + out = Path(t) / "bundle" + r = self.client.export(str(out), agent_id="bot", user_id="alice") + self.assertEqual(r.memories, 1) + s2 = MemoryStore(Path(t) / "v7c.db") + c2 = MemoryClient.memory(s2) + iv = c2.import_bundle(str(out), agent_id="bot", user_id="alice") + self.assertEqual(iv.memories_upserted, 1) + + +# --------------------------------------------------------------------------- +# HTTP /api/v1/* routes (v7 surface) +# --------------------------------------------------------------------------- + + +class HttpV7RoutesTests(unittest.TestCase): + def setUp(self) -> None: + self.store, _ = _new_store() + self.app = create_app(self.store, static_dir=None) + self.c = TestClient(self.app) + + def test_graph_edges_route(self) -> None: + r = self.c.post("/api/v1/graph/edges", + json={"src": "Alice", "dst": "Hangzhou", + "kind": "lives_in", "weight": 0.9}) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()["src"], "Alice") + + def test_graph_edges_rejects_distinct_names(self) -> None: + r = self.c.post("/api/v1/graph/edges", + json={"src": "x", "dst": "x"}) + self.assertEqual(r.status_code, 400) + + def test_cognitive_sleep_route(self) -> None: + self.c.post("/api/v1/memories", + json={"text": "old news", "importance": 0.1, + "agent_id": "bot", "external_id": "old-1", + "created_at": time.time() - 200*86400}) + r = self.c.post("/api/v1/cognitive/sleep", + json={"stale_days": 90, "min_score": 0.5, + "min_importance": 0.3}) + self.assertEqual(r.status_code, 200) + self.assertIn("counts", r.json()) + + def test_audit_route(self) -> None: + r = self.c.get("/api/v1/cognitive/audit", params={"limit": 5}) + self.assertEqual(r.status_code, 200) + self.assertIn("rows", r.json()) + + def test_export_import_route(self) -> None: + with tempfile.TemporaryDirectory() as t: + out = Path(t) / "bundle" + r = self.c.post("/api/v1/export", + json={"out_dir": str(out), "agent_id": "bot"}) + self.assertEqual(r.status_code, 200) + self.assertTrue((out / "MEMORY.md").exists()) + r2 = self.c.post("/api/v1/import", + json={"in_dir": str(out), "agent_id": "bot"}) + self.assertEqual(r2.status_code, 200) + + def test_fork_and_wiki_versions(self) -> None: + self.c.post("/api/v1/export", json={"out_dir": "/tmp/lm_v7_bundle_test"}) + r = self.c.post("/api/v1/fork", json={"branch_tag": "test"}) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()["tag"], "test") + r2 = self.c.get("/api/v1/wiki/versions", + params={"branch_tag": "test"}) + self.assertEqual(r2.status_code, 200) + + +# --------------------------------------------------------------------------- +# MCP v7 tools +# --------------------------------------------------------------------------- + + +class McpV7ToolsTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = Path(tempfile.mkdtemp(prefix="loop_mcp_v7_")) + self.db = self.tmp / "mcp_v7.db" + self.prev_db = os.environ.get("LOOP_MEMORY_DB") + self.prev_agent = os.environ.get("LOOP_MEMORY_AGENT_ID") + os.environ["LOOP_MEMORY_DB"] = str(self.db) + if self.prev_agent is None: + os.environ.pop("LOOP_MEMORY_AGENT_ID", None) + from loop_memory.storage.sqlite_store import MemoryStore + s = MemoryStore(self.db) + s.upsert_wiki_page(slug="atlas", title="Atlas", + body="Alice's project", summary="x", + tags=["atlas"], importance=0.8) + + def tearDown(self) -> None: + import shutil + if self.prev_db is None: + os.environ.pop("LOOP_MEMORY_DB", None) + else: + os.environ["LOOP_MEMORY_DB"] = self.prev_db + shutil.rmtree(self.tmp, ignore_errors=True) + + def _call(self, name, arguments): + from loop_memory.mcp import TOOL_DISPATCH + result = TOOL_DISPATCH[name](arguments) + self.assertEqual(len(result), 1) + return result[0]["text"] + + def test_tools_list_contains_v7(self) -> None: + from loop_memory.mcp import TOOLS + names = {t["name"] for t in TOOLS} + self.assertIn("remember_edge", names) + self.assertIn("subgraph", names) + self.assertIn("cognitive_sleep", names) + self.assertIn("audit", names) + + def test_remember_edge_via_mcp(self) -> None: + out = self._call("remember_edge", { + "src": "Alice", "dst": "Hangzhou", "kind": "lives_in", "weight": 0.9, + }) + self.assertIn("Alice", out) + self.assertIn("Hangzhou", out) + + def test_cognitive_sleep_via_mcp(self) -> None: + from loop_memory.storage.sqlite_store import MemoryStore + MemoryStore(self.db).upsert_memory( + kind="fact", text="old news", importance=0.1, + created_at=time.time() - 200*86400, + ) + out = self._call("cognitive_sleep", {"stale_days": 90, + "min_score": 0.5, + "min_importance": 0.3}) + self.assertIn("counts", out) + + def test_audit_via_mcp(self) -> None: + from loop_memory.storage.sqlite_store import MemoryStore + # Pre-seed an audit row so the tool returns the standard + # "Audit (N rows)" header instead of the empty-state hint. + MemoryStore(self.db).record_audit( + kind="forget", action="applied", target_kind="memory", + target_id="m1", reason="test", score=0.1, + ) + out = self._call("audit", {"limit": 5}) + self.assertIn("Audit", out) + + +if __name__ == "__main__": + unittest.main()