diff --git a/humux/api/admin.py b/humux/api/admin.py index 205df29..aa6897b 100644 --- a/humux/api/admin.py +++ b/humux/api/admin.py @@ -2004,6 +2004,49 @@ async def partial_llm() -> HTMLResponse: **(await _history_ctx()), ) + async def _llm_providers_ctx() -> dict: + """Vaulted flags + API keys for the LLM Providers sub-tab partial.""" + anthropic_key = await config_store.get("agent.anthropic_api_key") or "" + openai_key = await config_store.get("agent.openai_api_key") or "" + google_key = await config_store.get("agent.google_api_key") or "" + grok_key = await config_store.get("agent.grok_api_key") or "" + deepseek_key = await config_store.get("agent.deepseek_api_key") or "" + openrouter_key = await config_store.get("agent.openrouter_api_key") or "" + return { + "anthropic_api_key": "" if _is_vault_ref(anthropic_key) else anthropic_key, + "anthropic_vaulted": _is_vault_ref(anthropic_key), + "openai_api_key": "" if _is_vault_ref(openai_key) else openai_key, + "openai_vaulted": _is_vault_ref(openai_key), + "openai_base_url": await config_store.get("agent.openai_base_url") or "", + "google_api_key": "" if _is_vault_ref(google_key) else google_key, + "google_vaulted": _is_vault_ref(google_key), + "google_base_url": await config_store.get("agent.google_base_url") or "", + "grok_api_key": "" if _is_vault_ref(grok_key) else grok_key, + "grok_vaulted": _is_vault_ref(grok_key), + "grok_base_url": await config_store.get("agent.grok_base_url") or "", + "deepseek_api_key": "" if _is_vault_ref(deepseek_key) else deepseek_key, + "deepseek_vaulted": _is_vault_ref(deepseek_key), + "deepseek_base_url": await config_store.get("agent.deepseek_base_url") or "", + "openrouter_api_key": "" if _is_vault_ref(openrouter_key) else openrouter_key, + "openrouter_vaulted": _is_vault_ref(openrouter_key), + "openrouter_base_url": await config_store.get("agent.openrouter_base_url") or "", + } + + @app.get("/partials/llm/inference", dependencies=[Depends(auth)]) + async def partial_llm_inference() -> HTMLResponse: + """LLM Inference sub-tab content.""" + return _render_partial("partials/llm_inference.html") + + @app.get("/partials/llm/providers", dependencies=[Depends(auth)]) + async def partial_llm_providers() -> HTMLResponse: + """LLM Providers sub-tab content.""" + return _render_partial("partials/llm_providers.html", **(await _llm_providers_ctx())) + + @app.get("/partials/llm/history", dependencies=[Depends(auth)]) + async def partial_llm_history() -> HTMLResponse: + """LLM History sub-tab content.""" + return _render_partial("partials/llm_history.html", **(await _history_ctx())) + def _browser_rules() -> list[dict]: """Per-domain browser `act` rules (excludes the generic default rule).""" agent = agent_state.agent @@ -2462,12 +2505,12 @@ async def _fetch_memories_paginated( return entries, total from core.memory import MemoryStore + await MemoryStore(db_path=memory_db)._ensure_schema() if tier == "long-term": cols = ( - "id, category, subject, content, source, " - "confidence, created_at, updated_at, scope" + "id, category, subject, content, source, confidence, created_at, updated_at, scope" ) table = "long_term" base_where = "" @@ -2510,24 +2553,21 @@ async def _fetch_memories_paginated( # Paginated rows data_sql = ( - f"SELECT {cols} FROM {table} {where_clause} " - f"ORDER BY {order} LIMIT ? OFFSET ?" + f"SELECT {cols} FROM {table} {where_clause} ORDER BY {order} LIMIT ? OFFSET ?" ) cursor = await db.execute(data_sql, params + [str(limit), str(offset)]) entries = [dict(row) for row in await cursor.fetchall()] return entries, total - async def _render_memory_partial( - view: str = "settings", + async def _memory_context( + view: str, offset: int = 0, limit: int = 25, q: str | None = None, - ) -> HTMLResponse: - """Build the Memory tab partial for the given sub-tab view. + ) -> dict: + """Build shared context for a memory sub-tab partial.""" - Shared by the tab load, pagination, search, and post-delete/update refresh. - """ async def _cfg(key: str, default: str) -> str: val = await config_store.get(key) return default if val is None or val == "" else str(val) @@ -2536,10 +2576,6 @@ async def _bool(key: str, default: str) -> str: val = await config_store.get(key) return default if val is None else str(val).lower() - allowed_views = ("settings", "long-term", "short-term") - if view not in allowed_views: - view = "settings" - ctx: dict[str, object] = { "memory_view": view, "memory_offset": offset, @@ -2559,17 +2595,26 @@ async def _bool(key: str, default: str) -> str: "archive_min_idle_days": await _cfg("memory.archive_min_idle_days", "45"), "hygiene_threshold": await _cfg("memory.hygiene_similarity_threshold", "0.45"), } - # Agent slugs for the scope dropdown in the edit forms (#158). agent_store = await _agent_store_from_config(config_store) ctx["agent_slugs"] = [a.name for a in await agent_store.list_agents()] + return ctx + + async def _render_memory_subtab( + view: str = "settings", + offset: int = 0, + limit: int = 25, + q: str | None = None, + ) -> HTMLResponse: + """Build a single memory sub-tab content partial (no nav wrapper).""" + allowed_views = ("settings", "long-term", "short-term") + if view not in allowed_views: + view = "settings" + ctx = await _memory_context(view, offset, limit, q) memory_db = await config_store.get("memory.db_path") or "data/memory.db" - if view == "settings": - # Settings view: no memory data needed - pass - elif view == "long-term": + if view == "long-term": entries, total = await _fetch_memories_paginated( memory_db, "long-term", offset, limit, q ) @@ -2582,25 +2627,39 @@ async def _bool(key: str, default: str) -> str: ctx["short_term"] = entries ctx["short_term_total"] = total - return _render_partial("partials/memory.html", **ctx) + template_name = "partials/memory_" + view.replace("-", "_") + ".html" + return _render_partial(template_name, **ctx) @app.get("/partials/memory", dependencies=[Depends(auth)]) - async def partial_memory( - request: Request, - view: str = "settings", + async def partial_memory() -> HTMLResponse: + """Memory tab wrapper — Settings | Long-term | Short-term sub-tabs. + + Thin shell; each sub-tab lazy-loads its own partial via htmx. + """ + return _render_partial("partials/memory.html") + + @app.get("/partials/memory/settings", dependencies=[Depends(auth)]) + async def partial_memory_settings() -> HTMLResponse: + """Memory Settings sub-tab content.""" + return await _render_memory_subtab(view="settings") + + @app.get("/partials/memory/long-term", dependencies=[Depends(auth)]) + async def partial_memory_long_term( offset: int = 0, limit: int = 25, q: str | None = None, ) -> HTMLResponse: - """Memory tab partial with sub-tab support. + """Long-term memory sub-tab content with pagination & search.""" + return await _render_memory_subtab(view="long-term", offset=offset, limit=limit, q=q) - Query params: - view — sub-tab: settings | long-term | short-term (default: settings) - offset — row offset for pagination (default: 0) - limit — page size (default: 25) - q — search query to filter by content/subject/category - """ - return await _render_memory_partial(view=view, offset=offset, limit=limit, q=q) + @app.get("/partials/memory/short-term", dependencies=[Depends(auth)]) + async def partial_memory_short_term( + offset: int = 0, + limit: int = 25, + q: str | None = None, + ) -> HTMLResponse: + """Short-term memory sub-tab content with pagination & search.""" + return await _render_memory_subtab(view="short-term", offset=offset, limit=limit, q=q) async def _history_ctx() -> dict: """History + compaction config, shared by the History sub-tab of the LLM @@ -2703,18 +2762,36 @@ def _get_jobs_list(include_done: bool = False) -> list[dict]: ) return jobs - @app.get("/partials/jobs", dependencies=[Depends(auth)]) - async def partial_jobs(show_completed: bool = False) -> HTMLResponse: - """Jobs tab partial. ``show_completed`` reveals done/cancelled jobs.""" + async def _jobs_context(show_completed: bool = False) -> dict: + """Shared context for jobs partials.""" jobs = _get_jobs_list(include_done=show_completed) agent_running = agent_state.agent is not None + return { + "jobs": jobs, + "agent_running": agent_running, + "show_completed": show_completed, + } + + @app.get("/partials/jobs", dependencies=[Depends(auth)]) + async def partial_jobs() -> HTMLResponse: + """Jobs tab wrapper — Scheduled | Subagents sub-tabs. + + Thin shell; each sub-tab lazy-loads its own partial via htmx. + """ + return _render_partial("partials/jobs.html") + + @app.get("/partials/jobs/scheduled", dependencies=[Depends(auth)]) + async def partial_jobs_scheduled(show_completed: bool = False) -> HTMLResponse: + """Scheduled jobs sub-tab content.""" return _render_partial( - "partials/jobs.html", - jobs=jobs, - agent_running=agent_running, - show_completed=show_completed, + "partials/jobs_scheduled.html", **(await _jobs_context(show_completed)) ) + @app.get("/partials/jobs/subagents", dependencies=[Depends(auth)]) + async def partial_jobs_subagents() -> HTMLResponse: + """Subagent runs sub-tab content.""" + return _render_partial("partials/jobs_subagents.html") + @app.post("/jobs", dependencies=[Depends(auth)]) async def upsert_job(request: Request) -> HTMLResponse: """Add or update a scheduled job. Returns refreshed jobs partial.""" @@ -2791,13 +2868,8 @@ async def upsert_job(request: Request) -> HTMLResponse: log.info("Job %r upserted via admin: %s (%s)", job_id, cron, job_type) show_completed = str(body.get("show_completed", "")).strip().lower() in ("true", "on", "1") - jobs = _get_jobs_list(include_done=show_completed) - agent_running = agent is not None resp = _render_partial( - "partials/jobs.html", - jobs=jobs, - agent_running=agent_running, - show_completed=show_completed, + "partials/jobs_scheduled.html", **(await _jobs_context(show_completed)) ) resp.headers["HX-Trigger"] = json.dumps({"showToast": f'Job "{job_id}" saved'}) return resp @@ -2828,13 +2900,8 @@ async def delete_job(request: Request) -> HTMLResponse: log.info("Job %r deleted via admin", job_id) show_completed = str(body.get("show_completed", "")).strip().lower() in ("true", "on", "1") - jobs = _get_jobs_list(include_done=show_completed) - agent_running = agent is not None resp = _render_partial( - "partials/jobs.html", - jobs=jobs, - agent_running=agent_running, - show_completed=show_completed, + "partials/jobs_scheduled.html", **(await _jobs_context(show_completed)) ) resp.headers["HX-Trigger"] = json.dumps({"showToast": f'Job "{job_id}" deleted'}) return resp @@ -2876,13 +2943,8 @@ async def pause_resume_job(request: Request) -> HTMLResponse: log.info("Job %r %s via admin", job_id, action) show_completed = str(body.get("show_completed", "")).strip().lower() in ("true", "on", "1") - jobs = _get_jobs_list(include_done=show_completed) - agent_running = agent is not None resp = _render_partial( - "partials/jobs.html", - jobs=jobs, - agent_running=agent_running, - show_completed=show_completed, + "partials/jobs_scheduled.html", **(await _jobs_context(show_completed)) ) resp.headers["HX-Trigger"] = json.dumps({"showToast": f'Job "{job_id}" {action}'}) return resp @@ -3731,7 +3793,10 @@ async def upsert_skill(body: SkillUpsertIn) -> HTMLResponse: total = await store.count_skills() return _render_partial( "partials/skills.html", - skills=skills, total=total, offset=0, limit=25, + skills=skills, + total=total, + offset=0, + limit=25, ) @app.post("/skills/install", dependencies=[Depends(auth)]) @@ -3755,7 +3820,10 @@ async def install_skill(request: Request) -> HTMLResponse: total = await store.count_skills() return _render_partial( "partials/skills.html", - skills=skills, total=total, offset=0, limit=25, + skills=skills, + total=total, + offset=0, + limit=25, install_result=result, ) @@ -3771,7 +3839,10 @@ async def update_skill(name: str) -> HTMLResponse: total = await store.count_skills() return _render_partial( "partials/skills.html", - skills=skills, total=total, offset=0, limit=25, + skills=skills, + total=total, + offset=0, + limit=25, install_result=result, ) @@ -3793,7 +3864,10 @@ async def delete_skill(request: Request) -> HTMLResponse: total = await store.count_skills() return _render_partial( "partials/skills.html", - skills=skills, total=total, offset=0, limit=25, + skills=skills, + total=total, + offset=0, + limit=25, ) @app.post("/skills/{name}/reset", dependencies=[Depends(auth)]) @@ -3810,7 +3884,10 @@ async def reset_skill(name: str) -> HTMLResponse: total = await store.count_skills() return _render_partial( "partials/skills.html", - skills=skills, total=total, offset=0, limit=25, + skills=skills, + total=total, + offset=0, + limit=25, ) @app.post("/skills/validate", dependencies=[Depends(auth)]) @@ -4211,7 +4288,7 @@ async def delete_memory(request: Request) -> HTMLResponse: raise HTTPException(404, f"Memory {memory_id} not found in {tier}") # Return refreshed sub-view based on the deleted tier - return await _render_memory_partial(view=tier) + return await _render_memory_subtab(view=tier) @app.post("/memory/update", dependencies=[Depends(auth)]) async def update_memory(request: Request) -> HTMLResponse: @@ -4255,7 +4332,7 @@ async def update_memory(request: Request) -> HTMLResponse: if rows == 0: raise HTTPException(404, f"Memory {memory_id} not found in {tier}") # Return refreshed sub-view based on the updated tier - return await _render_memory_partial(view=tier) + return await _render_memory_subtab(view=tier) @app.get("/memory/embedding/status", dependencies=[Depends(auth)]) async def embedding_status() -> dict: @@ -4747,12 +4824,38 @@ async def _secrets_ctx() -> dict: def _no_vault_partial() -> HTMLResponse: return _render_partial("partials/secrets.html", configured=False) + async def _vault_subtab(view: str) -> HTMLResponse: + """Render a vault sub-tab content partial.""" + if secret_store is None: + return _no_vault_partial() + ctx = await _secrets_ctx() + return _render_partial(f"partials/secrets_{view}.html", **ctx) + @app.get("/partials/secrets", response_class=HTMLResponse, dependencies=[Depends(auth)]) async def partial_secrets() -> HTMLResponse: + """Vaults tab wrapper — Agents | Infra | Import sub-tabs. + + Thin shell; each sub-tab lazy-loads its own partial via htmx. + """ if secret_store is None: return _no_vault_partial() return _render_partial("partials/secrets.html", **(await _secrets_ctx())) + @app.get("/partials/secrets/agents", response_class=HTMLResponse, dependencies=[Depends(auth)]) + async def partial_secrets_agents() -> HTMLResponse: + """Vault Agents sub-tab content.""" + return await _vault_subtab("agents") + + @app.get("/partials/secrets/infra", response_class=HTMLResponse, dependencies=[Depends(auth)]) + async def partial_secrets_infra() -> HTMLResponse: + """Vault Infra sub-tab content.""" + return await _vault_subtab("infra") + + @app.get("/partials/secrets/import", response_class=HTMLResponse, dependencies=[Depends(auth)]) + async def partial_secrets_import() -> HTMLResponse: + """Vault Import sub-tab content.""" + return await _vault_subtab("import") + @app.post("/admin/secrets/migrate", response_class=HTMLResponse, dependencies=[Depends(auth)]) async def migrate_secrets() -> HTMLResponse: """Move any still-plaintext credentials onto the infra vault (issue #35). diff --git a/humux/api/templates/base.html b/humux/api/templates/base.html index 95bae55..fa5933e 100644 --- a/humux/api/templates/base.html +++ b/humux/api/templates/base.html @@ -284,8 +284,15 @@ url.searchParams.set('llmsub', s); history.replaceState(history.state, '', url); } catch (e) {} + this._loadSection(s); }, - reloadLlmPartial() { if (window.htmx) { htmx.ajax('GET', '/partials/llm', {target: '#tab-content', swap: 'innerHTML'}); } }, + _loadSection(s) { + if (window.htmx) { + showSkeleton('#llm-sub'); + htmx.ajax('GET', '/partials/llm/' + s, {target: '#llm-sub', swap: 'innerHTML'}); + } + }, + reloadLlmPartial() { this._loadSection(this.section); }, providerOptions: window.LLM_PROVIDERS, provider: currentProvider, apiKey: apiKey || '', @@ -549,6 +556,8 @@ this.resetMemoryModel('consolidation'); this.resetBackgroundModel('gd'); this.resetBackgroundModel('tr'); + // Load the initial sub-tab content after seeding model values. + this._loadSection(this.section); }); }, resetModel() { @@ -1319,41 +1328,56 @@ } window.accountsTabs = accountsTabs; - // Vaults tab: Agents | Infra | Import. Unlike inspect/accounts these are - // shown/hidden in place (x-show) because every action re-renders the whole - // partial into #tab-content; the sub-tab is mirrored in the URL (?vaultsub=) - // and restored after each re-render. + // Vaults tab: Agents | Infra | Import sub-tabs. + // Persisted via ?vaultsub= URL param; each sub-tab lazy-loads its own + // partial via htmx with skeleton loading. function vaultTabs() { - const allowed = ['agents', 'infra', 'import']; - return { - sub: (() => { - try { - const q = new URLSearchParams(window.location.search).get('vaultsub'); - if (allowed.includes(q)) { return q; } - const ls = localStorage.getItem('vault_sub'); - if (allowed.includes(ls)) { return ls; } - } catch (e) {} - return 'agents'; - })(), - init() { this._syncUrl(this.sub); }, - select(name) { - this.sub = name; - try { localStorage.setItem('vault_sub', name); } catch (e) {} - this._syncUrl(name); + return subTabs({ + subs: { + agents: '/partials/secrets/agents', + infra: '/partials/secrets/infra', + import: '/partials/secrets/import' }, - _syncUrl(name) { - try { - const url = new URL(window.location.href); - if (url.searchParams.get('vaultsub') !== name) { - url.searchParams.set('vaultsub', name); - history.replaceState(history.state, '', url); - } - } catch (e) {} - } - }; + param: 'vaultsub', + storageKey: 'vault_sub', + container: '#vault-sub', + fallback: 'agents' + }); } window.vaultTabs = vaultTabs; + // Memory tab: Settings | Long-term | Short-term sub-tabs. + // Persisted via ?memsub= URL param; each sub-tab lazy-loads its own + // partial via htmx with skeleton loading. + function memorySubtab() { + return subTabs({ + subs: { + settings: '/partials/memory/settings', + 'long-term': '/partials/memory/long-term', + 'short-term': '/partials/memory/short-term' + }, + param: 'memsub', + storageKey: 'memory_sub', + container: '#memory-sub', + fallback: 'settings' + }); + } + window.memorySubtab = memorySubtab; + + // Jobs tab: Scheduled | Subagents sub-tabs. + // Persisted via ?jobsub= URL param; each sub-tab lazy-loads its own + // partial via htmx with skeleton loading. + function jobsTabs() { + return subTabs({ + subs: { scheduled: '/partials/jobs/scheduled', subagents: '/partials/jobs/subagents' }, + param: 'jobsub', + storageKey: 'jobs_sub', + container: '#jobs-sub', + fallback: 'scheduled' + }); + } + window.jobsTabs = jobsTabs; + // Global: handle 401 by redirecting to auth gate document.body.addEventListener('htmx:responseError', function(e) { if (e.detail.xhr.status === 401) { diff --git a/humux/api/templates/partials/_macros.html b/humux/api/templates/partials/_macros.html new file mode 100644 index 0000000..9ae9fc8 --- /dev/null +++ b/humux/api/templates/partials/_macros.html @@ -0,0 +1,84 @@ +{# Shared Jinja macros used by multiple tab partials #} + +{# ── LLM thinking-level control ─────────────────────────────── #} +{%- macro think(level, prov, model, lid, bg=false) -%} +
+ +
+ + + + + + + effort docs ↗ +
+

+

Leave Off for non-reasoning models. Higher = more reasoning, more tokens. Anthropic: "Fetch levels" autodiscovers supported values; other providers: see the docs link and type the value.

+

Heads up: "" isn't a recognized reasoning model — only set a level if you know it supports thinking, or the inference call may error.

+
+{%- endmacro %} + +{# ── Memory pagination ──────────────────────────────────────── #} +{%- macro memory_pag(view, total, suffix='') %} +{% set off = memory_offset|default(0)|int %} +{% set lim = memory_limit|default(25)|int %} +{% set q = memory_q|default('') %} +{% set page_sizes = [25, 50, 100] %} +{% set total = total|default(0)|int %} +{% set cp = (off // lim) + 1 if total > 0 else 1 %} +{% set tp = ((total + lim - 1) // lim) if total > 0 else 1 %} +{% set ss = off + 1 if total > 0 else 0 %} +{% set se = [(off + lim), total]|min if total > 0 else 0 %} +{% set qs = ('&q=' + q|urlencode) if q else '' %} +{% set pid = 'ps-' + view + suffix %} +
+
+ + {%- if total > 0 %}Showing {{ ss }}–{{ se }} of {{ total }}{% else %}No entries{% endif -%} + + + +
+
+ + + Page {{ cp }} / {{ tp }} + + +
+
+{%- endmacro %} + +{# ── Memory search ──────────────────────────────────────────── #} +{%- macro memory_search(view) %} +{% set off = memory_offset|default(0)|int %} +{% set lim = memory_limit|default(25)|int %} +{% set q = memory_q|default('') %} +
+ + +
+{%- endmacro %} + +{# ── Memory category list ───────────────────────────────────── #} +{% set memory_categories = ['preference','relationship','fact','routine','work','health','travel'] %} diff --git a/humux/api/templates/partials/jobs.html b/humux/api/templates/partials/jobs.html index 91505c0..052109d 100644 --- a/humux/api/templates/partials/jobs.html +++ b/humux/api/templates/partials/jobs.html @@ -1,357 +1,18 @@ -{# Jobs tab partial #} -{% set job_agents = jobs | map(attribute='agent') | reject('equalto', '') | unique | sort %} -
- {# Sub-tabs: Scheduled | Subagents — selection remembered in URL hash #} +{# Jobs tab wrapper: Scheduled | Subagents sub-tabs. + Each sub-tab lazy-loads its own partial via htmx. The active sub-tab + is mirrored in the URL as ?jobsub= for copy-pasteable links. #} +
- - +
- - {# ── Scheduled jobs ───────────────────────────────────────── #} -
-
- - {{ jobs|length }} job{{ 's' if jobs|length != 1 else '' }} - {% if job_agents %} - - {% endif %} - {% if not agent_running %} - agent stopped — jobs won't run - {% endif %} - -
- - {# Jobs table #} -
- - - - - - - - - - - - - - - - {% for job in jobs %} - - - - - - - - - - - - {% endfor %} - {% if not jobs %} - - {% endif %} - -
IDScheduleTypeAgentTask / CommandStatusChannelNext Run
- {{ job.id }} - {% if job.description %} - {{ job.description[:40] }}{{ '...' if job.description|length > 40 else '' }} - {% endif %} - - {{ job.cron }} - - - {{ job.type }} - - {{ job.agent or '—' }} - {{ job.task }} - - - {{ job.status }} - - {{ job.channel }}{{ job.next_run or '—' }} - - - - -
No jobs configured
-
- - {# Pagination controls #} -
-
- - -
- -
- - - -
-
- - {# Flash area for run-now feedback #} -
- - {# Collapsible add / edit job form #} -
- - -
-
-

Add new job

- -
-
-
-
- - - Unique identifier (lowercase, dashes ok) -
-
- - -
-
- - - 5-field cron: min hour day month weekday -
-
- - - ISO datetime with timezone offset -
-
- - -
-
- - - Agent this job runs as (blank = default identity) -
-
- - - Where to deliver the result -
-
- - - Natural language instruction for the agent - Only sends when there are updates; otherwise stays quiet - Shell command to execute - Task for the subagent to run under the chosen agent - No task needed — runs memory consolidation automatically -
-
-
- - - -
-
- - {# Cron cheat-sheet #} -
- - Cron cheat sheet - -
- 0 7 * * * Every day at 07:00 - */15 * * * * Every 15 minutes - 0 9 * * 1-5 Weekdays at 09:00 - 0 0 * * 0 Sunday at midnight - 30 18 * * * Every day at 18:30 - 0 */6 * * * Every 6 hours -
-
-
-
-
- - {# ── Subagent runs ────────────────────────────────────────── #} -
-
-

Loading…

-
+
+
+
+
- - diff --git a/humux/api/templates/partials/jobs_scheduled.html b/humux/api/templates/partials/jobs_scheduled.html new file mode 100644 index 0000000..2bb6d76 --- /dev/null +++ b/humux/api/templates/partials/jobs_scheduled.html @@ -0,0 +1,291 @@ +{% set job_agents = jobs | map(attribute='agent') | reject('equalto', '') | unique | sort %} +
+
+ + {{ jobs|length }} job{{ 's' if jobs|length != 1 else '' }} + {% if job_agents %} + + {% endif %} + {% if not agent_running %} + agent stopped — jobs won't run + {% endif %} + +
+ + {# Jobs table #} +
+ + + + + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + + + + {% endfor %} + {% if not jobs %} + + {% endif %} + +
IDScheduleTypeAgentTask / CommandStatusChannelNext Run
+ {{ job.id }} + {% if job.description %} + {{ job.description[:40] }}{{ '...' if job.description|length > 40 else '' }} + {% endif %} + + {{ job.cron }} + + + {{ job.type }} + + {{ job.agent or '—' }} + {{ job.task }} + + + {{ job.status }} + + {{ job.channel }}{{ job.next_run or '—' }} + + + + +
No jobs configured
+
+ + {# Pagination controls #} +
+
+ + +
+ +
+ + + +
+
+ + {# Flash area for run-now feedback #} +
+ + {# Collapsible add / edit job form #} +
+ + +
+
+

Add new job

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ + {# Cron cheat-sheet #} +
+ Cron cheat sheet +
+ 0 7 * * * Every day at 07:00 + */15 * * * * Every 15 minutes + 0 9 * * 1-5 Weekdays at 09:00 + 0 0 * * 0 Sunday at midnight + 30 18 * * * Every day at 18:30 + 0 */6 * * * Every 6 hours +
+
+
+
+
diff --git a/humux/api/templates/partials/jobs_subagents.html b/humux/api/templates/partials/jobs_subagents.html new file mode 100644 index 0000000..931e785 --- /dev/null +++ b/humux/api/templates/partials/jobs_subagents.html @@ -0,0 +1,6 @@ +
+
+
+
+
diff --git a/humux/api/templates/partials/llm.html b/humux/api/templates/partials/llm.html index c5d051f..458058e 100644 --- a/humux/api/templates/partials/llm.html +++ b/humux/api/templates/partials/llm.html @@ -1,31 +1,8 @@ -{# LLM tab partial #} -{#- Reusable thinking-level control. `bg` adds the "shared config" badge for - background inference kinds that point at the same provider+model as main. -#} -{% macro think(level, prov, model, lid, bg=false) -%} -
- -
- - - - - - - effort docs ↗ -
-

-

Leave Off for non-reasoning models. Higher = more reasoning, more tokens. Anthropic: “Fetch levels” autodiscovers supported values; other providers: see the docs link and type the value.

-

Heads up: “” isn't a recognized reasoning model — only set a level if you know it supports thinking, or the inference call may error.

-
-{%- endmacro %} +{# LLM tab wrapper — Inference | Providers | History sub-tabs. + Holds the Alpine x-data with all LLM state. Each sub-tab is lazy-loaded + via htmx into #llm-sub (inside the Alpine scope so x-model etc. reach + the parent component). #} +{% import 'partials/_macros.html' as m %}
- {# Sub-tabs (issue #114): split the crowded LLM settings into views. - SysPrompt sub-tab removed (#137) — the Inspect tab shows the full - assembled system prompt directly. #} + {# Sub-tabs: Inference | Providers | History #}
- -
-
-

Active Inference Provider

-

Select the provider used for agent inference after testing its connection.

- -
-
- - -
- -
- - - - - -

Type any model id, or pick from the list. Use “Fetch models” in the provider card on the tab to load the live list from the API.

- - {# Native-vision indicator + one-tap vision-fallback prompt. #} -

- 🖼️ This model reads images natively. -

-
-

- This model can’t read images. Set up a vision fallback so photos still work? - Vision fallback is on — images are captioned by . -

- -
-
- -
-
- - -

Hard ceiling on tokens the model may emit per response. Too low truncates large outputs (e.g. web artifacts) mid-generation. Default 16384; raise for capable models (Claude allows up to 128000).

-
-
- - -

Hard backstop on LLM round-trips in a single user turn. Default 50; raise if a legitimate workflow needs more rounds.

-
-
- -
- - -

Sampling randomness for the main agent loop. Lower = steadier tool calls; higher = more varied prose. Skipped automatically when a thinking level is set. Default 0.5.

-
- -{{ think('thinkingLevel', 'provider', 'model', 'dl-think-main') }} -
- -
- -
- - +
+
+
+
- -
-

Memory Models

-

Choose which provider and model to use for memory extraction and consolidation. These can differ from the main inference model.

- -
-
- - -
-
- - - - - -

Model used to extract memories from conversations.

-
-{{ think('extractionThinkingLevel', 'extractionProvider', 'extractionModel', 'dl-think-ext', true) }} - -
- -
- - -
-
- - - - - -

Model used to consolidate and merge duplicate memories.

-
-{{ think('consolidationThinkingLevel', 'consolidationProvider', 'consolidationModel', 'dl-think-con', true) }} -
- -
- -
- -
- -
-

Goal Decomposition

-

Break complex user requests into structured sub-goals before the main LLM call. Uses a cheap/fast model for classification and decomposition.

- -
-
- - -
- -
- - -
-
- - - - - -

Model used to classify message complexity and decompose goals.

-
-{{ think('gdThinkingLevel', 'gdProvider', 'gdModel', 'dl-think-gd', true) }} -
- -
- -
- -
- -
-

Task Reflection

-

After the agent completes a task with tools, a background reflection step analyses the execution and stores lessons learned for future prompts.

- -
-
- - -
- -
- - -
-
- - - - - -

Model used to reflect on completed tasks and extract lessons.

-
-
- - -

How many past lessons are injected each turn. Keep small to limit prompt bloat. Default 12.

-
-{{ think('trThinkingLevel', 'trProvider', 'trModel', 'dl-think-tr', true) }} -
- -
- -
- -
- -
-

Reply Decision

-

In shared/group chats with several bots and people, decide per message whether to reply at all — staying quiet for messages aimed at another bot, caught in a bot-to-bot reaction loop, or that the agent can't usefully add to. Off by default; uses a cheap/fast model for the yes/no call.

- -
-
- - -
- -
- - -
-
- - - - - -

Model used to decide whether a group-chat message warrants a reply.

-
-{{ think('rdThinkingLevel', 'rdProvider', 'rdModel', 'dl-think-rd', true) }} - -
- - - When on, 1:1 chats always get a reply; only group chats are gated. -
-
-
- - -
-
- - -
-
-

Hard backstop: never send more than this many auto-replies into one chat per rolling window — guarantees a runaway loop terminates even if the model keeps voting to reply.

-
- -
- -
- -
- -
-

History Compaction

-

Model used to summarize older conversation turns when a session's context grows large. Enable compaction and set the trigger threshold in the sub-tab.

- -
-
- - -
-
- - - - - -

A small, fast model is recommended (summarization is cheap).

-
-{{ think('compactionThinkingLevel', 'compactionProvider', 'compactionModel', 'dl-think-cp', true) }} -
- -
- -
- -
- -
-

Vision Fallback

-

When the active model can’t read images, route image attachments to this model for a text description / OCR, injected in place of the image. Off by default; engages only when the active model lacks native vision. Reuses the provider keys configured below.

- -
-
- - -
- -
- - -
-
- - - - - -

- Heads up: “” isn’t a recognized vision model — pick one that can read images, or captioning will fail. -

-

A small, cheap vision model is recommended (captioning is a single short call).

-
-
- -
- -
- -
-
{# /inference #} - - {# /providers #} - - {# /history #}
diff --git a/humux/api/templates/partials/llm_history.html b/humux/api/templates/partials/llm_history.html new file mode 100644 index 0000000..6cc8e2d --- /dev/null +++ b/humux/api/templates/partials/llm_history.html @@ -0,0 +1,4 @@ +
+ {% include 'partials/history.html' %} +
{# /history #} +
diff --git a/humux/api/templates/partials/llm_inference.html b/humux/api/templates/partials/llm_inference.html new file mode 100644 index 0000000..29aeafe --- /dev/null +++ b/humux/api/templates/partials/llm_inference.html @@ -0,0 +1,498 @@ +{% from "partials/_macros.html" import think %} +
+
+

Active Inference Provider

+

Select the provider used for agent inference after testing its connection.

+ +
+
+ + +
+ +
+ + + + + +

Type any model id, or pick from the list. Use “Fetch models” in the provider card on the tab to load the live list from the API.

+ + {# Native-vision indicator + one-tap vision-fallback prompt. #} +

+ 🖼️ This model reads images natively. +

+
+

+ This model can’t read images. Set up a vision fallback so photos still work? + Vision fallback is on — images are captioned by . +

+ +
+
+ +
+
+ + +

Hard ceiling on tokens the model may emit per response. Too low truncates large outputs (e.g. web artifacts) mid-generation. Default 16384; raise for capable models (Claude allows up to 128000).

+
+
+ + +

Hard backstop on LLM round-trips in a single user turn. Default 50; raise if a legitimate workflow needs more rounds.

+
+
+ +
+ + +

Sampling randomness for the main agent loop. Lower = steadier tool calls; higher = more varied prose. Skipped automatically when a thinking level is set. Default 0.5.

+
+ +{{ think('thinkingLevel', 'provider', 'model', 'dl-think-main') }} +
+ +
+ +
+ + +
+ +
+

Memory Models

+

Choose which provider and model to use for memory extraction and consolidation. These can differ from the main inference model.

+ +
+
+ + +
+
+ + + + + +

Model used to extract memories from conversations.

+
+{{ think('extractionThinkingLevel', 'extractionProvider', 'extractionModel', 'dl-think-ext', true) }} + +
+ +
+ + +
+
+ + + + + +

Model used to consolidate and merge duplicate memories.

+
+{{ think('consolidationThinkingLevel', 'consolidationProvider', 'consolidationModel', 'dl-think-con', true) }} +
+ +
+ +
+ +
+ +
+

Goal Decomposition

+

Break complex user requests into structured sub-goals before the main LLM call. Uses a cheap/fast model for classification and decomposition.

+ +
+
+ + +
+ +
+ + +
+
+ + + + + +

Model used to classify message complexity and decompose goals.

+
+{{ think('gdThinkingLevel', 'gdProvider', 'gdModel', 'dl-think-gd', true) }} +
+ +
+ +
+ +
+ +
+

Task Reflection

+

After the agent completes a task with tools, a background reflection step analyses the execution and stores lessons learned for future prompts.

+ +
+
+ + +
+ +
+ + +
+
+ + + + + +

Model used to reflect on completed tasks and extract lessons.

+
+
+ + +

How many past lessons are injected each turn. Keep small to limit prompt bloat. Default 12.

+
+{{ think('trThinkingLevel', 'trProvider', 'trModel', 'dl-think-tr', true) }} +
+ +
+ +
+ +
+ +
+

Reply Decision

+

In shared/group chats with several bots and people, decide per message whether to reply at all — staying quiet for messages aimed at another bot, caught in a bot-to-bot reaction loop, or that the agent can't usefully add to. Off by default; uses a cheap/fast model for the yes/no call.

+ +
+
+ + +
+ +
+ + +
+
+ + + + + +

Model used to decide whether a group-chat message warrants a reply.

+
+{{ think('rdThinkingLevel', 'rdProvider', 'rdModel', 'dl-think-rd', true) }} + +
+ + + When on, 1:1 chats always get a reply; only group chats are gated. +
+
+
+ + +
+
+ + +
+
+

Hard backstop: never send more than this many auto-replies into one chat per rolling window — guarantees a runaway loop terminates even if the model keeps voting to reply.

+
+ +
+ +
+ +
+ +
+

History Compaction

+

Model used to summarize older conversation turns when a session's context grows large. Enable compaction and set the trigger threshold in the sub-tab.

+ +
+
+ + +
+
+ + + + + +

A small, fast model is recommended (summarization is cheap).

+
+{{ think('compactionThinkingLevel', 'compactionProvider', 'compactionModel', 'dl-think-cp', true) }} +
+ +
+ +
+ +
+ +
+

Vision Fallback

+

When the active model can’t read images, route image attachments to this model for a text description / OCR, injected in place of the image. Off by default; engages only when the active model lacks native vision. Reuses the provider keys configured below.

+ +
+
+ + +
+ +
+ + +
+
+ + + + + +

+ Heads up: “” isn’t a recognized vision model — pick one that can read images, or captioning will fail. +

+

A small, cheap vision model is recommended (captioning is a single short call).

+
+
+ +
+ +
+ +
+
{# /inference #} + diff --git a/humux/api/templates/partials/llm_providers.html b/humux/api/templates/partials/llm_providers.html new file mode 100644 index 0000000..036aca0 --- /dev/null +++ b/humux/api/templates/partials/llm_providers.html @@ -0,0 +1,404 @@ +
+
+ 🔒 Provider API keys are stored in the vault on save — only a ${vault:NAME} reference is kept in config, never the raw key. Manage or reuse them from the tab. +
+
+

Anthropic

+

Configure the Anthropic API key and test the connection.

+
+
+ {% if anthropic_vaulted %}Anthropic API Key{% else %}{% endif %} + + {% if anthropic_vaulted %} +
🔒 Stored in the vault as ${vault:ANTHROPIC_API_KEY} for easy reuse — manage it from the tab.
+ {% else %} + +

Get a key at console.anthropic.com

+ {% endif %} +
+ + + +
+ + +
+
+
+ +
+
+ +
+

OpenAI

+

Configure the OpenAI key and optional base URL, then test the connection.

+
+
+ {% if openai_vaulted %}OpenAI API Key{% else %}{% endif %} + {% if openai_vaulted %} +
🔒 Stored in the vault as ${vault:OPENAI_API_KEY} for easy reuse — manage it from the tab.
+ {% else %} + +

Create one in platform.openai.com

+ {% endif %} +
+
+ + +
+ + + +
+ + +
+
+
+ +
+
+ +
+

Google Gemini

+

Configure the Google API key and optional base URL, then test the connection.

+
+
+ {% if google_vaulted %}Google API Key{% else %}{% endif %} + {% if google_vaulted %} +
🔒 Stored in the vault as ${vault:GOOGLE_API_KEY} for easy reuse — manage it from the tab.
+ {% else %} + +

Create a key in Google AI Studio

+ {% endif %} +
+
+ + +
+ + + +
+ + +
+
+
+ +
+
+ +
+

Grok (xAI)

+

Configure the Grok key and optional base URL, then test the connection.

+
+
+ {% if grok_vaulted %}Grok API Key{% else %}{% endif %} + {% if grok_vaulted %} +
🔒 Stored in the vault as ${vault:GROK_API_KEY} for easy reuse — manage it from the tab.
+ {% else %} + +

Create a key in console.x.ai

+ {% endif %} +
+
+ + +
+ + + +
+ + +
+
+
+ +
+
+ +
+

DeepSeek

+

Configure the DeepSeek key and optional base URL, then test the connection.

+
+
+ {% if deepseek_vaulted %}DeepSeek API Key{% else %}{% endif %} + {% if deepseek_vaulted %} +
🔒 Stored in the vault as ${vault:DEEPSEEK_API_KEY} for easy reuse — manage it from the tab.
+ {% else %} + +

Create a key in platform.deepseek.com

+ {% endif %} +
+
+ + +
+ + + +
+ + +
+
+
+ +
+
+ +
+

OpenRouter

+

A provider-agnostic gateway: one API key reaches models from many labs (Anthropic, OpenAI, Google, Meta, DeepSeek, …). Model ids are namespaced provider/model (e.g. anthropic/claude-sonnet-4.5) — use Fetch models for the live catalog.

+

OpenRouter can auto-route around provider outages and fall back between hosts, and publishes transparent per-token pricing per model. Docs: openrouter.ai/docs · Models & pricing: openrouter.ai/models · Routing & fallbacks: provider routing.

+
+
+ {% if openrouter_vaulted %}OpenRouter API Key{% else %}{% endif %} + {% if openrouter_vaulted %} +
🔒 Stored in the vault as ${vault:OPENROUTER_API_KEY} for easy reuse — manage it from the tab.
+ {% else %} + +

Create a key in openrouter.ai/settings/keys

+ {% endif %} +
+
+ + +
+ + + +
+ + +
+
+
+ +
+
+
{# /providers #} + diff --git a/humux/api/templates/partials/memory.html b/humux/api/templates/partials/memory.html index 8413f53..cd98215 100644 --- a/humux/api/templates/partials/memory.html +++ b/humux/api/templates/partials/memory.html @@ -1,445 +1,16 @@ -{# Memory tab partial with sub-tabs: Settings | Long-term | Short-term #} -{% set categories = ['preference','relationship','fact','routine','work','health','travel'] %} -{% set page_sizes = [25, 50, 100] %} -{% set v = memory_view|default('settings') %} -{% set off = memory_offset|default(0)|int %} -{% set lim = memory_limit|default(25)|int %} -{% set q = memory_q|default('') %} - -{%- macro nav(view) %} -
- - - -
-{%- endmacro %} - -{%- macro pag(view, total) %} -{% set total = total|default(0)|int %} -{% set cp = (off // lim) + 1 if total > 0 else 1 %} -{% set tp = ((total + lim - 1) // lim) if total > 0 else 1 %} -{% set ss = off + 1 if total > 0 else 0 %} -{% set se = (off + lim)|min(total) if total > 0 else 0 %} -{% set qs = ('&q=' + q|urlencode) if q else '' %} -
-
- - {%- if total > 0 %}Showing {{ ss }}–{{ se }} of {{ total }}{% else %}No entries{% endif -%} - - - -
-
- - - Page {{ cp }} / {{ tp }} - - -
-
-{%- endmacro %} - -{%- macro search(view) %} -
- - -
-{%- endmacro %} - -{% if v == 'settings' %} -{{ nav('settings') }} -
- - {# Memory config section #} -
-

Memory Settings

-

Configure memory storage behaviour.

-
-
- - -

Maximum number of long-term memories before consolidation triggers cleanup.

-
-
-
- -
- -

- The models used for memory extraction and consolidation can be configured in the - LLM tab. -

-
- - {# Semantic memory (embeddings) #} -
-

Semantic memory (embeddings)

-

- Match memories by meaning, not just words - — so “allergic to shellfish” surfaces when you ask about eating prawns. -

-
- How it works (the science) -
-

An embedding model turns each memory and your current message into a vector — a list of numbers positioning the text in a “meaning space”. Texts with similar meaning land close together, even with no shared words.

-

Closeness is measured by cosine similarity (1.0 = same direction/meaning, 0 = unrelated). On every message we embed it, compare against stored memory vectors, and inject only the most relevant ones — ranked by relevance + importance + recency (a Generative-Agents-style score) instead of dumping everything.

-

The same similarity drives dedup (does this new fact match an existing one?) and the hygiene pass (cluster & merge near-duplicates).

-

Local runs the model on this machine (private, free, no key; the model file is bundled in the Docker image). API providers call a remote endpoint (needs a key; a few cents/year at most, but your memory text is sent to them). When disabled, memory falls back to fast word-overlap matching — still works, just less “fuzzy”.

-
-
-
-
- - - Off → lexical (word-overlap) retrieval, no model needed. -
-
- - -

- API key falls back to the matching provider key set in the - LLM tab. - (Note: DeepSeek has no embeddings endpoint.) -

-
-
- - -

Multilingual CPU model (384-dim, ~470MB).

-

e.g. text-embedding-3-small (OpenAI) or text-embedding-004 (Google).

-
-
- - -
-
- - -

How many of the most relevant long-term memories to put in the prompt each message.

-
-
- - -

Default number of memories the recall_memory tool returns when the agent searches its full long-term store on demand (the agent may request fewer/more per call, capped at 25).

-
-
- Model on disk: - yes ✓ - not downloaded yet -
-
-
- - - -
- - -

Changes apply live to the running agent (the local model loads on first use).

+{# Memory tab wrapper: Settings | Long-term | Short-term sub-tabs. + Each sub-tab lazy-loads its own partial via htmx. The active sub-tab + is mirrored in the URL as ?memsub= for copy-pasteable links. #} +{% import 'partials/_macros.html' as m %} +
+
+ + +
- - {# Memory lifecycle (forgetting + hygiene) #} -
-

Memory lifecycle (forgetting & hygiene)

-

Keep long-term memory from growing forever: reinforce what matters, forget cold trivia, merge duplicates.

-
- How it works (the science) -
-

Each memory has an importance (1–10). Recall reinforces it (access count + recency), and re-stating a fact bumps its importance — mirroring how human memory strengthens with use.

-

Forgetting: during consolidation, memories that are old, low-importance, and not accessed for a while are archived (soft-deleted, recoverable) rather than injected forever. This is decay, like the “use it or lose it” curve.

-

Hygiene: the same run clusters near-duplicate memories (by similarity) and asks the model to merge them and drop contradictions, keeping the most recent — so the store stays compact and consistent.

-
-
-
-
- - -

Starting importance for a new memory.

-
-
- - -

A memory must be at least this old before it can be archived.

-
-
- - -

Important memories are never auto-archived.

-
-
- - -

Require this long since last access/creation before archiving.

-
-
-
- - - Cluster & merge near-duplicates during consolidation. -
-
- - -

Higher = stricter (only very-similar memories merge). 0.45 is a sensible default.

-
-
-
- - - -
- +
+
+
+
- -{% elif v == 'long-term' %} -{{ nav('long-term') }} -{% set lt = long_term|default([]) %} -{% set lt_total = long_term_total|default(0) %} - -{{ search('long-term') }} -{{ pag('long-term', lt_total) }} - -
- - - - - - - - - - - - - {% for m in lt %} - - - - - - - - - {% endfor %} - {% if not lt %} - - {% endif %} - -
IDCategorySubjectContentScope
{{ m.id }} - {{ m.category }} - - - {{ m.subject }} - - - {{ m.content }} - - - {% if m.scope %}{{ m.scope }}{% else %}shared{% endif %} - - - - - - -
No long-term memories
-
-{{ pag('long-term', lt_total) }} - -{% elif v == 'short-term' %} -{{ nav('short-term') }} -{% set st = short_term|default([]) %} -{% set st_total = short_term_total|default(0) %} - -{{ search('short-term') }} -{{ pag('short-term', st_total) }} - -
- - - - - - - - - - - - {% for m in st %} - - - - - - - - {% endfor %} - {% if not st %} - - {% endif %} - -
IDContentScopeExpires
{{ m.id }} - {{ m.content }} - - - {% if m.scope %}{{ m.scope }}{% else %}shared{% endif %} - - - {{ m.expires_at }} - - - - - - -
No active short-term memories
-
-{{ pag('short-term', st_total) }} - -{% endif %} diff --git a/humux/api/templates/partials/memory_long_term.html b/humux/api/templates/partials/memory_long_term.html new file mode 100644 index 0000000..e2879f5 --- /dev/null +++ b/humux/api/templates/partials/memory_long_term.html @@ -0,0 +1,71 @@ +{% import 'partials/_macros.html' as m %} +{% set categories = m.memory_categories %} +{% set lt = long_term|default([]) %} +{% set lt_total = long_term_total|default(0) %} +{% set off = memory_offset|default(0)|int %} +{% set lim = memory_limit|default(25)|int %} +{% set q = memory_q|default('') %} +{% set agent_slugs = agent_slugs|default([]) %} + +{{ m.memory_search('long-term') }} +{{ m.memory_pag('long-term', lt_total) }} + +
+ + + + + + + + + + + + + {% for m in lt %} + + + + + + + + + {% endfor %} + {% if not lt %} + + {% endif %} + +
IDCategorySubjectContentScope
{{ m.id }} + {{ m.category }} + + + {{ m.subject }} + + + {{ m.content }} + + + {% if m.scope %}{{ m.scope }}{% else %}shared{% endif %} + + + + + + +
No long-term memories
+
+{{ m.memory_pag('long-term', lt_total, '-b') }} diff --git a/humux/api/templates/partials/memory_settings.html b/humux/api/templates/partials/memory_settings.html new file mode 100644 index 0000000..a6fb972 --- /dev/null +++ b/humux/api/templates/partials/memory_settings.html @@ -0,0 +1,249 @@ +{% import 'partials/_macros.html' as m %} +
+ + {# Memory config section #} +
+

Memory Settings

+

Configure memory storage behaviour.

+
+
+ + +

Maximum number of long-term memories before consolidation triggers cleanup.

+
+
+
+ +
+ +

+ The models used for memory extraction and consolidation can be configured in the + LLM tab. +

+
+ + {# Semantic memory (embeddings) #} +
+

Semantic memory (embeddings)

+

+ Match memories by meaning, not just words + — so “allergic to shellfish” surfaces when you ask about eating prawns. +

+
+ How it works (the science) +
+

An embedding model turns each memory and your current message into a vector — a list of numbers positioning the text in a “meaning space”. Texts with similar meaning land close together, even with no shared words.

+

Closeness is measured by cosine similarity (1.0 = same direction/meaning, 0 = unrelated). On every message we embed it, compare against stored memory vectors, and inject only the most relevant ones — ranked by relevance + importance + recency (a Generative-Agents-style score) instead of dumping everything.

+

The same similarity drives dedup (does this new fact match an existing one?) and the hygiene pass (cluster & merge near-duplicates).

+

Local runs the model on this machine (private, free, no key; the model file is bundled in the Docker image). API providers call a remote endpoint (needs a key; a few cents/year at most, but your memory text is sent to them). When disabled, memory falls back to fast word-overlap matching — still works, just less “fuzzy”.

+
+
+
+
+ + + Off → lexical (word-overlap) retrieval, no model needed. +
+
+ + +

+ API key falls back to the matching provider key set in the + LLM tab. + (Note: DeepSeek has no embeddings endpoint.) +

+
+
+ + +

Multilingual CPU model (384-dim, ~470MB).

+

e.g. text-embedding-3-small (OpenAI) or text-embedding-004 (Google).

+
+
+ + +
+
+ + +

How many of the most relevant long-term memories to put in the prompt each message.

+
+
+ + +

Default number of memories the recall_memory tool returns when the agent searches its full long-term store on demand (the agent may request fewer/more per call, capped at 25).

+
+
+ Model on disk: + yes ✓ + not downloaded yet +
+
+
+ + + +
+ + +

Changes apply live to the running agent (the local model loads on first use).

+
+ + {# Memory lifecycle (forgetting + hygiene) #} +
+

Memory lifecycle (forgetting & hygiene)

+

Keep long-term memory from growing forever: reinforce what matters, forget cold trivia, merge duplicates.

+
+ How it works (the science) +
+

Each memory has an importance (1–10). Recall reinforces it (access count + recency), and re-stating a fact bumps its importance — mirroring how human memory strengthens with use.

+

Forgetting: during consolidation, memories that are old, low-importance, and not accessed for a while are archived (soft-deleted, recoverable) rather than injected forever. This is decay, like the “use it or lose it” curve.

+

Hygiene: the same run clusters near-duplicate memories (by similarity) and asks the model to merge them and drop contradictions, keeping the most recent — so the store stays compact and consistent.

+
+
+
+
+ + +

Starting importance for a new memory.

+
+
+ + +

A memory must be at least this old before it can be archived.

+
+
+ + +

Important memories are never auto-archived.

+
+
+ + +

Require this long since last access/creation before archiving.

+
+
+
+ + + Cluster & merge near-duplicates during consolidation. +
+
+ + +

Higher = stricter (only very-similar memories merge). 0.45 is a sensible default.

+
+
+
+ + + +
+ +
+
diff --git a/humux/api/templates/partials/memory_short_term.html b/humux/api/templates/partials/memory_short_term.html new file mode 100644 index 0000000..d7ad24c --- /dev/null +++ b/humux/api/templates/partials/memory_short_term.html @@ -0,0 +1,64 @@ +{% import 'partials/_macros.html' as m %} +{% set categories = m.memory_categories %} +{% set st = short_term|default([]) %} +{% set st_total = short_term_total|default(0) %} +{% set off = memory_offset|default(0)|int %} +{% set lim = memory_limit|default(25)|int %} +{% set q = memory_q|default('') %} +{% set agent_slugs = agent_slugs|default([]) %} + +{{ m.memory_search('short-term') }} +{{ m.memory_pag('short-term', st_total) }} + +
+ + + + + + + + + + + + {% for m in st %} + + + + + + + + {% endfor %} + {% if not st %} + + {% endif %} + +
IDContentScopeExpires
{{ m.id }} + {{ m.content }} + + + {% if m.scope %}{{ m.scope }}{% else %}shared{% endif %} + + + {{ m.expires_at }} + + + + + + +
No active short-term memories
+
+{{ m.memory_pag('short-term', st_total, '-b') }} diff --git a/humux/api/templates/partials/secrets.html b/humux/api/templates/partials/secrets.html index 6f9387c..d6c892a 100644 --- a/humux/api/templates/partials/secrets.html +++ b/humux/api/templates/partials/secrets.html @@ -1,11 +1,13 @@ -{# Vaults tab (issue #19) — names + access matrix only, never values. #} +{# Vaults tab wrapper: Agents | Infra | Import sub-tabs. + Each sub-tab lazy-loads its own partial via htmx. The active sub-tab + is mirrored in the URL as ?vaultsub= for copy-pasteable links. #} {% if not configured %}

Vaults

The secrets vault is not configured in this deployment.

{% else %} -
+
{% if flash %}
{{ flash }}
{% endif %} {% if error %}
{{ error }}
{% endif %} {% if not unsealed %} @@ -15,177 +17,15 @@

Vaults

{% endif %} - {# Sub-tabs: Agents | Infra | Import #}
- - {# ── Agents: agent / login secrets ─────────────────────────── #} -
-
-

Agent & login secrets

-

- Values are write-only — only names, descriptions and recent use are ever shown. - Use a secret from run_command as {% raw %}{{secret:NAME}}{% endraw %}. -

- - {% if secrets %} -
- - - - - {% for p in agents %}{% endfor %} - - - - - - {% for s in secrets %} - - - {% for p in agents %} - - {% endfor %} - - - - {% endfor %} - -
Name{{ p }}all
-
{{ s.name }}{% if s.structured %} (login){% endif %}
- {% if s.description %}
{{ s.description }}
{% endif %} -
- used {{ s.use_count }}×{% if s.last_used_at %}, last {{ s.last_used_at }}{% endif %} - {% if s.expires_at %} · expires {{ s.expires_at }}{% endif %} - {% if s.max_uses %} · single-use{% endif %} -
-
- - {% if s.shared %}✓{% endif %} - -
-
- {% else %} -

No agent secrets yet.

- {% endif %} -
- - {# ── Add a secret ─────────────────────────────────────────────── #} -
-

Add / rotate a secret

-
-
- - -
-
- - -
-
- - -
-
- - - -
-
- - - -
- -
-
-
{# /agents #} - - {# ── Infrastructure secrets ───────────────────────────────────── #} - {# /infra #} - - {# ── Import from Bitwarden ────────────────────────────────────── #} - {% endif %} diff --git a/humux/api/templates/partials/secrets_agents.html b/humux/api/templates/partials/secrets_agents.html new file mode 100644 index 0000000..d30b394 --- /dev/null +++ b/humux/api/templates/partials/secrets_agents.html @@ -0,0 +1,96 @@ +
+
+

Agent & login secrets

+

+ Values are write-only — only names, descriptions and recent use are ever shown. + Use a secret from run_command as {% raw %}{{secret:NAME}}{% endraw %}. +

+ + {% if secrets %} +
+ + + + + {% for p in agents %}{% endfor %} + + + + + + {% for s in secrets %} + + + {% for p in agents %} + + {% endfor %} + + + + {% endfor %} + +
Name{{ p }}all
+
{{ s.name }}{% if s.structured %} (login){% endif %}
+ {% if s.description %}
{{ s.description }}
{% endif %} +
+ used {{ s.use_count }}×{% if s.last_used_at %}, last {{ s.last_used_at }}{% endif %} + {% if s.expires_at %} · expires {{ s.expires_at }}{% endif %} + {% if s.max_uses %} · single-use{% endif %} +
+
+ + {% if s.shared %}✓{% endif %} + +
+
+ {% else %} +

No agent secrets yet.

+ {% endif %} +
+ + {# ── Add a secret ─────────────────────────────────────────────── #} +
+

Add / rotate a secret

+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + + +
+ +
+
+
{# /agents #} + + {# ── Infrastructure secrets ───────────────────────────────────── #} diff --git a/humux/api/templates/partials/secrets_import.html b/humux/api/templates/partials/secrets_import.html index 724c4e1..5838641 100644 --- a/humux/api/templates/partials/secrets_import.html +++ b/humux/api/templates/partials/secrets_import.html @@ -1,56 +1,13 @@ -{# Bitwarden import — select which login items to bring into the vault. #} -
+
-

Import logins

+

Import from Bitwarden

- {{ items|length }} login item(s) found. Tick the ones to import; values are encrypted - on save and never shown again. + Export your Bitwarden vault as JSON (bw export --format json or the web + vault → Export), then upload it here. Login items become secrets you can scope.

- {% if not items %} -

No login items in this export.

- - {% else %} -
-
- - - - - - - - - {% for it in items %} - - - - - - - - {% endfor %} - -
NameUsernameURLTOTP
{{ it.name }} - - - - - {{ it.username }}{{ it.url }}{% if it.totp %}✓{% endif %}
-
-
- - - -
-
- - -
+ + +
- {% endif %}
-
diff --git a/humux/api/templates/partials/secrets_infra.html b/humux/api/templates/partials/secrets_infra.html new file mode 100644 index 0000000..1cda51a --- /dev/null +++ b/humux/api/templates/partials/secrets_infra.html @@ -0,0 +1,54 @@ +
+
+

Infrastructure secrets

+

+ Machine-key encrypted; read at boot via ${% raw %}{vault:NAME}{% endraw %} in config + (with .env fallback). + {% if not infra_available %}No machine key configured.{% endif %} +

+ + {# ── Migrate scattered plaintext credentials (issue #35) ──────── #} +
+ {% if migratable %} +

+ {{ migratable|length }} credential(s) still stored as plaintext in config: + {{ migratable|join(', ') }}. + Move them into the encrypted vault — config will reference them as + ${% raw %}{vault:NAME}{% endraw %} (.env stays a fallback). +

+ + {% else %} +

+ All known credentials reference the vault (or live only in .env). +

+ {% endif %} +
+ + {% if infra %} +
    + {% for i in infra %} +
  • + {{ i.name }} — {{ i.description }} + +
  • + {% endfor %} +
+ {% endif %} +
+ + + + +
+
+
{# /infra #} + + {# ── Import from Bitwarden ────────────────────────────────────── #} diff --git a/humux/tests/test_admin_api.py b/humux/tests/test_admin_api.py index 604c793..6bd9e36 100644 --- a/humux/tests/test_admin_api.py +++ b/humux/tests/test_admin_api.py @@ -152,7 +152,8 @@ def test_partial_jobs_hides_completed_by_default(tmp_path) -> None: client = _jobs_client(tmp_path) headers = {"Authorization": "Bearer secret"} - resp = client.get("/partials/jobs", headers=headers) + # Wrapper returns nav + skeleton; hit the scheduled sub-tab for content + resp = client.get("/partials/jobs/scheduled", headers=headers) assert resp.status_code == 200 assert "alpha-live" in resp.text assert "omega-done" not in resp.text # completed hidden by default @@ -163,7 +164,7 @@ def test_partial_jobs_show_completed_reveals_done(tmp_path) -> None: client = _jobs_client(tmp_path) headers = {"Authorization": "Bearer secret"} - resp = client.get("/partials/jobs?show_completed=true", headers=headers) + resp = client.get("/partials/jobs/scheduled?show_completed=true", headers=headers) assert resp.status_code == 200 assert "omega-done" in resp.text # completed revealed assert _toggle_checked(resp.text) is True @@ -332,11 +333,12 @@ def test_pause_resume_endpoint_returns_refreshed_partial(tmp_path) -> None: ) assert resp.status_code == 200 # Verify the partial shows the updated status (no longer "active") - assert 'text-green-400' not in resp.text # active status colouring gone + assert "text-green-400" not in resp.text # active status colouring gone # Verify the toast header import json - trigger = json.loads(resp.headers.get('HX-Trigger', '{}')) - assert 'paused' in str(trigger) + + trigger = json.loads(resp.headers.get("HX-Trigger", "{}")) + assert "paused" in str(trigger) # Resume the paused job resp = client.post( diff --git a/humux/tests/test_admin_ui.py b/humux/tests/test_admin_ui.py index 2cd3725..ae00b2d 100644 --- a/humux/tests/test_admin_ui.py +++ b/humux/tests/test_admin_ui.py @@ -296,6 +296,8 @@ def test_llm_partial_has_subtabs(self): # (#114 + History folded in). The SysPrompt sub-tab was removed (#137), # superseded by the Inspect tab. client = _client(setup_complete=True) + # Wrapper has Alpine navigation but skeleton loaders — sub-tab + # content is lazy-loaded via htmx. resp = client.get("/partials/llm", headers=AUTH) assert resp.status_code == 200 for sec in ("inference", "providers", "history"): @@ -303,12 +305,15 @@ def test_llm_partial_has_subtabs(self): assert f"section === '{sec}'" in resp.text assert "setSection('sysprompt')" not in resp.text assert "System Prompt Controls" not in resp.text - # History sub-tab content is included, not a separate top-level tab. - assert "Context compaction" in resp.text + # History sub-tab content lives in separate partial + hist = client.get("/partials/llm/history", headers=AUTH) + assert hist.status_code == 200 + assert "Context compaction" in hist.text def test_llm_partial_has_vision_fallback(self): client = _client(setup_complete=True) - resp = client.get("/partials/llm", headers=AUTH) + # Vision Fallback is in the inference sub-tab content + resp = client.get("/partials/llm/inference", headers=AUTH) assert resp.status_code == 200 assert "Vision Fallback" in resp.text # One-tap enable prompt + capability indicator are wired in. @@ -317,7 +322,8 @@ def test_llm_partial_has_vision_fallback(self): def test_llm_partial_has_reply_decision(self): client = _client(setup_complete=True) - resp = client.get("/partials/llm", headers=AUTH) + # Reply Decision card is in the inference sub-tab + resp = client.get("/partials/llm/inference", headers=AUTH) assert resp.status_code == 200 assert "Reply Decision" in resp.text # The save button wires the full config block, incl. the rate-cap knobs. @@ -328,7 +334,8 @@ def test_llm_partial_has_openrouter(self): # OpenRouter provider card (#128): renders (exercises the positional # llmTab() call), saves the dedicated key, and carries the info text. client = _client(setup_complete=True) - resp = client.get("/partials/llm", headers=AUTH) + # OpenRouter card is in the providers sub-tab + resp = client.get("/partials/llm/providers", headers=AUTH) assert resp.status_code == 200 assert "OpenRouter" in resp.text assert "agent.openrouter_api_key" in resp.text # save button wiring diff --git a/humux/tests/test_memory_admin.py b/humux/tests/test_memory_admin.py index fc4c8c4..b765b57 100644 --- a/humux/tests/test_memory_admin.py +++ b/humux/tests/test_memory_admin.py @@ -117,7 +117,8 @@ def test_endpoints_require_auth() -> None: def test_memory_partial_renders() -> None: - resp = _client().get("/partials/memory", headers=HEADERS) + # Wrapper returns nav + skeleton; check settings sub-tab for content + resp = _client().get("/partials/memory/settings", headers=HEADERS) assert resp.status_code == 200 body = resp.text assert "Semantic memory (embeddings)" in body diff --git a/humux/tests/test_memory_subtabs.py b/humux/tests/test_memory_subtabs.py index e59b308..a33d13c 100644 --- a/humux/tests/test_memory_subtabs.py +++ b/humux/tests/test_memory_subtabs.py @@ -1,4 +1,7 @@ -"""Tests for the Memory sub-tabs (Settings, Long-term, Short-term) with pagination and search.""" +"""Tests for the Memory sub-tabs (Settings, Long-term, Short-term) with pagination and search. + +Sub-tabs are now served as separate partials at /partials/memory/{settings,long-term,short-term}. +""" from __future__ import annotations @@ -49,102 +52,87 @@ def _client(overrides: dict | None = None) -> TestClient: return TestClient(app) -def test_memory_partial_renders_settings_by_default() -> None: - """GET /partials/memory renders the Settings view by default.""" +def test_memory_wrapper_renders() -> None: + """GET /partials/memory renders the wrapper with sub-tab navigation.""" resp = _client().get("/partials/memory", headers=HEADERS) assert resp.status_code == 200 body = resp.text - # Settings view includes the config panels - assert "Memory Settings" in body - assert "Semantic memory (embeddings)" in body - assert "Memory lifecycle" in body - # Sub-tab navigation is present + # Wrapper has the Alpine component and sub-tab nav + assert 'x-data="memorySubtab()"' in body + assert 'x-init="init()"' in body + assert 'id="memory-sub"' in body + assert "skeleton" in body + # Sub-tab labels are present assert "Settings" in body assert "Long-term" in body assert "Short-term" in body -def test_memory_partial_with_view_settings() -> None: - """GET /partials/memory?view=settings renders the Settings sub-tab.""" - resp = _client().get("/partials/memory?view=settings", headers=HEADERS) +def test_memory_settings_subtab() -> None: + """GET /partials/memory/settings renders the Settings sub-tab content.""" + resp = _client().get("/partials/memory/settings", headers=HEADERS) assert resp.status_code == 200 body = resp.text assert "Memory Settings" in body assert "Semantic memory (embeddings)" in body - assert "Settings" in body -def test_memory_partial_with_view_long_term_shows_table() -> None: - """GET /partials/memory?view=long-term renders the long-term table.""" - resp = _client().get("/partials/memory?view=long-term", headers=HEADERS) +def test_memory_long_term_subtab() -> None: + """GET /partials/memory/long-term renders the long-term table.""" + resp = _client().get("/partials/memory/long-term", headers=HEADERS) assert resp.status_code == 200 body = resp.text # Long-term table headers are present assert "Category" in body or "Subject" in body or "Content" in body # Pagination controls present - assert "page_size" in body or "Per page" in body or "Showing" in body - assert "href=" in body or "hx-get" in body or "hx-target" in body + assert "Per page" in body or "Showing" in body -def test_memory_partial_with_view_short_term_shows_table() -> None: - """GET /partials/memory?view=short-term renders the short-term table.""" - resp = _client().get("/partials/memory?view=short-term", headers=HEADERS) +def test_memory_short_term_subtab() -> None: + """GET /partials/memory/short-term renders the short-term table.""" + resp = _client().get("/partials/memory/short-term", headers=HEADERS) assert resp.status_code == 200 body = resp.text # Short-term table headers are present assert "Expires" in body or "Content" in body or "Scope" in body # Pagination controls present - assert "page_size" in body or "Per page" in body or "Showing" in body - - -def test_memory_partial_invalid_view_falls_back_to_settings() -> None: - """An unknown view value falls back to Settings.""" - resp = _client().get("/partials/memory?view=nonexistent", headers=HEADERS) - assert resp.status_code == 200 - body = resp.text - assert "Memory Settings" in body + assert "Per page" in body or "Showing" in body -def test_memory_partial_long_term_pagination_params_accepted() -> None: - """Query params offset, limit, q are accepted on long-term view.""" +def test_memory_long_term_pagination_params() -> None: + """Query params offset, limit, q are accepted on long-term sub-tab.""" resp = _client().get( - "/partials/memory?view=long-term&offset=10&limit=25&q=test", + "/partials/memory/long-term?offset=10&limit=25&q=test", headers=HEADERS, ) assert resp.status_code == 200 -def test_memory_partial_short_term_pagination_params_accepted() -> None: - """Query params offset, limit, q are accepted on short-term view.""" +def test_memory_short_term_pagination_params() -> None: + """Query params offset, limit, q are accepted on short-term sub-tab.""" resp = _client().get( - "/partials/memory?view=short-term&offset=0&limit=50&q=search", + "/partials/memory/short-term?offset=0&limit=50&q=search", headers=HEADERS, ) assert resp.status_code == 200 -def test_memory_partial_with_legacy_tab_param() -> None: - """Legacy URL with ?tab=memory still works (defaults to Settings view).""" - # The admin dashboard passes ?tab=memory which loads /partials/memory +def test_sub_tab_nav_buttons_use_alpine() -> None: + """Sub-tab buttons have Alpine @click handlers.""" resp = _client().get("/partials/memory", headers=HEADERS) - assert resp.status_code == 200 - - -def test_sub_tab_nav_buttons_link_correctly() -> None: - """Sub-tab buttons have correct HTMX attributes.""" - resp = _client().get("/partials/memory?view=settings", headers=HEADERS) body = resp.text - # Each nav button should target #tab-content via hx-get - assert 'hx-get="/partials/memory?view=settings"' in body - long_hx = 'hx-get="/partials/memory?view=long-term' - assert long_hx in body - short_hx = 'hx-get="/partials/memory?view=short-term' - assert short_hx in body + assert 'x-data="memorySubtab()"' in body + assert 'x-init="init()"' in body + assert "@click=\"select('settings')\"" in body or "@click='select(\"settings\")'" in body + assert "@click=\"select('long-term')\"" in body or "@click='select(\"long-term\")'" in body + assert "@click=\"select('short-term')\"" in body or "@click='select(\"short-term\")'" in body + assert ":class" in body + assert "'tab-link-active'" in body def test_endpoints_require_auth() -> None: - """Memory partial endpoint requires authentication.""" + """Memory partial endpoints require authentication.""" assert _client().get("/partials/memory").status_code in (401, 403) - assert _client().get("/partials/memory?view=settings").status_code in (401, 403) - assert _client().get("/partials/memory?view=long-term").status_code in (401, 403) - assert _client().get("/partials/memory?view=short-term").status_code in (401, 403) + assert _client().get("/partials/memory/settings").status_code in (401, 403) + assert _client().get("/partials/memory/long-term").status_code in (401, 403) + assert _client().get("/partials/memory/short-term").status_code in (401, 403) diff --git a/humux/tests/test_reply_decision.py b/humux/tests/test_reply_decision.py index dcadb94..f7dc11c 100644 --- a/humux/tests/test_reply_decision.py +++ b/humux/tests/test_reply_decision.py @@ -100,6 +100,7 @@ def agent(tmp_path, monkeypatch): cfg = Config() cfg.reply_decision.enabled = True cfg.goal_decomposition.enabled = False # keep the gate the only background call + cfg.memory.embedding.enabled = False # no sidecar in tests → 12s timeout per embed call return AgentCore(cfg) diff --git a/humux/tests/test_secrets_vault.py b/humux/tests/test_secrets_vault.py index 6754089..9812e87 100644 --- a/humux/tests/test_secrets_vault.py +++ b/humux/tests/test_secrets_vault.py @@ -359,22 +359,30 @@ async def test_vaults_tab_has_three_subtabs(admin_client) -> None: async def test_add_and_list_secret_via_admin(admin_client) -> None: client, s, _cs = admin_client + # POST creates the secret; response is the vault wrapper with skeleton. resp = client.post( "/admin/secrets", data={"name": "STRIPE", "value": "sk_live", "scope": "all", "duration": "forever"}, headers=_auth(), ) - assert resp.status_code == 200 and "STRIPE" in resp.text + # POST returns the wrapper (flash/skeleton) not the secret list + assert resp.status_code == 200 assert "sk_live" not in resp.text # value never rendered assert await s.get_secret("STRIPE") == "sk_live" + # Verify secret appears in the agents sub-tab + body = client.get("/partials/secrets/agents", headers=_auth()).text + assert "STRIPE" in body async def test_delete_secret_via_admin(admin_client) -> None: client, s, _cs = admin_client await s.set_secret("DOOMED", "v", shared=True) resp = client.post("/admin/secrets/delete", data={"name": "DOOMED"}, headers=_auth()) - assert resp.status_code == 200 and "DOOMED" not in resp.text + # POST returns wrapper; verify via agents sub-tab + assert resp.status_code == 200 assert await s.get_secret("DOOMED") is None + body = client.get("/partials/secrets/agents", headers=_auth()).text + assert "DOOMED" not in body async def test_delete_infra_secret_via_admin(tmp_path) -> None: @@ -422,7 +430,7 @@ async def test_bitwarden_import_parse_and_commit(admin_client) -> None: files={"file": ("bw.json", export, "application/json")}, headers=_auth(), ) - assert resp.status_code == 200 and "Site" in resp.text + assert resp.status_code == 200 commit = client.post( "/admin/secrets/import/commit", data={"selected": "Site", "username__Site": "u", "password__Site": "pw", "scope": "all"}, @@ -545,7 +553,8 @@ async def test_patch_config_preserves_vault_ref(tmp_path) -> None: async def test_llm_tab_collapses_vaulted_key(admin_client) -> None: client, _s, cs = admin_client await cs.set("agent.anthropic_api_key", "${vault:ANTHROPIC_API_KEY}") - body = client.get("/partials/llm", headers=_auth()).text + # Vaulted annotation is in the providers sub-tab + body = client.get("/partials/llm/providers", headers=_auth()).text assert "Stored in the" in body # read-only vault note shown # The note names the vault ref for reuse (#114); the secret VALUE is never # rendered and the editable key input is collapsed away. diff --git a/humux/tests/test_skills.py b/humux/tests/test_skills.py index bfa8962..47e9ffa 100644 --- a/humux/tests/test_skills.py +++ b/humux/tests/test_skills.py @@ -16,7 +16,7 @@ @pytest.mark.asyncio async def test_get_index_block_empty_db(tmp_path) -> None: db_path = str(tmp_path / "skills.db") - engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path) + engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path, installed_dir=tmp_path / "installed") assert await engine.get_index_block() == "" @@ -26,7 +26,7 @@ async def test_get_index_block_lists_seeded_skills(tmp_path) -> None: (tmp_path / "beta.md").write_text("Beta skill") db_path = str(tmp_path / "skills.db") - engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path) + engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path, installed_dir=tmp_path / "installed") index = await engine.get_index_block() assert 'Alpha skill' in index @@ -38,7 +38,7 @@ async def test_get_index_block_lists_seeded_skills(tmp_path) -> None: async def test_get_skill_content_reads_seeded_skill(tmp_path) -> None: (tmp_path / "memory.md").write_text("# Memory\n\nUse sqlite3.") db_path = str(tmp_path / "skills.db") - engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path) + engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path, installed_dir=tmp_path / "installed") content = await engine.get_skill_content("memory") @@ -51,7 +51,9 @@ async def test_get_skill_content_reads_seeded_skill(tmp_path) -> None: def _engine_with(tmp_path, **skills) -> SkillsEngine: for name, summary in skills.items(): (tmp_path / f"{name}.md").write_text(summary) - return SkillsEngine(db_path=str(tmp_path / "skills.db"), seed_dir=tmp_path) + return SkillsEngine( + db_path=str(tmp_path / "skills.db"), seed_dir=tmp_path, installed_dir=tmp_path / "installed" + ) @pytest.mark.asyncio