From 5e667b6545431ea37e269b61fc3fef3b3d6ab102 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:46:19 -0400 Subject: [PATCH 01/65] docs: defaults settings redesign mockups and implementation plan Blessed mockup: docs/mockups/defaults-settings-essentials.html (2026-10-14, 5.20.4). Co-Authored-By: Claude Opus 5.5 --- docs/mockups/defaults-settings-essentials.css | 309 ++++++++ .../mockups/defaults-settings-essentials.html | 47 ++ docs/mockups/defaults-settings-essentials.js | 439 +++++++++++ docs/mockups/defaults-settings-options.css | 295 ++++++++ docs/mockups/defaults-settings-options.html | 40 ++ docs/mockups/defaults-settings-options.js | 432 +++++++++++ docs/plans/defaults-settings-redesign-plan.md | 679 ++++++++++++++++++ 7 files changed, 2241 insertions(+) create mode 100644 docs/mockups/defaults-settings-essentials.css create mode 100644 docs/mockups/defaults-settings-essentials.html create mode 100644 docs/mockups/defaults-settings-essentials.js create mode 100644 docs/mockups/defaults-settings-options.css create mode 100644 docs/mockups/defaults-settings-options.html create mode 100644 docs/mockups/defaults-settings-options.js create mode 100644 docs/plans/defaults-settings-redesign-plan.md diff --git a/docs/mockups/defaults-settings-essentials.css b/docs/mockups/defaults-settings-essentials.css new file mode 100644 index 00000000..8d2169a2 --- /dev/null +++ b/docs/mockups/defaults-settings-essentials.css @@ -0,0 +1,309 @@ +/* Obsidian default-theme approximations. Setting-group tokens and rules mirror app.css (1.13+), + captured from the running app; values are approximated for the default theme. */ +:root { + --background-primary: #1e1e1e; + --background-primary-alt: #242424; + --background-secondary: #262626; + --background-secondary-alt: #2e2e2e; + --background-modifier-border: #363636; + --background-modifier-border-hover: #4a4a4a; + --background-modifier-hover: rgba(255, 255, 255, 0.075); + --background-modifier-form-field: #2a2a2a; + --background-modifier-error: #d04255; + --interactive-normal: #363636; + --interactive-hover: #3f3f3f; + --interactive-accent: #7f6df2; + --interactive-accent-hover: #8875ff; + --interactive-accent-rgb: 127, 109, 242; + --text-normal: #dadada; + --text-muted: #a3a3a3; + --text-faint: #7a7a7a; + --text-accent: #a68af9; + --text-on-accent: #ffffff; + --text-warning: #e9973f; + --text-success: #44cf6e; + --text-error: #fb464c; + --font-interface: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Ubuntu, sans-serif; + --font-monospace: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --font-ui-smaller: 12px; + --font-ui-small: 13px; + --font-ui-medium: 15px; + --radius-s: 4px; + --radius-m: 8px; + --radius-l: 12px; + --shadow-l: 0 14px 40px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.3); + --input-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09), 0 2px 3px rgba(0, 0, 0, 0.2); + --setting-items-background: #262626; + --setting-items-radius: 12px; + --setting-items-padding-x: 18px; + --setting-items-padding-y: 14px; + --setting-items-divider-color: #363636; + --setting-group-heading-size: 16px; + --setting-group-max-width: 700px; + --space-xxs: 2px; + --space-xs: 4px; + --space-s: 8px; + --space-m: 12px; + --space-l: 16px; + --space-xl: 24px; + --space-xxl: 32px; + --mock-backdrop: rgba(0, 0, 0, 0.55); + --mock-annotate: #3fb6c9; +} +body.theme-light { + --background-primary: #ffffff; + --background-primary-alt: #fafafa; + --background-secondary: #f6f6f6; + --background-secondary-alt: #ececec; + --background-modifier-border: #e0e0e0; + --background-modifier-border-hover: #c8c8c8; + --background-modifier-hover: rgba(0, 0, 0, 0.05); + --background-modifier-form-field: #ffffff; + --interactive-normal: #ffffff; + --interactive-hover: #f2f2f2; + --interactive-accent: #7852ee; + --interactive-accent-hover: #6d45e6; + --interactive-accent-rgb: 120, 82, 238; + --text-normal: #222222; + --text-muted: #5c5c5c; + --text-faint: #8a8a8a; + --text-accent: #6c3fe6; + --text-warning: #c46a00; + --text-success: #08a540; + --shadow-l: 0 14px 40px rgba(0, 0, 0, 0.14), 0 2px 8px rgba(0, 0, 0, 0.08); + --input-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.06); + --setting-items-background: #f4f4f4; + --setting-items-divider-color: #e3e3e3; + --mock-backdrop: rgba(0, 0, 0, 0.25); + --mock-annotate: #0b7f92; +} + +* { box-sizing: border-box; } +body { margin: 0; background: var(--background-secondary); color: var(--text-normal); font: 14px/1.5 var(--font-interface); } +button, select, input { font: inherit; color: inherit; } +button { cursor: pointer; } +:focus-visible { outline: 2px solid var(--interactive-accent); outline-offset: 2px; } +[hidden] { display: none !important; } +.nexus-icon { display: inline-grid; place-items: center; flex: 0 0 auto; } +svg { width: 16px; height: 16px; display: block; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } + +/* ---------- mock chrome ---------- */ +.mock-page { max-width: 1320px; margin: auto; padding: 48px var(--space-xxl) var(--space-xl); } +.mock-eyebrow { font: 11px var(--font-monospace); letter-spacing: .12em; color: var(--text-accent); } +.mock-intro h1 { font-size: 34px; line-height: 1.15; font-weight: 550; letter-spacing: -.03em; margin: var(--space-l) 0 var(--space-m); } +.mock-intro p { margin: 0; max-width: 760px; color: var(--text-muted); } +.mock-intro strong { color: var(--text-normal); font-weight: 550; } +.mock-toolbar { position: sticky; top: 0; z-index: 20; margin: var(--space-xl) 0; padding: var(--space-s) 0; background: var(--background-secondary); display: flex; gap: var(--space-s); align-items: center; flex-wrap: wrap; } +.mock-toolbar label { display: flex; align-items: center; gap: var(--space-s); color: var(--text-muted); font-size: 13px; } +.mock-toolbar > button, .mock-toolbar select, .mock-seg { border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary); padding: var(--space-s) var(--space-m); min-height: 40px; font-size: 13px; } +.mock-toolbar > button:hover, .mock-toolbar > button[aria-pressed="true"] { border-color: var(--interactive-accent); color: var(--text-accent); } +.mock-seg { display: flex; padding: 3px; gap: 2px; } +.mock-seg button { border: 0; background: transparent; padding: 0 var(--space-m); border-radius: 5px; color: var(--text-muted); font-size: 13px; } +.mock-seg button[aria-checked="true"] { background: var(--interactive-accent); color: var(--text-on-accent); } +.mock-toolbar-spacer { flex: 1; } +.mock-link { color: var(--text-muted); font-size: 13px; padding: 0 var(--space-s); } +.mock-link:hover { color: var(--text-accent); } +.mock-layout { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 40px; align-items: start; } +.mock-context { font-size: 13px; color: var(--text-muted); position: sticky; top: 72px; max-height: calc(100vh - 90px); overflow-y: auto; } +.mock-context h2 { font-size: 11px; font-weight: 600; color: var(--text-normal); text-transform: uppercase; letter-spacing: .06em; margin: 0 0 var(--space-s); } +.mock-context ul { padding-left: var(--space-l); margin: 0 0 var(--space-xl); } +.mock-context li { margin-bottom: var(--space-s); } +.mock-context strong { color: var(--text-normal); font-weight: 550; } +.mock-context code { font: 11px var(--font-monospace); color: var(--text-accent); } +.mock-p { margin: 0 0 var(--space-s); } +.mock-warn li::marker { color: var(--text-warning); } +.mock-caption { color: var(--text-faint); font-size: 12px; } +.mock-flag { display: inline-block; font: 10px var(--font-monospace); letter-spacing: .04em; color: var(--text-warning); border: 1px solid currentColor; border-radius: var(--radius-s); padding: 0 var(--space-xs); vertical-align: middle; white-space: nowrap; } +.mock-footer { margin-top: var(--space-xxl); padding-top: var(--space-l); border-top: 1px solid var(--background-modifier-border); font: 10px var(--font-monospace); color: var(--text-faint); display: flex; gap: var(--space-m); justify-content: space-between; } + +/* frames */ +.mock-settings-window { position: relative; height: 780px; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); overflow: hidden; background: var(--background-primary); box-shadow: var(--shadow-l); } +.vertical-tab-content { height: 100%; overflow-y: auto; padding: var(--space-xl) 40px var(--space-xxl); } +.mock-unchanged { max-width: var(--setting-group-max-width); margin: 0 auto var(--space-xl); display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-xs); padding-bottom: var(--space-m); border-bottom: 1px dashed var(--background-modifier-border); opacity: .55; font-size: 12px; } +.mock-unchanged span { padding: 3px var(--space-s); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); } +.mock-unchanged .is-active { border-color: var(--interactive-accent); color: var(--text-accent); } +.mock-unchanged .mock-unchanged-label { border: 0; font: 10px var(--font-monospace); text-transform: uppercase; letter-spacing: .08em; color: var(--text-faint); padding-left: 0; } +.mock-chat-window { position: relative; width: 600px; height: 780px; margin: 0 auto; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); overflow: hidden; background: var(--background-primary); box-shadow: var(--shadow-l); display: flex; align-items: center; justify-content: center; } +.mock-chat-behind { position: absolute; inset: 0; padding: var(--space-xl); display: flex; flex-direction: column; gap: var(--space-l); } +.mock-chat-behind div { height: 48px; border-radius: var(--radius-l); background: var(--background-secondary-alt); } +.mock-chat-behind div:nth-child(1) { width: 60%; margin-left: auto; } +.mock-chat-behind div:nth-child(2) { height: 120px; width: 90%; } +.mock-chat-behind::after { content: ""; position: absolute; inset: 0; background: var(--mock-backdrop); } +.mock-modal-layer { position: absolute; inset: 0; z-index: 5; background: var(--mock-backdrop); display: flex; align-items: center; justify-content: center; } + +/* ---------- Obsidian: Modal ---------- */ +.modal { position: relative; z-index: 1; width: calc(100% - 40px); max-width: 560px; max-height: calc(100% - 40px); display: flex; flex-direction: column; padding: var(--space-l) var(--space-l) var(--space-l); background: var(--background-primary); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); box-shadow: var(--shadow-l); } +.modal.mod-confirm { max-width: 440px; } +.modal-close-button { position: absolute; top: 12px; right: 12px; color: var(--text-faint); padding: var(--space-xs); border-radius: var(--radius-s); cursor: pointer; } +.modal-close-button:hover { background: var(--background-modifier-hover); color: var(--text-normal); } +.modal-title { font-size: 18px; font-weight: 600; margin: 0 0 var(--space-m); padding-right: 32px; } +.modal-content { overflow-y: auto; font-size: 14px; } +.modal-content p { margin: 0 0 var(--space-s); } +.modal-content ul { margin: 0 0 var(--space-s); padding-left: 20px; color: var(--text-muted); font-size: var(--font-ui-small); } +.modal-content li { margin-bottom: var(--space-xs); } +.modal-content .mod-muted { color: var(--text-muted); font-size: var(--font-ui-small); } +.chat-settings-renderer { margin: 0 calc(var(--space-l) * -1); padding: 0 var(--space-l); } +.modal-button-container { display: flex; justify-content: flex-end; gap: var(--space-s); margin-top: var(--space-l); } + +/* ---------- Obsidian: SettingGroup + Setting ---------- */ +.setting-group { width: 100%; max-width: var(--setting-group-max-width); margin-inline: auto; } +.setting-group + .setting-group { margin-top: var(--space-l); } +.modal .setting-group + .setting-group { margin-top: var(--space-m); } +.setting-group .setting-items { background-color: var(--setting-items-background); border-radius: var(--setting-items-radius); } +.setting-item { position: relative; display: flex; align-items: center; gap: var(--space-l); } +.setting-group .setting-item:not(.setting-item-heading) { padding: var(--setting-items-padding-y) var(--setting-items-padding-x); } +.setting-group .setting-item:not(.setting-item-heading)::before { content: ""; position: absolute; top: 0; left: var(--setting-items-padding-x); right: var(--setting-items-padding-x); border-top: 1px solid var(--setting-items-divider-color); } +.setting-group .setting-item:not(.setting-item-heading):first-child::before { content: none; } +.setting-item-heading { padding: 0 var(--space-l); margin-bottom: var(--space-s); } +.setting-item-heading .setting-item-name { font-size: var(--setting-group-heading-size); font-weight: 600; } +.setting-item-heading .setting-item-description { font-size: var(--font-ui-smaller); } +.setting-item-info { flex: 1 1 auto; min-width: 0; } +.setting-item-name { font-size: var(--font-ui-medium); line-height: 1.3; display: flex; align-items: center; gap: var(--space-s); } +.setting-item-description { color: var(--text-muted); font-size: var(--font-ui-smaller); padding-top: var(--space-xs); line-height: 1.35; } +.setting-item-description:empty { display: none; } +.setting-item-control { flex: 0 1 auto; display: flex; justify-content: flex-end; align-items: center; gap: var(--space-s); } +.setting-item-control:empty { display: none; } +.setting-item.is-disabled .setting-item-name { color: var(--text-muted); } +.setting-item-value { color: var(--text-muted); font-size: var(--font-ui-small); white-space: nowrap; } + +/* Accordion: the plugin's shared component, restyled. Closed = a quiet row; open = one grey card + holding the header and its settings. */ +.nexus-accordion { max-width: var(--setting-group-max-width); margin: var(--space-m) auto 0; border-radius: var(--setting-items-radius); } +.nexus-defaults > .setting-group + .nexus-accordion, .chat-settings-renderer > .setting-group + .nexus-accordion { margin-top: var(--space-l); } +.nexus-accordion.is-open { background: var(--setting-items-background); } +.nexus-accordion-toggle { display: flex; align-items: center; gap: var(--space-m); width: 100%; padding: var(--space-m) var(--setting-items-padding-x); border: 0; background: transparent; border-radius: var(--setting-items-radius); text-align: left; cursor: pointer; } +.nexus-accordion:not(.is-open) .nexus-accordion-toggle:hover { background: var(--background-modifier-hover); } +.nexus-accordion-icon { color: var(--text-accent); } +.nexus-accordion-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.nexus-accordion-title { font-size: var(--setting-group-heading-size); font-weight: 600; } +.nexus-accordion-summary { font-size: var(--font-ui-smaller); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.nexus-accordion-chevron { color: var(--text-faint); } +.nexus-accordion-content { max-width: none; } +.nexus-accordion-content .setting-items { background: transparent; border-radius: 0 0 var(--setting-items-radius) var(--setting-items-radius); padding-bottom: var(--space-xs); } +.nexus-accordion-content .setting-items > .setting-item:first-child::before { content: "" !important; } +.modal .nexus-accordion { margin-top: var(--space-s); } + +.mock-preview-label { max-width: var(--setting-group-max-width); margin: 0 auto var(--space-xs); padding-left: var(--setting-items-padding-x); font: 10px var(--font-monospace); text-transform: uppercase; letter-spacing: .08em; color: var(--text-faint); } +.mock-unchanged-after { margin-top: var(--space-l); } +.mock-preview-label + .nexus-accordion { margin-top: 0; outline: 1px dashed var(--background-modifier-border); } + +.nexus-subhead { padding-bottom: 0 !important; } +.nexus-subhead .setting-item-name { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); } +.nexus-subhead + .setting-item::before { content: none !important; } +.nexus-note-row { padding-top: 6px !important; padding-bottom: 6px !important; } +.nexus-note-row::before { content: none !important; } +.nexus-note-row .setting-item-name { font-size: var(--font-ui-small); color: var(--text-muted); padding-left: var(--space-m); } +.nexus-note-icon svg { width: 14px; height: 14px; color: var(--text-faint); } +.nexus-note .setting-item-name { font-size: var(--font-ui-small); color: var(--text-muted); } +.nexus-note { padding-bottom: 0 !important; } +.nexus-note + .setting-item::before { content: none !important; } +.nexus-mock-flag-row { padding: 0 var(--setting-items-padding-x) var(--setting-items-padding-y); font-size: 11px; color: var(--text-faint); } + +/* ---------- Obsidian: controls ---------- */ +select.dropdown { appearance: none; -webkit-appearance: none; height: 30px; padding: 0 28px 0 12px; border: 0; border-radius: var(--radius-s); background-color: var(--interactive-normal); box-shadow: var(--input-shadow); font-size: var(--font-ui-small); max-width: 260px; cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 15 5 5 5-5'/%3E%3Cpath d='m7 9 5-5 5 5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 10px center; } +select.dropdown:hover { background-color: var(--interactive-hover); } +select.dropdown:disabled { opacity: .5; cursor: default; } +select.dropdown option { background: var(--background-primary); color: var(--text-normal); } +.setting-item-control input[type="text"] { height: 30px; width: 170px; padding: 0 var(--space-s); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); background: var(--background-modifier-form-field); font-size: var(--font-ui-small); } +.setting-item-control button, .modal-button-container button { height: 30px; padding: 0 14px; border: 0; border-radius: var(--radius-s); background: var(--interactive-normal); box-shadow: var(--input-shadow); font-size: var(--font-ui-small); white-space: nowrap; } +.setting-item-control button:hover, .modal-button-container button:hover { background: var(--interactive-hover); } +button.mod-cta { background: var(--interactive-accent) !important; color: var(--text-on-accent); box-shadow: none; } +button.mod-warning { background: var(--background-modifier-error) !important; color: var(--text-on-accent); box-shadow: none; } +.clickable-icon { display: grid; place-items: center; padding: var(--space-xs); border-radius: var(--radius-s); color: var(--text-faint); cursor: pointer; min-width: 26px; min-height: 26px; } +.clickable-icon:hover { background: var(--background-modifier-hover); color: var(--text-normal); } +.clickable-icon[aria-disabled="true"] { opacity: .35; cursor: default; } +.clickable-icon[aria-disabled="true"]:hover { background: transparent; } +.checkbox-container { position: relative; display: inline-block; width: 42px; height: 24px; border-radius: 12px; background: var(--background-modifier-border-hover); cursor: pointer; transition: background 150ms ease; flex: 0 0 auto; } +.checkbox-container input { position: absolute; opacity: 0; pointer-events: none; } +.checkbox-container::after { content: ""; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: var(--text-on-accent); transition: transform 150ms ease; box-shadow: 0 1px 2px rgba(0, 0, 0, .3); } +.checkbox-container.is-enabled { background: var(--interactive-accent); } +.checkbox-container.is-enabled::after { transform: translateX(18px); } +.slider-value { font-size: var(--font-ui-small); color: var(--text-muted); min-width: 30px; text-align: right; } +input.slider { -webkit-appearance: none; appearance: none; width: 120px; height: 4px; border-radius: 2px; background: var(--background-modifier-border-hover); cursor: pointer; } +input.slider::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--text-on-accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .4); } +input.slider::-moz-range-thumb { width: 16px; height: 16px; border: 0; border-radius: 50%; background: var(--text-on-accent); } +input.slider:disabled { opacity: .35; cursor: default; } +.setting-progress-bar { width: 140px; height: 6px; border-radius: 3px; background: var(--background-modifier-border-hover); overflow: hidden; } +.setting-progress-bar-inner { height: 100%; background: var(--interactive-accent); } + +/* ---------- component labels (mock-only) ---------- */ +.mock-comp-tag { display: none; } +body.mock-annotate .mock-comp-tag { display: inline-block; font: 9px/1.4 var(--font-monospace); color: var(--background-primary); background: var(--mock-annotate); border-radius: 3px; padding: 0 4px; white-space: nowrap; } +body.mock-annotate .mock-comp-tag.is-group { position: absolute; top: -9px; right: 10px; z-index: 2; } +body.mock-annotate .nexus-accordion, body.mock-annotate .setting-group:not(.nexus-accordion-content) { position: relative; outline: 1px dashed var(--mock-annotate); outline-offset: 4px; border-radius: var(--setting-items-radius); } +body.mock-annotate .setting-item-control [data-component] { outline: 1px dashed var(--mock-annotate); outline-offset: 2px; } +body.mock-annotate .modal[data-component] { outline: 1px dashed var(--mock-annotate); outline-offset: 4px; } + +/* ---------- phone width ---------- */ +body.is-mobile .mock-stage .mock-settings-window, body.is-mobile .mock-stage .mock-chat-window { width: 390px; margin: 0 auto; } +body.is-mobile .mock-stage .vertical-tab-content { padding: var(--space-l) var(--space-m); } +body.is-mobile .mock-stage .mock-unchanged { display: none; } +body.is-mobile .mock-stage .chat-settings-modal { width: 100%; max-height: 100%; height: 100%; border-radius: 0; border: 0; } +body.is-mobile .mock-stage .setting-item { min-height: 52px; } +body.is-mobile .mock-stage select.dropdown { max-width: 160px; } +body.is-mobile .mock-stage .clickable-icon { min-width: 40px; min-height: 40px; } +body.is-mobile .mock-stage input.slider { width: 90px; } +body.is-mobile .mock-stage .setting-progress-bar { width: 70px; } +body.is-mobile .mock-stage .nexus-accordion-toggle { min-height: 52px; } + +@media (max-width: 960px) { + .mock-layout { display: flex; flex-direction: column-reverse; } + .mock-context { position: static; max-height: none; } +} +@media (max-width: 600px) { + .mock-page { padding: var(--space-xl) var(--space-m); } + .mock-stage .mock-chat-window, .mock-stage .mock-settings-window { width: 100%; } + .vertical-tab-content { padding: var(--space-l) var(--space-m); } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition: none !important; } +} +.nexus-ctx-value { min-width: 40px; } + +/* Get started: button row + pages */ +.nexus-get-started { margin-bottom: var(--space-l); } +.nexus-get-started .setting-item-name .nexus-icon { margin-right: 2px; } +.nexus-page-head { max-width: var(--setting-group-max-width); margin: 0 auto var(--space-m); display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-xs); } +.nexus-back-button { display: inline-flex; align-items: center; gap: var(--space-xs); border: 0; background: transparent; padding: 4px 8px 4px 4px; margin-left: -4px; font-size: var(--font-ui-small); color: var(--text-muted); } +.nexus-back-button svg { width: 16px; height: 16px; } +.nexus-page-title { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -.01em; } +.nexus-page-lede { max-width: var(--setting-group-max-width); margin: 0 auto var(--space-l); color: var(--text-muted); font-size: var(--font-ui-small); } +.nexus-can { max-width: var(--setting-group-max-width); margin: 0 auto var(--space-xl); padding: 0; list-style: none; display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-s) var(--space-xl); font-size: var(--font-ui-small); } +.nexus-can li { display: flex; align-items: center; gap: var(--space-s); } +.nexus-can .nexus-icon { color: var(--text-accent); } +.nexus-gs-heading { max-width: var(--setting-group-max-width); margin: 0 auto var(--space-s); flex-wrap: wrap; } +.setting-item.mod-navigable { cursor: pointer; } +.setting-item.mod-navigable:hover { background: var(--background-modifier-hover); } +.setting-item.mod-navigable:first-child:hover { border-radius: var(--setting-items-radius) var(--setting-items-radius) 0 0; } +.setting-item.mod-navigable:last-child:hover { border-radius: 0 0 var(--setting-items-radius) var(--setting-items-radius); } +.setting-item-chevron { display: flex; align-items: center; color: var(--text-faint); } +.nexus-status { display: inline-flex; align-items: center; gap: 4px; } +.nexus-status.is-ok { color: var(--text-success); } +.nexus-status svg { width: 14px; height: 14px; } +.nexus-step { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: rgba(var(--interactive-accent-rgb), .18); color: var(--text-accent); font-size: 12px; font-weight: 600; margin-right: 2px; } +.nexus-gs-cli { margin-top: var(--space-m); } +.nexus-child-row .setting-item-name { font-size: var(--font-ui-small); color: var(--text-muted); padding-left: var(--space-l); } +.nexus-child-row .setting-item-description { padding-left: var(--space-l); } +.nexus-path-row .setting-item-description { font-family: var(--font-monospace); font-size: 11px; } +.nexus-child-row::before { left: calc(var(--setting-items-padding-x) + var(--space-l)) !important; } +body.is-mobile .mock-stage .nexus-can { grid-template-columns: 1fr; } +.nexus-code { margin: var(--space-s) 0 0; padding: var(--space-s) var(--space-m); border-radius: var(--radius-s); background: var(--background-primary); color: var(--text-normal); font: 11px/1.5 var(--font-monospace); white-space: pre-wrap; overflow-wrap: anywhere; } +.nexus-prompt { font-family: var(--font-interface); font-size: var(--font-ui-smaller); } +.setting-item:has(.nexus-code) { align-items: flex-start; } +.setting-item:has(.nexus-code) .setting-item-control { padding-top: 2px; } +.setting-group + .nexus-gs-heading { margin-top: var(--space-xl); } +.nexus-gs-apps { margin-top: var(--space-m); } +.setting-item-description code { font: 11px var(--font-monospace); color: var(--text-normal); overflow-wrap: anywhere; } +.nexus-node-missing .setting-item-name { color: var(--text-warning); } +.setting-item-control button:disabled { opacity: .45; cursor: default; } +.nexus-other-row { cursor: pointer; } +.nexus-other-row:hover { background: var(--background-modifier-hover); } +.nexus-other-row:last-child:hover { border-radius: 0 0 var(--setting-items-radius) var(--setting-items-radius); } +.nexus-other-steps { margin: 0 var(--setting-items-padding-x) var(--space-s) calc(var(--setting-items-padding-x) + var(--space-s)); padding-left: var(--space-m); border-left: 2px solid var(--setting-items-divider-color); } +.nexus-other-steps .setting-item { padding-left: 0 !important; padding-right: 0 !important; } +.nexus-other-steps .setting-item::before { left: 0 !important; right: 0 !important; } +.nexus-other-steps .setting-item:first-child::before { content: none !important; } +.nexus-code { white-space: pre; overflow-x: auto; overflow-wrap: normal; } +.nexus-prompt { white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/docs/mockups/defaults-settings-essentials.html b/docs/mockups/defaults-settings-essentials.html new file mode 100644 index 00000000..56743015 --- /dev/null +++ b/docs/mockups/defaults-settings-essentials.html @@ -0,0 +1,47 @@ + + + + + + Nexus · Defaults and chat settings: essentials + folds + + + + +
+
+ NEXUS / DEFAULTS + CHAT SETTINGS · OPTION 2, ROUND 3 +

Essentials + folds, built from Obsidian parts

+

Every control is an Obsidian component, drawn the way Obsidian 1.13+ renders it. Turn on Label components to see which component each control is. The only custom code is the fold: a chevron button that hides a group’s card.

+
+ +
+
+ + +
+ + + + + +
+ All 10 options + + +
+ +
+ +
+
+ +
DRAFT · 14 OCT 2026 Proposed layout, not implemented. Values and index counts are sample data; nothing is saved.
+
+ + diff --git a/docs/mockups/defaults-settings-essentials.js b/docs/mockups/defaults-settings-essentials.js new file mode 100644 index 00000000..74587b84 --- /dev/null +++ b/docs/mockups/defaults-settings-essentials.js @@ -0,0 +1,439 @@ +// Standalone simulation only. No persistence, provider calls, or plugin integration. +// Markup mirrors what Obsidian 1.13+ renders for Setting / SettingGroup and the control components, +// captured from the running app. Model capabilities mirror the registry (supportsThinking, +// supportsSamplingParams, contextWindow). + +const paths = { + 'chevron-down': '', + 'chevron-up': '', + 'chevron-left': '', + 'chevron-right': '', + 'message-square': '', + plug: '', + 'pencil-line': '', + search: '', + folder: '', + 'wifi-off': '', + terminal: '', + 'circle-check': '', + rocket: '', + x: '', + 'rotate-ccw': '', + pause: '', + 'file-text': '', + 'sliders-horizontal': '', + layers: '', + bot: '', + image: '', + film: '', + mic: '', + 'file-input': '', + 'scan-search': '', +}; +const ic = (name, cls = '') => ``; + +/* ---------------- model registry sample ---------------- */ +const MODELS = { + Anthropic: [ + { name: 'Claude Opus 5.5', think: true, temp: false, ctx: 1000000 }, + { name: 'Claude Sonnet 5.5', think: true, temp: false, ctx: 1000000 }, + { name: 'Claude Haiku 5.5', think: true, temp: false, ctx: 1000000 }, + { name: 'Claude Opus 4.6', think: true, temp: true, ctx: 200000 }, + ], + 'Mistral AI': [ + { name: 'Mistral Large Latest', think: false, temp: true, ctx: 128000 }, + { name: 'Magistral Medium', think: true, temp: true, ctx: 40000 }, + ], +}; +const THINK = ['Off', 'Low', 'Medium', 'High', 'Extra high', 'Max']; +const WORKSPACES = ['None', 'Daily Notes & Workflow', 'On Networks', 'Personal Operations', 'House Weatherization', 'Job Search Command Center']; +const PROMPTS = ['None', 'meditations-on-alignment', 'job-application-materials']; + +const st = { + surface: 'defaults', + open: new Set(), + semantic: 'on', + connector: false, + other: false, + os: 'mac', + node: true, + page: null, // null | 'gs' | 'gs-chat' | 'gs-agents' + chat: { provider: 'Anthropic', model: 'Claude Opus 5.5', think: 'High', temp: 0.5, ctx: 1000000 }, + agent: { provider: 'Anthropic', model: 'Claude Haiku 5.5', think: 'Off', temp: 0.5, ctx: 1000000 }, + web: false, + workspace: { defaults: 'None', chat: 'On Networks' }, + workflow: 'None', + prompt: 'None', + pdf: true, + pdfMode: 'Text extraction', + pdfAuto: false, +}; +const spec = m => MODELS[m.provider].find(x => x.name === m.model); +const CTX_PRESETS = [32000, 64000, 128000, 200000, 350000, 750000, 1000000]; +// Presets up to the model's limit; the limit itself is always offered (covers small local models). +const ctxChoices = max => [...new Set([...CTX_PRESETS.filter(n => n < max), max])]; +const fmtCtx = n => n >= 1000000 ? `${n / 1000000}M` : `${Math.round(n / 1000)}K`; +const isChat = () => st.surface === 'chat'; + +/* ---------------- Obsidian component markup ---------------- */ +const opts = (arr, v) => arr.map(o => `${o}`).join(''); +const dropdown = (key, arr, v, disabled = false) => ``; +const toggle = (key, on) => ``; +const extraButton = (icon, label, attrs = '') => `
${ic(icon)}
`; +const button = (text, cls = '', attrs = '') => ``; +const slider = (key, value, label, min, max, step, { disabled = false } = {}) => + `${label}`; +function setting(name, desc, control, mods = '') { + return `
${name}
${desc}
${control}
`; +} +// Plain block: an Obsidian SettingGroup with no heading. +// Folds: the plugin's shared Accordion (src/components/Accordion.ts), restyled. The same component +// is used for Get started, so every accordion in Nexus looks and behaves the same. +function group(id, { icon, title, summary, body, collapsible = true }) { + if (!collapsible) return `
${body}
`; + const open = st.open.has(id); + return `
+ +
${body}
+
`; +} +const subhead = t => `
${t}
`; + +/* ---------------- model rows driven by capabilities ---------------- */ +function coreRows(key) { + const m = st[key]; + const s = spec(m); + return setting('Provider', '', dropdown(`${key}.provider`, Object.keys(MODELS), m.provider)) + + setting('Model', '', dropdown(`${key}.model`, MODELS[m.provider].map(x => x.name), m.model)) + + (s.think ? setting('Thinking', '', dropdown(`${key}.think`, THINK, m.think)) : ''); +} +function tuningRows(key) { + const m = st[key]; + const s = spec(m); + const ctx = Math.min(m.ctx, s.ctx); + const temp = s.temp + ? setting('Creativity', 'Lower is more focused, higher is more creative.', slider(`${key}.temp`, m.temp, m.temp.toFixed(1), 0, 1, 0.1)) + : setting('Creativity', `${m.model} sets its own temperature.`, slider(`${key}.temp`, 0.5, '—', 0, 1, 0.1, { disabled: true }), 'is-disabled'); + // SliderComponent over preset steps: setLimits(0, steps - 1, 1); the readout shows the preset size. + const steps = ctxChoices(s.ctx); + const idx = Math.max(0, steps.indexOf(ctx)); + return temp + setting('Context window', 'Older messages are summarized past this size.', + `${fmtCtx(ctx)}`); +} +const tuningSummary = key => { + const m = st[key]; const s = spec(m); + return [s.temp ? `Creativity ${m.temp.toFixed(1)}` : 'Fixed temperature', `${fmtCtx(Math.min(m.ctx, s.ctx))} context`]; +}; + +/* ---------------- semantic search states ---------------- */ +function semanticGroup() { + const on = st.semantic !== 'off'; + let body = setting('Semantic search', 'Find notes and past chats by meaning, not just exact words. Runs on this device.', toggle('semantic', on), 'mod-toggle'); + if (st.semantic === 'on' || st.semantic === 'confirm') { + body += setting('Index', 'Kept up to date as notes and chats change.', '
4,512 notes · 318 chats
'); + } else if (st.semantic === 'indexing') { + body += setting('Indexing', 'About 4 minutes left. You can keep working.', + `
1,904 / 4,512
${extraButton('pause', 'Pause indexing')}`); + } else { + body += setting('Index', 'Kept on this device, so turning search back on only re-indexes notes that changed.', '
Paused · 4,512 notes
') + + setting('Delete index', 'Frees the space it uses. Turning search back on re-indexes the whole vault.', button('Delete index', 'mod-warning')) + + `
NEEDS CODE There is no delete path today. Vectors stay in SQLite until “Rebuild cache”.
`; + } + const summary = { on: 'On · 4,512 notes', indexing: 'Indexing · 42%', confirm: 'On · 4,512 notes', off: 'Off · index kept' }[st.semantic]; + return group('semantic', { icon: 'scan-search', title: 'Semantic search', summary, body }); +} +function confirmModal() { + return `
`; +} + +const OS = { + mac: { node: '/opt/homebrew/bin/node', connector: '/Users/you/Documents/Vault/.obsidian/plugins/nexus/connector.js', version: 'v24.11.0' }, + win: { node: 'C:\\Program Files\\nodejs\\node.exe', connector: 'C:\\Users\\you\\Documents\\Vault\\.obsidian\\plugins\\nexus\\connector.js', version: 'v24.11.0' }, + linux: { node: '/usr/bin/node', connector: '/home/you/Vault/.obsidian/plugins/nexus/connector.js', version: 'v24.11.0' }, +}; +const esc = t => t.replace(/&/g, '&').replace(/ JSON.stringify({ mcpServers: { 'nexus-professor-synapse': { command: OS[st.os].node, args: [OS[st.os].connector] } } }, null, 2); + +/* ---------------- Get started pages (in place of the tab, with a back button) ---------------- */ +const backButton = (label, to) => ``; +const navRow = (icon, name, desc, to, value = '') => `
${ic(icon, 'nexus-accordion-icon')}${name}
${desc}
${value ? `
${value}
` : ''}
${ic('chevron-right')}
`; +const status = (ok, text) => `
${ok ? ic('circle-check') : ''}${text}
`; +const step = (n, name, desc, control) => setting(`${n}${name}`, desc, control); +function getStartedPage() { + const head = (back, backTo, title) => `
${backButton(back, backTo)}

${title}

`; + if (st.page === 'gs-chat') { + return head('Get started', 'gs', 'Chat inside Obsidian') + + `
` + + step(1, 'Connect a model provider', 'An API key, a sign-in, or a local model such as Ollama.', status(true, 'Connected') + button('Open providers')) + + step(2, 'Open the chat', 'From the ribbon icon or the “Nexus: Open chat” command.', button('Open chat', 'mod-cta')) + + step(3, 'Start chatting', 'Ask about your notes. Nexus can read, search and edit them.', '') + + `
`; + } + if (st.page === 'gs-agents') { + const o = OS[st.os]; + const need = st.node ? '' : ' disabled'; + const blocked = st.node ? '' : status(false, 'Needs Node.js'); + return head('Get started', 'gs', 'Connect an external agent') + + `

Let apps outside Obsidian use your vault through Nexus.

` + + `
` + + (st.node + ? setting('Node.js', `Runs the connector. Found ${o.version} at ${esc(o.node)}.`, status(true, 'Found')) + : setting('Node.js', 'Every agent connection runs through Node.js, and it isn’t installed or Nexus can’t find it. Install it, then check again.', button('Install Node.js', 'mod-cta') + button('Check again', '', 'data-node-check'), 'nexus-node-missing')) + + `
` + + `
` + + setting('Claude Desktop', '', st.node ? status(true, 'Connected') + button('Open config') : blocked) + + setting('Codex', '', (st.node ? status(false, 'Ready') : blocked) + button('Connect', 'mod-cta', need)) + + setting('Antigravity', '', (st.node ? status(false, 'Ready') : blocked) + button('Connect', 'mod-cta', need)) + + `
Other agents
Cursor, Cline, Copilot, Windsurf and others. Four steps.
${extraButton(st.other ? 'chevron-up' : 'chevron-down', st.other ? 'Hide steps' : 'Show steps')}
` + + (!st.other ? '' : `
` + + step(1, 'Create the connector file', 'connector.js lets the app talk to this vault. It goes in the Nexus plugin folder.', status(st.connector, st.connector ? 'Created' : '') + (st.connector ? '' : button('Create', 'mod-cta', 'data-connector' + need))) + + step(2, 'Copy the config', `The Nexus entry for the app’s MCP settings, with this computer’s paths.
${st.node ? esc(configJson()) : 'Install Node.js first. The config needs its location.'}
`, button('Copy config', '', need)) + + step(3, 'Add it to your app', `Paste it into the app’s MCP settings yourself, or paste this prompt into the agent and let it make the change.
${st.node ? esc(`Add an MCP server to your configuration. Name: nexus-professor-synapse. Command: ${o.node}. Args: ["${o.connector}"]. Use these paths exactly. Merge it into the existing mcpServers and keep any servers already there. Then tell me where you saved it.`) : 'Install Node.js first.'}
`, button('Copy prompt', '', need)) + + step(4, 'Restart the app', 'Quit and reopen it so it loads Nexus. Then ask it to list your vault’s folders to check.', '') + + `
`) + + `
` + + `
` + + setting(`${ic('terminal', 'nexus-accordion-icon')}Command line`, 'Lets Claude Code, Cursor and Codex use Nexus without MCP.', status(false, 'Not installed') + button('Install')) + + setting('Claude Code skills', '~/.claude/skills', toggle('x', true), 'mod-toggle nexus-child-row nexus-path-row') + + setting('Cursor skills', '~/.cursor/skills', toggle('x', false), 'mod-toggle nexus-child-row nexus-path-row') + + setting('Codex AGENTS.md', '~/.codex/AGENTS.md', toggle('x', true), 'mod-toggle nexus-child-row nexus-path-row') + + `
`; + } + const can = [['pencil-line', 'Read, create and edit notes'], ['search', 'Search by keyword or by meaning'], ['layers', 'Remember context with workspaces'], ['folder', 'Organize files and folders'], ['bot', 'Run saved prompts and subagents'], ['wifi-off', 'Work offline with local models']]; + return head('Settings', '', 'Get started') + + `

Nexus is an assistant that lives in your vault. It reads and writes your notes, searches them, and remembers context between conversations. Your data stays in your vault.

` + + `
    ${can.map(([i, t]) => `
  • ${ic(i)}${t}
  • `).join('')}
` + + `
Choose your setup
Pick one, or use both.
` + + `
` + + navRow('message-square', 'Chat inside Obsidian', 'Connect a model provider and chat in a side panel.', 'gs-chat', 'Ready') + + navRow('plug', 'Connect an external agent', 'Claude Desktop, Codex, Cursor and more use your vault.', 'gs-agents', '2 connected') + + `
`; +} + +/* ---------------- the surface ---------------- */ +function body() { + const chat = isChat(); + const ws = st.workspace[st.surface]; + const notes = chat ? ['Episode 05 outline', 'Vault Map of Content', 'AGENTS'] : ['Vault Map of Content', 'AGENTS']; + let h = group('main', { body: coreRows('chat'), collapsible: false }); + + h += group('chat-options', { + icon: 'sliders-horizontal', title: 'Chat options', + summary: [...tuningSummary('chat'), ...(chat ? [`Web search ${st.web ? 'on' : 'off'}`] : [])].join(' · '), + body: tuningRows('chat') + (chat ? setting('Web search', 'Let the model search the web when needed.', toggle('web', st.web), 'mod-toggle') : ''), + }); + + h += group('context', { + icon: 'layers', title: 'Context', + summary: [ws === 'None' ? 'No workspace' : ws, st.prompt === 'None' ? 'No prompt' : st.prompt, `${notes.length} notes`].join(' · '), + body: setting('Workspace', '', dropdown('workspace', WORKSPACES, ws)) + + (chat ? setting('Workflow', 'Loads its instructions and tools. Steps run only when you ask.', dropdown('workflow', ['None', 'Episode prep'], st.workflow)) : '') + + setting('Prompt', '', dropdown('prompt', PROMPTS, st.prompt)) + + setting('Context notes', '', button('Add note')) + + notes.map(n => setting(`${ic('file-text', 'nexus-note-icon')}${n}`, '', extraButton('x', `Remove ${n}`), 'nexus-note-row')).join(''), + }); + + const a = st.agent; const as = spec(a); + h += group('subagents', { + icon: 'bot', title: 'Subagents', + summary: [a.model, as.think ? (a.think === 'Off' ? 'Thinking off' : `${a.think} thinking`) : null, ...tuningSummary('agent')].filter(Boolean).join(' · '), + body: setting('Used for', 'Subagents, saved prompts and automations. Must be a cloud model.', '', 'nexus-note') + + coreRows('agent') + tuningRows('agent'), + }); + + h += group('images', { icon: 'image', title: 'Images', summary: 'Gemini 3.1 Flash Image', + body: setting('Provider', '', dropdown('x', ['Google AI', 'OpenAI', 'OpenRouter'], 'Google AI')) + setting('Model', '', dropdown('x', ['Gemini 3.1 Flash Image'], 'Gemini 3.1 Flash Image')) }); + + if (!chat) { + h += group('video', { icon: 'film', title: 'Video', summary: 'Veo 3.1 · 16:9 · 720p', + body: setting('Provider', '', dropdown('x', ['Google AI'], 'Google AI')) + setting('Model', '', dropdown('x', ['Veo 3.1'], 'Veo 3.1')) + + setting('Aspect ratio', '', dropdown('x', ['16:9', '9:16'], '16:9')) + setting('Resolution', '', dropdown('x', ['720p', '1080p'], '720p')) }); + } + + h += group('voice', { icon: 'mic', title: 'Voice', summary: 'Deepgram Nova-3 · HeyGen Voice (Alloy)', + body: subhead('Transcription') + + setting('Provider', 'Chat microphone and audio files.', dropdown('x', ['OpenRouter', 'Deepgram', 'AssemblyAI'], 'OpenRouter')) + setting('Model', '', dropdown('x', ['Deepgram Nova-3'], 'Deepgram Nova-3')) + + subhead('Read aloud') + + setting('Provider', '', dropdown('x', ['OpenRouter'], 'OpenRouter')) + setting('Model', '', dropdown('x', ['HeyGen Voice'], 'HeyGen Voice')) + setting('Voice', '', dropdown('x', ['Provider default', 'Alloy', 'Verse'], 'Alloy')) + + (chat ? '' : setting('Saved audio folder', 'Inside the Nexus data folder.', '')) + + subhead('Live voice') + + setting('Provider', 'None of your providers offer live voice.', dropdown('x', ['None available'], 'None available', true), 'is-disabled') + + setting('Model', '', dropdown('x', ['—'], '—', true), 'is-disabled') + setting('Output', '', dropdown('x', ['Provider default'], 'Provider default', true), 'is-disabled') }); + + if (!chat) { + const vision = st.pdfMode === 'Vision'; + h += group('file-conversion', { icon: 'file-input', title: 'File conversion', + summary: st.pdf ? `PDF import on · ${st.pdfMode}${st.pdfAuto ? ' · auto-convert' : ''}` : 'PDF import off', + body: setting('PDF import', 'Drop PDFs into chat to turn them into notes.', toggle('pdf', st.pdf), 'mod-toggle') + + (st.pdf ? setting('Reading method', 'Text is free. Vision reads scanned pages with a model.', dropdown('pdfMode', ['Text extraction', 'Vision'], st.pdfMode)) + + (vision ? setting('Vision provider', '', dropdown('x', ['Google AI', 'OpenRouter'], 'Google AI')) + setting('Vision model', '', dropdown('x', ['Gemini 3 Flash'], 'Gemini 3 Flash')) : '') + + setting('Convert new PDFs automatically', 'Adds a Markdown note next to each PDF added to the vault.', toggle('pdfAuto', st.pdfAuto), 'mod-toggle') : '') }); + h += semanticGroup(); + } + return h; +} + +function frame(inner) { + if (!isChat()) { + return `
+ ${st.page ? getStartedPage() : `
Get started: replaces the accordion above the tabs
+
${navRow('rocket', 'Get started', 'Set up chat in Obsidian, or connect Claude Desktop, Codex and other agents.', 'gs')}
+ +
${inner}
`}
${st.semantic === 'confirm' ? confirmModal() : ''}
`; + } + return `
+
`; +} + +/* ---------------- side notes ---------------- */ +function context() { + const common = `

Built from Obsidian parts

    +
  • SettingGroup for every block: a rounded card with its heading outside, like Obsidian’s own Appearance tab.
  • +
  • Setting rows with DropdownComponent, ToggleComponent, SliderComponent, TextComponent, ButtonComponent and ExtraButtonComponent for reset and remove.
  • +
  • Context window is a SliderComponent that snaps to set sizes: 32K, 64K, 128K, 200K, 350K, 750K, 1M. Sizes above the model’s limit are left out, and the model’s own limit is always the last stop. Small local models (LM Studio, WebLLM), which can be under 32K, get a single stop and the slider is disabled.
  • +
  • Folds use the plugin’s existing Accordion (src/components/Accordion.ts), restyled, so every accordion in Nexus matches.
  • +
  • Node.js comes first on the external agent page. It uses the existing lookup (which, or where on Windows). If Node is missing, every Connect, Create and Copy is disabled with “Needs Node.js”. Today the copyable config falls back to a bare node, which often fails on macOS because apps opened from the Dock don’t see the terminal’s PATH.
  • +
  • Config, prompt and paths follow the OS. They use the absolute Node and connector paths for this computer, written with JSON.stringify so Windows backslashes are escaped. The copied prompt says “use these paths exactly”. Use the OS and Node.js switches above to check each case.
  • +
  • Get started is one row above the tabs; the whole row opens a page in place of the tab, with the plugin’s existing BackButton: intro → choose a setup → Chat inside Obsidian or Connect an external agent. Each step back goes up one level.
  • +
  • Chat settings stays an Obsidian Modal with the standard close button and Cancel / Save.
  • +
`; + const semantic = isChat() ? '' : `

Turning off semantic search

+

It’s on by default and almost everyone has it on, so turning it off should be deliberate and easy to undo.

+
    +
  • Ask first. A confirmation modal explains what gets worse. Switch the state above to see it.
  • +
  • Keep the index. Turning it back on only re-indexes notes that changed, which is how re-enabling works today.
  • +
  • Deleting the index is a separate, explicit button. NEEDS CODE
  • +
  • Apply right away. Stop the watchers and unload the model without a restart. NEEDS CODE Today it only takes effect after restarting Obsidian.
  • +
  • Show index status using getStats() and the indexing queue’s progress, which already exist but no screen uses.
  • +
+

Problems this surfaced

+
    +
  • When it’s off, search tools still offer semantic search to agents, and the error they get back says it is “still initializing”.
  • +
  • Index entries for notes deleted while it was off are never removed.
  • +
  • Five places look up an embeddingService that nothing registers, so searching past chats and semantic note suggestions may never work, even when it’s on. Worth checking before anything else.
  • +
`; + document.getElementById('mock-context').innerHTML = common + semantic + + '

Model capabilities come from the registry: Opus, Sonnet and Haiku 5.5 reject temperature, Opus 4.6 accepts it, Mistral Large can’t reason, and Magistral Medium has a 40K context.

'; +} + +/* ---------------- wiring ---------------- */ +const stage = document.getElementById('mock-stage'); +function render() { + const scroller = stage.querySelector('.vertical-tab-content, .modal-content'); + const top = scroller ? scroller.scrollTop : 0; + stage.innerHTML = frame(body()); + // Mock-only labels naming the Obsidian component behind each control (shown with "Label components"). + stage.querySelectorAll('[data-component]').forEach(el => { + const name = el.dataset.component; + if (name === 'Setting') return; + const tag = document.createElement('span'); + tag.className = 'mock-comp-tag'; + tag.textContent = name; + if (name === 'SettingGroup' || name === 'Modal' || name === 'Accordion') { tag.classList.add('is-group'); el.prepend(tag); } else el.before(tag); + }); + const next = stage.querySelector('.vertical-tab-content, .chat-settings-renderer'); + if (next) next.scrollTop = top; + document.querySelectorAll('[data-surface]').forEach(b => b.setAttribute('aria-checked', String(b.dataset.surface === st.surface))); + document.getElementById('mock-semantic-wrap').hidden = isChat(); + document.getElementById('mock-semantic').value = st.semantic; + document.getElementById('mock-os').value = st.os; + document.getElementById('mock-node').value = st.node ? 'found' : 'missing'; + context(); +} + +stage.addEventListener('change', e => { + const key = e.target.dataset.key; + if (!key || key === 'x') return; + const [a, b] = key.split('.'); + if (b) { + st[a][b] = e.target.value; + if (b === 'provider' || b === 'model') { if (b === 'provider') st[a].model = MODELS[st[a].provider][0].name; st[a].ctx = spec(st[a]).ctx; } + } else if (a === 'workspace') st.workspace[st.surface] = e.target.value; + else st[a] = e.target.value; + render(); +}); +stage.addEventListener('input', e => { + const c = e.target.dataset.ctx; + if (c) { + st[c].ctx = ctxChoices(spec(st[c]).ctx)[Number(e.target.value)]; + e.target.closest('.setting-item-control').querySelector('.slider-value').textContent = fmtCtx(st[c].ctx); + e.target.setAttribute('aria-valuetext', `${fmtCtx(st[c].ctx)} tokens`); + return; + } + const r = e.target.dataset.range; + if (!r) return; + const [a, b] = r.split('.'); + st[a].temp = Number(e.target.value); + e.target.closest('.setting-item-control').querySelector('.slider-value').textContent = st[a].temp.toFixed(1); +}); +stage.addEventListener('click', e => { + const fold = e.target.closest('[data-fold]'); + if (fold) { const id = fold.dataset.fold; st.open.has(id) ? st.open.delete(id) : st.open.add(id); render(); return; } + if (e.target.closest('[data-other]')) { st.other = !st.other; render(); return; } + if (e.target.closest('[data-node-check]')) { st.node = true; render(); return; } + if (e.target.closest('[data-connector]')) { st.connector = true; render(); return; } + const pg = e.target.closest('[data-page]'); + if (pg) { st.page = pg.dataset.page || null; stage.innerHTML = ''; render(); return; } + const set = e.target.closest('[data-semantic-set]'); + if (set) { st.semantic = set.dataset.semanticSet; render(); return; } + const t = e.target.closest('[data-toggle]'); + if (!t) return; + e.preventDefault(); + const key = t.dataset.toggle; + if (key === 'x') { t.classList.toggle('is-enabled'); return; } + if (key === 'semantic') st.semantic = st.semantic === 'off' ? 'indexing' : 'confirm'; + else st[key] = !st[key]; + render(); +}); +document.addEventListener('keydown', e => { + if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('.checkbox-container, [role="button"]')) { e.preventDefault(); e.target.click(); } + if (e.key === 'Escape' && st.semantic === 'confirm') { st.semantic = 'on'; render(); } +}); +document.querySelectorAll('[data-surface]').forEach(b => b.addEventListener('click', () => { st.surface = b.dataset.surface; stage.innerHTML = ''; render(); })); +document.getElementById('mock-os').addEventListener('change', e => { st.os = e.target.value; st.page = 'gs-agents'; render(); }); +document.getElementById('mock-node').addEventListener('change', e => { st.node = e.target.value === 'found'; st.page = 'gs-agents'; render(); }); +document.getElementById('mock-semantic').addEventListener('change', e => { st.semantic = e.target.value; st.open.add('semantic'); render(); }); +document.getElementById('mock-expand').addEventListener('click', e => { + const all = ['chat-options', 'context', 'subagents', 'images', 'video', 'voice', 'file-conversion', 'semantic']; + const opening = st.open.size < 3; + st.open = opening ? new Set(all) : new Set(); + e.currentTarget.textContent = opening ? 'Close all folds' : 'Open all folds'; + render(); +}); +document.getElementById('mock-annotate').addEventListener('click', e => { + const on = document.body.classList.toggle('mock-annotate'); + e.currentTarget.setAttribute('aria-pressed', String(on)); +}); +document.getElementById('mock-theme').addEventListener('click', e => { + const light = document.body.classList.toggle('theme-light'); + document.body.classList.toggle('theme-dark', !light); + e.currentTarget.setAttribute('aria-pressed', String(light)); +}); +document.getElementById('mock-phone').addEventListener('click', e => { + const on = document.body.classList.toggle('is-mobile'); + e.currentTarget.setAttribute('aria-pressed', String(on)); +}); + +render(); diff --git a/docs/mockups/defaults-settings-options.css b/docs/mockups/defaults-settings-options.css new file mode 100644 index 00000000..0b57bac0 --- /dev/null +++ b/docs/mockups/defaults-settings-options.css @@ -0,0 +1,295 @@ +/* Obsidian default-theme approximations (inherited tokens) + Nexus spacing scale. */ +:root { + --background-primary: #1e1e1e; + --background-primary-alt: #242424; + --background-secondary: #262626; + --background-secondary-alt: #2e2e2e; + --background-modifier-border: #363636; + --background-modifier-border-hover: #4a4a4a; + --background-modifier-hover: rgba(255, 255, 255, 0.075); + --background-modifier-form-field: #2a2a2a; + --interactive-normal: #2f2f2f; + --interactive-hover: #3a3a3a; + --interactive-accent: #7f6df2; + --interactive-accent-hover: #8875ff; + --interactive-accent-rgb: 127, 109, 242; + --text-normal: #dadada; + --text-muted: #a3a3a3; + --text-faint: #7a7a7a; + --text-accent: #a68af9; + --text-on-accent: #ffffff; + --text-warning: #e9973f; + --font-interface: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Ubuntu, sans-serif; + --font-monospace: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --font-ui-smaller: 12px; + --font-ui-small: 13px; + --font-ui-medium: 15px; + --radius-s: 4px; + --radius-m: 8px; + --radius-l: 12px; + --shadow-l: 0 14px 40px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.3); + --space-xxs: 2px; + --space-xs: 4px; + --space-s: 8px; + --space-m: 12px; + --space-l: 16px; + --space-xl: 24px; + --space-xxl: 32px; + --mock-backdrop: rgba(0, 0, 0, 0.55); +} +body.theme-light { + --background-primary: #ffffff; + --background-primary-alt: #fafafa; + --background-secondary: #f6f6f6; + --background-secondary-alt: #ececec; + --background-modifier-border: #e0e0e0; + --background-modifier-border-hover: #c8c8c8; + --background-modifier-hover: rgba(0, 0, 0, 0.05); + --background-modifier-form-field: #ffffff; + --interactive-normal: #ffffff; + --interactive-hover: #f0f0f0; + --interactive-accent: #7852ee; + --interactive-accent-hover: #6d45e6; + --interactive-accent-rgb: 120, 82, 238; + --text-normal: #222222; + --text-muted: #5c5c5c; + --text-faint: #8a8a8a; + --text-accent: #6c3fe6; + --text-warning: #c46a00; + --shadow-l: 0 14px 40px rgba(0, 0, 0, 0.14), 0 2px 8px rgba(0, 0, 0, 0.08); + --mock-backdrop: rgba(0, 0, 0, 0.25); +} + +* { box-sizing: border-box; } +body { margin: 0; background: var(--background-secondary); color: var(--text-normal); font: 14px/1.5 var(--font-interface); } +button, select, input { font: inherit; color: inherit; } +button { cursor: pointer; } +:focus-visible { outline: 2px solid var(--interactive-accent); outline-offset: 2px; } +.nexus-icon { display: inline-grid; place-items: center; flex: 0 0 auto; } +svg { width: 16px; height: 16px; display: block; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +[hidden] { display: none !important; } + +/* ---------- mock chrome ---------- */ +.mock-page { max-width: 1320px; margin: auto; padding: 48px var(--space-xxl) var(--space-xl); } +.mock-eyebrow { font: 11px var(--font-monospace); letter-spacing: .12em; color: var(--text-accent); } +.mock-intro h1 { font-size: 34px; line-height: 1.15; font-weight: 550; letter-spacing: -.03em; margin: var(--space-l) 0 var(--space-m); } +.mock-intro p { margin: 0; max-width: 720px; color: var(--text-muted); } +.mock-intro strong { color: var(--text-normal); font-weight: 550; } +.mock-toolbar { position: sticky; top: 0; z-index: 10; margin: var(--space-xl) 0; padding: var(--space-s) 0; background: var(--background-secondary); display: flex; gap: var(--space-s); align-items: center; flex-wrap: wrap; } +.mock-toolbar label { display: flex; align-items: center; gap: var(--space-s); color: var(--text-muted); font-size: 13px; } +.mock-toolbar > button, .mock-toolbar select, .mock-seg { border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary); padding: var(--space-s) var(--space-m); min-height: 40px; font-size: 13px; } +.mock-toolbar > button:hover, .mock-toolbar > button[aria-pressed="true"] { border-color: var(--interactive-accent); color: var(--text-accent); } +.mock-seg { display: flex; padding: 3px; gap: 2px; } +.mock-seg button { border: 0; background: transparent; padding: 0 var(--space-m); border-radius: 5px; color: var(--text-muted); font-size: 13px; } +.mock-seg button[aria-checked="true"] { background: var(--interactive-accent); color: var(--text-on-accent); } +.mock-toolbar-spacer, .mock-spacer { flex: 1; } + +.mock-overview { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: var(--space-l); } +.mock-thumb { cursor: pointer; display: flex; flex-direction: column; padding: 0; text-align: left; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); background: var(--background-primary); overflow: hidden; } +.mock-thumb:hover { border-color: var(--interactive-accent); } +.mock-thumb-view { height: 300px; overflow: hidden; position: relative; pointer-events: none; border-bottom: 1px solid var(--background-modifier-border); } +.mock-thumb-inner { width: 820px; transform: scale(.36); transform-origin: 0 0; position: absolute; left: 0; top: 0; } +.mock-thumb-inner .mock-settings-window, .mock-thumb-inner .mock-chat-window { height: 830px; box-shadow: none; border: 0; border-radius: 0; } +.mock-thumb-inner .mock-chat-window { width: 820px; } +.mock-thumb-meta { display: grid; grid-template-columns: auto 1fr; column-gap: var(--space-s); padding: var(--space-m) var(--space-l); } +.mock-thumb-meta strong { font-weight: 600; } +.mock-thumb-meta span:last-child { grid-column: 2; color: var(--text-muted); font-size: 12px; } +.mock-thumb-num { grid-row: span 2; width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: rgba(var(--interactive-accent-rgb), .18); color: var(--text-accent); font: 600 12px var(--font-monospace); } + +.mock-layout { display: grid; grid-template-columns: 290px minmax(0, 1fr); gap: 40px; align-items: start; } +.mock-context { font-size: 13px; color: var(--text-muted); position: sticky; top: 72px; } +.mock-variant-num { font: 11px var(--font-monospace); letter-spacing: .08em; color: var(--text-faint); text-transform: uppercase; } +.mock-context h2 { font-size: 22px; font-weight: 600; color: var(--text-normal); margin: var(--space-xs) 0 var(--space-xxs); letter-spacing: -.02em; } +.mock-variant-tag { display: inline-block; font-size: 11px; color: var(--text-accent); margin-bottom: var(--space-m); } +.mock-context h3 { font-size: 11px; font-weight: 600; color: var(--text-normal); margin: var(--space-l) 0 var(--space-xs); text-transform: uppercase; letter-spacing: .06em; } +.mock-context p { margin: 0; } +.mock-context ul { padding-left: var(--space-l); margin: 0; } +.mock-context li { margin-bottom: var(--space-xs); } +.mock-build { font-size: 12px; color: var(--text-faint); } +.mock-nav { display: flex; gap: var(--space-s); margin-top: var(--space-xl); } +.mock-nav button { flex: 1; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary); padding: var(--space-s); font-size: 12px; } +.mock-nav button:hover { border-color: var(--interactive-accent); color: var(--text-accent); } +.mock-footer { margin-top: var(--space-xxl); padding-top: var(--space-l); border-top: 1px solid var(--background-modifier-border); font: 10px var(--font-monospace); color: var(--text-faint); display: flex; gap: var(--space-m); justify-content: space-between; } + +/* Defaults frame: Obsidian settings pane, with today's tab strip shown as unchanged context */ +.mock-settings-window { height: 760px; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); overflow: hidden; background: var(--background-primary); box-shadow: var(--shadow-l); } +.vertical-tab-content { height: 100%; overflow-y: auto; padding: var(--space-xl) 40px var(--space-xxl); } +.mock-unchanged { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-xs); padding-bottom: var(--space-m); margin-bottom: var(--space-s); border-bottom: 1px dashed var(--background-modifier-border); opacity: .55; font-size: 12px; } +.mock-unchanged span { padding: 3px var(--space-s); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); } +.mock-unchanged .is-active { border-color: var(--interactive-accent); color: var(--text-accent); } +.mock-unchanged .mock-unchanged-label { border: 0; font: 10px var(--font-monospace); text-transform: uppercase; letter-spacing: .08em; color: var(--text-faint); padding-left: 0; } + +/* Chat frame: ChatSettingsModal over a dimmed chat view */ +.mock-chat-window { position: relative; width: 560px; height: 760px; margin: 0 auto; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); overflow: hidden; background: var(--background-primary); box-shadow: var(--shadow-l); display: flex; align-items: center; justify-content: center; } +.mock-chat-behind { position: absolute; inset: 0; padding: var(--space-xl); display: flex; flex-direction: column; gap: var(--space-l); } +.mock-chat-behind div { height: 48px; border-radius: var(--radius-l); background: var(--background-secondary-alt); } +.mock-chat-behind div:nth-child(1) { width: 60%; margin-left: auto; } +.mock-chat-behind div:nth-child(2) { height: 120px; width: 90%; } +.mock-chat-behind::after { content: ""; position: absolute; inset: 0; background: var(--mock-backdrop); } +.modal { position: relative; z-index: 1; width: calc(100% - 40px); max-height: calc(100% - 40px); display: flex; flex-direction: column; background: var(--background-primary); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); box-shadow: var(--shadow-l); } +.modal-header { display: flex; align-items: center; padding: var(--space-l) var(--space-l) var(--space-xs); } +.modal-title { flex: 1; font-size: 17px; font-weight: 600; } +.modal-content { overflow-y: auto; padding: 0 var(--space-l) var(--space-s); } +.modal-content .setting-item-name { font-size: var(--font-ui-small); } +.modal-content select.dropdown { max-width: 190px; } +.modal-button-container { display: flex; gap: var(--space-s); padding: var(--space-m) var(--space-l); border-top: 1px solid var(--background-modifier-border); } + +/* ---------- Obsidian primitives ---------- */ +.setting-item { display: flex; align-items: center; gap: var(--space-l); padding: 0.7em 0; border-top: 1px solid var(--background-modifier-border); } +.setting-item-heading { border-top: none; padding-top: 1.6em; padding-bottom: var(--space-xs); } +.setting-item-heading + .setting-item, .nexus-fold-body > .setting-item:first-child, .nexus-side-pane > .setting-item-heading:first-child { border-top: none; } +.nexus-side-pane > .setting-item-heading:first-child, .nexus-tile-panel > .setting-item-heading:first-child { padding-top: 0; } +.setting-item-heading .setting-item-name { font-size: var(--font-ui-medium); font-weight: 600; } +.setting-item-info { flex: 1 1 auto; min-width: 0; } +.setting-item-name { color: var(--text-normal); font-size: var(--font-ui-medium); display: flex; align-items: center; gap: var(--space-s); } +.setting-item-description { color: var(--text-muted); font-size: var(--font-ui-smaller); padding-top: var(--space-xxs); line-height: 1.4; } +.setting-item-control { flex: 0 0 auto; display: flex; align-items: center; justify-content: flex-end; gap: var(--space-s); } +.setting-item.is-disabled { opacity: .55; } +.setting-item.nexus-child { margin-left: var(--space-l); padding-left: var(--space-m); border-left: 2px solid var(--background-modifier-border); } +.nexus-note-item { padding: 0.35em 0 0.35em var(--space-m); } +.nexus-note-item .setting-item-name { font-size: var(--font-ui-small); color: var(--text-muted); } +.setting-item-control button:not(.clickable-icon), .modal-button-container button, .nexus-model-button { background: var(--interactive-normal); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); padding: 4px 12px; font-size: var(--font-ui-small); box-shadow: 0 1px 1px rgba(0, 0, 0, .1); } +button.mod-cta { background: var(--interactive-accent) !important; border-color: transparent !important; color: var(--text-on-accent); } +button.mod-muted { background: transparent !important; border-color: transparent !important; box-shadow: none !important; color: var(--text-muted); } +.clickable-icon { display: grid; place-items: center; background: transparent; border: 0; padding: var(--space-xs); border-radius: var(--radius-s); color: var(--text-muted); min-width: 28px; min-height: 28px; } +.clickable-icon:hover { background: var(--background-modifier-hover); color: var(--text-normal); } +select.dropdown, .setting-item-control input[type="text"] { background: var(--interactive-normal); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); padding: 4px var(--space-s); font-size: var(--font-ui-small); max-width: 230px; } +.setting-item-control input[type="text"] { background: var(--background-modifier-form-field); width: 160px; } +.checkbox-container { width: 40px; height: 22px; border-radius: 11px; background: var(--background-modifier-border-hover); position: relative; cursor: pointer; transition: background 150ms ease; flex: 0 0 auto; } +.checkbox-container::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: var(--text-on-accent); transition: transform 150ms ease; box-shadow: 0 1px 2px rgba(0, 0, 0, .3); } +.checkbox-container.is-enabled { background: var(--interactive-accent); } +.checkbox-container.is-enabled::after { transform: translateX(18px); } +input.slider { width: 130px; accent-color: var(--interactive-accent); } +.nexus-model-button { display: inline-flex; align-items: center; gap: var(--space-s); } +.nexus-model-button svg { width: 13px; height: 13px; color: var(--text-faint); } +.nexus-model-button:disabled { opacity: .6; cursor: default; } +.nexus-slider-value { font: var(--font-ui-smaller) var(--font-monospace); color: var(--text-muted); min-width: 70px; text-align: right; } +.nexus-defaults-lede { color: var(--text-muted); font-size: var(--font-ui-small); margin: var(--space-xs) 0 0; } +.nexus-empty { padding: var(--space-l); text-align: center; color: var(--text-faint); font-size: var(--font-ui-small); } + +/* 2 + 6: folds */ +.nexus-fold { border-top: 1px solid var(--background-modifier-border); } +.nexus-fold summary { list-style: none; display: flex; align-items: center; gap: var(--space-s); padding: var(--space-m) 0; cursor: pointer; } +.nexus-fold summary::-webkit-details-marker { display: none; } +.nexus-fold summary .nexus-icon { color: var(--text-faint); transition: transform 150ms ease; } +.nexus-fold[open] summary .nexus-icon { transform: rotate(90deg); } +.nexus-fold-name { font-size: var(--font-ui-medium); font-weight: 550; flex: 0 0 auto; } +.nexus-fold-summary { flex: 1; min-width: 0; text-align: right; color: var(--text-faint); font-size: var(--font-ui-smaller); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.nexus-fold-body { padding: 0 0 var(--space-s) 24px; } +.nexus-fold-body .setting-item-heading { padding-top: var(--space-m); } + +/* 3: drill-in */ +.nexus-nav-row { cursor: pointer; } +.nexus-nav-row:hover { background: var(--background-modifier-hover); box-shadow: -8px 0 0 var(--background-modifier-hover), 8px 0 0 var(--background-modifier-hover); } +.nexus-nav-row .setting-item-name .nexus-icon { color: var(--text-accent); } +.nexus-nav-row .setting-item-control { color: var(--text-faint); } +.nexus-page-header { display: flex; align-items: center; gap: var(--space-xs); margin: var(--space-xs) 0 var(--space-xs) -6px; } +.nexus-page-header h3 { margin: 0; font-size: 18px; font-weight: 600; } +.nexus-page-crumb, .nexus-page-sep { color: var(--text-faint); font-size: 18px; } + +/* 4: sidebar */ +.nexus-side-layout { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: var(--space-xl); margin-top: var(--space-m); } +.nexus-side-nav { display: flex; flex-direction: column; gap: 2px; border-right: 1px solid var(--background-modifier-border); padding-right: var(--space-m); } +.nexus-side-item { display: flex; align-items: center; gap: var(--space-s); border: 0; background: transparent; text-align: left; padding: 6px var(--space-s); border-radius: var(--radius-s); color: var(--text-muted); font-size: var(--font-ui-small); } +.nexus-side-item:hover { background: var(--background-modifier-hover); color: var(--text-normal); } +.nexus-side-item.is-active { background: rgba(var(--interactive-accent-rgb), .16); color: var(--text-normal); } +.nexus-side-item.is-active .nexus-icon { color: var(--text-accent); } +.nexus-side-layout.is-tabs { grid-template-columns: 1fr; gap: var(--space-s); } +.nexus-side-layout.is-tabs .nexus-side-nav { flex-direction: row; border-right: 0; border-bottom: 1px solid var(--background-modifier-border); padding: 0 0 var(--space-s); overflow-x: auto; } +.nexus-side-layout.is-tabs .nexus-side-item { flex: 0 0 auto; } + +/* 5: routing table */ +.nexus-jobs { width: 100%; border-collapse: collapse; margin-top: var(--space-l); font-size: var(--font-ui-small); } +.nexus-jobs th { text-align: left; font-size: 11px; font-weight: 600; color: var(--text-faint); text-transform: uppercase; letter-spacing: .05em; padding: 0 var(--space-s) var(--space-s); border-bottom: 1px solid var(--background-modifier-border); } +.nexus-jobs td { padding: var(--space-s); border-bottom: 1px solid var(--background-modifier-border); vertical-align: middle; } +.nexus-jobs tr.is-disabled { opacity: .55; } +.nexus-jobs-job { font-weight: 550; white-space: nowrap; } +.nexus-jobs-provider { font-size: 11px; color: var(--text-faint); margin-top: 2px; padding-left: 2px; } +.nexus-jobs-opts { display: flex; flex-wrap: wrap; gap: var(--space-s); } +.nexus-jobs-opts label { display: flex; align-items: center; gap: var(--space-xs); color: var(--text-faint); font-size: 11px; } +.nexus-jobs-none { color: var(--text-faint); } +.nexus-jobs td:first-child, .nexus-jobs th:first-child { padding-left: 0; } + +/* 6: presets */ +.nexus-presets { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-s); margin: var(--space-l) 0; } +.nexus-preset { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left; padding: var(--space-m); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary-alt); } +.nexus-preset .nexus-icon { color: var(--text-muted); margin-bottom: var(--space-xs); } +.nexus-preset strong { font-weight: 600; } +.nexus-preset-model { font-size: var(--font-ui-smaller); color: var(--text-normal); } +.nexus-preset-desc { font-size: 11px; color: var(--text-faint); } +.nexus-preset:hover { border-color: var(--background-modifier-border-hover); } +.nexus-preset.is-selected { border-color: var(--interactive-accent); background: rgba(var(--interactive-accent-rgb), .1); box-shadow: inset 0 0 0 1px var(--interactive-accent); } +.nexus-preset.is-selected .nexus-icon { color: var(--text-accent); } + +/* 7: sentences */ +.nexus-sentences { margin: var(--space-l) 0 var(--space-s); font-size: 17px; line-height: 2.1; color: var(--text-muted); } +.nexus-sentences p { margin: 0 0 var(--space-xs); } +.nexus-token { field-sizing: content; appearance: none; -webkit-appearance: none; background: transparent; border: 0; border-bottom: 1.5px dashed var(--text-accent); border-radius: 0; color: var(--text-normal); font-weight: 550; padding: 0 2px; font-size: inherit; line-height: 1.3; cursor: pointer; } +.nexus-token:hover { background: rgba(var(--interactive-accent-rgb), .12); } +select.nexus-token option { color: var(--text-normal); background: var(--background-primary); } + +/* 8: tiles */ +.nexus-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: var(--space-s); margin-top: var(--space-l); } +.nexus-tile { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; padding: var(--space-m); text-align: left; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary-alt); min-width: 0; } +.nexus-tile:hover { border-color: var(--background-modifier-border-hover); } +.nexus-tile.is-active { border-color: var(--interactive-accent); box-shadow: inset 0 0 0 1px var(--interactive-accent); } +.nexus-tile-head { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-faint); text-transform: uppercase; letter-spacing: .05em; font-weight: 600; } +.nexus-tile-head .nexus-icon { color: var(--text-accent); } +.nexus-tile-value { font-size: 15px; font-weight: 600; margin-top: var(--space-xs); max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.nexus-tile-sub { font-size: 11px; color: var(--text-muted); max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.nexus-tile-panel { margin-top: var(--space-m); padding: var(--space-m) var(--space-l); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary-alt); } + +/* 9: search */ +.nexus-search { display: flex; align-items: center; gap: var(--space-s); margin-top: var(--space-l); padding: 6px var(--space-m); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-modifier-form-field); color: var(--text-faint); } +.nexus-search input { flex: 1; border: 0; background: transparent; outline: none; font-size: 14px; color: var(--text-normal); } +.nexus-chips { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin: var(--space-s) 0 var(--space-xs); } +.nexus-chip { border: 1px solid var(--background-modifier-border); background: transparent; border-radius: 12px; padding: 1px 10px; font-size: 12px; color: var(--text-muted); } +.nexus-chip.is-active { background: var(--interactive-accent); border-color: transparent; color: var(--text-on-accent); } +.nexus-tag { font-size: 10px; font-weight: 600; color: var(--text-faint); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); padding: 0 5px; text-transform: uppercase; letter-spacing: .04em; } + +/* 10: scope */ +.nexus-scope-note { display: flex; align-items: center; gap: var(--space-s); padding: var(--space-s) var(--space-m); border-radius: var(--radius-m); background: rgba(var(--interactive-accent-rgb), .1); color: var(--text-muted); font-size: var(--font-ui-smaller); margin-top: var(--space-xs); } +.nexus-scope-note .nexus-icon { color: var(--text-accent); } +.setting-item.is-override { box-shadow: inset 3px 0 0 var(--interactive-accent); padding-left: var(--space-m); } +.nexus-inherited { display: grid; grid-template-columns: 1fr 1fr; gap: 2px var(--space-l); margin-top: var(--space-xs); } +.nexus-inherited-row { display: flex; align-items: center; gap: var(--space-s); border: 0; background: transparent; padding: 5px var(--space-xs); border-radius: var(--radius-s); text-align: left; font-size: var(--font-ui-smaller); min-width: 0; } +.nexus-inherited-row span:first-child { color: var(--text-muted); flex: 0 0 auto; } +.nexus-inherited-row span:nth-child(2) { flex: 1; text-align: right; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.nexus-inherited-row .nexus-icon { color: transparent; } +.nexus-inherited-row:hover { background: var(--background-modifier-hover); } +.nexus-inherited-row:hover .nexus-icon { color: var(--text-accent); } + +/* ---------- phone width ---------- */ +body.is-mobile .mock-stage .mock-settings-window, body.is-mobile .mock-stage .mock-chat-window { width: 390px; margin: 0 auto; } +body.is-mobile .mock-stage .vertical-tab-content { padding: var(--space-l); } +body.is-mobile .mock-stage .mock-unchanged { display: none; } +body.is-mobile .mock-stage .modal { width: 100%; max-height: 100%; height: 100%; border-radius: 0; border: 0; } +body.is-mobile .mock-stage .setting-item { min-height: 52px; } +body.is-mobile .mock-stage select.dropdown { max-width: 140px; } +body.is-mobile .mock-stage .clickable-icon { min-width: 44px; min-height: 44px; } +body.is-mobile .mock-stage .nexus-side-layout { grid-template-columns: 1fr; gap: var(--space-s); } +body.is-mobile .mock-stage .nexus-side-nav { flex-direction: row; border-right: 0; border-bottom: 1px solid var(--background-modifier-border); padding: 0 0 var(--space-s); overflow-x: auto; } +body.is-mobile .mock-stage .nexus-side-item { flex: 0 0 auto; min-height: 40px; } +body.is-mobile .mock-stage .nexus-presets { grid-template-columns: 1fr 1fr; } +body.is-mobile .mock-stage .nexus-tiles { grid-template-columns: 1fr 1fr; } +body.is-mobile .mock-stage .nexus-inherited { grid-template-columns: 1fr; } +body.is-mobile .mock-stage .nexus-fold-summary { display: none; } +body.is-mobile .mock-stage .nexus-sentences { font-size: 16px; } +body.is-mobile .mock-stage .nexus-jobs thead { display: none; } +body.is-mobile .mock-stage .nexus-jobs, body.is-mobile .mock-stage .nexus-jobs tbody, body.is-mobile .mock-stage .nexus-jobs tr, body.is-mobile .mock-stage .nexus-jobs td { display: block; width: 100%; } +body.is-mobile .mock-stage .nexus-jobs tr { padding: var(--space-s) 0; border-bottom: 1px solid var(--background-modifier-border); } +body.is-mobile .mock-stage .nexus-jobs td { border: 0; padding: 2px 0; } + +@media (max-width: 960px) { + .mock-layout { display: flex; flex-direction: column-reverse; } + .mock-context { position: static; } +} +@media (max-width: 600px) { + .mock-page { padding: var(--space-xl) var(--space-m); } + .mock-stage .mock-chat-window, .mock-stage .mock-settings-window { width: 100%; } + .vertical-tab-content { padding: var(--space-l); } + .nexus-presets { grid-template-columns: 1fr 1fr; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition: none !important; } +} diff --git a/docs/mockups/defaults-settings-options.html b/docs/mockups/defaults-settings-options.html new file mode 100644 index 00000000..19b542cc --- /dev/null +++ b/docs/mockups/defaults-settings-options.html @@ -0,0 +1,40 @@ + + + + + + Nexus · Defaults tab and chat settings: 10 options + + + + +
+
+ NEXUS / DEFAULTS + CHAT SETTINGS · 10 OPTIONS +

Same settings. Ten ways to lay them out.

+

Each option redraws two surfaces: Settings → Nexus → Defaults and the Chat settings modal behind the chat gear. Every field that exists today is still there. Nothing else in the plugin changes.

+
+ +
+ + +
+ + +
+
+ + +
+ +
+ + + +
DRAFT · 14 OCT 2026 Proposed layouts, not implemented. Values are sample data, and nothing is saved.
+
+ + diff --git a/docs/mockups/defaults-settings-options.js b/docs/mockups/defaults-settings-options.js new file mode 100644 index 00000000..4c64ee96 --- /dev/null +++ b/docs/mockups/defaults-settings-options.js @@ -0,0 +1,432 @@ +// Standalone simulation only. No persistence, provider calls, or plugin integration. +// Every option renders the same field inventory: the Defaults tab and ChatSettingsModal as they ship today. + +const paths = { + 'chevron-right': '', + 'chevron-left': '', + 'chevrons-up-down': '', + search: '', + x: '', + check: '', + plus: '', + 'rotate-ccw': '', + 'file-text': '', + sparkles: '', + bot: '', + box: '', + image: '', + film: '', + mic: '', + file: '', + zap: '', + scale: '', + brain: '', + 'sliders-horizontal': '', + globe: '', +}; +const ic = name => ``; + +/* ---------------- field inventory (matches what ships) ---------------- */ +const THINK = ['Off', 'Low', 'Medium', 'High', 'Extra high', 'Max']; +const WORKSPACES = ['None', 'Daily Notes & Workflow', 'On Networks', 'Personal Operations', 'House Weatherization', 'Job Search Command Center']; +const PROMPTS = ['None', 'meditations-on-alignment', 'job-application-materials']; +const PROVIDERS = ['Anthropic', 'OpenRouter', 'Google AI']; + +const sections = [ + { id: 'chat', name: 'Chat model', short: 'Chat', icon: 'sparkles' }, + { id: 'context', name: 'Context', short: 'Context', icon: 'box' }, + { id: 'agent', name: 'Subagents', short: 'Subagents', icon: 'bot', desc: 'Model for subagents, saved prompts and automations. Must be a cloud model.' }, + { id: 'image', name: 'Images', short: 'Images', icon: 'image' }, + { id: 'video', name: 'Video', short: 'Video', icon: 'film', only: 'defaults' }, + { id: 'voice', name: 'Voice', short: 'Voice', icon: 'mic' }, + { id: 'files', name: 'Files and search', short: 'Files', icon: 'file', only: 'defaults' }, +]; + +const fields = [ + { id: 'chat-model', sec: 'chat', name: 'Model', kind: 'model', value: 'Claude Opus 5.5', chatValue: 'Claude Sonnet 5.5', provider: 'Anthropic', common: true }, + { id: 'chat-thinking', sec: 'chat', name: 'Thinking', kind: 'select', opts: THINK, value: 'High', common: true, desc: 'Off turns reasoning off. Hidden for models that can’t reason.' }, + { id: 'creativity', sec: 'chat', name: 'Creativity', kind: 'slider', value: '0.5', min: 0, max: 1, step: 0.1, desc: 'Lower is more focused, higher is more creative.' }, + { id: 'chat-context', sec: 'chat', name: 'Context window', kind: 'slider', value: '1M tokens', min: 0, max: 100, step: 1, desc: 'Older messages are summarized past this size.' }, + { id: 'web-search', sec: 'chat', name: 'Web search', kind: 'toggle', value: false, only: 'chat', desc: 'Let the model search the web when needed.' }, + { id: 'workspace', sec: 'context', name: 'Workspace', kind: 'select', opts: WORKSPACES, value: 'None', chatValue: 'On Networks', common: true }, + { id: 'workflow', sec: 'context', name: 'Workflow', kind: 'select', opts: ['None', 'Episode prep'], value: 'None', only: 'chat', desc: 'Loads its instructions and tools. Steps run only when you ask.' }, + { id: 'prompt', sec: 'context', name: 'Prompt', kind: 'select', opts: PROMPTS, value: 'None', common: true }, + { id: 'notes', sec: 'context', name: 'Context notes', kind: 'notes', value: ['Vault Map of Content', 'AGENTS'] }, + { id: 'agent-model', sec: 'agent', name: 'Model', kind: 'model', value: 'Claude Haiku 5.5', provider: 'OpenRouter' }, + { id: 'agent-thinking', sec: 'agent', name: 'Thinking', kind: 'select', opts: THINK, value: 'Off' }, + { id: 'image-model', sec: 'image', name: 'Image model', kind: 'model', value: 'Gemini 3.1 Flash Image', provider: 'Google AI' }, + { id: 'video-model', sec: 'video', name: 'Video model', kind: 'model', value: 'Veo 3.1', provider: 'Google AI' }, + { id: 'video-aspect', sec: 'video', name: 'Aspect ratio', kind: 'select', opts: ['16:9', '9:16'], value: '16:9' }, + { id: 'video-res', sec: 'video', name: 'Resolution', kind: 'select', opts: ['720p', '1080p'], value: '720p' }, + { id: 'stt-model', sec: 'voice', name: 'Transcription', kind: 'model', value: 'Deepgram Nova-3', provider: 'OpenRouter', desc: 'Chat microphone and audio files.' }, + { id: 'tts-model', sec: 'voice', name: 'Read-aloud model', kind: 'model', value: 'HeyGen Voice', provider: 'OpenRouter' }, + { id: 'tts-voice', sec: 'voice', name: 'Read-aloud voice', kind: 'select', opts: ['Provider default', 'Alloy', 'Verse'], value: 'Alloy' }, + { id: 'audio-folder', sec: 'voice', name: 'Saved audio folder', kind: 'text', value: 'audio', only: 'defaults', desc: 'Inside the Nexus data folder.' }, + { id: 'live-model', sec: 'voice', name: 'Live voice model', kind: 'model', value: 'None available', provider: '', disabled: true, desc: 'None of your providers offer live voice.' }, + { id: 'live-output', sec: 'voice', name: 'Live voice output', kind: 'select', opts: ['Provider default'], value: 'Provider default', disabled: true }, + { id: 'pdf-enable', sec: 'files', name: 'PDF import', kind: 'toggle', value: true, only: 'defaults', desc: 'Drop PDFs into chat to turn them into notes.' }, + { id: 'pdf-mode', sec: 'files', name: 'Reading method', kind: 'select', opts: ['Text extraction', 'Vision'], value: 'Text extraction', only: 'defaults', desc: 'Text is free. Vision reads scanned pages with a model.', parent: 'pdf-enable' }, + { id: 'pdf-ocr', sec: 'files', name: 'Vision model', kind: 'model', value: 'Gemini 3 Flash', provider: 'Google AI', only: 'defaults', desc: 'Only used with Vision.', parent: 'pdf-enable' }, + { id: 'pdf-auto', sec: 'files', name: 'Convert new PDFs automatically', kind: 'toggle', value: false, only: 'defaults', desc: 'Adds a Markdown note next to each new PDF.', parent: 'pdf-enable' }, + { id: 'embeddings', sec: 'files', name: 'Semantic search', kind: 'toggle', value: true, only: 'defaults', desc: 'Local embeddings, about 23 MB. Applies after restart.' }, +]; +const F = Object.fromEntries(fields.map(f => [f.id, f])); +const secOnly = id => sections.find(x => x.id === id)?.only; +const onlyOf = x => x.only || (x.sec ? secOnly(x.sec) : undefined); +const inSurface = (x, s) => !onlyOf(x) || onlyOf(x) === s; +const secsFor = s => sections.filter(x => inSurface(x, s)); +const fieldsFor = (s, sec) => fields.filter(f => inSurface(f, s) && (!sec || f.sec === sec)); +const val = (f, s) => (s === 'chat' && f.chatValue) ? f.chatValue : f.value; +const show = (f, s) => { + const v = val(f, s); + if (f.kind === 'toggle') return v ? 'On' : 'Off'; + if (f.kind === 'notes') return `${v.length} notes`; + return v; +}; +const summary = (secId, s, n = 3) => fieldsFor(s, secId).filter(f => !f.disabled).slice(0, n).map(f => { + if (f.id.endsWith('thinking')) return `${show(f, s)} thinking`; + if (f.kind === 'toggle') return `${f.name} ${show(f, s).toLowerCase()}`; + return show(f, s); +}).join(' · '); + +/* ---------------- control + row primitives (Obsidian Setting shape) ---------------- */ +const opts = (arr, v) => arr.map(o => `${o}`).join(''); +function control(f, s, o = {}) { + const v = val(f, s); + const dis = f.disabled ? ' disabled' : ''; + switch (f.kind) { + case 'model': return ``; + case 'select': return ``; + case 'toggle': return `
`; + case 'slider': return `${v}`; + case 'text': return ``; + case 'notes': return ``; + } + return ''; +} +function row(f, s, o = {}) { + const desc = o.desc !== undefined ? o.desc : (f.kind === 'model' && f.provider ? f.provider : (f.desc || '')); + const name = o.name || f.name; + const cls = ['setting-item', f.disabled ? 'is-disabled' : '', f.parent ? 'nexus-child' : '', o.cls || ''].join(' '); + let html = `
+
${name}
${desc ? `
${desc}
` : ''}
+
${o.control !== undefined ? o.control : control(f, s, o)}
`; + if (f.kind === 'notes' && !o.compact) { + const list = s === 'chat' ? ['Episode 05 outline', ...f.value] : f.value; + html += list.map(n => `
${ic('file-text')}${n}
`).join(''); + } + return html; +} +function splitModelRows(f, s) { + return row(f, s, { name: f.sec === 'chat' || f.sec === 'agent' ? 'Provider' : `${f.name.replace(/ model$/, '')} provider`, desc: '', control: `` }) + + row(f, s, { desc: '', control: `` }); +} +const heading = (name, desc = '', ctl = '') => `
${name}
${desc ? `
${desc}
` : ''}
${ctl ? `
${ctl}
` : ''}
`; +const lede = s => s === 'defaults' + ? '

Used when you start a new chat.

' + : ''; + +/* ---------------- state ---------------- */ +const st = { variant: 0, surface: 'defaults', overview: true, v3: null, v4: 'chat', v8: 'chat', preset: 'deep', v9: '', v9sec: 'all', v10: ['chat-model', 'workspace'] }; + +/* ---------------- the ten options ---------------- */ +const variants = [ + { + name: 'Tidy list', + tag: 'Smallest change', + idea: 'Today’s single page with the rough edges fixed. The sections are ordered by how often they’re used, and each uses an Obsidian heading instead of a boxed panel.', + good: ['Almost a pure restyle, with no new navigation to learn.', 'Matches every other Obsidian plugin’s settings page.'], + cost: ['Still one long page with about 27 rows in Defaults.', 'Provider and model stay as separate dropdowns.'], + build: 'Plain Setting rows with setHeading(). Merges the Reasoning toggle and Effort slider into one Thinking dropdown, and shows each slider value once instead of twice.', + render(s) { + return lede(s) + secsFor(s).map(sec => heading(sec.name, sec.desc) + fieldsFor(s, sec.id).map(f => f.kind === 'model' ? splitModelRows(f, s) : row(f, s)).join('')).join(''); + }, + }, + { + name: 'Essentials + folds', + tag: 'Progressive disclosure', + idea: 'The four settings people actually change sit at the top. Every other section is folded, and its summary line shows the current values, so you can check them without opening it.', + good: ['Short on first open. The common path takes no scrolling.', 'The summary lines make a quick audit possible.'], + cost: ['Two places to look for chat settings (the top block and the Chat model fold).', 'Folded content is hidden from Ctrl+F.'], + build: 'Setting rows inside
elements. Remembers which folds are open, per NN/g guidance. One merged model picker per job.', + render(s) { + const common = fields.filter(f => f.common && inSurface(f, s)); + let h = lede(s) + heading(s === 'chat' ? 'This chat' : 'New chats') + common.map(f => row(f, s)).join(''); + h += secsFor(s).map(sec => { + const rest = fieldsFor(s, sec.id).filter(f => !f.common); + if (!rest.length) return ''; + const label = sec.id === 'chat' ? 'More chat options' : sec.id === 'context' ? 'More context' : sec.name; + return `
${ic('chevron-right')}${label}${rest.slice(0, 3).map(f => show(f, s)).join(' · ')}
${rest.map(f => row(f, s)).join('')}
`; + }).join(''); + return h; + }, + }, + { + name: 'Drill-in pages', + tag: 'Native sub-pages', + idea: 'A short index with one row per section. Each row shows its current values. Clicking a row opens that section on its own page.', + good: ['The index fits on one screen.', 'Fits Obsidian 1.13 settings pages, so each setting shows up in Obsidian’s settings search.'], + cost: ['Changing anything takes a click to reach the page first.', 'Inside the chat modal, pages feel heavier than in Settings.'], + build: 'Uses Obsidian 1.13’s declarative page type in Settings (raises minAppVersion from 1.10) and an in-modal back stack in Chat settings.', + render(s) { + if (st.v3) { + const sec = sections.find(x => x.id === st.v3); + return `
${s === 'chat' ? 'Chat settings' : 'Defaults'}/

${sec.name}

` + + (sec.desc ? `

${sec.desc}

` : '') + + fieldsFor(s, sec.id).map(f => row(f, s)).join(''); + } + return lede(s) + secsFor(s).map(sec => `
+
${ic(sec.icon)}${sec.name}
${summary(sec.id, s)}
+
${ic('chevron-right')}
`).join(''); + }, + }, + { + name: 'Section sidebar', + tag: 'Master–detail', + idea: 'A small section list on the left and the chosen section on the right. In the chat modal the list becomes a row of tabs.', + good: ['Everything is one click away, and the section list never scrolls away.', 'Each pane is short.'], + cost: ['It’s a second sidebar inside Obsidian’s own settings sidebar.', 'Narrow panes on small windows. Phone width falls back to tabs.'], + build: 'A custom two-column layout inside the tab with Setting rows in the pane. No new primitives needed.', + render(s) { + const list = secsFor(s); + if (!list.find(x => x.id === st.v4)) st.v4 = 'chat'; + const nav = list.map(sec => ``).join(''); + const sec = list.find(x => x.id === st.v4); + return `
${heading(sec.name, sec.desc)}${fieldsFor(s, sec.id).map(f => row(f, s)).join('')}
`; + }, + }, + { + name: 'Model routing table', + tag: 'Everything at a glance', + idea: 'Nexus uses up to eight different models, each for a different job. One table lists each job, the model doing it, and that job’s options. Chat behaviour, context and file settings go underneath.', + good: ['Every model choice is visible at once, so it’s obvious which jobs aren’t set up.', 'Matches how power users think about it: one model per job.'], + cost: ['Dense. Can look technical to new users.', 'The table turns into stacked cards on phones.'], + build: 'A custom table. Model cells open a searchable SuggestModal, and option cells are compact dropdowns.', + render(s) { + const jobs = [ + ['Chat', 'chat-model', ['chat-thinking']], ['Subagents', 'agent-model', ['agent-thinking']], ['Images', 'image-model', []], + ['Video', 'video-model', ['video-aspect', 'video-res']], ['Transcription', 'stt-model', []], ['Read aloud', 'tts-model', ['tts-voice']], + ['Live voice', 'live-model', ['live-output']], ['PDF vision', 'pdf-ocr', []], + ].filter(([, m]) => inSurface(F[m], s)); + const table = `${jobs.map(([job, m, o]) => { + const f = F[m]; + return ``; + }).join('')}
JobModelOptions
${job}${f.provider ? `
${f.provider}
` : ''}
${o.map(id => { const x = F[id]; return ``; }).join('') || '—'}
`; + const behavior = ['creativity', 'chat-context', 'web-search'].map(id => F[id]).filter(f => inSurface(f, s)); + const files = fieldsFor(s, 'files').filter(f => f.id !== 'pdf-ocr').concat(inSurface(F['audio-folder'], s) ? [F['audio-folder']] : []); + return lede(s) + table + heading('Chat behaviour') + behavior.map(f => row(f, s)).join('') + + heading('Context') + fieldsFor(s, 'context').map(f => row(f, s)).join('') + + (files.length ? heading('Files and storage') + files.map(f => row(f, s)).join('') : ''); + }, + }, + { + name: 'Presets', + tag: 'Pick a mode', + idea: 'Three presets set the chat model, thinking and creativity in one click. "Custom" opens every setting. Context and the other models sit below the presets.', + good: ['Newcomers decide in one click without learning what "effort" means.', 'Power users still get every setting under Custom.'], + cost: ['Presets are a new idea that has to be named and maintained, and the models in them go out of date.', 'Changing any single value quietly turns the preset into "Custom".'], + build: 'Presets only write existing settings keys; nothing new is stored. The active preset is worked out by matching the current values.', + render(s) { + const ps = [ + ['quick', 'Quick', 'zap', 'Claude Haiku 5.5', 'Thinking off · creativity 0.3'], + ['balanced', 'Balanced', 'scale', 'Claude Sonnet 5.5', 'Medium thinking · creativity 0.5'], + ['deep', 'Deep', 'brain', 'Claude Opus 5.5', 'High thinking · creativity 0.5'], + ['custom', 'Custom', 'sliders-horizontal', 'Your choice', 'Set everything yourself'], + ]; + const cards = `
${ps.map(([id, n, i, m, d]) => ``).join('')}
`; + const chatRows = fieldsFor(s, 'chat').filter(f => f.id !== 'web-search').map(f => row(f, s)).join(''); + const others = ['agent', 'image', 'video', 'voice'].filter(id => inSurface(sections.find(x => x.id === id), s)); + return lede(s) + cards + + `
${ic('chevron-right')}Customize${summary('chat', s, 4)}
${chatRows}
` + + (s === 'chat' ? row(F['web-search'], s) : '') + + heading('Context') + fieldsFor(s, 'context').map(f => row(f, s)).join('') + + `
${ic('chevron-right')}Other modelsSubagents, images${s === 'defaults' ? ', video' : ''}, voice
${others.map(id => heading(sections.find(x => x.id === id).name) + fieldsFor(s, id).map(f => row(f, s)).join('')).join('')}
` + + (s === 'defaults' ? heading('Files and search') + fieldsFor(s, 'files').map(f => row(f, s)).join('') : ''); + }, + }, + { + name: 'Plain sentences', + tag: 'Reads like English', + idea: 'The settings are written as sentences, with each choice an inline dropdown. Anything that doesn’t fit a sentence goes in a short "Details" list below.', + good: ['Very approachable. Explains what each setting does as you read it.', 'Distinctive without being decorative.'], + cost: ['Harder to scan for one specific setting.', 'Inline dropdowns wrap awkwardly at narrow widths, and the sentences need care to translate.'], + build: 'Custom inline tokens: native selects styled as underlined text, and buttons for the model pickers. A details list uses Setting rows.', + render(s) { + const t = id => { const f = F[id]; const v = val(f, s); return f.kind === 'model' ? `` : ``; }; + const onoff = (id, a, b) => ``; + const p = []; + p.push(`${s === 'chat' ? 'This chat uses' : 'New chats use'} ${t('chat-model')} with ${t('chat-thinking')} thinking.`); + p.push(`${s === 'chat' ? 'It works' : 'They start'} in ${t('workspace')}${s === 'chat' ? ` with the ${t('workflow')} workflow,` : ''} using ${t('prompt')} as the prompt.`); + if (s === 'chat') p.push(`Web search is ${onoff('web-search', 'allowed', 'off')}.`); + p.push(`Subagents run on ${t('agent-model')} with ${t('agent-thinking')} thinking.`); + p.push(`Images come from ${t('image-model')}${s === 'defaults' ? `, and video from ${t('video-model')} at ${t('video-aspect')} ${t('video-res')}` : ''}.`); + p.push(`Dictation uses ${t('stt-model')}. Read aloud uses ${t('tts-model')} with the ${t('tts-voice')} voice.`); + if (s === 'defaults') p.push(`Dropped PDFs are ${onoff('pdf-enable', 'imported', 'ignored')} using ${t('pdf-mode')}, and new PDFs ${onoff('pdf-auto', 'are', 'aren’t')} converted automatically.`); + const details = ['creativity', 'chat-context', 'notes', 'live-model', 'live-output', 'pdf-ocr', 'audio-folder', 'embeddings'].map(id => F[id]).filter(f => inSurface(f, s)); + return lede(s) + `
${p.map(x => `

${x}

`).join('')}
` + heading('Details') + details.map(f => row(f, s)).join(''); + }, + }, + { + name: 'Tiles', + tag: 'Visual dashboard', + idea: 'Each section is a tile showing its main value large. Clicking a tile opens its settings in a panel under the grid.', + good: ['Friendly and easy to scan; the most "designed" option.', 'Easy to show a warning on a tile, such as "No model available".'], + cost: ['Takes more space for the same information.', 'Opening a panel under the grid moves the content below it.'], + build: 'A custom CSS grid of buttons, with an editor panel of Setting rows. On phones, the tiles stack into two columns.', + render(s) { + const list = secsFor(s); + if (!list.find(x => x.id === st.v8)) st.v8 = 'chat'; + const main = { chat: 'chat-model', context: 'workspace', agent: 'agent-model', image: 'image-model', video: 'video-model', voice: 'stt-model', files: 'pdf-mode' }; + const tiles = list.map(sec => { const f = F[main[sec.id]]; const sub = summary(sec.id, s).split(' · ').slice(1, 3).join(' · '); + return ``; }).join(''); + const sec = list.find(x => x.id === st.v8); + return lede(s) + `
${tiles}
${heading(sec.name, sec.desc, ``)}${fieldsFor(s, sec.id).map(f => row(f, s)).join('')}
`; + }, + }, + { + name: 'Search first', + tag: 'Find, don’t browse', + idea: 'A search box and section filter chips over one flat list. Each row carries a section tag, so the list stays readable while filtered.', + good: ['The fastest way to reach a setting if you know roughly what it’s called.', 'Scales as more settings are added.'], + cost: ['No sense of hierarchy, and every row looks equally important.', 'Duplicates Obsidian’s own settings search (1.13+).'], + build: 'A text input and chips filter Setting rows on the client. Each row carries data-section and searchable text.', + render(s) { + const chips = [['all', 'All']].concat(secsFor(s).map(x => [x.id, x.short])).map(([id, n]) => ``).join(''); + const items = fieldsFor(s).map(f => { const sec = sections.find(x => x.id === f.sec); + return row(f, s, { name: `${f.name}${sec.short}` }); }).join(''); + return lede(s) + `
${chips}
${items}
`; + }, + after() { applySearch(); }, + }, + { + name: 'Defaults vs this chat', + tag: 'Scope made explicit', + idea: 'Defaults splits what each chat can change from what applies everywhere. Chat settings lists only what this chat changed, each with a reset. Everything else sits in a quiet "using defaults" list, where one click overrides a setting.', + good: ['Removes the confusion over whether a change affects only this chat or every chat.', 'The chat modal is usually two or three rows long.'], + cost: ['A new idea, overrides, that needs one clear sentence of explanation.', 'Defaults gains a second grouping on top of the sections.'], + build: 'Uses the per-conversation settings that already exist; an override is any value that differs from the global default. Changes apply as soon as they’re made, with a Done button.', + render(s) { + if (s === 'defaults') { + const perChat = fields.filter(f => inSurface(f, 'defaults') && inSurface(f, 'chat')); + const global = fields.filter(f => onlyOf(f) === 'defaults'); + return lede(s) + + heading('New chats start with', 'Any chat can change these from its own settings.') + perChat.map(f => row(f, s)).join('') + + heading('Everywhere', 'These apply to every chat and can’t be changed per chat.') + global.map(f => row(f, s)).join(''); + } + const changed = st.v10.map(id => F[id]); + const rest = fieldsFor('chat').filter(f => !st.v10.includes(f.id)); + const defVal = { 'chat-model': 'Claude Opus 5.5', workspace: 'None' }; + return `
${ic('sparkles')}Changes apply right away, to this chat only.
` + + heading('Changed for this chat') + + changed.map(f => row(f, 'chat', { desc: `Default: ${defVal[f.id] ?? show(f, 'defaults')}`, cls: 'is-override', control: control(f, 'chat') + `` })).join('') + + heading('Using your defaults', 'Click one to change it for this chat.') + + `
${rest.map(f => ``).join('')}
`; + }, + chatFooter: '', + }, +]; + +/* ---------------- surface frames ---------------- */ +function frame(v, s, body) { + if (s === 'defaults') { + return `
+ +
${body}
`; + } + const footer = v.chatFooter || ''; + return `
+
`; +} + +/* ---------------- page wiring ---------------- */ +const $ = id => document.getElementById(id); +const variantSel = $('mock-variant'); +variantSel.innerHTML = variants.map((v, i) => ``).join(''); + +function renderContext(v, i) { + $('mock-context').innerHTML = `
Option ${i + 1} of 10

${v.name}

${v.tag}
+

${v.idea}

Good

    ${v.good.map(x => `
  • ${x}
  • `).join('')}
+

Trade-offs

    ${v.cost.map(x => `
  • ${x}
  • `).join('')}

Build notes

${v.build}

+
`; +} +function renderStage() { + const v = variants[st.variant]; + $('mock-stage').innerHTML = frame(v, st.surface, v.render(st.surface)); + renderContext(v, st.variant); + v.after?.(); +} +function renderOverview() { + $('mock-overview-grid').innerHTML = variants.map((v, i) => `
+
${frame(v, st.surface, v.render(st.surface))}
+
${i + 1}${v.name}${v.tag}
`).join(''); +} +function render() { + $('mock-overview-grid').hidden = !st.overview; + $('mock-detail').hidden = st.overview; + $('mock-overview').setAttribute('aria-pressed', String(st.overview)); + variantSel.value = String(st.variant); + document.querySelectorAll('[data-surface]').forEach(b => b.setAttribute('aria-checked', String(b.dataset.surface === st.surface))); + if (st.overview) renderOverview(); else renderStage(); +} +function applySearch() { + const q = st.v9.trim().toLowerCase(); + let shown = 0; + document.querySelectorAll('.nexus-search-results > .setting-item').forEach(el => { + const ok = (st.v9sec === 'all' || el.dataset.sec === st.v9sec) && (!q || el.dataset.text.includes(q)); + el.hidden = !ok; if (ok) shown++; + }); + const empty = document.querySelector('[data-v9-empty]'); + if (empty) empty.hidden = shown > 0; +} + +document.addEventListener('click', e => { + const t = e.target; + const open = t.closest('[data-open]'); + if (open) { st.variant = Number(open.dataset.open); st.overview = false; render(); window.scrollTo({ top: $('mock-detail').offsetTop - 16 }); return; } + if (t.closest('#mock-overview')) { st.overview = !st.overview; render(); return; } + const surf = t.closest('[data-surface]'); + if (surf) { st.surface = surf.dataset.surface; st.v3 = null; render(); return; } + const step = t.closest('[data-step]'); + if (step) { st.variant = (st.variant + Number(step.dataset.step) + 10) % 10; st.v3 = null; render(); return; } + const v3 = t.closest('[data-v3]'); if (v3) { st.v3 = v3.dataset.v3 || null; renderStage(); return; } + const v4 = t.closest('[data-v4]'); if (v4) { st.v4 = v4.dataset.v4; renderStage(); return; } + const v8 = t.closest('[data-v8]'); + if (v8) { st.v8 = v8.dataset.v8 || (st.v8 ? '' : 'chat'); if (!st.v8) { document.querySelector('.nexus-tile-panel').hidden = true; document.querySelectorAll('.nexus-tile').forEach(x => x.classList.remove('is-active')); } else renderStage(); return; } + const preset = t.closest('[data-preset]'); if (preset) { st.preset = preset.dataset.preset; renderStage(); return; } + const v9sec = t.closest('[data-v9sec]'); if (v9sec) { st.v9sec = v9sec.dataset.v9sec; document.querySelectorAll('[data-v9sec]').forEach(c => c.classList.toggle('is-active', c === v9sec)); applySearch(); return; } + const add = t.closest('[data-v10-add]'); if (add) { st.v10.push(add.dataset.v10Add); renderStage(); return; } + const reset = t.closest('[data-v10-reset]'); if (reset) { st.v10 = st.v10.filter(id => id !== reset.dataset.v10Reset); renderStage(); return; } + const tg = t.closest('.checkbox-container'); + if (tg && !t.closest('.mock-thumb')) { + const on = !tg.classList.contains('is-enabled'); + tg.classList.toggle('is-enabled', on); tg.setAttribute('aria-checked', String(on)); + if (tg.dataset.toggle === 'pdf-enable') document.querySelectorAll('.mock-stage [data-field].nexus-child').forEach(r => { if (F[r.dataset.field]?.parent === 'pdf-enable') r.hidden = !on; }); + } +}); +document.addEventListener('input', e => { + if (e.target.matches('[data-v9]')) { st.v9 = e.target.value; applySearch(); } + if (e.target.matches('[data-slider]')) { + const out = e.target.previousElementSibling; + out.textContent = e.target.dataset.slider === 'creativity' ? Number(e.target.value).toFixed(1) : (e.target.value >= 100 ? '1M tokens' : `${Math.max(8, Math.round(e.target.value * 10))}K tokens`); + } +}); +document.addEventListener('keydown', e => { + if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('[role="button"], .checkbox-container')) { e.preventDefault(); e.target.click(); } + if (!st.overview && !e.target.matches('input, select') && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) { st.variant = (st.variant + (e.key === 'ArrowRight' ? 1 : -1) + 10) % 10; st.v3 = null; render(); } +}); +variantSel.addEventListener('change', () => { st.variant = Number(variantSel.value); st.overview = false; st.v3 = null; render(); }); +$('mock-theme').addEventListener('click', e => { + const light = document.body.classList.toggle('theme-light'); + document.body.classList.toggle('theme-dark', !light); + e.currentTarget.setAttribute('aria-pressed', String(light)); +}); +$('mock-phone').addEventListener('click', e => { + const on = document.body.classList.toggle('is-mobile'); + e.currentTarget.setAttribute('aria-pressed', String(on)); +}); + +render(); diff --git a/docs/plans/defaults-settings-redesign-plan.md b/docs/plans/defaults-settings-redesign-plan.md new file mode 100644 index 00000000..fd392947 --- /dev/null +++ b/docs/plans/defaults-settings-redesign-plan.md @@ -0,0 +1,679 @@ +# Implementation Plan: Defaults, Chat Settings and Get Started Redesign + +> Analysis date: 2026-10-14 +> Source baseline: `daa9abd5` (Nexus 5.20.4) +> Status: proposed. Nothing under `src/`, `styles.css` or `tests/` has changed. Only this document was written. +> Visual contract: `docs/mockups/defaults-settings-essentials.html` and its same-stem CSS/JS, accepted by the owner on 2026-10-14 against plugin version 5.20.4 ("Essentials + folds", option 2, round 3). `docs/mockups/defaults-settings-options.html` (+ CSS/JS) is the 10-option exploration and is reference only. + +Cite the mockup by file, never by line number. Copy strings that matter are lifted into the [copy appendix](#appendix-a--copy-lifted-from-the-mockup) so tests can hold them as constants without pointing at the mockup. + +## Outcome + +Three surfaces change: the Settings → Nexus → **Defaults** tab, the in-chat **Chat settings** modal and the **Get started** entry above the tab strip. Semantic search gets real settings behaviour as well. The other settings tabs keep their current behaviour. + +- **Defaults and Chat settings** keep their shared renderer and show the "Essentials + folds" layout. There is no intro line and no heading above the top block. The top block is always open and holds Provider, Model and Thinking. Below it are icon folds, collapsed by default, each showing a one-line summary: Chat options, Context, Subagents, Images, Video, Voice, File conversion and Semantic search. The selected model's capabilities decide which controls appear and whether they are enabled. +- **Get started** becomes one navigable row above the tab strip. It opens in-tab pages (Get started → Chat inside Obsidian / Connect an external agent) that use the existing `BackButton` and `SettingsRouter`. The external-agent page checks for Node.js first, adds Antigravity next to Claude Desktop and Codex, and replaces the "Other agents" manual flow with four numbered steps. +- **Semantic search** stays on by default. It shows its index status. Turning it off asks for confirmation and takes effect immediately, with no restart. The index is kept, and a separate **Delete index** button appears only while search is off. Stale vectors are pruned. When search is off, agents are not offered semantic search. +- Bug fixes, the high-priority one first: five call sites ask the service registry for an `embeddingService` that nothing registers. + +## What the code actually does + +Line numbers are from the baseline above and come from reading the code for this plan. + +**Rendering** + +- `ChatSettingsRenderer.render()` empties its container (`src/components/shared/ChatSettingsRenderer.ts:210`) and rebuilds the whole form whenever any of these changes: + - LLM settings (`:194`) + - chat or subagent provider/model (`ModelDropdownRenderer.ts:188`, `:287` via `reRender`) + - image provider (`:682`) + - workspace when workflows are shown (`:1270`) + - prompt sync (`:1327`) + + Fold state and scroll position are lost on every redraw. +- The section order today is chat model (with Reasoning, Effort, Context window, Web search), Subagent model, Image model, the Defaults-only `renderAfterImageSection` hook (Video, Voice), Voice/Transcription, Temperature, Context (`:206-219`). Thinking is a Reasoning toggle plus an Effort slider (`:344-418`). Both readouts are hand-made spans (`csr-effort-value`, `csr-context-window-value`). The second span has **no CSS rule** (`styles.css` only defines `.csr-effort-value` at 4473 and `.csr-temp-value` at 4482). +- Context window is a 1-token-step slider from 1,024 to the model maximum (`:452-479`). Keys are `getContextWindowOverrideKey(provider, model)` and values are clamped by `resolveContextWindowLimit` (`src/ui/chat/utils/ContextWindowSettings.ts`). +- Creativity is always shown, range 0–1 (`:600-623`). `StaticModelsService.ModelWithProvider.capabilities` does not carry `supportsSamplingParams` (`src/services/StaticModelsService.ts:23-40`, `:210-222`), so the renderer cannot know that Claude Opus/Sonnet/Haiku 5.5, Fable 5/5.1 and others reject temperature (`AnthropicModels.ts`, 10 `supportsSamplingParams: false` entries). The adapters already drop the parameter (`adapters/shared/SamplingParams.ts`). +- Voice is implemented twice: + - `DefaultsTab.renderVoiceSection` / `renderSpeechSettings` / `renderRealtimeVoiceSettings` (`src/settings/tabs/DefaultsTab.ts:473-982`) writes straight to `llmProviderSettings` and calls `saveSettings()` outside the tab's save queue. + - `ChatSettingsRenderer.renderVoiceSection` (`:777-1240`) writes to the pending settings object. + + The labels differ ("Voice input" vs "Transcription", "Speech provider", "Live voice provider"). +- Context notes use raw `onclick` assignments (`:1310`, `:1346`), which breaks the `registerDomEvent` rule. +- `Accordion` (`src/components/Accordion.ts`) is used only by `SettingsView` for Get started. `getContentEl()` creates a **new** child div every time it is called (`:91-94`). + +**Subagent settings mostly have no effect** + +- `LLMService.getAgentModel()` returns the chat default model unless that model is local (`src/services/llm/core/LLMService.ts:126-151`). The saved subagent model therefore only matters for `executePrompt` when the chat model is WebLLM, Ollama or LM Studio. +- The `subagent` tool inherits the parent chat's provider, model and thinking (`src/agents/promptManager/tools/subagent.ts:232-243`, built by `SubagentController.buildSubagentContext` at `src/ui/chat/controllers/SubagentController.ts:455-483`). It never reads `agentModel` or `agentThinking`. +- `SubagentExecutor` receives `thinkingEnabled`/`thinkingEffort` but does not forward them to its streaming generator (`src/services/chat/SubagentExecutor.ts:367-375`). The generator's options type has no thinking or temperature fields (`SubagentController.ts:315-358`). +- `PromptExecutor` sends no temperature and no thinking (`src/agents/promptManager/tools/executePrompts/services/PromptExecutor.ts:89-97`). +- `agentThinking` is saved and restored (`ModelAgentDefaultsResolver.ts:89-92`, `ModelAgentManager.ts:309-313`) but no execution path reads it. + +**Thinking state** + +`renderReasoningControls` returns early for models without `supportsThinking` (`CSR:352`). `thinking.enabled` stays `true` but hidden, and `ModelAgentPromptContextAssembler.buildMessageOptions` still sends `enableThinking: true` (`:120`, `:126`). Some adapters gate on capability (e.g. `AnthropicAdapter.ts:185`, `:428`). Not every adapter was checked. + +**Context window ownership** + +- Each conversation already has its own budget: `committedContextWindow`, `getEffectiveContextWindow()` and `effectiveContextWindow` in the conversation metadata (`ModelAgentManager.ts:687-698`, `:1234`). +- The Chat settings modal *additionally* merges the edited value into the global `contextWindowOverrides` and saves it (`src/ui/chat/components/ChatSettingsModal.ts:234-258`). PR #410 did this on purpose, and `tests/unit/ChatSettingsModalContextHandoff.test.ts` locks it in. +- In the other direction, a Defaults edit is pushed to the active chat when its model matches (`DefaultsTab.ts:154-161` → `ChatView.applyContextWindowDefault`, `src/ui/chat/ChatView.ts:1145-1155`). This is also deliberate and tested (`tests/unit/DefaultsTabContextHandoff.test.ts`). + +**Embeddings** + +- `enableEmbeddings` is read once, after storage is ready (`src/core/PluginLifecycleManager.ts:362-371`). `EmbeddingManager.initialize()` returns early when disabled (`src/services/embeddings/EmbeddingManager.ts:91`). Nothing listens for later changes. +- The dream cycle uses `registerInterval` plus `addCommand` (`:301-336`). Neither can be undone today. +- `getStats()` (`:265-294`) and the `IndexingQueue` `progress` events (`IndexingQueue.ts:39-46`, `:587`, with pause/resume at `:288-325`) exist, but only the status bar reads them. +- Re-enabling is incremental. `filterUnindexedNotes` compares `contentHash` (`IndexingQueue.ts:392-414`). +- Stale vectors are never pruned. That pass walks only existing files, and `NoteEmbeddingService.embedNote` removes a vector only when it is asked to embed a path that has gone (`NoteEmbeddingService.ts:127-133`). +- **"Rebuild cache" does not delete note or trace vectors.** `SQLiteMaintenanceService.clearAllData()` drops and recreates only `conversation_embeddings` and clears `conversation_embedding_metadata` and `embedding_backfill_state` (`src/database/storage/SQLiteMaintenanceService.ts:93-118`). `note_embeddings`, `embedding_metadata`, `trace_embeddings` and `trace_embedding_metadata` survive. Nothing deletes them. + +**Settings sync** + +`Settings.reconcileExternalSettings` treats a synced change to any non-`llmProviders` key as "needs reload" (`src/settings.ts:182-201`). Two consequences: +- `enableEmbeddings` is synced, and a change made on another device currently forces a reload. +- Any UI state stored in `data.json`, for example the old `getStartedOpen`, has the same effect. + +**Ingestion** + +`enableIngestion` gates audio conversion as well as PDF (`src/core/ingest/VaultIngestionManager.ts:189-195`, `:261`) and decides whether ingestion models are listed (`GenericProviderModal.ts:445-446`). + +## Bug verification + +| # | Claim | Verdict | Evidence | +|---|---|---|---| +| 1 | Nothing registers `'embeddingService'`, so `getServiceIfReady('embeddingService')` always returns null | **CONFIRMED** | Five callers: `src/agents/searchManager/services/ServiceAccessors.ts:77`, `src/agents/searchManager/searchManager.ts:114` (the result is cached and passed to the tool at `:140-141`), `src/ui/chat/components/suggesters/initializeSuggesters.ts:47`, `src/ui/tasks/TaskBoardView.ts:61`, `src/components/workspace/ProjectsManagerView.ts:146`. The registry is `CORE_SERVICE_DEFINITIONS` (no entry with that name; `grep "name: '"` over `src/core/services/ServiceDefinitions.ts`) plus `ADDITIONAL_SERVICE_FACTORIES`, which is empty (`:730-733`). `ServiceContainer.getIfReady` reads only already-created instances (`src/core/ServiceContainer.ts:298-300`). There are no string aliases, and the only other `register` calls are logger/pathManager/dataManager/vaultOperations in `src/core/index.ts:111-114`. `EmbeddingManager` is never in the container; it is attached as `plugin.embeddingManager` (`PluginLifecycleManager.ts:371`). Only `SearchContentTool` reads it there (`searchContent.ts:257-279`). The registration was dropped in commit `e1eada3a` ("removing embeddings"). Effects: `ConversationSearchStrategy.search` returns `[]` (`ConversationSearchStrategy.ts:69-71`), so **semantic search of past chats never runs**; `[[` note suggestions are always fuzzy (`noteSearch.ts:24`); task and project note linking never uses embeddings. `ChatTraceService` gets its service by direct injection (`PluginLifecycleManager.ts:390-397`) and works. Every existing test mocks around the gap (`tests/unit/SearchManagerAgent.test.ts:19-21` answers only `hybridStorageAdapter`). | +| 2 | Ingestion and Embeddings vanish after a redraw on the Defaults tab | **CONFIRMED** | They are appended to `rendererContainer` after `renderer.render()` (`DefaultsTab.ts:259`, `:281`). `render()` empties that container (`CSR:210`) on every redraw trigger listed above. Video and Voice survive only because they run inside `renderAfterImageSection`. | +| 3 | Ingestion rows sit above their toggle; Auto-convert is visible while off; the copy says "the defaults below" | **CONFIRMED** | `ingestionSettingsContainer = content.createDiv()` is created at `DefaultsTab.ts:1075`, before the "Enable ingestion" `Setting` at `:1078-1079`, so the PDF mode and OCR rows render first. "Auto-convert new files" is added to `content`, not the hidden container (`:1095-1106`). The copy at `:1097` says "using the defaults below". | +| 4 | Chat welcome "Open settings" opens Chat settings even with no provider | **CONFIRMED** | The button appears only in the "no providers" branch (`src/ui/chat/controllers/UIStateController.ts:93-114`). Its callback calls `openChatSettingsModal()` (`ChatView.ts:647-649`). | +| 5 | The header has a stray `;` and no title; `StatusBadge.ts` is unused | **CONFIRMED** | `SettingsView.ts:251-252` (comment "Title and description" followed by a bare `;`). `StatusBadge` has no importer in `src/` or `tests/`; its CSS is at `styles.css:4736-4754`. | +| 6 | `WorkspaceFormRenderer` saves top-level `dedicatedAgentId` while `syncWorkspacePrompt` reads `context.dedicatedAgent.agentId` | **CONFIRMED** | Writer: `src/components/workspace/WorkspaceFormRenderer.ts:165-175`. Reader: `CSR:1316-1330`. The canonical resolver prefers the top-level field and treats `context.dedicatedAgent` as deprecated (`src/agents/memoryManager/services/WorkspacePromptResolver.ts:83-97`). It is worse on Defaults: `DefaultsTab.loadWorkspaces` passes only `{id, name}` (`:91-99`), so the sync never fires there. | +| 7 | The modal does not persist without an open conversation; web search cannot be a default | **CONFIRMED (note only)** | `ChatSettingsModal.ts:326-328` saves only when `conversationId` is set. The values stay in `ModelAgentManager` memory. `initializeDefaults` resets `webSearch = false` (`ModelAgentManager.ts:394`), and it is restored per conversation (`:351`). | +| 8 | Temperature range is 0–1 | **CONFIRMED (note only)** | `CSR:613` `setLimits(0, 1, 0.1)`, clamped at `ModelAgentManager.ts:355`, `:993`. | +| — | "High 2" and "1,000,000 tokens 1000000" duplicate readouts | **NOT CONFIRMED (cause)** | The renderer adds its own value span next to each slider (`CSR:400`, `:462`). The second number is most likely a value label Obsidian 1.13 draws itself; the mockup header says its slider markup was "captured from the running app" with a `.slider-value`. I could not confirm this from `obsidian.d.ts` 1.12.3, which has no value-label API. Phase 1 starts by checking it in the app. | +| — | The CLI copy claims a Ctrl/Cmd+Shift+C hotkey | **CONFIRMED** | `GetStartedTab.ts:233`. The `open-chat` command has no `hotkeys` (`src/core/ui/ChatUIManager.ts:106-112`). | +| — | Bare `'node'` fallback in the copyable config | **CONFIRMED** | `GetStartedTab.ts:979`, `:992`. | +| — | "MCP setup guide" link uses `href: '#'` | **CONFIRMED** | `GetStartedTab.ts:518`. It opens the guide only when a `component` is supplied. The target `guide/mcp-setup.md` exists. | +| — | "Gemini CLI" in user-facing copy | **CONFIRMED** | `GetStartedTab.ts:500` (Other agents heading), `BuiltinProviderDrivers.ts:278` (`displayName`), `src/utils/platform.ts:159` (mobile-unavailable list), `GoogleGeminiCliAdapter.ts:244` (error text), `README.md:16`. Elsewhere the UI already says "Antigravity CLI" (`ProvidersTab.ts:115`, `ModelDropdownRenderer.ts:24`). | + +## Decisions + +| Topic | Decision | Why | +|---|---|---| +| `SettingGroup` (API 1.11) vs `minAppVersion` 1.10.0 | **Raise `minAppVersion` to `1.11.0`** in `manifest.json` and add the `versions.json` entry through the `nexus-release` machinery. No fallback shim. | `SettingGroup`, `Setting.addComponent` and the core `.setting-group .setting-items` CSS that the accordion content reuses all arrived in 1.11. A shim would need a second, unstyled code path that nobody tests. The owner's machines run 1.12.7–1.14.4. Users on 1.10 remain on 5.20.x. | +| Fold open/closed memory | **Device-local**: `app.saveLocalStorage('nexus-settings-folds', { v: 1, defaults: string[], chat: string[] })`, kept separately for each surface. | A synced `data.json` key would mark every other device "needs reload" (`settings.ts:182-201`) whenever a fold is toggled. `loadLocalStorage` is already scoped to the vault and is used for device IDs (`JSONLWriter.ts:174-180`). | +| Chat modal context window | **Applies to this conversation only.** Remove the global merge-and-save (`ChatSettingsModal.ts:234-258`). | This reverses part of PR #410 (see open question 2). Defaults stay the source for *new* chats. The modal already commits through `requestContextChange`. | +| Defaults → active chat context push | **Keep** (`applyContextWindowDefault`). | It is deliberate and tested. Changing it is out of scope (open question 3). | +| Web search default | **Stays per chat.** It is shown only in the Chat settings modal, as in the mockup. | Matches the mockup. A default is a new feature. | +| No conversation open | **Keep the in-memory behaviour.** The values carry into the next chat. Phase 0 adds a test proving that the first conversation created afterwards persists them. | No copy for this case exists in the mockup. | +| Thinking on non-reasoning models | **Keep the stored preference and gate it at the request boundary.** A new `resolveThinkingForModel(provider, model, thinking)` returns `{enabled: false}` when the model lacks `supportsThinking`. It is used by `ModelAgentPromptContextAssembler`, the subagent path and the prompt-execution path. | Switching to a non-reasoning model and back keeps the user's level. The hidden value can no longer be sent. | +| Thinking control | One `DropdownComponent`: Off, Low, Medium, High, Extra high, Max. Off is `{enabled:false, effort: }`; any other value is `{enabled:true, effort}`. | Uses the existing `DefaultThinkingSettings` storage, so nothing needs migrating. | +| Context window control | `SliderComponent` over indices `0..n-1` of `contextWindowStops(max)` = presets 32K, 64K, 128K, 200K, 350K, 750K, 1M that are **strictly below** `max`, followed by `max` itself. A single stop disables the slider. The readout shows `formatContextWindow` ("200K", "1M"). Changing provider or model clears the pending override for the newly selected key, so the readout shows that model's maximum. | Matches the mockup's `ctxChoices`. Storage stays `contextWindowOverrides[provider::model]`, and the maximum is still stored as "no override". A legacy value that is not a preset (e.g. 150,000) is shown as-is in the readout, with the thumb at the nearest lower stop, until the user moves it. No migration. | +| Creativity | Shown when `supportsSamplingParams !== false`. Otherwise the row is disabled with the readout "—" and the description ` sets its own temperature.`. | Matches the mockup. It reads the registry flag through `StaticModelsService`. | +| Subagent settings take effect | Precedence for subagents and `executePrompt`: an explicit tool parameter, then the conversation's `agentProvider`/`agentModel`, then the default `agentModel`, then the chat model if it is a cloud model (today's behaviour), then the first cloud provider (today's local fallback). The new subagent Creativity and Context window settings travel with the agent model. | The mockup says "Used for: Subagents, saved prompts and automations". Today almost none of it applies (see findings). Users who never set a subagent model see no change. | +| Get started auto-open | Replace `getStartedOpen` and `shouldOpenGetStarted` with this rule: on the **first** `display()` in a plugin session, if `!hasConfiguredProviders && !hasExternalAgentSetup`, open the Get started page instead of the tab list. Back goes to Settings. Nothing is persisted. | First-run users still land on guidance. Saving a preference would cause the sync-reload problem. The old key is left inert in `data.json` and is no longer read. Deleting it would itself create a synced change. | +| Get started on mobile | Show the row and the "Chat inside Obsidian" path on every platform. Hide "Connect an external agent" (desktop only). | Today mobile has no Get started at all (`SettingsView.ts:535`). The chat path does not depend on Node. | +| `embeddingService` fix | Add one accessor, `resolveEmbeddingService(plugin)`, that reads `plugin.embeddingManager` and returns its service only while the manager is enabled and initialized. Replace all five string lookups with it. Callers resolve it on every use and never cache it. | Registering a container entry would need a factory for an optional, late, toggleable service. Hot-apply also means the instance changes during a session, so caching it (`searchManager.ts:113`, `searchContent.ts:269`, `initializeSuggesters.ts:47`) becomes a bug of its own. | +| Semantic toggle when off | `searchContent` keeps the `semantic` property, with a description saying it is unavailable, and drops the semantic pitch from the tool description. `semantic=true` falls back to keyword search and the result carries a `note` saying semantic search is turned off in Nexus settings. | The mockup copy says "agents stop being offered semantic search". The property stays because CLI flag parsing is driven by the schema (check this in the nexus-agents verify step). Removing `--semantic` could turn a harmless request into a parse error. | +| Delete index | Shown only while search is off. It runs right away (mod-warning), with no second confirmation, exactly as in the mockup. | Vectors are derived cache data, so deleting them is legal under the storage invariants. Turning search back on rebuilds them. | +| `enableEmbeddings` scope | Stays synced. Synced changes are hot-applied instead of forcing a reload. | Lowest-change option. Device-local is open question 5. | + +## Settings keys + +All new keys are optional. **No migration**: an absent key falls back as listed. + +| Key | Type | Default when absent | Phase | Notes | +|---|---|---|---|---| +| `llmProviders.agentTemperature` | `number` (0–1) | `llmProviders.defaultTemperature ?? 0.5` | 2 | Subagent Creativity. Ignored when the agent model has `supportsSamplingParams === false`. | +| `llmProviders.agentContextWindowOverrides` | `Record` keyed by `getContextWindowOverrideKey` | the agent model's advertised window | 2 | A separate map, so the chat and subagent sliders for the same model never write the same key. | +| Conversation `chatSettings.agentTemperature` | `number` | conversation absent → defaults above | 2 | Extends `ModelAgentConversationSettingsStore` and the `chatSettings` metadata (`src/types/chat/ChatTypes.ts:175-180`). The JSONL metadata event already carries `chatSettings` as an object. Check with `nexus-storage` `persist-new-data.md` that the field survives a cache rebuild. | +| Conversation `chatSettings.agentContextWindow` | `number` | resolved from defaults | 2 | Same as above. | +| `getStartedOpen` (`PluginTypes.ts:84`) | — | — | 3 | **Retired.** No longer read or written. Remove it from the type and leave it inert on disk. | +| Device-local `nexus-settings-folds` | `{ v: 1; defaults: string[]; chat: string[] }` | everything collapsed | 1 | Not in `data.json`. | + +Existing keys keep their meaning: `defaultThinking`, `agentThinking`, `defaultTemperature`, `contextWindowOverrides`, `agentModel`, `enableIngestion`, `autoIngestion`, `defaultPdfMode`, `defaultOcrModel`, `enableEmbeddings`. + +--- + +## Phase 0 — Correctness fixes (can ship alone) + +**Goal:** fix the confirmed bugs that do not need the new layout. + +### Files + +| Path | Change | Kind | +|---|---|---| +| `src/services/embeddings/embeddingAccess.ts` | `resolveEmbeddingService(plugin: unknown): EmbeddingService \| null`. It reads `(plugin as {embeddingManager?}).embeddingManager` and returns `getService()` only when `isEmbeddingEnabled()`. No top-level Node imports; it only pulls types from `EmbeddingService`. | new | +| `src/agents/searchManager/services/ServiceAccessors.ts` | `getEmbeddingService()` uses the accessor (`:72-83`). | modified | +| `src/agents/searchManager/searchManager.ts` | Remove the cached `embeddingService` field and the lookup at `:113-115`. Stop calling `setEmbeddingService` at `:140-141`; the tool resolves on use. | modified | +| `src/agents/searchManager/tools/searchContent.ts` | `getEmbeddingService()` (`:257-279`) delegates to the accessor with no caching. Remove `setEmbeddingService` or make it test-only. | modified | +| `src/ui/chat/components/suggesters/initializeSuggesters.ts` | Pass a resolver `() => resolveEmbeddingService(plugin)` instead of a captured instance (`:44-50`). | modified | +| `src/ui/chat/components/suggesters/TextAreaNoteSuggester.ts`, `src/utils/noteSearch.ts` | Accept `EmbeddingService \| null \| (() => EmbeddingService \| null)`. Resolve per query. | modified | +| `src/ui/tasks/TaskBoardView.ts` (`:61`), `src/components/workspace/ProjectsManagerView.ts` (`:146`) | Use the accessor. | modified | +| `src/ui/chat/controllers/UIStateController.ts`, `src/ui/chat/ChatView.ts` | The welcome "Open settings" button (`UIStateController.ts:102-113`) calls a new `onOpenProviderSettings` callback. `ChatView` (`:647`) wires it to `openNexusSettings({ tab: 'providers' })`. The "Configure a provider to start chatting." hint stays. | modified | +| `src/core/settings/SettingsTabManager.ts` | Add `openNexusSettings(route: Partial)`. It stores the pending route on the `SettingsView`, then calls `app.setting.open()` and `app.setting.openTabById(manifest.id)`. The private `app.setting` typing follows the shape already used in `CommandDefinitions.ts:11`. | modified | +| `src/settings/SettingsView.ts` | Add `openTo(route)`, consumed by `display()`. Delete the stray `;` and the dead comment (`:251-252`). The header title is still out of scope (the mockup marks the header as unchanged), so only the noise goes. | modified | +| `src/settings/components/StatusBadge.ts` | Delete. | deleted | +| `src/components/shared/ChatSettingsRenderer.ts` | `syncWorkspacePrompt` (`:1316-1330`) uses the same precedence as `WorkspacePromptResolver`: top-level `dedicatedAgentId` first, then `context.dedicatedAgent.agentId`. The `ChatSettingsOptions.workspaces` type gains `dedicatedAgentId?: string`. Replace the `onclick` assignments (`:1310`, `:1346`) with `registerDomEvent` on the renderer's `Component`. | modified | +| `src/settings/tabs/DefaultsTab.ts` | `loadWorkspaces` (`:91-99`) passes `dedicatedAgentId` and `context` as the modal does. Move Ingestion and Embeddings into the renderer's `renderAfterImageSection` hook so a redraw cannot drop them (an interim fix; Phase 1 replaces the hook). In Ingestion: create the toggle first, put "Auto-convert" inside the gated container, and fix the copy (see Phase 1 for the final copy). | modified | +| `src/ui/chat/components/ChatSettingsModal.ts` | Remove the global context-window merge (`:234-258`). | modified | + +### CSS + +Delete `.nexus-status-badge*` (`styles.css:4736-4754`). Nothing else changes. + +### Mobile + +The accessor has no Node imports. `openNexusSettings` uses only Obsidian APIs. Run `npm run lint:mobile`. + +### Tests (mocked Jest lane, `tests/unit/`, per nexus-testing `write-a-test.md`) + +| Test | Asserts | Why it fails today | +|---|---|---| +| `ServiceNameRegistryContract.test.ts` | Statically collects every string literal passed to `getServiceIfReady` / `getService` under `src/`. Every name must be in `CORE_SERVICE_DEFINITIONS`, `ADDITIONAL_SERVICE_FACTORIES` or the `src/core/index.ts` registrations. | `'embeddingService'` is in none of them. Keeps the class of bug from coming back. | +| `ConversationMemorySearchEmbeddingAccess.test.ts` | Builds `MemorySearchProcessor` with a plugin stub whose `embeddingManager` is enabled and whose service's `semanticConversationSearch` is a spy, and **no** `embeddingService` registry entry. A conversation memory search must call the spy. | Today the accessor asks the registry, gets null and returns `[]`. | +| `SearchContentEmbeddingResolution.test.ts` | Manager disabled then re-enabled: the tool must use the new service instance, not a cached old one. | Today the tool caches forever (`searchContent.ts:269`). | +| `NoteSuggesterSemantic.test.ts` | `searchNotes` with a resolver that returns an enabled service calls `semanticSearch`. | The captured null means it never does. | +| `ChatWelcomeOpenSettings.test.ts` | The no-provider welcome button invokes the provider-settings callback, not the chat-settings modal. | It opens the modal today. | +| `ChatSettingsRendererWorkspacePrompt.test.ts` | Selecting a workspace whose **top-level** `dedicatedAgentId` names a prompt sets `promptId`. | It reads only the deprecated path today. | +| `DefaultsTabIngestionOrder.test.ts` | Uses a DOM-recording `Setting` mock (see the Phase 1 mock work). Asserts the order Enable → Auto-convert → mode, that Auto-convert is hidden while off, and that the rows survive `renderer.render()` being called twice. | All three fail today. | +| Update `ChatSettingsModalContextHandoff.test.ts` | Rename "saves defaults only after commit" to "never writes global defaults". Delete "merges only the edited model budget…". Keep the stale-handoff and cancel cases. | They encode the behaviour being removed. | +| `ChatSettingsWithoutConversation.test.ts` | Save with `conversationId = null`, then create a conversation. Its `chatSettings` must hold the saved temperature and thinking. | Expected to pass. It proves decision 7 rather than fixing anything; if it fails, that is a new bug. | + +Make each new assertion fail once against the baseline before writing the fix (`mock-honesty.md`). + +### In-app verification (nexus-testing `live-loop.md`, test vault only) + +1. `npm run build`, then `obsidian plugin:reload id=nexus vault=`, assert `app.vault.getName()`, then `obsidian dev:errors`. +2. Wait for indexing to finish, then: `nexus --vault use --memory "Verify chat memory search" --goal "semantic conversation hits" -- search memory `. Expect conversation results with similarity scores. +3. `obsidian eval` on the chat view: type `[[concept` in the composer and confirm the suggestions differ from basename fuzzy matching for a conceptual query. +4. Remove all providers in the test vault, open chat, click "Open settings", and confirm the Providers tab opens. + +### Acceptance + +- Conversation semantic search returns results in the live vault. +- No `getServiceIfReady('embeddingService')` remains (`grep`). +- The welcome button lands on Providers. +- Ingestion survives provider and model changes on Defaults. + +--- + +## Phase 1 — Shared accordion and the Essentials + folds layout + +**Goal:** both surfaces render the mockup's structure with Obsidian components. Capability rules are partly in place here and finished in Phase 2. + +### Step 1a — settle the slider readout (in-app, before any code) + +In a test vault, run `obsidian eval` against a settings tab's `containerEl` and collect the markup of a plain `Setting().addSlider()`. If Obsidian renders its own value element, the readout helper writes the formatted text into that element. Otherwise it adds exactly one `span.nexus-slider-value`. Either way each value appears once. Record the finding in the testing skill's `refinement-log.md`. + +### Files + +| Path | Change | Kind | +|---|---|---| +| `src/components/Accordion.ts` | Rewrite to `new Accordion(containerEl, { id, title, icon?, summary?, open?, onToggle? })`. Markup:
• root `div.nexus-accordion` with `is-open`
• `button.nexus-accordion-toggle` with `aria-expanded` and `aria-controls`, holding `setIcon` icon, `.nexus-accordion-text` (title plus summary, summary hidden while open) and a chevron (`chevron-down` / `chevron-up`)
• `div.nexus-accordion-content.setting-group` with `id` and `hidden`, containing one stable `div.setting-items` exposed as `itemsEl`
API: `setSummary()`, `setOpen(open, {silent})`, `isOpen`, `itemsEl`. Events use `registerDomEvent`, and Enter/Space come from the native button. Remove `getContentEl()`, which created a new div per call. | modified | +| `src/settings/SettingsView.ts` | Interim: move the Get started accordion to the options object (`title: 'Get started'`, `icon: 'rocket'`). Phase 3 deletes this use. | modified | +| `src/components/shared/settings/FoldStateStore.ts` | Wraps `app.loadLocalStorage` / `saveLocalStorage` for `nexus-settings-folds` as `isOpen(surface, id)` and `setOpen(surface, id, open)`. Tolerates malformed stored values. | new | +| `src/components/shared/settings/settingGroup.ts` | `createSettingGroup(container, cls?)` returns `new SettingGroup(container)`. Kept in one place for the mock and for a future API change. | new | +| `src/components/shared/settings/sliderReadout.ts` | `bindSliderReadout(setting, slider, format)`: one readout per the step 1a finding, live `input` updates through the owning `Component.registerDomEvent`, and `aria-valuetext`. | new | +| `src/components/shared/ChatSettingsRenderer.ts` | Restructure (details below). | modified | +| `src/components/shared/ModelDropdownRenderer.ts` | Split into `renderProviderSetting(itemsEl, cfg)` and `renderModelSetting(itemsEl, cfg)` that add rows to a provided `setting-items` (no `csr-section` shell, no section title, no info icon). `reRender` becomes `onSelectionChanged(kind)`, so the caller redraws only the affected group. | modified | +| `src/components/shared/chatSettings/VoiceSettings.ts` | **The single voice implementation.** It merges `CSR.renderVoiceSection/renderSpeechSettings/renderRealtimeVoiceSettings/renderTranscriptionControls/getVoiceWarningMessages` with the Defaults-only bits: `renderAudioSaveSettings` and the `buildSpeechDefault` / `buildRealtimeDefault` source/auto bookkeeping (`DefaultsTab.ts:566-600`, `:984-1006`). Subheads: Transcription, Read aloud, Live voice. It writes only to the renderer's settings object, and Defaults persists it through its save queue (see DefaultsTab). Warning messages become the description of the affected Provider row. | new | +| `src/components/shared/chatSettings/sectionSummaries.ts` | Pure functions that build each fold's collapsed summary from settings plus capabilities (copy appendix). | new | +| `src/settings/tabs/defaults/VideoSettings.ts` | Moves `renderVideoSection` (`DefaultsTab.ts:288-470`) into fold rows: Provider, Model, Aspect ratio, Resolution. | new | +| `src/settings/tabs/defaults/FileConversionSettings.ts` | Rows in order: PDF import toggle (`enableIngestion`), then, only when on, Reading method (`defaultPdfMode`: Text extraction / Vision), Vision provider and Vision model (only when Vision, via `renderIngestModelDropdowns` with the new names) and Convert new PDFs automatically (`autoIngestion`). The section redraws its own rows when the toggle or mode changes. | new | +| `src/settings/tabs/defaults/SemanticSearchSettings.ts` | Phase 1: the toggle row only, keeping today's restart Notice, so the fold exists. Phase 4 completes it. Desktop only. | new | +| `src/settings/tabs/DefaultsTab.ts` | Delete the header and intro (`:221-229`), the voice, video and ingestion implementations, the Embeddings section and the `renderAfterImageSection` use. Construct the renderer with `surface: 'defaults'` and `extraFolds` = Video, File conversion and Semantic search. Extend `getCurrentSettings`/`saveSettings` to persist `speech*`, `realtimeVoice*` and the video fields through the save queue, so nothing writes `llmProviderSettings` outside it. | modified (large reduction) | +| `src/ui/chat/components/ChatSettingsModal.ts` | Use `this.setTitle('Chat settings')` and put Cancel / Save in a bottom `div.modal-button-container` (replacing the `chat-settings-header` block at `:55-72`). Keep the `role=status` save text there. `surface: 'chat'`. | modified | +| `tests/mocks/obsidian/components.ts`, `tests/mocks/obsidian/index.ts` | Add `SettingGroup`, `ExtraButtonComponent`, `ProgressBarComponent`, `Setting.addExtraButton`, `Setting.addProgressBar`, `Setting.setDisabled`, `SliderComponent.setDisabled` and `DropdownComponent.setDisabled`. Make the `Setting` mock **append** `settingEl` to the container passed in and record its name, so tests can assert order. Today it appends nothing and drops the name (`components.ts:8-28`). | modified | + +**Renderer structure** (`ChatSettingsRendererConfig` gains `surface: 'defaults' | 'chat'`, `foldStore` and `extraFolds?: FoldDefinition[]`; `showWorkflowSelection`, `showVoiceSection`, `showTranscriptionSection`, `showWebSearch` and `renderAfterImageSection` are removed): + +1. `render()` builds a **stable shell** once: the top `SettingGroup` (no heading) and one `Accordion` per fold, in mockup order. Order and icons: + - Chat options — `sliders-horizontal` + - Context — `layers` + - Subagents — `bot` + - Images — `image` + - Video — `film` (Defaults only) + - Voice — `mic` + - File conversion — `file-input` (Defaults only) + - Semantic search — `scan-search` (Defaults only, desktop only) +2. Each group has its own `renderX(itemsEl)` that empties and refills only its own `itemsEl`, then calls `accordion.setSummary(...)`. Redraw map: + + | Change | Redraws | + |---|---| + | chat provider/model | top block, Chat options, Subagents summary (inherits) | + | subagent provider/model | Subagents | + | image provider | Images | + | workspace | Context | + | `LLMSettingsNotifier` | all groups, but **not** the shell | + + Because accordions are never rebuilt, fold state and scroll position survive. For a full redraw (`destroy()` and re-render on surface switch), capture `scrollTop` of the scroll parent (modal `contentEl` or the settings `.vertical-tab-content`) and restore it after. +3. Top block: Provider and Model as separate dropdowns, then the Thinking dropdown. Thinking is present only when `supportsThinking`. +4. Chat options: Creativity (disabled rule in Phase 2; in Phase 1 always enabled, with the new description), Context window (preset slider), and Web search only on `surface === 'chat'`, using the existing provider gating (`CSR:483-501`) and the new description. +5. Context: Workspace, Workflow (chat only, new description), Prompt, then a "Context notes" `Setting` with a `ButtonComponent` "Add note" and one row per note. Each note row shows a `file-text` icon and the basename, plus an `ExtraButtonComponent` `x` with tooltip `Remove `. +6. Subagents: a "Used for" note row, then the same row builders as the top block and Chat options with `variant: 'agent'`. Creativity and Context window for subagents arrive in Phase 2. Until then the fold shows Provider, Model and Thinking. +7. Images: Provider and Model (existing logic, `CSR:627-707`). +8. Voice: `VoiceSettings` with `surface`. +9. Extra folds supplied by Defaults. + +`ChatSettings` gains nothing in Phase 1 apart from moving the voice and video fields that Defaults now persists through the queue. + +### Settings keys + +Only the device-local fold store. + +### CSS (`styles.css`) + +**Add** a "Settings: shared accordion" section that replaces `ORIGINAL ACCORDION STYLES` (`:1754-1866`). Port these rules from the mockup's CSS, using theme variables only: +- `.nexus-accordion` (max-width `var(--setting-group-max-width)`, margin, radius) +- `.nexus-accordion.is-open { background: var(--setting-items-background) }`, so the whole open fold, header included, is one lighter card +- `.nexus-accordion-toggle` (flex, padding `var(--setting-items-padding-x)`, hover only while closed) +- `.nexus-accordion-icon` (accent) +- `.nexus-accordion-title` (`--setting-group-heading-size`, 600) +- `.nexus-accordion-summary` (muted, single line, ellipsis) +- `.nexus-accordion-chevron` +- `.nexus-accordion-content .setting-items { background: transparent }` +- the first-row divider rule +- spacing between folds, more than between rows +- the modal variant +- `.nexus-subhead`, `.nexus-note-row`, `.nexus-note`, `.nexus-ctx-value` (or `.nexus-slider-value`) +- `is-mobile` tap targets of at least 44 px on the toggle + +**Delete:** +- `.mcp-accordion*` (`:1754-1866`) +- `.csr-effort-row`, `.csr-effort-value`, `.csr-temp-value`, `.csr-provider-warning*`, `.csr-notes-*`, `.csr-note-*`, `.csr-add-btn`, `.csr-info-icon` (`:4408-4417`, `:4466-4584`) +- `.nexus-voice-*` (`:4435-4460`) +- `.thinking-settings-section`, `.thinking-effort-value` (`:4345-4361`), if `grep` shows no remaining users +- the `.chat-settings-header` / `.chat-settings-buttons` rules (`:3900-3910`, `:4255-4270`, after checking each selector) + +**Keep** `.csr-section`, `.csr-section-header`, `.csr-section-content` and `.csr-section-desc`, because `DataTab.ts:76-134` still uses them. Leave a one-line comment naming DataTab as the remaining consumer. **Keep** `.nexus-ingest-confirm-hidden` (`:8555`), which `IngestConfirmModal.ts:145-157` uses. Do not reference the mockup by file or line in CSS comments; name the plan. + +The text-only provider warning (`CSR:546-596`) becomes the Model row's description (`setDesc`), with its title in bold through a `DocumentFragment`. No dedicated CSS. + +### Mobile + +The renderer stays mobile-safe: no Node imports, and the Semantic search fold is constructed only when `Platform.isDesktop`. The Video fold follows today's provider availability. Verify `is-mobile` layout with `obsidian dev:mobile on` (layout only; it is not evidence of import safety) and run `npm run lint:mobile`. + +### Tests + +| Test | Lane | Asserts | Fails today because | +|---|---|---|---| +| `Accordion.test.ts` | unit | Collapsed by default. Toggle flips `aria-expanded`, `hidden` and `is-open`. The summary is hidden while open. `itemsEl` is the same node across calls. `onToggle` fires once. | `getContentEl()` returns a new node each call, and there is no summary or icon API. | +| `FoldStateStore.test.ts` | unit | Round-trip per surface. Malformed stored JSON gives all closed. Nothing touches `saveSettings`. | New. | +| `ChatSettingsRendererLayout.test.ts` | unit, DOM-recording mocks | Defaults order: top group (Provider, Model, Thinking) then folds Chat options, Context, Subagents, Images, Video, Voice, File conversion, Semantic search. Chat order omits Video, File conversion and Semantic search. Chat options includes Web search only in chat. Context includes Workflow only in chat. No element with the old intro text. | The current order and section names differ. | +| `ChatSettingsRendererRedraw.test.ts` | unit | Open Context, change the chat model: Context is still open and the Context `itemsEl` was not rebuilt. Change the workspace: only Context rebuilt. | `render()` empties everything. | +| `ThinkingDropdown.test.ts` | unit | Mapping Off↔`{enabled:false}` and level↔`{enabled:true, effort}`. Hidden for a non-thinking model. Off keeps the stored effort. | The toggle and slider exist today. | +| `ContextWindowStops.test.ts` | unit | `contextWindowStops(1_000_000)` = 7 stops ending at 1M. `(200_000)` = [32K, 64K, 128K, 200K]. `(40_000)` = [32K, 40K]. `(4096)` = [4096] and the slider is disabled. A legacy 150,000 keeps the readout "150K" and puts the thumb on 128K. A provider/model change shows the new model's maximum. | 1-token slider today. | +| `SettingsSectionSummaries.test.ts` | unit | Each summary string, using the copy constants in Appendix A. | New. | +| `VoiceSettingsSingleImplementation.test.ts` | unit | Defaults and chat produce the same row labels. The Defaults-only "Saved audio folder" row appears only on Defaults. Defaults persists `defaultSpeechModel.source = 'user'` on a manual pick. | Two implementations with different labels. | +| `FileConversionSettings.test.ts` | unit | Toggle first. Dependent rows only when on. Vision rows only in Vision mode. The copy matches Appendix A. | Bug 3. | +| Rewrite `ChatSettingsRendererEffort.test.ts`, `ChatSettingsRendererContextWindow.test.ts` | unit | Retarget them at the dropdown and the preset slider. | Old controls removed. | +| Keep `ChatSettingsRendererTextOnlyWarning.test.ts` | unit | Update it to assert the Model row description. | Location moves. | + +### In-app verification + +Build and reload, then use `obsidian eval` on `app.setting.activeTab.containerEl` (settings may be a separate window, per `live-loop.md`) to dump each group's title and its row names. + +1. Defaults: open Context, switch the chat model, and check that Context is still open and the scroll position is unchanged. +2. Close and reopen settings: the fold state is remembered. Restart Obsidian: still remembered. +3. Chat settings modal: same checks, then Cancel discards and Save persists. +4. Light and dark themes, and narrow width with `dev:mobile on`. +5. `dev:screenshot` both surfaces for the owner, next to the mockup. + +### Acceptance (against the mockup) + +- No intro line and no heading above the top block. +- The top block is a `SettingGroup` with Provider, Model and Thinking. +- Folds are collapsed by default, in mockup order with mockup icons, and show a one-line summary while collapsed. +- An open fold is a single lighter card, header included. +- Every value appears once. +- No "restore default" buttons. +- The only custom component is the accordion. `grep -n "createEl('button'" src/components/shared/ChatSettingsRenderer.ts` finds nothing. + +--- + +## Phase 2 — Capability-driven controls and subagent settings that take effect + +### Files + +| Path | Change | Kind | +|---|---|---| +| `src/services/StaticModelsService.ts` | `ModelWithProvider` gains `supportsSamplingParams?: boolean` (mapped at `:210-222`). | modified | +| `src/components/shared/chatSettings/modelCapabilities.ts` | `getModelCapabilities(staticModels, providerManager, provider, model)` returns `{ supportsThinking, supportsSamplingParams, contextWindow }`. Static models answer synchronously; discovered windows (Ollama, LM Studio) come from `providerManager.getModelsForProvider`, as `CSR:430-449` does today. Merged provider IDs (`openai-codex` → `openai`, `anthropic-claude-code` → `anthropic`, `google-gemini-cli` → `google`) are handled like `ModelDropdownRenderer.ts:199-206`. | new | +| `src/services/llm/utils/resolveThinkingForModel.ts` | Shared gating helper (see Decisions). | new | +| `src/components/shared/ChatSettingsRenderer.ts` | The Creativity row follows `supportsSamplingParams`: disabled `Setting` and slider, readout "—", description ` sets its own temperature.`. The Subagents fold gets Creativity and Context window rows. On agent provider/model change, clear the pending `agentContextWindowOverrides[key]`. The "Used for" row is always present. | modified | +| `src/types/llm/ProviderTypes.ts` | Add `agentTemperature?`, `agentContextWindowOverrides?` to `LLMProviderSettings` (`:128-152`), with comments. | modified | +| `src/components/shared/ChatSettingsRenderer.ts` (`ChatSettings`) | Add `agentTemperature?: number` and `agentContextWindowOverrides?: Record`. | modified | +| `src/settings/tabs/DefaultsTab.ts` | Load and save the two new keys. | modified | +| `src/ui/chat/services/ModelAgentDefaultsResolver.ts`, `ModelAgentConversationSettingsStore.ts`, `ModelAgentManager.ts` | Carry `agentTemperature` and `agentContextWindow` through defaults, conversation restore (`:303-313`), `buildChatSettingsMetadata` (`:1229-1255`) and new getters/setters. | modified | +| `src/types/chat/ChatTypes.ts` | `chatSettings` gains the two fields (`:175-180`). | modified | +| `src/ui/chat/components/ChatSettingsModal.ts` | Read and write the new agent fields. | modified | +| `src/ui/chat/services/ModelAgentPromptContextAssembler.ts` | `enableThinking` and `thinkingEffort` go through `resolveThinkingForModel` (`:120`, `:126`). | modified | +| `src/ui/chat/controllers/SubagentController.ts` | `buildSubagentContext` (`:455-483`) resolves provider, model, thinking, temperature and context window using the agent precedence in Decisions, via a new `SubagentModelResolver`. `createStreamingGenerator` (`:315-358`) accepts and forwards `enableThinking`, `thinkingEffort` and `temperature`. | modified | +| `src/ui/chat/services/SubagentModelResolver.ts` | Pure precedence function, with the `LOCAL_PROVIDERS` list shared with `LLMService`. | new | +| `src/agents/promptManager/tools/subagent.ts` | The context type gains `temperature` and `contextWindow`. Pass them into `executeSubagent` and to remote jobs where supported (`:200-206`). | modified | +| `src/types/branch/BranchTypes.ts`, `src/services/chat/SubagentExecutor.ts` | Params gain `temperature` and `contextWindow`. Forward `thinkingEnabled`, `thinkingEffort` and `temperature` to `streamingGenerator` (`:367-375`). Use `contextWindow` as the budget for inherited `contextFiles`/`workspaceData` assembly: drop whole files beyond the budget and add one line saying which were omitted. No mid-run compaction. | modified | +| `src/services/llm/core/LLMService.ts` | `getAgentModel()` (`:126-151`) honours a configured `agentModel` whatever the chat model is. The precedence lives in `SubagentModelResolver`, imported here. | modified | +| `src/agents/promptManager/tools/executePrompts/services/PromptExecutor.ts` | Pass the agent temperature, unless sampling is unsupported, and agent thinking through `resolveThinkingForModel` into `executeParams` (`:89-97`). `LLMExecutionOptions` already extends `GenerateOptions`, which has `temperature`, `enableThinking` and `thinkingEffort`. | modified | + +### CSS + +None beyond Phase 1. `.setting-item.is-disabled` comes from Obsidian. + +### Mobile + +No new imports. Local providers stay excluded from the Subagents provider list (`CSR` `LOCAL_PROVIDERS`). + +### Tests + +| Test | Asserts | Fails today because | +|---|---|---| +| `CreativityCapability.test.ts` | Claude Opus 5.5: row disabled, description `Claude Opus 5.5 sets its own temperature.`. Claude Opus 4.6: enabled. A model missing from the registry: enabled. | The flag is not mapped and the slider is always enabled. | +| `ResolveThinkingForModel.test.ts` | Non-thinking model gives `{enabled:false}`, the stored preference is untouched, and a thinking model passes through. | New. | +| `PromptContextThinkingGate.test.ts` | Stored `enabled:true` with Mistral Large Latest gives `buildMessageOptions().enableThinking === false`. | It sends `true` today. | +| `SubagentModelResolver.test.ts` | Precedence table, including "no agent model set and chat model is cloud → chat model", which is today's behaviour. | New. | +| `SubagentControllerForwardsSettings.test.ts` | With agent Haiku 5.5, thinking High and temperature 0.3, the generator calls `llmService.generateResponseStream` with the agent provider and model, `enableThinking: true`, `thinkingEffort: 'high'` and **no** temperature (Haiku rejects sampling). With Opus 4.6, temperature 0.3 is passed. | Today it inherits the chat model and drops thinking and temperature. | +| `PromptExecutorAgentSettings.test.ts` | `executePrompt` receives the agent temperature and thinking. | Not passed today. | +| `LLMServiceAgentModel.test.ts` | A configured `agentModel` wins even when the chat model is cloud. | Returns the chat default today. | +| `SubagentContextBudget.test.ts` | Context files beyond `contextWindow` are omitted, and the prompt says which. | No budget today. | + +Gated live smoke (`nexus-testing` `run-gated-lanes.md`): one subagent run against a real provider, confirming the provider and model that the adapter receives. Optional and not in CI. + +### In-app verification + +1. In Defaults set Subagents to Claude Haiku 5.5 with Thinking Off; set the chat model to Opus 5.5. +2. Ask the chat to spawn a subagent. +3. With `obsidian eval`, read the subagent branch's message metadata (provider/model). Expect Haiku. +4. Switch the chat model to Mistral Large Latest and send a message. Its request carries no thinking, confirmed by temporary console logging in a dev build only, or by adapter tests. + +### Acceptance + +- Creativity and Thinking rows follow the model, exactly as the mockup's caption describes ("Opus, Sonnet and Haiku 5.5 reject temperature, Opus 4.6 accepts it, Mistral Large can't reason, and Magistral Medium has a 40K context"). +- Every Subagents value demonstrably reaches the execution path. + +--- + +## Phase 3 — Get started pages, Node.js and Antigravity + +### Files + +| Path | Change | Kind | +|---|---|---| +| `src/settings/SettingsRouter.ts` | `RouterState` gains `page?: 'get-started' \| 'get-started-chat' \| 'get-started-agents'`. Add `openPage(page)` and `closePage()`. `setTab()` clears `page`. `back()` from a page goes up one level: agents or chat → get-started → none. | modified | +| `src/settings/SettingsView.ts` | Above the tab strip, a `SettingGroup` (class `nexus-get-started`) with one navigable `Setting`. Its name is the `rocket` icon plus "Get started", it has the description from Appendix A and a `chevron-right` control, and the whole row is clickable (`registerDomEvent` on `settingEl`, `role=button`, `tabindex=0`, Enter/Space). While `state.page` is set, `display()`/`renderTabContent` hides the row, the tab strip and the panes, and renders the page into a page container. Apply the first-run auto-open rule. Delete `renderGetStartedAccordion` and the `getStartedAccordion` field (`:534-597`). The external-agent page still loads through `await import(...)`. | modified | +| `src/settings/getStarted/GetStartedPage.ts` | Mobile-safe. Page head: `BackButton` ("Settings") and title. Lede, the six capability bullets (Lucide icons via `setIcon`), heading "Choose your setup" with description "Pick one, or use both.", and a `SettingGroup` with two navigable rows. "Chat inside Obsidian" has value `Ready` / `Not set up` from `hasConfiguredProviders`. "Connect an external agent" (desktop only) has value `N connected`, where N counts Claude Desktop, Codex and Antigravity configs that contain a Nexus server key plus the command line when installed. | new | +| `src/settings/getStarted/ChatSetupPage.ts` | Mobile-safe. Back: "Get started". Three numbered steps (`.nexus-step` badge):
1. Status "Connected" (circle-check) only when a provider is configured, plus a "Open providers" button that calls `router.setTab('providers')`.
2. "Open chat" (mod-cta) runs `app.commands.executeCommandById(':open-chat')` and closes settings.
3. Text only. | new | +| `src/settings/getStarted/ExternalAgentsPage.ts` | Desktop only. **Dynamically imported.** Built from the reusable parts of `GetStartedTab.ts` (config paths, `ensureConnectorFile`, `autoConfigureNexus`, `autoConfigureCodex`, `openConfigFile`, the Local CLI install, PATH fix and providers logic). Layout as in the list after this table. | new | +| `src/settings/tabs/GetStartedTab.ts` | Deleted once its logic is moved. | deleted | +| `src/utils/nodeRuntime.ts` | `resolveNodeRuntime(): { path, version, manager: 'nvm' \| 'other' } \| null`. It uses `resolveDesktopBinaryPath('node')` and then runs `execFileSync(path, ['--version'], { timeout: 3000 })` through `desktopRequire`. `manager` is `'nvm'` when the path contains `/.nvm/versions/node/` (or `\nvm\` on Windows nvm-windows). No top-level Node imports. | new | +| `src/utils/mcpClientConfigs.ts` | Path and status helpers: `getAntigravityConfigPath()` (`~/.gemini/config/mcp_config.json`, using `%USERPROFILE%` on Windows); `getAntigravityLegacyConfigPath()` (`~/.gemini/antigravity/mcp_config.json`); `getMcpJsonStatus(path, serverKeys)`. These are shared by Claude Desktop and Antigravity, since both use the `mcpServers {command,args}` shape. Also `buildMcpServerEntry(nodePath, connectorPath)`, which **requires** a node path; there is no `'node'` fallback. | new | +| `src/settings/getStartedStatus.ts` | Remove `shouldOpenGetStarted` and the `getStartedOpen` branch. `hasExternalAgentSetup` also checks Antigravity. Export a `countExternalConnections(app)`. | modified | +| `src/types/plugin/PluginTypes.ts` | Remove `getStartedOpen` (`:84`). | modified | +| `src/settings/components/BackButton.ts` | Make `component` required, removing the `addEventListener` fallback at `:28-30`. All callers already pass one or can. | modified | +| `src/services/llm/providers/BuiltinProviderDrivers.ts` (`:278`), `src/utils/platform.ts` (`:159`), `src/services/llm/adapters/google-gemini-cli/GoogleGeminiCliAdapter.ts` (`:244`), `README.md` (`:16`) | Replace "Gemini CLI" in user-facing strings with "Antigravity CLI", or "Antigravity" for the MCP client in README. Internal IDs (`google-gemini-cli`), class names and code comments stay. Update `tests/unit/GenericProviderModalSaveBehavior.test.ts:131-149` only if the notice label source changes (it is `oauthConfig.providerLabel`, already "Antigravity CLI" at `ProvidersTab.ts:717-721`, so check before editing). | modified | + +**External-agent page layout:** + +- Lede: "Let apps outside Obsidian use your vault through Nexus." +- **Node.js group** + - Found: description "Runs the connector. Found `` at ``." with the status "Found". If `manager === 'nvm'`, add a warning line: "This path changes when you switch Node versions with nvm. Reconnect your apps after switching, or install Node.js from nodejs.org for a stable path." + - Missing: the Appendix A copy with "Install Node.js" (mod-cta, opens `https://nodejs.org/`) and "Check again", which re-resolves and redraws. +- **Apps group** (one `SettingGroup`, no descriptions under the names): + - Claude Desktop: status, plus "Open config" when connected or "Connect". + - Codex: status plus "Connect". + - Antigravity: status plus "Connect". **New**: writes the JSON entry to `getAntigravityConfigPath()`, merging into existing `mcpServers` with the same code path as Claude Desktop. + - Every Connect calls `ensureConnectorFile` first. + - Stale detection: if a config's `command` path no longer exists (the nvm switch case), the status reads "Reconnect". + - Last row, "Other agents": description from Appendix A, with a chevron `ExtraButtonComponent` ("Show steps" / "Hide steps"). It expands inline into four numbered steps: + 1. Create → "Created". + 2. A `pre.nexus-code` preview of `JSON.stringify({mcpServers:{[serverKey]:{command, args:[connector]}}}, null, 2)` and "Copy config". + 3. Prompt preview and "Copy prompt". + 4. Text only. + - When Node is missing, every Connect, Create and Copy is disabled, the status reads "Needs Node.js", and the previews read "Install Node.js first." (step 2 uses the long form from the mockup). +- **Command line group** + - Name `terminal` icon plus "Command line", description from Appendix A, status (the existing `installer.status()` wording, shortened to "Installed" / "Not installed" / "Not on your PATH"), and Install or Uninstall. + - Three `ToggleComponent` child rows: "Claude Code skills" `~/.claude/skills`, "Cursor skills" `~/.cursor/skills`, "Codex AGENTS.md" `~/.codex/AGENTS.md`. + - The toggles behave as the checkboxes do today: they pick targets before install and wire immediately after install (`GetStartedTab.ts:660-695`). + - "Add to path" and the PATH-fix panel keep their behaviour, as extra rows when applicable. +- Fix the guide link: `createEl('a', { href: 'https://github.com/ProfSynapse/nexus/blob/main/guide/mcp-setup.md' })`, with the click going through `registerDomEvent` + `window.open`. The link sits under the Other agents steps. + +### CSS + +**Add** a "Settings: Get started pages" section with: +- `.nexus-page-head`, `.nexus-page-title`, `.nexus-page-lede` +- `.nexus-can` (icon list) +- `.nexus-gs-heading` +- `.setting-item.mod-navigable` (cursor, hover) +- `.nexus-step` (number badge) +- `.nexus-status` and `.nexus-status.is-ok` (circle-check) +- `.nexus-code` and `.nexus-prompt` (monospace, wrapping, max-height with scroll) +- `.nexus-other-steps`, `.nexus-child-row`, `.nexus-path-row`, `.nexus-node-missing` +- 44 px tap targets on `is-mobile` + +**Delete** the old Get started CSS: +- `.nexus-intro*`, `.nexus-capabilit*`, `.nexus-cap-icon`, `.nexus-divider`, `.nexus-setup-*` (`:4773-4925`) +- `.nexus-mcp-*`, `.nexus-cli-*`, `.nexus-manual-*`, `.nexus-config-code`, `.nx-*` (`:4869-5280`) +- `.nexus-settings-accordion*` (`:2210-2216`) + +Check each selector with `grep -rn` across `src/` first; some `nexus-mcp-*` names may be shared. + +### Mobile + +`GetStartedPage` and `ChatSetupPage` import only `obsidian` and `getStartedStatus` (already imported at init). `ExternalAgentsPage`, `nodeRuntime` and `mcpClientConfigs` are reached only through `await import()` behind `Platform.isDesktop && supportsMCPBridge()`. Run `node scripts/check-mobile-imports.mjs . --trace src/settings/getStarted/ExternalAgentsPage.ts` to confirm it is not on the init path. `src/settings/components/**` stays under the ESLint Node ban. + +### Tests + +| Test | Asserts | Fails today because | +|---|---|---| +| `SettingsRouterPages.test.ts` | `openPage`/`back` hierarchy; `setTab` clears the page. | No pages. | +| `GetStartedFirstRun.test.ts` | No provider and no external setup: the first `display()` shows the Get started page and the second does not. A configured provider shows the tab list. | The accordion and `getStartedOpen` exist today. | +| `GetStartedChatPage.test.ts` | Step 1 shows "Connected" only when configured and never lists provider names. "Open providers" switches to the providers tab. Copy has no "Ctrl/Cmd". | Hotkey copy today. | +| `ExternalAgentsNodeMissing.test.ts` | `resolveNodeRuntime` returns null: every Connect, Create and Copy is disabled, status "Needs Node.js", the preview says "Install Node.js first.", and no config string contains `"node"` as a bare command. | Bare `'node'` fallback. | +| `McpServerEntryWindows.test.ts` | With Windows paths the copied JSON escapes backslashes and `JSON.parse` round-trips to the exact paths. | New. | +| `AntigravityConnect.test.ts` | With a mocked `desktopRequire` fs: creates `connector.js` first, creates the file if missing, merges into existing `mcpServers` and preserves other servers. | New. | +| `NodeRuntimeNvm.test.ts` | Detects an nvm path; parses `v24.11.0`. | New. | +| `NoGeminiCliCopy.test.ts` | Scans `src/**/*.ts` string literals in UI and notice contexts for "Gemini CLI". The allowlist is limited to comments and IDs. | Five user-facing hits. | +| Update `GetStartedStatus.test.ts`, `SettingsViewDataTab.test.ts:164-199` | Remove the `getStartedOpen` cases. | The key is retired. | +| `BackButton.test.ts` | Update for the required `component`. | Signature change. | + +### In-app verification (desktop test vault) + +1. With providers configured, open Settings → Nexus. The row sits above the tabs and the whole row opens the page. Back returns to the tab list on the same tab. +2. "Chat inside Obsidian": "Open providers" lands on Providers; "Open chat" opens the chat view. +3. External agents: the Node row shows version and path. Temporarily point `PATH` to exclude node and use a dev build that forces `resolveNodeRuntime` to null; check the disabled state. +4. Click Antigravity Connect on a machine with Antigravity (see Not confirmed). Check that the file is written, then reload MCP servers with `/mcp` or Refresh, then ask Antigravity to list vault folders. +5. Copy config and check the clipboard content with `obsidian eval` (`navigator.clipboard.readText()`). +6. On mobile (a real device, not emulation), Get started shows only the chat path. + +### Acceptance + +The pages, the row, copy, order and states match the mockup: Get started intro, Chat inside Obsidian, Connect an external agent with OS-specific paths and the Node found/missing states, and the Other agents four steps. There is no emoji, no "Gemini CLI" and no `href="#"`. + +--- + +## Phase 4 — Semantic search: status, hot-apply, delete, prune, tool schema + +### Files + +| Path | Change | Kind | +|---|---|---| +| `src/services/embeddings/EmbeddingManager.ts` | Separate **disable** from **unload**:
• `setEnabled(enabled): Promise`. Enabling sets `isEnabled` and calls `initialize()`, which builds fresh components. Disabling calls `stop()`.
• `stop()`: cancel the timer, `queue.destroy()`, `watcher.stop()`, `conversationWatcher.shutdown()`, `statusBar.destroy()`, await `backgroundIndexingWork`, `engine.dispose()` (unloads the model), clear the dream interval (store its ID; it is currently anonymous at `:322-325`), `plugin.removeCommand('consolidate-retrieval-memory')` (API 1.7.2), null all components, `isInitialized = false`.
• `shutdown()` keeps its permanent `shuttingDown` fence and calls the shared teardown.
• Add `onStateChange(listener)` (Obsidian `Events`), emitted on start, stop and every queue `progress` event.
• `getStats()` adds `conversationCount` (`SELECT COUNT(DISTINCT conversationId) FROM conversation_embedding_metadata`) and returns stored counts even while disabled, so the off state can show "Paused · N notes". It reads the DB directly when `service` is null. | modified | +| `src/services/embeddings/IndexingQueue.ts` | Add `pruneMissingNotes()`: `SELECT notePath FROM embedding_metadata`, and for every path where `app.vault.getAbstractFileByPath(path)` is null, call `embeddingService.removeEmbedding`. Add `pruneMissingConversations()` for `conversation_embedding_metadata` rows whose conversation no longer exists. Expose `getProgress()` for initial paint. | modified | +| `src/services/embeddings/EmbeddingManager.ts` (`runIndexingPhases`, `:379-411`) | Run the prune passes before Phase 1. This covers startup, re-enable and rebuild. | modified | +| `src/database/storage/SQLiteMaintenanceService.ts`, `SQLiteCacheManager.ts` | Add `clearEmbeddingIndex()` in a transaction: drop and recreate `note_embeddings`, `trace_embeddings` and `conversation_embeddings` (`USING vec0(embedding float[384])`, matching `schema.ts:305-367`), `DELETE FROM` `embedding_metadata`, `trace_embedding_metadata`, `conversation_embedding_metadata` and `embedding_backfill_state`, then save. No schema change. Still run `check_schema_consistency.py`, since the DDL strings must match. | modified | +| `src/services/embeddings/EmbeddingManager.ts` | `deleteIndex()` is allowed only while disabled; it throws otherwise. | modified | +| `src/core/PluginLifecycleManager.ts` | Keep constructing `EmbeddingManager` on desktop whatever the setting (it already does, `:363-371`). Subscribe to `onStateChange`, so `chatTraceService.setEmbeddingService(service \| null)` follows the state (`:389-397`). Expose `setEmbeddingsEnabled(value)`. | modified | +| `src/services/chat/ChatTraceService.ts` | `setEmbeddingService(service: EmbeddingService \| null)` (`:56`). | modified | +| `src/settings.ts` | `reloadSettings` (`:195-201`) excludes `enableEmbeddings`. `reconcileExternalSettings` returns a flag so the plugin can hot-apply a synced change. | modified | +| `src/main.ts` | In `onExternalSettingsChange`, when `enableEmbeddings` changed, call `lifecycle.setEmbeddingsEnabled`. | modified | +| `src/settings/tabs/defaults/SemanticSearchSettings.ts` | Full fold:
• Toggle row.
• On: status row "Index" (`4,512 notes · 318 chats`).
• Indexing: a "Indexing" row with `ProgressBarComponent`, "1,904 / 4,512", ETA text from `estimatedTimeRemaining`, and a pause `ExtraButtonComponent` (`pause` / "Pause indexing"; `play` / "Resume indexing" while paused).
• Off: "Index" (`Paused · N notes`) plus a "Delete index" `ButtonComponent` (`mod-warning`). After deletion it shows "No index on this device".
• Turning off opens the confirmation modal; Cancel reverts the toggle and Escape cancels.
• Subscribes to `onStateChange` while mounted and unsubscribes on destroy.
• Summary: `On · N notes` / `Indexing · P%` / `Off · index kept`. | modified | +| `src/settings/tabs/defaults/SemanticSearchOffModal.ts` | An Obsidian `Modal` with the Appendix A copy, `mod-confirm` and the buttons Cancel / Turn off (`mod-warning`). | new | +| `src/agents/searchManager/tools/searchContent.ts` | Constructor takes `getSemanticState: () => 'available' \| 'off' \| 'unsupported'`, derived from `settings.enableEmbeddings`, `Platform` and the manager state. `refreshDescription()` reassigns `this.description` (BaseTool's `description` is a field, so a getter override would clash in TS). The constructor calls it, and so does an `onStateChange` subscription made in `SearchManagerAgent`. `getParameterSchema()` (`:666-709`) picks the `semantic` property description by state. In `execute()`, `semantic === true` while off runs keyword search and adds `note: 'Semantic search is turned off in Nexus settings, so this used keyword search.'`. While unsupported (mobile) it does the same with a "not available on this device" note. "Still initializing" (`:321-322`) is used only when enabled but not yet initialized. | modified | +| `src/agents/searchManager/searchManager.ts` | Wire `getSemanticState` and the refresh subscription. | modified | +| `src/agents/searchManager/types` (result type) | `ContentSearchResult` gains an optional `note?: string`. | modified | +| `cli-first-tool-schemas.json` and `docs/generated/*` | Regenerate with `npm run schemas:tools -- --output cli-first-tool-schemas.json` per `nexus-tool-schemas` `refresh-catalog.md`, then `check_catalog.py`. The headless exporter has no plugin settings, so the default state **must** be `'available'` to keep the committed description stable. | regenerated | + +### CSS + +Add `.nexus-semantic-progress` (progress bar width 140 px, as in the mockup) and `.nexus-semantic-value`. Nothing else is needed: the modal uses Obsidian's `.modal` and `mod-confirm`. + +### Mobile + +The fold is not constructed on mobile. `EmbeddingManager` already disables itself (`:83`). The tool's `'unsupported'` state reports keyword fallback. `SemanticSearchOffModal` is a plain `Modal`. + +### Tests + +| Test | Lane | Asserts | Fails today because | +|---|---|---|---| +| `EmbeddingManagerHotToggle.test.ts` | unit | `setEnabled(false)`: watcher stopped, queue destroyed, status bar destroyed, engine disposed, interval cleared, `removeCommand` called, `getService()` null, and a state event emitted. `setEnabled(true)`: new components and indexing scheduled. A later `shutdown()` does not throw. | No runtime toggle; `initialize` refuses after shutdown. | +| `EmbeddingPrune.test.ts` | unit | Rows for missing paths are removed; existing ones are kept. Conversations likewise. | No prune pass. | +| `ClearEmbeddingIndex.test.ts` | integration (sql.js, as other SQLite tests do) | After `clearEmbeddingIndex()` all seven embedding tables are empty, non-embedding tables are untouched, and the vec0 tables accept inserts. | No delete path. | +| `SemanticSearchSettings.test.ts` | unit | The off flow needs the modal; Cancel keeps it on; the off state shows Delete index; indexing shows the progress bar and pause; the summaries match. | New. | +| `SearchContentSemanticOff.test.ts` | unit | With state `off`: the description has no "Set semantic=true for AI-powered" pitch; `semantic=true` returns success, keyword results and the note. With state `available` and not initialized: "still initializing". | Misleading error today (`:321-322`). | +| `SettingsSyncEmbeddingsHotApply.test.ts` | unit | A synced `enableEmbeddings` change returns `requiresReload === false` and triggers hot-apply. | It requires a reload today. | +| `shippedGuidanceCommands` / `ToolManagerCliSyntax` | existing | Stay green after the catalog regenerates (`fix-shipped-docs-drift.md` if not). | — | + +### In-app verification + +1. In a test vault with notes, open Defaults → Semantic search. Check the status counts against `obsidian eval "app.plugins.plugins.nexus.embeddingManager.getStats().then(JSON.stringify)"`. +2. Turn it off and confirm the modal copy. After confirming: the status bar item has gone and `getService()` is null, all without a restart. +3. Run `nexus ... -- search content "x" --semantic`. Expect success, keyword results and the note. +4. Delete two notes, turn search on, and confirm their `embedding_metadata` rows are gone (eval query) and only changed notes are re-indexed (progress total). +5. Turn it off and click Delete index. Table counts read 0. Turn it on and a full re-index runs. +6. Escalate to disable/enable the plugin (`live-loop.md` step 4) to confirm no orphan intervals or commands remain (`app.commands.commands`). + +### Acceptance + +The semantic fold states match the mockup's "On, indexed / On, indexing / Turning off (confirm) / Off" states. Hot-apply takes effect with no restart. The tool no longer claims it is "initializing" when search is turned off. + +--- + +## Phase 5 — Follow-ups (not core) + +- One-click **"Add to"** install links in the Other agents card: + - Cursor: `cursor://anysphere.cursor-deeplink/mcp/install?name=&config=` + - VS Code / Copilot: `vscode:mcp/install?` or `code --add-mcp` + - LM Studio: `lmstudio://add_mcp`. **Check the spelling against LM Studio docs**; it is unconfirmed here. + - Windsurf: write `~/.codeium/windsurf/mcp_config.json`. + + Each needs the same Node.js gate and an absolute-path entry. +- Temperature range 0–2 for providers that accept it (Bug 8). This needs a per-provider maximum on `ModelSpec`. +- Web search as a default (Bug 7), if wanted. +- Migrate `DataTab` off `csr-section`, so the remaining `csr-*` CSS can be deleted. +- Decide whether Defaults should keep pushing context-window changes to the active chat (open question 3). + +--- + +## Appendix A — Copy lifted from the mockup + +Hold these as constants in `src/components/shared/chatSettings/copy.ts` and `src/settings/getStarted/copy.ts`. Tests import the constants and never point at the mockup. + +**Defaults and Chat settings** +- Creativity: "Lower is more focused, higher is more creative." / disabled: "`` sets its own temperature." Readout "—". +- Context window: "Older messages are summarized past this size." Readout examples "32K", "200K", "1M"; `aria-valuetext` "`` tokens". +- Web search (chat only): "Let the model search the web when needed." +- Workflow (chat only): "Loads its instructions and tools. Steps run only when you ask." +- Context notes: button "Add note"; remove tooltip "Remove ``". +- Subagents note row: name "Used for", description "Subagents, saved prompts and automations. Must be a cloud model." +- Voice: subheads "Transcription", "Read aloud", "Live voice". + - Transcription Provider description: "Chat microphone and audio files." + - "Saved audio folder" (Defaults only): "Inside the Nexus data folder." + - Live voice with none available: "None of your providers offer live voice." +- File conversion: + - "PDF import": "Drop PDFs into chat to turn them into notes." + - "Reading method": "Text is free. Vision reads scanned pages with a model." Options "Text extraction", "Vision". + - "Vision provider", "Vision model". + - "Convert new PDFs automatically": "Adds a Markdown note next to each PDF added to the vault." +- Summaries: + - Chat options: "Creativity 0.5 · 1M context" or "Fixed temperature · 1M context"; chat adds " · Web search on|off". + - Context: "``|No workspace · ``|No prompt · N notes". + - Subagents: "`` · Thinking off|`` thinking · ``". + - Images: "``". + - Video: "`` · `` · ``". + - Voice: "`` · `` (``)". + - File conversion: "PDF import on · ``[ · auto-convert]" / "PDF import off". + - Semantic: "On · N notes" / "Indexing · P%" / "Off · index kept". +- Semantic search: + - Toggle: "Semantic search" — "Find notes and past chats by meaning, not just exact words. Runs on this device." + - On: "Index" — "Kept up to date as notes and chats change." value "N notes · M chats". + - Indexing: "Indexing" — "About N minutes left. You can keep working." value "x / y"; "Pause indexing". + - Off: "Index" — "Kept on this device, so turning search back on only re-indexes notes that changed." value "Paused · N notes". + - "Delete index" — "Frees the space it uses. Turning search back on re-indexes the whole vault." Button "Delete index". +- Off modal: title "Turn off semantic search?"; "Nexus will match exact words only."; bullets "Vault search falls back to keyword matching, and agents stop being offered semantic search." / "Searching past chats by meaning stops working." / "Note suggestions in chat use file names only."; muted "Your index stays on this device, so turning it back on is quick."; buttons "Cancel", "Turn off". + +**Get started** +- Row: "Get started" — "Set up chat in Obsidian, or connect Claude Desktop, Codex and other agents." +- Intro lede: "Nexus is an assistant that lives in your vault. It reads and writes your notes, searches them, and remembers context between conversations. Your data stays in your vault." +- Capabilities, each with its icon: + - pencil-line: "Read, create and edit notes" + - search: "Search by keyword or by meaning" + - layers: "Remember context with workspaces" + - folder: "Organize files and folders" + - bot: "Run saved prompts and subagents" + - wifi-off: "Work offline with local models" +- "Choose your setup" / "Pick one, or use both." +- "Chat inside Obsidian" — "Connect a model provider and chat in a side panel." (value "Ready"). +- "Connect an external agent" — "Claude Desktop, Codex, Cursor and more use your vault." (value "N connected"). +- Chat page: + 1. "Connect a model provider" — "An API key, a sign-in, or a local model such as Ollama." Status "Connected", button "Open providers". + 2. "Open the chat" — "From the ribbon icon or the “Nexus: Open chat” command." Button "Open chat". + 3. "Start chatting" — "Ask about your notes. Nexus can read, search and edit them." +- Agents page: + - Lede "Let apps outside Obsidian use your vault through Nexus." + - Node.js found: "Runs the connector. Found `` at ``." status "Found". + - Node.js missing: "Every agent connection runs through Node.js, and it isn’t installed or Nexus can’t find it. Install it, then check again." Buttons "Install Node.js", "Check again". Blocked status "Needs Node.js". + - "Other agents" — "Cursor, Cline, Copilot, Windsurf and others. Four steps." + - Steps: + 1. "Create the connector file" — "connector.js lets the app talk to this vault. It goes in the Nexus plugin folder." Button "Create"; status "Created". + 2. "Copy the config" — "The Nexus entry for the app’s MCP settings, with this computer’s paths." Button "Copy config". Missing Node: "Install Node.js first. The config needs its location." + 3. "Add it to your app" — "Paste it into the app’s MCP settings yourself, or paste this prompt into the agent and let it make the change." Button "Copy prompt". Prompt: "Add an MCP server to your configuration. Name: ``. Command: ``. Args: ["``"]. Use these paths exactly. Merge it into the existing mcpServers and keep any servers already there. Then tell me where you saved it." Missing Node: "Install Node.js first." + 4. "Restart the app" — "Quit and reopen it so it loads Nexus. Then ask it to list your vault’s folders to check." + - "Command line" — "Lets Claude Code, Cursor and Codex use Nexus without MCP." Status "Not installed", button "Install". Child toggles "Claude Code skills", "Cursor skills", "Codex AGENTS.md" with their paths. + +In the prompt, `` must be JSON-escaped (build it with `JSON.stringify(path)`), because the mockup's template literal would not escape Windows backslashes inside `[" "]`. + +## Risks + +- **Fixing `embeddingService` changes live behaviour.** `[[` suggestions switch from fuzzy to semantic-first (`noteSearch.ts:24-33`). Semantic ranking can be worse for a typed filename prefix. Check this in the app. If it regresses, put exact and prefix basename matches ahead of semantic hits. Conversation memory search starts returning results, which changes agent behaviour. +- **Subagent precedence is a behaviour change** for anyone who has set a subagent model: subagents move from the chat model to that model. Call it out in the changelog. +- **Hot-apply teardown order.** Stopping must await the in-flight indexing phase before disposing the engine, the same constraint `shutdown()` documents at `:178-195`. A race leaves a write after the DB is closed. `deleteIndex()` while disabled avoids racing with the queue. +- **Settings in a separate window.** `live-loop.md` notes that `document` targets the main window. DOM assertions must go through the setting tab's `containerEl`. +- **The `Setting` mock does not render.** Order assertions need the mock change in Phase 1. Until then a green test proves nothing about order (`mock-honesty.md`). +- **The `minAppVersion` bump** strands 1.10 users on 5.20.x. This is acceptable but must be noted in the release. +- **Tool description churn.** Agents connected over MCP cache `getTools` results, so a runtime description change reaches them only on their next discovery. + +## Owner decisions (2026-10-14) + +These answer the former open questions. They override the Decisions table above wherever the two disagree. + +1. **Subagent default.** When no subagent model is set, subagents keep using the chat model if it is a cloud model (as proposed). +2. **Chat modal context window applies to this conversation only.** This reverses PR #410's save-to-defaults. Confirmed. +3. **Changing Defaults does not change the open chat.** Remove the Defaults → active chat context push (`applyContextWindowDefault`) and update its tests. Defaults apply to new chats only. *This overrides the Decisions table's "Keep".* +4. **The toggle is labelled "File import"**, with the description "Drop PDFs or audio into chat to turn them into notes.", because `enableIngestion` gates audio as well. The fold is still called "File conversion". +5. **`enableEmbeddings` stays synced** (hot-applied). The index stays per device. +6. **Delete index asks for confirmation** in an Obsidian `Modal`: title "Delete the search index?", buttons Cancel / Delete (mod-warning). It deletes vectors only and keeps the learned retrieval adapter (`AdapterStore`). *This overrides the Decisions table's "no second confirmation".* +7. **Get started opens automatically** on the first `display()` of each session while nothing is configured (as proposed). +8. **The Thinking dropdown hides levels the model or provider does not support.** If no capability data exists, show all six. + +## Could not confirm + +- **Antigravity config path and reload.** Antigravity's MCP docs (fetched 2026-10-14) list the global config as `~/.gemini/config/mcp_config.json` (workspace: `.agents/mcp_config.json`) with `mcpServers {command, args, env}`. Reload is `/mcp` in the CLI or Refresh in the app. I have not confirmed this against an installed Antigravity. It remains **must-verify** on a machine that has it, along with whether older builds read `~/.gemini/antigravity/mcp_config.json` and whether the desktop app manages servers only through its own Settings UI. If both paths exist, write the one the installed build reads, and do not write both. +- **The native slider value label** in Obsidian 1.13 (the cause of "High 2" and "1,000,000 tokens 1000000"). Phase 1 step 1a settles it. I did not run `obsidian eval` against the owner's vaults. +- **Whether `ToolCliNormalizer` rejects a flag missing from the schema.** This decides whether `semantic` could ever be removed rather than re-described. Check it in the nexus-agents verify step. +- **The LM Studio deep-link spelling** (`lmstudio://add_mcp`) and the other Phase 5 link formats. +- **Whether every thinking-capable adapter ignores `enableThinking` for non-thinking models.** Only Anthropic was checked. The new gate makes this moot for requests built by Nexus. +- **Whether the conversation `chatSettings` JSONL payload keeps unknown fields** through a cache rebuild (Phase 2 keys). Check with `nexus-storage` `persist-new-data.md` before relying on it. + +## Analysis completion + +Analysis only. No production files, styles, tests, the running plugin or any vault were changed or exercised. The file counts below are planned, not executed. + +| Phase | New | Modified | Deleted | +|---|---|---|---| +| 0 | 1 | ~15 | 1 | +| 1 | 9 | ~8 | 0 | +| 2 | 3 | ~16 | 0 | +| 3 | 6 | ~12 | 1 | +| 4 | 1 | ~14 | 0 (plus regenerated catalogs) | + +Tests and `styles.css` are not counted. From a25b8493b536b621b257905058bf441496080b99 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:50:55 -0400 Subject: [PATCH 02/65] chore(settings): remove unused StatusBadge and stray header statement StatusBadge had no importer in src/ or tests/, so the component and its .nexus-status-badge rules go. The settings header also loses a bare ';' and the dead "Title and description" comment above it. Co-Authored-By: Claude Opus 5.5 --- src/settings/SettingsView.ts | 2 -- src/settings/components/StatusBadge.ts | 49 -------------------------- styles.css | 19 ---------- 3 files changed, 70 deletions(-) delete mode 100644 src/settings/components/StatusBadge.ts diff --git a/src/settings/SettingsView.ts b/src/settings/SettingsView.ts index 25b87275..e1789f9c 100644 --- a/src/settings/SettingsView.ts +++ b/src/settings/SettingsView.ts @@ -248,8 +248,6 @@ export class SettingsView extends PluginSettingTab { private renderHeader(containerEl: HTMLElement): void { const header = containerEl.createDiv('nexus-settings-header'); - // Title and description - ; header.createEl('p', { text: 'An assistant for your vault', cls: 'nexus-settings-desc' diff --git a/src/settings/components/StatusBadge.ts b/src/settings/components/StatusBadge.ts deleted file mode 100644 index 56e478f3..00000000 --- a/src/settings/components/StatusBadge.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * StatusBadge - Shows configured/not configured status indicator - * Uses Obsidian-native styling with CSS classes - */ - -export class StatusBadge { - /** - * Render a status badge - * @param container Parent element to attach to - * @param configured Whether the item is configured - * @returns The created badge element - */ - static render(container: HTMLElement, configured: boolean): HTMLElement { - const badge = container.createSpan('nexus-status-badge'); - badge.addClass(configured ? 'configured' : 'not-configured'); - badge.setText(configured ? '✓' : '○'); - return badge; - } - - /** - * Render a status badge with custom text - * @param container Parent element to attach to - * @param configured Whether the item is configured - * @param configuredText Text to show when configured - * @param notConfiguredText Text to show when not configured - */ - static renderWithText( - container: HTMLElement, - configured: boolean, - configuredText = 'Configured', - notConfiguredText = 'Not configured' - ): HTMLElement { - const badge = container.createSpan('nexus-status-badge'); - badge.addClass(configured ? 'configured' : 'not-configured'); - badge.setText(configured ? configuredText : notConfiguredText); - return badge; - } - - /** - * Update an existing badge's status - * @param badge The badge element to update - * @param configured New configuration status - */ - static update(badge: HTMLElement, configured: boolean): void { - badge.removeClass('configured', 'not-configured'); - badge.addClass(configured ? 'configured' : 'not-configured'); - badge.setText(configured ? '✓' : '○'); - } -} diff --git a/styles.css b/styles.css index 67ed0f73..64ba0204 100644 --- a/styles.css +++ b/styles.css @@ -4733,25 +4733,6 @@ body.is-mobile .message-action-btn.message-branch-next { text-overflow: ellipsis; } -/* Status badges */ -.nexus-status-badge { - font-size: 0.7rem; - padding: 2px 8px; - border-radius: 10px; - margin-left: auto; - flex-shrink: 0; -} - -.nexus-status-badge.configured { - background: var(--background-modifier-success); - color: var(--text-on-accent); -} - -.nexus-status-badge.not-configured { - color: var(--text-muted); - background: var(--background-modifier-border); -} - /* Provider defaults section */ .nexus-provider-defaults { background: var(--background-secondary); From 67277d1b187d445efc0993bf7277dc7a8dd65e34 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:55:27 -0400 Subject: [PATCH 03/65] fix(chat): keep context window edits scoped to one chat The Chat settings modal no longer merges its context window into the global contextWindowOverrides defaults; the value applies to the open conversation only, through the existing requestContextChange handoff. This reverses the save-to-defaults half of PR #410. Defaults no longer push context window changes into the active chat (ChatView.applyContextWindowDefault is removed). Defaults apply to new chats only, per the owner decision of 2026-10-14. Tests: the modal handoff suite now asserts global defaults are never written; the Defaults suite asserts the open chat is not touched. Co-Authored-By: Claude Opus 5.5 --- src/settings/tabs/DefaultsTab.ts | 10 +--- src/ui/chat/ChatView.ts | 14 ------ src/ui/chat/components/ChatSettingsModal.ts | 29 +---------- .../ChatSettingsModalContextHandoff.test.ts | 35 +++---------- tests/unit/DefaultsTabContextHandoff.test.ts | 49 +++++++++---------- 5 files changed, 33 insertions(+), 104 deletions(-) diff --git a/src/settings/tabs/DefaultsTab.ts b/src/settings/tabs/DefaultsTab.ts index 84d6a4da..dcecd29e 100644 --- a/src/settings/tabs/DefaultsTab.ts +++ b/src/settings/tabs/DefaultsTab.ts @@ -51,7 +51,6 @@ import { } from '../../services/llm/types/VideoTypes'; import { SpeechModelCatalogService } from '../../services/readAloud/SpeechModelCatalogService'; import { sortModelsNewestFirst } from '../../utils/modelOrdering'; -import { ChatView } from '../../ui/chat/ChatView'; export interface DefaultsTabServices { app: App; @@ -150,15 +149,8 @@ export class DefaultsTab { const llmSettings = this.services.llmProviderSettings; const pluginSettings = this.services.settings.settings; + // Defaults apply to new chats only; an open chat keeps its own context budget. if (llmSettings) { - const previousOverrides = llmSettings.contextWindowOverrides ?? {}; - const nextOverrides = settings.contextWindowOverrides ?? {}; - const changedKeys = new Set([...Object.keys(previousOverrides), ...Object.keys(nextOverrides)] - .filter(key => previousOverrides[key] !== nextOverrides[key])); - if (changedKeys.size > 0) { - const activeChat = this.services.app.workspace.getActiveViewOfType(ChatView); - if (activeChat && !await activeChat.applyContextWindowDefault(nextOverrides, changedKeys)) return; - } llmSettings.defaultModel = { provider: settings.provider, model: settings.model diff --git a/src/ui/chat/ChatView.ts b/src/ui/chat/ChatView.ts index c1a159ab..93846469 100644 --- a/src/ui/chat/ChatView.ts +++ b/src/ui/chat/ChatView.ts @@ -56,7 +56,6 @@ import { ChatEventBinder } from './utils/ChatEventBinder'; import { CHAT_VIEW_TYPES } from '../../constants/branding'; import { getNexusPlugin } from '../../utils/pluginLocator'; import { generateUUID } from '../../utils/uuid'; -import { getContextWindowOverrideKey } from './utils/ContextWindowSettings'; // Nexus Lifecycle import { getWebLLMLifecycleManager } from '../../services/llm/adapters/webllm/WebLLMLifecycleManager'; @@ -1141,19 +1140,6 @@ export class ChatView extends ItemView { }, this).open(); } - /** Apply a changed default only to this active chat, through the normal handoff. */ - async applyContextWindowDefault(overrides: Record, changedKeys: ReadonlySet): Promise { - const model = this.modelAgentManager?.getSelectedModel(); - if (!model) return true; - const key = getContextWindowOverrideKey(model.providerId, model.modelId); - if (!changedKeys.has(key)) return true; - return this.modelAgentManager.requestContextChange({ - providerId: model.providerId, - modelId: model.modelId, - contextWindowOverride: overrides[key] - }); - } - private async ensurePreservationServiceAndCompact(): Promise { await this.ensurePreservationService(); await this.sendCoordinator.compactCurrentConversation(); diff --git a/src/ui/chat/components/ChatSettingsModal.ts b/src/ui/chat/components/ChatSettingsModal.ts index e88723e8..ea396a1e 100644 --- a/src/ui/chat/components/ChatSettingsModal.ts +++ b/src/ui/chat/components/ChatSettingsModal.ts @@ -27,7 +27,6 @@ export class ChatSettingsModal extends Modal { private conversationId: string | null; private renderer: ChatSettingsRenderer | null = null; private pendingSettings: ChatSettings | null = null; - private displayedContextWindowOverrides: Record | null = null; private saveButton: ButtonComponent | null = null; private saveStatus: HTMLElement | null = null; private saving = false; @@ -148,7 +147,6 @@ export class ChatSettingsModal extends Modal { const key = getContextWindowOverrideKey(model.providerId, model.modelId); contextWindowOverrides[key] = this.modelAgentManager.getEffectiveContextWindow(); } - this.displayedContextWindowOverrides = { ...contextWindowOverrides }; return { provider: model?.providerId || llmSettings?.defaultModel?.provider || '', @@ -231,31 +229,7 @@ export class ChatSettingsModal extends Modal { if (!committed) return; } - const plugin = getNexusPlugin(this.app); - const llmProviders = plugin?.settings?.settings?.llmProviders; - if (llmProviders && settings.contextWindowOverrides) { - const displayed = this.displayedContextWindowOverrides ?? {}; - const changedKeys = new Set([...Object.keys(displayed), ...Object.keys(settings.contextWindowOverrides)]); - const oldOverrides = llmProviders.contextWindowOverrides; - const nextOverrides = { ...oldOverrides }; - let changed = false; - for (const key of changedKeys) { - if (displayed[key] === settings.contextWindowOverrides[key]) continue; - changed = true; - if (settings.contextWindowOverrides[key] === undefined) delete nextOverrides[key]; - else nextOverrides[key] = settings.contextWindowOverrides[key]; - } - if (changed) { - llmProviders.contextWindowOverrides = nextOverrides; - try { - await plugin?.settings?.saveSettings(); - } catch (error) { - llmProviders.contextWindowOverrides = oldOverrides; - const reason = error instanceof Error ? error.message : 'Unknown error'; - throw new Error(`Context change was saved, but default context settings could not be saved: ${reason}`); - } - } - } + // The context window applies to this conversation only; Defaults own the value for new chats. if (this.closed) return; // Update prompt @@ -346,7 +320,6 @@ export class ChatSettingsModal extends Modal { this.renderer?.destroy(); this.renderer = null; this.pendingSettings = null; - this.displayedContextWindowOverrides = null; this.contentEl.empty(); } } diff --git a/tests/unit/ChatSettingsModalContextHandoff.test.ts b/tests/unit/ChatSettingsModalContextHandoff.test.ts index 570668fa..ffbfae00 100644 --- a/tests/unit/ChatSettingsModalContextHandoff.test.ts +++ b/tests/unit/ChatSettingsModalContextHandoff.test.ts @@ -1,4 +1,8 @@ -/** A delayed context handoff must not leak a staged model or budget through the settings modal. */ +/** + * A delayed context handoff must not leak a staged model or budget through the settings modal. + * The modal's context window applies to this conversation only: it never writes the global + * contextWindowOverrides defaults (owner decision, 2026-10-14, reversing PR #410's save-to-defaults). + */ import { App, Notice } from 'obsidian'; import { ChatSettingsModal } from '../../src/ui/chat/components/ChatSettingsModal'; import type { ChatSettings } from '../../src/components/shared/ChatSettingsRenderer'; @@ -84,7 +88,7 @@ it('shows the conversation pinned context budget when opening settings', () => { expect(llmProviders.contextWindowOverrides[oldKey]).toBe(80_000); }); -it('sends model and budget in one staged request and saves defaults only after commit', async () => { +it('sends model and budget in one staged request and never writes global defaults', async () => { const { state, manager, llmProviders, saveSettings } = fixture(); let finish!: (value: boolean) => void; manager.requestContextChange.mockImplementation(() => new Promise(resolve => { finish = resolve; })); @@ -95,11 +99,10 @@ it('sends model and budget in one staged request and saves defaults only after c expect(manager.requestContextChange).toHaveBeenCalledWith({ providerId: 'openai', modelId: 'next', contextWindowOverride: 64_000 }); expect(state.saveStatus.setText).toHaveBeenCalledWith('Preparing context…'); expect(state.saveButton.setDisabled).toHaveBeenCalledWith(true); - expect(saveSettings).not.toHaveBeenCalled(); - expect(llmProviders.contextWindowOverrides).toEqual({ [oldKey]: 80_000 }); finish(true); await first; - expect(saveSettings).toHaveBeenCalledTimes(1); + expect(saveSettings).not.toHaveBeenCalled(); + expect(llmProviders.contextWindowOverrides).toEqual({ [oldKey]: 80_000 }); expect(manager.saveToConversation).toHaveBeenCalledWith('conversation'); }); @@ -126,13 +129,6 @@ it('saving an unrelated setting preserves newer global context defaults', async expect(manager.setTemperature).toHaveBeenCalledWith(0.7); }); -it('merges only the edited model budget into the current defaults', async () => { - const { state, llmProviders } = fixture(); - llmProviders.contextWindowOverrides[oldKey] = 90_000; - await state.handleSave(); - expect(llmProviders.contextWindowOverrides).toEqual({ [oldKey]: 90_000, [key]: 64_000 }); -}); - it('passes the advertised maximum when the slider removes an override', async () => { const { state, manager } = fixture(); state.pendingSettings.contextWindowOverrides = { [oldKey]: 70_000 }; @@ -166,18 +162,3 @@ it('cancels only its own pending handoff and ignores completion after closing', expect(saveSettings).not.toHaveBeenCalled(); expect(manager.handlePromptChange).not.toHaveBeenCalled(); }); - -it('restores the default override map when its separate save fails', async () => { - const { state, manager, llmProviders, saveSettings } = fixture(); - const original = llmProviders.contextWindowOverrides; - saveSettings.mockRejectedValueOnce(new Error('disk full')); - const log = jest.spyOn(console, 'error').mockImplementation(() => undefined); - try { - await state.handleSave(); - expect(manager.requestContextChange).toHaveBeenCalledTimes(1); - expect(llmProviders.contextWindowOverrides).toBe(original); - expect(manager.handlePromptChange).not.toHaveBeenCalled(); - } finally { - log.mockRestore(); - } -}); diff --git a/tests/unit/DefaultsTabContextHandoff.test.ts b/tests/unit/DefaultsTabContextHandoff.test.ts index 05fc9510..ec19cf18 100644 --- a/tests/unit/DefaultsTabContextHandoff.test.ts +++ b/tests/unit/DefaultsTabContextHandoff.test.ts @@ -1,17 +1,26 @@ -jest.mock('../../src/ui/chat/ChatView', () => ({ ChatView: class {} })); +/** + * Defaults apply to new chats only (owner decision, 2026-10-14). Changing a + * default context window must not reach into the open chat: no handoff, no + * wait for it, and the default is published and saved straight away. The + * previous behaviour pushed the edit into the active chat through + * ChatView.applyContextWindowDefault and held the save until it committed. + */ import { DefaultsTab } from '../../src/settings/tabs/DefaultsTab'; import { getContextWindowOverrideKey } from '../../src/ui/chat/utils/ContextWindowSettings'; -// Defaults cannot publish a reduced budget before the active chat is prepared. function harness() { const key = getContextWindowOverrideKey('anthropic', 'source'); const oldOverrides = { [key]: 250_000 }; const llmProviderSettings = { contextWindowOverrides: oldOverrides }; const saveSettings = jest.fn().mockResolvedValue(undefined); - const applyContextWindowDefault = jest.fn().mockResolvedValue(true); - const tab = Object.create(DefaultsTab.prototype); + const activeChat = { + applyContextWindowDefault: jest.fn().mockResolvedValue(true), + requestContextChange: jest.fn().mockResolvedValue(true) + }; + const getActiveViewOfType = jest.fn(() => activeChat); + const tab = Object.create(DefaultsTab.prototype) as { saveSettings(settings: unknown): Promise; services: unknown }; tab.services = { - app: { workspace: { getActiveViewOfType: () => ({ applyContextWindowDefault }) } }, + app: { workspace: { getActiveViewOfType } }, llmProviderSettings, settings: { settings: {}, saveSettings } }; @@ -19,34 +28,22 @@ function harness() { provider: 'anthropic', model: 'source', contextWindowOverrides: { [key]: 128_000 }, thinking: { enabled: false, effort: 'medium' }, contextNotes: [] }; - return { tab, key, oldOverrides, llmProviderSettings, saveSettings, applyContextWindowDefault, next }; + return { tab, key, llmProviderSettings, saveSettings, activeChat, getActiveViewOfType, next }; } -test('waits for the active chat before publishing a lower default', async () => { +test('a lower default context window is saved without touching the open chat', async () => { const h = harness(); - let release!: (value: boolean) => void; - h.applyContextWindowDefault.mockImplementation(() => new Promise(resolve => { release = resolve; })); - const save = h.tab.saveSettings(h.next); - expect(h.llmProviderSettings.contextWindowOverrides).toBe(h.oldOverrides); - expect(h.saveSettings).not.toHaveBeenCalled(); - release(true); - await save; - expect(h.applyContextWindowDefault).toHaveBeenCalledWith(h.next.contextWindowOverrides, new Set([h.key])); + await h.tab.saveSettings(h.next); + expect(h.getActiveViewOfType).not.toHaveBeenCalled(); + expect(h.activeChat.applyContextWindowDefault).not.toHaveBeenCalled(); + expect(h.activeChat.requestContextChange).not.toHaveBeenCalled(); expect(h.llmProviderSettings.contextWindowOverrides).toEqual(h.next.contextWindowOverrides); expect(h.saveSettings).toHaveBeenCalledTimes(1); }); -test('cancelled preparation preserves the defaults', async () => { +test('the saved default is a copy, so later renderer edits do not leak into settings', async () => { const h = harness(); - h.applyContextWindowDefault.mockResolvedValue(false); await h.tab.saveSettings(h.next); - expect(h.llmProviderSettings.contextWindowOverrides).toBe(h.oldOverrides); - expect(h.saveSettings).not.toHaveBeenCalled(); -}); - -test('unrelated settings changes do not hand off the active chat', async () => { - const h = harness(); - await h.tab.saveSettings({ ...h.next, contextWindowOverrides: h.oldOverrides }); - expect(h.applyContextWindowDefault).not.toHaveBeenCalled(); - expect(h.saveSettings).toHaveBeenCalledTimes(1); + h.next.contextWindowOverrides[h.key] = 64_000; + expect(h.llmProviderSettings.contextWindowOverrides[h.key]).toBe(128_000); }); From adf3c6fd9debb65beae918fa9420fb21dd8fd4a5 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:55:31 -0400 Subject: [PATCH 04/65] fix(chat): carry settings saved with no chat open into the next chat Saving Chat settings while no conversation is open kept the values in ModelAgentManager memory, but selecting the first conversation created afterwards re-read the plugin defaults and dropped them. The modal now marks those selections to carry; the next conversation that has no saved selections yet adopts them (including the committed context window) and stores them in its chatSettings. Reloading the welcome defaults clears the carry. ChatSettingsWithoutConversation.test.ts drives the real modal save, ModelAgentManager and chat ConversationManager.createConversation; it failed on the baseline with temperature reset to the default. Co-Authored-By: Claude Opus 5.5 --- src/ui/chat/components/ChatSettingsModal.ts | 4 +- src/ui/chat/services/ModelAgentManager.ts | 31 ++++ .../ChatSettingsWithoutConversation.test.ts | 161 ++++++++++++++++++ 3 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 tests/unit/ChatSettingsWithoutConversation.test.ts diff --git a/src/ui/chat/components/ChatSettingsModal.ts b/src/ui/chat/components/ChatSettingsModal.ts index ea396a1e..2fc5c34a 100644 --- a/src/ui/chat/components/ChatSettingsModal.ts +++ b/src/ui/chat/components/ChatSettingsModal.ts @@ -296,9 +296,11 @@ export class ChatSettingsModal extends Modal { settings.transcriptionModel || null ); - // Save to conversation metadata + // Save to conversation metadata; with no conversation open, the next new one adopts them. if (this.conversationId) { await this.modelAgentManager.saveToConversation(this.conversationId); + } else { + this.modelAgentManager.carrySelectionsIntoNextConversation(); } if (!this.closed) this.close(); diff --git a/src/ui/chat/services/ModelAgentManager.ts b/src/ui/chat/services/ModelAgentManager.ts index 88706dc4..c4e1dbe6 100644 --- a/src/ui/chat/services/ModelAgentManager.ts +++ b/src/ui/chat/services/ModelAgentManager.ts @@ -127,6 +127,8 @@ export class ModelAgentManager { private contextTokenTracker: ContextTokenTracker | null = null; // For token-limited models private compactionFrontier: CompactionFrontierRecord[] = []; // Active bounded compaction frontier private compactionFrontierService = new CompactionFrontierService(); + // Settings saved with no conversation open, held for the next new conversation. + private carriedSelections: { contextWindow: number | null } | null = null; constructor( private app: App, // Obsidian App @@ -186,10 +188,23 @@ export class ModelAgentManager { * Call this when no conversation exists (e.g., welcome state) */ async initializeDefaults(): Promise { + this.carriedSelections = null; this.clearCompactionFrontier(); await this.initializeDefaultModel(); } + /** + * Keep the current selections for the next new conversation. Used when chat + * settings are saved while no conversation is open: there is nothing to + * persist them to yet, so the first conversation created afterwards adopts + * and stores them instead of starting from the plugin defaults. + */ + carrySelectionsIntoNextConversation(): void { + this.carriedSelections = this.currentConversationId === null + ? { contextWindow: this.committedContextWindow } + : null; + } + /** * Set the current conversation ID used for session lookups and persistence. */ @@ -207,10 +222,21 @@ export class ModelAgentManager { * Initialize from conversation metadata (if available), otherwise use plugin default */ async initializeFromConversation(conversationId: string): Promise { + const carried = this.carriedSelections; + this.carriedSelections = null; try { const { conversationMetadata, chatSettings } = await this.conversationSettingsStore.load(conversationId); + if (carried && !this.hasSavedSelections(chatSettings)) { + // A brand-new conversation adopts the selections saved while none was open. + this.clearCompactionFrontier(); + this.restoreCompactionFrontierFromMetadata(conversationMetadata); + if (carried.contextWindow !== null) this.committedContextWindow = carried.contextWindow; + await this.saveToConversation(conversationId); + return; + } + this.clearCompactionFrontier(); await this.initializeDefaultModel(); this.restoreCompactionFrontierFromMetadata(conversationMetadata); @@ -225,6 +251,11 @@ export class ModelAgentManager { } } + /** True once chat settings were saved to the conversation, not just its creation metadata. */ + private hasSavedSelections(settings: ConversationSettingsMetadata | undefined): boolean { + return !!settings && ('temperature' in settings || 'thinking' in settings); + } + private hasStoredChatSettings(settings: ConversationSettingsMetadata | undefined): boolean { if (!settings) { return false; diff --git a/tests/unit/ChatSettingsWithoutConversation.test.ts b/tests/unit/ChatSettingsWithoutConversation.test.ts new file mode 100644 index 00000000..33349042 --- /dev/null +++ b/tests/unit/ChatSettingsWithoutConversation.test.ts @@ -0,0 +1,161 @@ +/** + * Chat settings saved while no conversation is open stay in memory and carry + * into the next chat (plan decision 7). The first conversation created + * afterwards must hold them in its stored chatSettings. + * + * Failure this buys: if selecting the freshly created conversation reset the + * manager to the plugin defaults (initializeFromConversation re-reads the + * defaults first), the temperature and thinking chosen in the modal would be + * silently dropped and the new chat would run with the defaults. + * + * Real pieces: ModelAgentManager, ChatSettingsModal.handleSave and the chat + * ConversationManager.createConversation that writes the initial metadata. + * Faked: the conversation store (an in-memory map) and the defaults resolver. + */ +import { App } from 'obsidian'; +import { ChatSettingsModal } from '../../src/ui/chat/components/ChatSettingsModal'; +import { ModelAgentManager } from '../../src/ui/chat/services/ModelAgentManager'; +import { ConversationManager } from '../../src/services/chat/ConversationManager'; +import type { ChatSettings } from '../../src/components/shared/ChatSettingsRenderer'; +import type { WorkspaceService } from '../../src/services/WorkspaceService'; +import type { ModelAgentDefaultState } from '../../src/ui/chat/services/ModelAgentDefaultsResolver'; +import type { ConversationData } from '../../src/types/chat/ChatTypes'; +import type { ModelOption } from '../../src/ui/chat/types/SelectionTypes'; +import { getContextWindowOverrideKey } from '../../src/ui/chat/utils/ContextWindowSettings'; + +jest.mock('../../src/utils/pluginLocator', () => ({ getNexusPlugin: jest.fn(() => null) })); + +const model: ModelOption = { + providerId: 'openai', providerName: 'OpenAI', modelId: 'gpt-test', modelName: 'GPT Test', contextWindow: 128_000 +}; + +function defaultState(): ModelAgentDefaultState { + return { + selectedModel: model, + selectedPrompt: null, + currentSystemPrompt: null, + workspaceState: { selectedWorkspaceId: null, workspaceContext: null, loadedWorkspaceData: null }, + contextNotes: [], + thinkingSettings: { enabled: false, effort: 'medium' }, + agentProvider: null, + agentModel: null, + agentThinkingSettings: { enabled: false, effort: 'medium' }, + imageProvider: 'google', + imageModel: 'gemini-3.1-flash-image', + speechProvider: null, + speechModel: null, + speechVoice: null, + realtimeVoiceProvider: null, + realtimeVoiceModel: null, + realtimeVoiceVoice: null, + transcriptionProvider: null, + transcriptionModel: null, + temperature: 0.5 + }; +} + +function inMemoryConversations() { + const store = new Map(); + let next = 0; + return { + store, + getConversation: jest.fn(async (id: string) => store.get(id) ?? null), + updateConversationMetadata: jest.fn(async (id: string, metadata: Record) => { + const conversation = store.get(id); + if (conversation) store.set(id, { ...conversation, metadata: { ...conversation.metadata, ...metadata } }); + }), + createConversation: jest.fn(async (data: unknown) => { + const conversation = { ...(data as object), id: `conv_${++next}`, created: Date.now(), updated: Date.now() } as ConversationData; + store.set(conversation.id, conversation); + return conversation; + }), + addMessage: jest.fn(async () => undefined), + updateConversation: jest.fn(async () => undefined), + deleteConversation: jest.fn(async () => undefined) + }; +} + +it('carries settings saved with no open chat into the first conversation created afterwards', async () => { + const conversations = inMemoryConversations(); + const manager = new ModelAgentManager( + new App(), + { onModelChanged: jest.fn(), onPromptChanged: jest.fn(), onSystemPromptChanged: jest.fn() }, + conversations + ); + const internals = manager as unknown as { + defaultsResolver: { resolveDefaultState: jest.Mock; resolveModelOption: jest.Mock; getSelectedModelOrDefault: jest.Mock }; + }; + internals.defaultsResolver = { + resolveDefaultState: jest.fn(async () => defaultState()), + resolveModelOption: jest.fn(async () => model), + getSelectedModelOrDefault: jest.fn(async () => model) + }; + + // Welcome state: nothing open, defaults loaded. + await manager.initializeDefaults(); + expect(manager.getTemperature()).toBe(0.5); + + // Save the modal with no conversation. + const modal = new ChatSettingsModal(new App(), null, {} as WorkspaceService, manager); + const modalState = modal as unknown as { pendingSettings: ChatSettings; handleSave(): Promise }; + modalState.pendingSettings = { + provider: 'openai', model: 'gpt-test', + contextWindowOverrides: { [getContextWindowOverrideKey('openai', 'gpt-test')]: 64_000 }, + thinking: { enabled: true, effort: 'high' }, webSearch: false, + workspaceId: null, promptId: null, contextNotes: [], temperature: 0.8, + imageProvider: 'google', imageModel: 'gemini-3.1-flash-image' + }; + await modalState.handleSave(); + expect(manager.getTemperature()).toBe(0.8); + expect(manager.getEffectiveContextWindow()).toBe(64_000); + expect(manager.getThinkingSettings()).toEqual({ enabled: true, effort: 'high' }); + + // "Start your first conversation": create it, then select it the way ChatSessionCoordinator does. + const chatConversations = new ConversationManager( + { conversationService: conversations, streamingGenerator: async function* () { /* unused */ } }, + 'vault' + ); + const created = await chatConversations.createConversation({ title: 'First chat' }); + manager.setCurrentConversationId(created.id); + await manager.initializeFromConversation(created.id); + + expect(manager.getTemperature()).toBe(0.8); + expect(manager.getThinkingSettings()).toEqual({ enabled: true, effort: 'high' }); + expect(manager.getEffectiveContextWindow()).toBe(64_000); + const stored = conversations.store.get(created.id)?.metadata?.chatSettings as + { temperature?: number; thinking?: { enabled: boolean; effort: string }; effectiveContextWindow?: number; sessionId?: string } | undefined; + expect(stored?.temperature).toBe(0.8); + expect(stored?.thinking).toEqual({ enabled: true, effort: 'high' }); + expect(stored?.effectiveContextWindow).toBe(64_000); + expect(stored?.sessionId).toBe(created.metadata?.chatSettings?.sessionId); + + // Reopening the conversation restores what was stored, not the defaults. + await manager.initializeFromConversation(created.id); + expect(manager.getTemperature()).toBe(0.8); +}); + +it('a conversation opened after the welcome defaults reload starts from the defaults', async () => { + const conversations = inMemoryConversations(); + const manager = new ModelAgentManager( + new App(), + { onModelChanged: jest.fn(), onPromptChanged: jest.fn(), onSystemPromptChanged: jest.fn() }, + conversations + ); + (manager as unknown as { defaultsResolver: unknown }).defaultsResolver = { + resolveDefaultState: jest.fn(async () => defaultState()), + resolveModelOption: jest.fn(async () => model), + getSelectedModelOrDefault: jest.fn(async () => model) + }; + await manager.initializeDefaults(); + manager.setTemperature(0.9); + manager.carrySelectionsIntoNextConversation(); + // The welcome state reloads the defaults, which drops anything carried. + await manager.initializeDefaults(); + const created = await new ConversationManager( + { conversationService: conversations, streamingGenerator: async function* () { /* unused */ } }, + 'vault' + ).createConversation({ title: 'Later chat' }); + manager.setCurrentConversationId(created.id); + await manager.initializeFromConversation(created.id); + expect(manager.getTemperature()).toBe(0.5); +}); From 79ea236da24c14b37f0546b4a3b28110140b5767 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:57:10 -0400 Subject: [PATCH 05/65] fix(search): reach conversation embeddings through the plugin manager Add resolveEmbeddingService(plugin), which reads plugin.embeddingManager and returns its service only while the manager is enabled and initialized. ServiceAccessors used to ask the registry for 'embeddingService', a name nothing registers, so semantic search of past conversations never ran. Co-Authored-By: Claude Opus 5.5 --- .../services/ServiceAccessors.ts | 21 ++- src/services/embeddings/embeddingAccess.ts | 58 ++++++ ...rsationMemorySearchEmbeddingAccess.test.ts | 167 ++++++++++++++++++ 3 files changed, 237 insertions(+), 9 deletions(-) create mode 100644 src/services/embeddings/embeddingAccess.ts create mode 100644 tests/unit/ConversationMemorySearchEmbeddingAccess.test.ts diff --git a/src/agents/searchManager/services/ServiceAccessors.ts b/src/agents/searchManager/services/ServiceAccessors.ts index d12e4d0a..59376174 100644 --- a/src/agents/searchManager/services/ServiceAccessors.ts +++ b/src/agents/searchManager/services/ServiceAccessors.ts @@ -13,6 +13,7 @@ import type NexusPlugin from '../../../main'; import type { MemoryService } from '../../memoryManager/services/MemoryService'; import type { WorkspaceService } from '../../../services/WorkspaceService'; import type { EmbeddingService } from '../../../services/embeddings/EmbeddingService'; +import { resolveEmbeddingService } from '../../../services/embeddings/embeddingAccess'; import type { IMessageRepository } from '../../../database/repositories/interfaces/IMessageRepository'; import type { IStorageAdapter } from '../../../database/interfaces/IStorageAdapter'; @@ -21,9 +22,10 @@ export type StorageAdapterResolver = IStorageAdapter | (() => IStorageAdapter | /** * Provides runtime service resolution for the search subsystem. * - * Services are resolved lazily via the plugin's `getServiceIfReady` API, - * returning `undefined` when a service has not yet initialised or is - * unavailable (e.g. embeddings on mobile). + * Services are resolved lazily on every call, returning `undefined` when a + * service has not yet initialised or is unavailable (e.g. embeddings on mobile + * or turned off). Registry services go through `getServiceIfReady`; the + * embedding service goes through `resolveEmbeddingService`. */ export class ServiceAccessors { private plugin: Plugin; @@ -67,16 +69,17 @@ export class ServiceAccessors { } /** - * Resolve the EmbeddingService from the running plugin instance. + * Resolve the live EmbeddingService from the plugin's embedding manager. + * + * The embedding system is not a registry entry, so this goes through + * `resolveEmbeddingService` and is re-read on every call: the manager can be + * turned off or rebuilt during a session. */ getEmbeddingService(): EmbeddingService | undefined { try { const app: App = this.plugin.app; - const plugin = getNexusPlugin(app); - if (plugin) { - return plugin.getServiceIfReady('embeddingService') || undefined; - } - return undefined; + const plugin = getNexusPlugin(app) ?? this.plugin; + return resolveEmbeddingService(plugin) ?? undefined; } catch { return undefined; } diff --git a/src/services/embeddings/embeddingAccess.ts b/src/services/embeddings/embeddingAccess.ts new file mode 100644 index 00000000..764d9e54 --- /dev/null +++ b/src/services/embeddings/embeddingAccess.ts @@ -0,0 +1,58 @@ +/** + * Location: src/services/embeddings/embeddingAccess.ts + * Purpose: The one way to reach the live EmbeddingService. + * + * The embedding system is not a service-container entry. PluginLifecycleManager + * attaches an EmbeddingManager to the plugin as `plugin.embeddingManager`, and + * the manager owns the EmbeddingService it creates. Asking the service registry + * for `'embeddingService'` therefore always returned null, which silently turned + * off semantic conversation search, semantic `[[` note suggestions and semantic + * note linking for tasks and projects. + * + * Callers must resolve on every use and never keep the result: the manager can + * be enabled, disabled or rebuilt during a session, and each start builds a + * fresh service instance. + * + * Mobile-safe: only types are imported from the embedding modules, so loading + * this file never pulls in the embedding engine or any Node built-in. + */ + +import type { EmbeddingService } from './EmbeddingService'; + +/** The parts of EmbeddingManager this accessor relies on. */ +interface EmbeddingManagerView { + isEmbeddingEnabled(): boolean; + getService(): EmbeddingService | null; +} + +function isEmbeddingManagerView(value: unknown): value is EmbeddingManagerView { + if (value === null || typeof value !== 'object') { + return false; + } + const candidate = value as Partial; + return typeof candidate.isEmbeddingEnabled === 'function' + && typeof candidate.getService === 'function'; +} + +/** + * Return the plugin's live EmbeddingService, or null when semantic search is + * unavailable: no manager (mobile, or before storage is ready), embeddings + * turned off, or the manager not yet initialized. + * + * @param plugin The Nexus plugin instance (or anything carrying `embeddingManager`). + */ +export function resolveEmbeddingService(plugin: unknown): EmbeddingService | null { + if (plugin === null || typeof plugin !== 'object') { + return null; + } + + try { + const manager = (plugin as { embeddingManager?: unknown }).embeddingManager; + if (!isEmbeddingManagerView(manager) || !manager.isEmbeddingEnabled()) { + return null; + } + return manager.getService() ?? null; + } catch { + return null; + } +} diff --git a/tests/unit/ConversationMemorySearchEmbeddingAccess.test.ts b/tests/unit/ConversationMemorySearchEmbeddingAccess.test.ts new file mode 100644 index 00000000..af412d34 --- /dev/null +++ b/tests/unit/ConversationMemorySearchEmbeddingAccess.test.ts @@ -0,0 +1,167 @@ +/** + * Regression cover: semantic search of past conversations never ran. + * + * `ServiceAccessors.getEmbeddingService()` asked the service registry for + * `'embeddingService'`, a name nothing registers. The embedding system lives on + * the plugin as `plugin.embeddingManager`, so the lookup always returned null, + * `ConversationSearchStrategy` reported conversations "unavailable" and + * `search memory` silently dropped every conversation hit. + * + * What is real: `MemorySearchProcessor`, `ServiceAccessors`, + * `ConversationSearchStrategy` and the plugin lookup. What is faked: the + * plugin (a registry that answers null for every name, exactly like the real + * container did for `'embeddingService'`) and the leaf EmbeddingService, whose + * `semanticConversationSearch` is a spy. The fake decides nothing about whether + * the processor finds the service; that is what the test measures. + * + * If the accessor went back to the registry, the spy would never be called and + * every test below that expects a hit goes red. + */ + +import { MemorySearchProcessor } from '../../src/agents/searchManager/services/MemorySearchProcessor'; +import { PLUGIN_IDS } from '../../src/constants/branding'; +import type { ConversationSearchResult } from '../../src/services/embeddings/ConversationEmbeddingService'; + +const QUERY = 'how did we wire the auth callback'; + +function conversationHit(id: string): ConversationSearchResult { + return { + conversationId: `conv-${id}`, + conversationTitle: `Auth work ${id}`, + workspaceId: 'workspace-1', + pairId: `pair-${id}`, + matchedSequenceRange: [0, 1], + question: 'How should the OAuth callback be wired?', + answer: `Register the redirect handler first (${id}).`, + matchedSide: 'answer', + distance: 0.2, + score: 0.2, + pairType: 'conversation_turn' + }; +} + +function fakeService(id: string) { + return { + semanticConversationSearch: jest.fn(async () => [conversationHit(id)]) + }; +} + +interface FakeManager { + enabled: boolean; + service: ReturnType | null; + isEmbeddingEnabled(): boolean; + getService(): ReturnType | null; +} + +function fakeManager(service: ReturnType | null, enabled = true): FakeManager { + return { + enabled, + service, + isEmbeddingEnabled() { return this.enabled; }, + getService() { return this.service; } + }; +} + +/** + * A plugin whose registry knows no `'embeddingService'`, found by the + * processor the way production finds it: through `app.plugins`. + */ +function createPlugin(manager: FakeManager | undefined) { + const getServiceIfReady = jest.fn(() => null); + const plugin: Record = { + embeddingManager: manager, + getServiceIfReady, + getService: jest.fn(async () => null) + }; + plugin.app = { + plugins: { + getPlugin: (id: string) => (id === PLUGIN_IDS.current ? plugin : null) + } + }; + return { plugin, getServiceIfReady }; +} + +function createProcessor(plugin: Record) { + return new MemorySearchProcessor(plugin as never); +} + +async function searchConversations(processor: MemorySearchProcessor) { + return processor.process({ + query: QUERY, + workspaceId: 'workspace-1', + memoryTypes: ['conversations'] + } as never); +} + +describe('conversation memory search reaches the embedding service', () => { + it('calls semanticConversationSearch on the manager-owned service and returns its hits', async () => { + const service = fakeService('a'); + const { plugin } = createPlugin(fakeManager(service)); + + const { results, metadata } = await searchConversations(createProcessor(plugin)); + + expect(service.semanticConversationSearch).toHaveBeenCalledWith( + QUERY, + 'workspace-1', + undefined, + expect.any(Number) + ); + expect(metadata.typesSearched).toContain('conversations'); + expect(metadata.typesUnavailable).not.toContain('conversations'); + expect(results).toHaveLength(1); + expect(JSON.stringify(results[0])).toContain('conv-a'); + }); + + it('does not depend on a registry entry named embeddingService', async () => { + const service = fakeService('a'); + const { plugin, getServiceIfReady } = createPlugin(fakeManager(service)); + + await searchConversations(createProcessor(plugin)); + + expect(getServiceIfReady).not.toHaveBeenCalledWith('embeddingService'); + expect(service.semanticConversationSearch).toHaveBeenCalledTimes(1); + }); + + it('reports conversations unavailable while embeddings are turned off', async () => { + const service = fakeService('a'); + const { plugin } = createPlugin(fakeManager(service, false)); + + const { results, metadata } = await searchConversations(createProcessor(plugin)); + + expect(service.semanticConversationSearch).not.toHaveBeenCalled(); + expect(metadata.typesUnavailable).toContain('conversations'); + expect(results).toEqual([]); + }); + + it('reports conversations unavailable when the plugin has no embedding manager (mobile)', async () => { + const { plugin } = createPlugin(undefined); + + const { metadata } = await searchConversations(createProcessor(plugin)); + + expect(metadata.typesUnavailable).toContain('conversations'); + }); + + it('follows the manager when its service is replaced, without caching the old one', async () => { + const first = fakeService('first'); + const manager = fakeManager(first); + const { plugin } = createPlugin(manager); + const processor = createProcessor(plugin); + + await searchConversations(processor); + expect(first.semanticConversationSearch).toHaveBeenCalledTimes(1); + + manager.enabled = false; + const whileOff = await searchConversations(processor); + expect(whileOff.metadata.typesUnavailable).toContain('conversations'); + expect(first.semanticConversationSearch).toHaveBeenCalledTimes(1); + + const second = fakeService('second'); + manager.service = second; + manager.enabled = true; + const { results } = await searchConversations(processor); + + expect(first.semanticConversationSearch).toHaveBeenCalledTimes(1); + expect(second.semanticConversationSearch).toHaveBeenCalledTimes(1); + expect(JSON.stringify(results[0])).toContain('conv-second'); + }); +}); From e71d91784a7e5515f2b2f60006496a54a07c45a8 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:57:10 -0400 Subject: [PATCH 06/65] fix(search): resolve the content-search embedding service on every call SearchContentTool cached the first service it saw and SearchManagerAgent pre-seeded that cache from a dead registry lookup. Drop both and the setEmbeddingService seam; the tool now asks resolveEmbeddingService on each semantic search, so a stopped or rebuilt service is never used. Co-Authored-By: Claude Opus 5.5 --- src/agents/searchManager/searchManager.ts | 12 +- .../searchManager/tools/searchContent.ts | 46 +------ .../SearchContentEmbeddingResolution.test.ts | 123 ++++++++++++++++++ tests/unit/SearchContentSemanticScope.test.ts | 7 +- 4 files changed, 138 insertions(+), 50 deletions(-) create mode 100644 tests/unit/SearchContentEmbeddingResolution.test.ts diff --git a/src/agents/searchManager/searchManager.ts b/src/agents/searchManager/searchManager.ts index ad285fbc..6fa61fa9 100644 --- a/src/agents/searchManager/searchManager.ts +++ b/src/agents/searchManager/searchManager.ts @@ -11,7 +11,6 @@ import { MemorySettings, DEFAULT_MEMORY_SETTINGS } from '../../types'; import { MemoryService } from "../memoryManager/services/MemoryService"; import { WorkspaceService } from '../../services/WorkspaceService'; import { IStorageAdapter } from '../../database/interfaces/IStorageAdapter'; -import { EmbeddingService } from '../../services/embeddings/EmbeddingService'; import { getNexusPlugin } from '../../utils/pluginLocator'; import { ServiceManager } from '../../core/ServiceManager'; import type { StorageAdapterResolver } from './services/ServiceAccessors'; @@ -54,7 +53,6 @@ export class SearchManagerAgent extends BaseAgent { private memoryService: MemoryService | null = null; private workspaceService: WorkspaceService | null = null; private storageAdapterResolver: (() => IStorageAdapter | undefined) | null = null; - private embeddingService: EmbeddingService | null = null; private searchContentTool: SearchContentTool | null = null; private settings: MemorySettings; @@ -110,9 +108,6 @@ export class SearchManagerAgent extends BaseAgent { if (!this.storageAdapterResolver) { this.storageAdapterResolver = () => serviceContainer.getServiceIfReady('hybridStorageAdapter') || undefined; } - if (!this.embeddingService) { - this.embeddingService = serviceContainer.getServiceIfReady('embeddingService'); - } } } } @@ -134,12 +129,9 @@ export class SearchManagerAgent extends BaseAgent { // Tools accept Plugin interface which only requires app property const pluginOrFallback: Plugin = pluginRef || this.createMinimalPlugin(app); - // Register ContentSearchTool (fuzzy + keyword + semantic search) + // Register ContentSearchTool (fuzzy + keyword + semantic search). The tool + // resolves the embedding service from the plugin on every semantic call. this.searchContentTool = new SearchContentTool(pluginOrFallback); - // Wire up EmbeddingService for semantic search if available - if (this.embeddingService) { - this.searchContentTool.setEmbeddingService(this.embeddingService); - } this.registerTool(this.searchContentTool); // Register focused search tools - lazy loaded diff --git a/src/agents/searchManager/tools/searchContent.ts b/src/agents/searchManager/tools/searchContent.ts index c693ff9e..b976edbd 100644 --- a/src/agents/searchManager/tools/searchContent.ts +++ b/src/agents/searchManager/tools/searchContent.ts @@ -3,21 +3,14 @@ import { BaseTool } from '../../baseTool'; import { getErrorMessage } from '../../../utils/errorUtils'; import { BRAND_NAME } from '../../../constants/branding'; import { isGlobPattern, globToRegex, normalizePath, isWithinPathScope } from '../../../utils/pathUtils'; -import { EmbeddingService } from '../../../services/embeddings/EmbeddingService'; -import { EmbeddingManager } from '../../../services/embeddings/EmbeddingManager'; +import type { EmbeddingService } from '../../../services/embeddings/EmbeddingService'; +import { resolveEmbeddingService } from '../../../services/embeddings/embeddingAccess'; import { CommonParameters } from '../../../types'; import type { ToolStatusTense } from '../../interfaces/ITool'; import { labelQuery, verbs } from '../../utils/toolStatusLabels'; type SearchContentSchema = ReturnType['getMergedSchema']>; -/** - * Extended plugin interface that includes optional embedding manager - */ -interface PluginWithEmbeddings extends Plugin { - embeddingManager?: EmbeddingManager; -} - /** * How a result was matched. * @@ -227,7 +220,6 @@ function scoreTextMatch( */ export class SearchContentTool extends BaseTool { private plugin: Plugin; - private embeddingService: EmbeddingService | null = null; constructor(plugin: Plugin) { super( @@ -244,37 +236,13 @@ export class SearchContentTool extends BaseTool { diff --git a/tests/unit/SearchContentEmbeddingResolution.test.ts b/tests/unit/SearchContentEmbeddingResolution.test.ts new file mode 100644 index 00000000..76969dbe --- /dev/null +++ b/tests/unit/SearchContentEmbeddingResolution.test.ts @@ -0,0 +1,123 @@ +/** + * Regression cover: `search content --semantic` held on to the first + * EmbeddingService it ever saw. + * + * `SearchContentTool.getEmbeddingService()` cached the service on first use, + * and `SearchManagerAgent` pre-seeded that cache from a registry lookup for + * `'embeddingService'` (a name nothing registers). Once the embedding manager + * can be turned off and on during a session, each start builds a new service, + * so a cached instance is either a stopped service or the wrong one. + * + * What is real: `SearchManagerAgent`, `SearchContentTool` and the plugin + * lookup. What is faked: the plugin and two leaf services whose + * `semanticSearch` is a spy. The fake manager only reports what it holds; the + * test measures whether the tool asks it on every call. + * + * If the tool cached again, the disabled step would still hit the first + * service and the re-enabled step would never reach the second. + */ + +import { TFile } from 'obsidian'; +import { SearchManagerAgent } from '../../src/agents/searchManager/searchManager'; +import { PLUGIN_IDS } from '../../src/constants/branding'; + +const CONTEXT = { + workspaceId: 'default', + sessionId: 'session-1', + memory: 'Looking for a concept.', + goal: 'Find related notes.' +}; + +const NOTE_PATH = 'Ideas/auth.md'; + +function fakeService(label: string) { + return { + label, + isServiceEnabled: jest.fn(() => true), + getStats: jest.fn(async () => ({ noteCount: 1, chunkCount: 1 })), + semanticSearch: jest.fn(async () => [{ notePath: NOTE_PATH, distance: 0.1, score: 0.1 }]) + }; +} + +type FakeService = ReturnType; + +function createHarness(initial: FakeService) { + const manager = { + enabled: true, + service: initial as FakeService | null, + isEmbeddingEnabled() { return this.enabled; }, + getService() { return this.service; } + }; + + const file = new TFile('auth.md', NOTE_PATH); + const getServiceIfReady = jest.fn(() => null); + const plugin: Record = { + embeddingManager: manager, + getServiceContainer: () => ({ getServiceIfReady }), + settings: { settings: { memory: {} } } + }; + const app = { + plugins: { + getPlugin: (id: string) => (id === PLUGIN_IDS.current ? plugin : null) + }, + vault: { + getMarkdownFiles: () => [file], + getAbstractFileByPath: (path: string) => (path === NOTE_PATH ? file : null), + read: async () => '' + }, + metadataCache: { getFileCache: () => null } + }; + plugin.app = app; + + const agent = new SearchManagerAgent(app as never, false, {} as never, {} as never); + const search = () => agent.executeTool('content', { + query: 'authentication flow', + semantic: true, + context: CONTEXT + }) as Promise<{ success: boolean; error?: string }>; + + return { manager, search, getServiceIfReady }; +} + +describe('search content resolves the embedding service on every call', () => { + it('uses the manager-owned service without a registry entry', async () => { + const first = fakeService('first'); + const { search, getServiceIfReady } = createHarness(first); + + const result = await search(); + + expect(result.success).toBe(true); + expect(first.semanticSearch).toHaveBeenCalledTimes(1); + expect(getServiceIfReady).not.toHaveBeenCalledWith('embeddingService'); + }); + + it('stops using the old instance when embeddings are turned off', async () => { + const first = fakeService('first'); + const { manager, search } = createHarness(first); + + await search(); + manager.enabled = false; + const whileOff = await search(); + + expect(whileOff.success).toBe(false); + expect(first.semanticSearch).toHaveBeenCalledTimes(1); + }); + + it('uses the new instance after embeddings are turned back on', async () => { + const first = fakeService('first'); + const { manager, search } = createHarness(first); + + await search(); + manager.enabled = false; + await search(); + + const second = fakeService('second'); + manager.service = second; + manager.enabled = true; + const result = await search(); + + expect(result.success).toBe(true); + expect(first.semanticSearch).toHaveBeenCalledTimes(1); + expect(second.semanticSearch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/SearchContentSemanticScope.test.ts b/tests/unit/SearchContentSemanticScope.test.ts index 58e40ee8..98785638 100644 --- a/tests/unit/SearchContentSemanticScope.test.ts +++ b/tests/unit/SearchContentSemanticScope.test.ts @@ -208,6 +208,12 @@ function createHarness(rows: NoteRow[] = VAULT, options: { vectorTableBroken?: b })); const plugin = { + // The tool resolves its service from the plugin's embedding manager on + // every call, exactly as it does in the running plugin. + embeddingManager: { + isEmbeddingEnabled: () => true, + getService: () => embeddingService + }, app: { vault: { getMarkdownFiles: () => [...files.values()], @@ -221,7 +227,6 @@ function createHarness(rows: NoteRow[] = VAULT, options: { vectorTableBroken?: b } as unknown as Plugin; const tool = new SearchContentTool(plugin); - tool.setEmbeddingService(embeddingService); return { tool, captured, embeddingService }; } From 0bf765e5a479356669c4f155805b08178c04b8f2 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:57:10 -0400 Subject: [PATCH 07/65] fix(chat): make [[ note suggestions semantic again initializeSuggesters captured getServiceIfReady('embeddingService') once, which was always null, so composer suggestions were always fuzzy. searchNotes now accepts a resolver as well as an instance, and the note suggester resolves the service per query. Co-Authored-By: Claude Opus 5.5 --- .../suggesters/TextAreaNoteSuggester.ts | 12 +- .../suggesters/initializeSuggesters.ts | 22 ++- src/utils/noteSearch.ts | 25 ++- tests/unit/NoteSuggesterSemantic.test.ts | 159 ++++++++++++++++++ 4 files changed, 203 insertions(+), 15 deletions(-) create mode 100644 tests/unit/NoteSuggesterSemantic.test.ts diff --git a/src/ui/chat/components/suggesters/TextAreaNoteSuggester.ts b/src/ui/chat/components/suggesters/TextAreaNoteSuggester.ts index f7a7c774..1a714e75 100644 --- a/src/ui/chat/components/suggesters/TextAreaNoteSuggester.ts +++ b/src/ui/chat/components/suggesters/TextAreaNoteSuggester.ts @@ -12,15 +12,15 @@ import { } from './base/SuggesterInterfaces'; import { MessageEnhancer } from '../../services/MessageEnhancer'; import { TokenCalculator } from '../../utils/TokenCalculator'; -import type { EmbeddingService } from '../../../../services/embeddings/EmbeddingService'; -import { searchNotes } from '../../../../utils/noteSearch'; +import { searchNotes, type EmbeddingServiceSource } from '../../../../utils/noteSearch'; export class TextAreaNoteSuggester extends ContentEditableSuggester { private messageEnhancer: MessageEnhancer; - private embeddingService: EmbeddingService | null; + /** Resolved on every query; see EmbeddingServiceSource. */ + private embeddingSource: EmbeddingServiceSource; private maxTokensPerNote = 10000; - constructor(app: App, element: HTMLElement, messageEnhancer: MessageEnhancer, component?: Component, embeddingService?: EmbeddingService | null) { + constructor(app: App, element: HTMLElement, messageEnhancer: MessageEnhancer, component?: Component, embeddingSource?: EmbeddingServiceSource) { super(app, element, { trigger: /\[\[([^\]]*?)$/, maxSuggestions: 50, @@ -29,11 +29,11 @@ export class TextAreaNoteSuggester extends ContentEditableSuggester[]> { - const files = await searchNotes(this.app, query, this.embeddingService, this.config.maxSuggestions); + const files = await searchNotes(this.app, query, this.embeddingSource, this.config.maxSuggestions); return files.map(file => this.createSuggestion(file, 1.0)); } diff --git a/src/ui/chat/components/suggesters/initializeSuggesters.ts b/src/ui/chat/components/suggesters/initializeSuggesters.ts index 8af33f2e..773b4fa3 100644 --- a/src/ui/chat/components/suggesters/initializeSuggesters.ts +++ b/src/ui/chat/components/suggesters/initializeSuggesters.ts @@ -12,7 +12,7 @@ import { CustomPromptStorageService } from '../../../../agents/promptManager/ser import { WorkspaceService } from '../../../../services/WorkspaceService'; import { getNexusPlugin } from '../../../../utils/pluginLocator'; import type { Settings } from '../../../../settings'; -import type { EmbeddingService } from '../../../../services/embeddings/EmbeddingService'; +import { resolveEmbeddingService } from '../../../../services/embeddings/embeddingAccess'; /** * Interface for NexusPlugin with settings and services @@ -21,7 +21,6 @@ interface NexusPluginWithServices extends Plugin { settings?: Settings; services?: Record; workspaceService?: WorkspaceService; - getServiceIfReady?: (name: string) => T | null; } export interface SuggesterInstances { @@ -40,15 +39,22 @@ export function initializeSuggesters( ): SuggesterInstances { const messageEnhancer = new MessageEnhancer(); - // Create suggesters - let embeddingService: EmbeddingService | null = null; + // Create suggesters. The note suggester resolves the embedding service on + // every query, so embeddings turned on or off later in the session apply to + // a composer that is already open. No service means fuzzy-only. + let pluginForEmbeddings: NexusPluginWithServices | null = null; try { - const pluginForEmbeddings = getNexusPlugin(app); - embeddingService = pluginForEmbeddings?.getServiceIfReady?.('embeddingService') ?? null; + pluginForEmbeddings = getNexusPlugin(app); } catch { - // Embedding service unavailable — fuzzy-only + pluginForEmbeddings = null; } - const noteSuggester = new TextAreaNoteSuggester(app, element, messageEnhancer, component, embeddingService); + const noteSuggester = new TextAreaNoteSuggester( + app, + element, + messageEnhancer, + component, + () => resolveEmbeddingService(pluginForEmbeddings) + ); const toolSuggester = new TextAreaToolSuggester(app, element, messageEnhancer, component); // Try to get CustomPromptStorageService for prompt suggester diff --git a/src/utils/noteSearch.ts b/src/utils/noteSearch.ts index cc1fe07e..184cdd33 100644 --- a/src/utils/noteSearch.ts +++ b/src/utils/noteSearch.ts @@ -1,6 +1,28 @@ import { App, TFile, prepareFuzzySearch } from 'obsidian'; import type { EmbeddingService } from '../services/embeddings/EmbeddingService'; +/** + * Where `searchNotes` gets its embedding service: an instance, nothing, or a + * resolver called on every query. Long-lived callers should pass a resolver, + * because the embedding manager can be turned off or rebuilt during a session. + */ +export type EmbeddingServiceSource = + | EmbeddingService + | null + | undefined + | (() => EmbeddingService | null | undefined); + +function resolveSource(source: EmbeddingServiceSource): EmbeddingService | null { + if (typeof source === 'function') { + try { + return source() ?? null; + } catch { + return null; + } + } + return source ?? null; +} + /** * Search notes by semantic similarity (with fuzzy fallback). * Primary: EmbeddingService.semanticSearch if enabled. @@ -10,7 +32,7 @@ import type { EmbeddingService } from '../services/embeddings/EmbeddingService'; export async function searchNotes( app: App, query: string, - embeddingService: EmbeddingService | null | undefined, + embeddingSource: EmbeddingServiceSource, limit = 10 ): Promise { const trimmed = query.trim(); @@ -21,6 +43,7 @@ export async function searchNotes( .slice(0, limit); } + const embeddingService = resolveSource(embeddingSource); if (embeddingService?.isServiceEnabled()) { try { const results = await embeddingService.semanticSearch(trimmed, limit); diff --git a/tests/unit/NoteSuggesterSemantic.test.ts b/tests/unit/NoteSuggesterSemantic.test.ts new file mode 100644 index 00000000..d43b5b06 --- /dev/null +++ b/tests/unit/NoteSuggesterSemantic.test.ts @@ -0,0 +1,159 @@ +/** + * Regression cover: `[[` note suggestions in the chat composer were always + * fuzzy, never semantic. + * + * `initializeSuggesters` captured `getServiceIfReady('embeddingService')` once + * when the composer was built. Nothing registers that name, so the captured + * value was null for the life of the chat view and `searchNotes` always took + * the basename fuzzy branch. Capturing any instance is also wrong now that the + * embedding manager can be turned off and on during a session. + * + * What is real: `initializeSuggesters`, `TextAreaNoteSuggester`, `searchNotes` + * and the plugin lookup. What is faked: the plugin, the vault file list and a + * leaf service whose `semanticSearch` is a spy. The query is chosen so that + * fuzzy matching finds nothing, which makes "semantic was used" observable in + * the returned files, not only in the spy. + */ + +import { App, Component, TFile } from 'obsidian'; +import { searchNotes } from '../../src/utils/noteSearch'; +import { initializeSuggesters } from '../../src/ui/chat/components/suggesters/initializeSuggesters'; +import { PLUGIN_IDS } from '../../src/constants/branding'; +import type { EmbeddingService } from '../../src/services/embeddings/EmbeddingService'; + +/** A conceptual query that no basename or path fuzzy-matches. */ +const QUERY = 'zzq conceptual'; +const SEMANTIC_HIT = 'Ideas/login-flow.md'; + +function note(name: string, path: string): TFile { + // The suggester reads `stat.size` for its token estimate; the TFile mock has no stat. + return Object.assign(new TFile(name, path), { stat: { size: 400, mtime: 0, ctime: 0 } }); +} + +const FILES = [ + note('login-flow.md', SEMANTIC_HIT), + note('groceries.md', 'Lists/groceries.md') +]; + +function fakeService(enabled = true) { + return { + isServiceEnabled: jest.fn(() => enabled), + semanticSearch: jest.fn(async () => [{ notePath: SEMANTIC_HIT, distance: 0.1, score: 0.1 }]) + }; +} + +function asService(service: ReturnType): EmbeddingService { + return service as unknown as EmbeddingService; +} + +function fakeApp(plugin?: Record): App { + const byPath = new Map(FILES.map(file => [file.path, file])); + return { + vault: { + getMarkdownFiles: () => [...FILES], + getFileByPath: (path: string) => byPath.get(path) ?? null + }, + plugins: { + getPlugin: (id: string) => (plugin && id === PLUGIN_IDS.current ? plugin : null) + } + } as unknown as App; +} + +describe('searchNotes with an embedding resolver', () => { + it('calls semanticSearch on the service the resolver returns', async () => { + const service = fakeService(); + + const files = await searchNotes(fakeApp(), QUERY, () => asService(service), 10); + + expect(service.semanticSearch).toHaveBeenCalledWith(QUERY, 10); + expect(files.map(file => file.path)).toEqual([SEMANTIC_HIT]); + }); + + it('asks the resolver again on every query', async () => { + const first = fakeService(); + const second = fakeService(); + let current: ReturnType | null = first; + const resolver = () => (current ? asService(current) : null); + const app = fakeApp(); + + await searchNotes(app, QUERY, resolver, 10); + current = null; + const whileOff = await searchNotes(app, QUERY, resolver, 10); + current = second; + await searchNotes(app, QUERY, resolver, 10); + + expect(whileOff).toEqual([]); + expect(first.semanticSearch).toHaveBeenCalledTimes(1); + expect(second.semanticSearch).toHaveBeenCalledTimes(1); + }); + + it('still accepts a plain instance', async () => { + const service = fakeService(); + + await searchNotes(fakeApp(), QUERY, asService(service), 10); + + expect(service.semanticSearch).toHaveBeenCalledTimes(1); + }); +}); + +describe('composer [[ suggestions use the plugin embedding manager', () => { + const globalWithWindow = globalThis as unknown as { window?: unknown }; + let originalWindow: unknown; + + beforeEach(() => { + originalWindow = globalWithWindow.window; + globalWithWindow.window = { + activeDocument: {}, + setTimeout, + clearTimeout + }; + }); + + afterEach(() => { + globalWithWindow.window = originalWindow; + }); + + function build(manager: { enabled: boolean; service: ReturnType | null }) { + const plugin: Record = { + embeddingManager: { + isEmbeddingEnabled: () => manager.enabled, + getService: () => manager.service + }, + getServiceIfReady: jest.fn(() => null) + }; + const app = fakeApp(plugin); + plugin.app = app; + const component = new Component(); + const suggesters = initializeSuggesters(app, {} as HTMLElement, component); + return { plugin, suggesters }; + } + + it('suggests the semantic hit for a conceptual query', async () => { + const service = fakeService(); + const { plugin, suggesters } = build({ enabled: true, service }); + + const suggestions = await suggesters.noteSuggester.getSuggestions(QUERY); + + expect(service.semanticSearch).toHaveBeenCalledTimes(1); + expect(suggestions.map(item => item.data.path)).toEqual([SEMANTIC_HIT]); + expect(plugin.getServiceIfReady).not.toHaveBeenCalledWith('embeddingService'); + }); + + it('picks up embeddings turned on after the composer was built', async () => { + const manager: { enabled: boolean; service: ReturnType | null } = { + enabled: false, + service: null + }; + const { suggesters } = build(manager); + + const before = await suggesters.noteSuggester.getSuggestions(QUERY); + const service = fakeService(); + manager.service = service; + manager.enabled = true; + const after = await suggesters.noteSuggester.getSuggestions(QUERY); + + expect(before).toEqual([]); + expect(service.semanticSearch).toHaveBeenCalledTimes(1); + expect(after.map(item => item.data.path)).toEqual([SEMANTIC_HIT]); + }); +}); From 1dcf04ea8f3cc27655d13d9f4f12328a07797d0b Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:57:10 -0400 Subject: [PATCH 08/65] fix(tasks): use the embedding accessor for task note linking The task board and the Workspaces tab task detail looked up 'embeddingService' in the registry and always got null. Both now go through resolveEmbeddingService. Co-Authored-By: Claude Opus 5.5 --- .../workspace/ProjectsManagerView.ts | 5 +- src/ui/tasks/TaskBoardView.ts | 3 +- tests/unit/EmbeddingAccess.test.ts | 106 ++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 tests/unit/EmbeddingAccess.test.ts diff --git a/src/components/workspace/ProjectsManagerView.ts b/src/components/workspace/ProjectsManagerView.ts index 77a30c01..3d7fb8b4 100644 --- a/src/components/workspace/ProjectsManagerView.ts +++ b/src/components/workspace/ProjectsManagerView.ts @@ -11,7 +11,8 @@ import { ProjectWorkspace } from '../../database/workspace-types'; import { TaskService } from '../../agents/taskManager/services/TaskService'; import { DAGService } from '../../agents/taskManager/services/DAGService'; import type { ServiceManager } from '../../core/ServiceManager'; -import type { EmbeddingService } from '../../services/embeddings/EmbeddingService'; +import { resolveEmbeddingService } from '../../services/embeddings/embeddingAccess'; +import { getNexusPlugin } from '../../utils/pluginLocator'; import type { HybridStorageAdapter } from '../../database/adapters/HybridStorageAdapter'; import type { ProjectMetadata } from '../../database/repositories/interfaces/IProjectRepository'; import type { LinkType, NoteLink, TaskMetadata, TaskPriority, TaskStatus } from '../../database/repositories/interfaces/ITaskRepository'; @@ -143,7 +144,7 @@ export class ProjectsManagerView { onUnlinkNote: (taskId, notePath) => this.unlinkNote(taskId, notePath), getApp: () => this.callbacks.getApp(), getEmbeddingService: () => - this.serviceManager?.getServiceIfReady('embeddingService') ?? null + resolveEmbeddingService(getNexusPlugin(this.callbacks.getApp())) }; } diff --git a/src/ui/tasks/TaskBoardView.ts b/src/ui/tasks/TaskBoardView.ts index d6ee10d8..5ff769e8 100644 --- a/src/ui/tasks/TaskBoardView.ts +++ b/src/ui/tasks/TaskBoardView.ts @@ -21,6 +21,7 @@ import { TaskBoardFilterController } from './services/TaskBoardFilterController' import { TaskBoardGroupingService } from './services/TaskBoardGroupingService'; import { TaskBoardRenderer } from './services/TaskBoardRenderer'; import { TaskBoardSyncCoordinator } from './services/TaskBoardSyncCoordinator'; +import { resolveEmbeddingService } from '../../services/embeddings/embeddingAccess'; export class TaskBoardView extends ItemView { private dataController: TaskBoardDataController; @@ -58,7 +59,7 @@ export class TaskBoardView extends ItemView { getTaskService: () => this.dataController.getTaskService(), getProjects: () => this.projects, getTasks: () => this.tasks, - getEmbeddingService: () => this.plugin.getServiceIfReady('embeddingService') ?? undefined, + getEmbeddingService: () => resolveEmbeddingService(this.plugin) ?? undefined, reloadBoard: () => this.loadBoardData(), renderBoard: () => this.renderBoard(), onEditModalStateChange: (isOpen) => { diff --git a/tests/unit/EmbeddingAccess.test.ts b/tests/unit/EmbeddingAccess.test.ts new file mode 100644 index 00000000..78169350 --- /dev/null +++ b/tests/unit/EmbeddingAccess.test.ts @@ -0,0 +1,106 @@ +/** + * `resolveEmbeddingService` is the only way to reach the live EmbeddingService. + * + * Five call sites used to ask the service registry for `'embeddingService'`, a + * name nothing registers, so every one of them silently got null. The + * accessor reads `plugin.embeddingManager` instead and answers only while the + * manager is enabled and initialized. These cases pin that contract, plus the + * two view call sites (task board and the Workspaces tab's task detail) that + * hand the service to the note-link suggester. + */ + +import { App, WorkspaceLeaf } from 'obsidian'; +import { resolveEmbeddingService } from '../../src/services/embeddings/embeddingAccess'; +import { ProjectsManagerView, type ProjectsManagerCallbacks } from '../../src/components/workspace/ProjectsManagerView'; +import { WorkspaceDetailRenderer } from '../../src/components/workspace/WorkspaceDetailRenderer'; +import { TaskBoardView } from '../../src/ui/tasks/TaskBoardView'; +import { PLUGIN_IDS } from '../../src/constants/branding'; + +const SERVICE = { label: 'live service' }; + +function manager(enabled: boolean, service: unknown = SERVICE) { + return { + isEmbeddingEnabled: () => enabled, + getService: () => service + }; +} + +describe('resolveEmbeddingService', () => { + it('returns the manager service while the manager is enabled and initialized', () => { + expect(resolveEmbeddingService({ embeddingManager: manager(true) })).toBe(SERVICE); + }); + + it('returns null while embeddings are turned off or not yet initialized', () => { + expect(resolveEmbeddingService({ embeddingManager: manager(false) })).toBeNull(); + }); + + it('returns null when the manager has no service', () => { + expect(resolveEmbeddingService({ embeddingManager: manager(true, null) })).toBeNull(); + }); + + it('returns null without a manager (mobile, or before storage is ready)', () => { + expect(resolveEmbeddingService({})).toBeNull(); + expect(resolveEmbeddingService(null)).toBeNull(); + expect(resolveEmbeddingService(undefined)).toBeNull(); + }); + + it('ignores a registry that would answer for embeddingService', () => { + const getServiceIfReady = jest.fn(() => ({ label: 'registry' })); + expect(resolveEmbeddingService({ getServiceIfReady })).toBeNull(); + expect(getServiceIfReady).not.toHaveBeenCalled(); + }); + + it('returns null instead of throwing when the manager throws', () => { + const broken = { + isEmbeddingEnabled: () => { throw new Error('torn down'); }, + getService: () => SERVICE + }; + expect(resolveEmbeddingService({ embeddingManager: broken })).toBeNull(); + }); +}); + +describe('view call sites resolve through the plugin embedding manager', () => { + function pluginWith(embeddingManager: unknown) { + const plugin: Record = { + embeddingManager, + getServiceIfReady: jest.fn(() => null), + getService: jest.fn(async () => null) + }; + const app = { + plugins: { getPlugin: (id: string) => (id === PLUGIN_IDS.current ? plugin : null) }, + workspace: { on: jest.fn(), offref: jest.fn() }, + vault: { on: jest.fn(), offref: jest.fn() } + }; + plugin.app = app; + return { plugin, app: app as unknown as App }; + } + + it('Workspaces tab task detail hands the note suggester the live service', () => { + const mgr = { enabled: true, isEmbeddingEnabled() { return this.enabled; }, getService: () => SERVICE }; + const { plugin, app } = pluginWith(mgr); + const callbacks = { getApp: () => app } as unknown as ProjectsManagerCallbacks; + const view = new ProjectsManagerView({} as WorkspaceDetailRenderer, undefined, callbacks); + + const taskCallbacks = (view as unknown as { + buildTaskDetailCallbacks(): { getEmbeddingService?: () => unknown }; + }).buildTaskDetailCallbacks(); + + expect(taskCallbacks.getEmbeddingService?.()).toBe(SERVICE); + mgr.enabled = false; + expect(taskCallbacks.getEmbeddingService?.()).toBeNull(); + expect(plugin.getServiceIfReady).not.toHaveBeenCalledWith('embeddingService'); + }); + + it('task board edit modal gets the live service', () => { + const { plugin, app } = pluginWith(manager(true)); + const leaf = { app } as unknown as WorkspaceLeaf; + const view = new TaskBoardView(leaf, plugin as never); + + const deps = (view as unknown as { + editCoordinator: { deps: { getEmbeddingService: () => unknown } }; + }).editCoordinator.deps; + + expect(deps.getEmbeddingService()).toBe(SERVICE); + expect(plugin.getServiceIfReady).not.toHaveBeenCalledWith('embeddingService'); + }); +}); From 5f2dd8cd34bad9febb487468ccf20f20090dc979 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:57:10 -0400 Subject: [PATCH 09/65] test: require every looked-up service name to be registered Statically collects string names passed to getServiceIfReady/getService under src/ and checks them against CORE_SERVICE_DEFINITIONS, ADDITIONAL_SERVICE_FACTORIES and the src/core/index.ts registrations. The dead 'stateManager' shutdown lookup is listed as a known exception. Co-Authored-By: Claude Opus 5.5 --- .../unit/ServiceNameRegistryContract.test.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/unit/ServiceNameRegistryContract.test.ts diff --git a/tests/unit/ServiceNameRegistryContract.test.ts b/tests/unit/ServiceNameRegistryContract.test.ts new file mode 100644 index 00000000..70108ff7 --- /dev/null +++ b/tests/unit/ServiceNameRegistryContract.test.ts @@ -0,0 +1,126 @@ +/** + * Every service name looked up by string must be one something registers. + * + * The defect this buys: five call sites asked for + * `getServiceIfReady('embeddingService')` after the registration was dropped. + * `getServiceIfReady` answers null for an unknown name rather than throwing, + * so semantic conversation search, semantic `[[` suggestions and semantic note + * linking all switched off silently, and every test mocked around the gap. + * + * This test reads the source under `src/`, collects every string literal passed + * to `getServiceIfReady(...)` or `getService(...)`, and requires each name to be + * registered: a `CORE_SERVICE_DEFINITIONS` entry, an + * `ADDITIONAL_SERVICE_FACTORIES` entry, or a `container.register('', ...)` + * call in `src/core/index.ts`. Nothing here is mocked; the registry lists are + * the real exported arrays. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { + CORE_SERVICE_DEFINITIONS, + ADDITIONAL_SERVICE_FACTORIES +} from '../../src/core/services/ServiceDefinitions'; + +const ROOT = path.resolve(__dirname, '..', '..'); +const SRC = path.join(ROOT, 'src'); + +/** + * Lookups known to target a name nothing registers, kept so this contract can + * land without widening its change. Each entry must stay unregistered (the + * last test fails once it is fixed, so the list only shrinks) and must never + * gain a member. + * + * - `stateManager`: PluginLifecycleManager's shutdown asks for it to save + * processed-file state. ProcessedFilesStateManager was removed in the + * simplify-search-architecture work, so the lookup is a no-op. + */ +const KNOWN_UNREGISTERED = new Set(['stateManager']); + +/** + * `getServiceIfReady('x')`, `getServiceIfReady('x')`, `?.getService?.("x")` + * and multi-line variants. The type-argument group refuses parentheses so it + * cannot run on into a later call. + */ +const LOOKUP = /\b(?:getServiceIfReady|getService)\s*(?:\?\.)?\s*(?:<[^()]*?>)?\s*(?:\?\.)?\s*\(\s*['"`]([A-Za-z0-9_]+)['"`]/g; + +function listSourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...listSourceFiles(full)); + } else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { + out.push(full); + } + } + return out; +} + +function collectLookups(): Map { + const byName = new Map(); + for (const file of listSourceFiles(SRC)) { + const source = fs.readFileSync(file, 'utf8'); + for (const match of source.matchAll(LOOKUP)) { + const name = match[1]; + const line = source.slice(0, match.index).split('\n').length; + const where = `${path.relative(ROOT, file)}:${line}`; + byName.set(name, [...(byName.get(name) ?? []), where]); + } + } + return byName; +} + +function registeredNames(): Set { + const names = new Set(); + for (const def of CORE_SERVICE_DEFINITIONS) names.add(def.name); + for (const factory of ADDITIONAL_SERVICE_FACTORIES) names.add(factory.name); + + const coreIndex = fs.readFileSync(path.join(SRC, 'core', 'index.ts'), 'utf8'); + for (const match of coreIndex.matchAll(/\.register\(\s*['"`]([A-Za-z0-9_]+)['"`]/g)) { + names.add(match[1]); + } + return names; +} + +describe('service name registry contract', () => { + const lookups = collectLookups(); + const registered = registeredNames(); + + it('scans real lookups (guards against a regex that matches nothing)', () => { + expect(lookups.get('hybridStorageAdapter')?.length ?? 0).toBeGreaterThan(0); + expect(lookups.get('workspaceService')?.length ?? 0).toBeGreaterThan(0); + expect(registered.has('hybridStorageAdapter')).toBe(true); + expect(registered.has('vaultOperations')).toBe(true); + }); + + it('matches the call shapes used in the codebase', () => { + const sample = [ + "a.getServiceIfReady('one')", + 'b?.getServiceIfReady?.("two")', + 'c.getService>(\n `three`\n)', + "d.getServiceIfReady('four')" + ].join('\n'); + const found = [...sample.matchAll(LOOKUP)].map(match => match[1]); + expect(found).toEqual(['one', 'two', 'three', 'four']); + }); + + it('every looked-up service name is registered', () => { + const unregistered = [...lookups.entries()] + .filter(([name]) => !registered.has(name) && !KNOWN_UNREGISTERED.has(name)) + .map(([name, sites]) => `${name} (${sites.join(', ')})`); + + expect(unregistered).toEqual([]); + }); + + it('nothing looks up embeddingService; use resolveEmbeddingService(plugin)', () => { + expect(lookups.get('embeddingService')).toBeUndefined(); + }); + + it('known exceptions are still unregistered and still looked up', () => { + for (const name of KNOWN_UNREGISTERED) { + expect(registered.has(name)).toBe(false); + expect(lookups.has(name)).toBe(true); + } + }); +}); From ceaa09c2cd299ad2996cc514bf567dafe0fce4ba Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:57:30 -0400 Subject: [PATCH 10/65] fix(chat): welcome "Open settings" opens the Providers tab With no provider configured, the chat welcome's "Open settings" button opened the per-chat Chat settings modal, which has nothing to offer until a provider exists. It now opens Settings -> Nexus on the Providers tab. - UIStateController: the welcome callback is now setOpenProviderSettingsCallback. - SettingsTabManager.openNexusSettings(route) hands the route to the settings view, then opens Obsidian settings on the Nexus tab; exposed through PluginLifecycleManager and NexusPlugin.openNexusSettings. - SettingsView.openTo(route) navigates at once when settings are on screen, otherwise on the next display(), before the tab strip is built. Co-Authored-By: Claude Opus 5.5 --- src/core/PluginLifecycleManager.ts | 6 + src/core/settings/SettingsTabManager.ts | 22 ++++ src/main.ts | 9 ++ src/settings/SettingsView.ts | 30 +++++ src/ui/chat/ChatView.ts | 12 +- src/ui/chat/controllers/UIStateController.ts | 13 +- tests/unit/ChatWelcomeOpenSettings.test.ts | 132 +++++++++++++++++++ tests/unit/SettingsViewOpenTo.test.ts | 113 ++++++++++++++++ 8 files changed, 328 insertions(+), 9 deletions(-) create mode 100644 tests/unit/ChatWelcomeOpenSettings.test.ts create mode 100644 tests/unit/SettingsViewOpenTo.test.ts diff --git a/src/core/PluginLifecycleManager.ts b/src/core/PluginLifecycleManager.ts index 36ba3dd9..6ab93551 100644 --- a/src/core/PluginLifecycleManager.ts +++ b/src/core/PluginLifecycleManager.ts @@ -24,6 +24,7 @@ import { VaultIngestionManager } from './ingest/VaultIngestionManager'; import type { ServiceCreationContext } from './services/ServiceDefinitions'; import type { HybridStorageAdapter } from '../database/adapters/HybridStorageAdapter'; import type { ChatTraceService } from '../services/chat/ChatTraceService'; +import type { RouterState } from '../settings/SettingsRouter'; // Type-only import to avoid bundling Node.js dependencies on mobile type MCPConnectorType = import('../connector').MCPConnector; @@ -334,6 +335,11 @@ export class PluginLifecycleManager { // Configuration reloading handled by individual services } + /** Open Settings on the Nexus tab at the requested route. */ + openNexusSettings(route: Partial): boolean { + return this.settingsTabManager.openNexusSettings(route); + } + /** * Get initialization status */ diff --git a/src/core/settings/SettingsTabManager.ts b/src/core/settings/SettingsTabManager.ts index 5bf0a0f9..ca20cf83 100644 --- a/src/core/settings/SettingsTabManager.ts +++ b/src/core/settings/SettingsTabManager.ts @@ -11,6 +11,7 @@ import type { App, Plugin } from 'obsidian'; import type { Settings } from '../../settings'; // Use new SettingsView (tab-based UI) instead of old SettingsTab (accordion-based) import { SettingsView } from '../../settings/SettingsView'; +import type { RouterState } from '../../settings/SettingsRouter'; import type { BackgroundProcessor } from '../background/BackgroundProcessor'; import type { PluginLifecycleManager } from '../PluginLifecycleManager'; import type { ServiceManager } from '../ServiceManager'; @@ -18,6 +19,14 @@ import type { ServiceManager } from '../ServiceManager'; // Type-only import to avoid bundling Node.js dependencies on mobile type MCPConnectorType = import('../../connector').MCPConnector; +/** Obsidian's settings modal is not part of the public App typings. */ +interface AppWithSettingModal { + setting?: { + open(): void; + openTabById(id: string): void; + }; +} + export interface SettingsTabManagerConfig { plugin: Plugin; app: App; @@ -96,6 +105,19 @@ export class SettingsTabManager { return this.settingsTab; } + /** + * Open Settings on the Nexus tab, at the requested route (for example the + * Providers tab). Returns false when Obsidian's settings modal is unavailable. + */ + openNexusSettings(route: Partial): boolean { + const setting = (this.config.app as unknown as AppWithSettingModal).setting; + if (!setting) return false; + this.settingsTab?.openTo(route); + setting.open(); + setting.openTabById(this.config.plugin.manifest.id); + return true; + } + /** * Check if settings tab is initialized */ diff --git a/src/main.ts b/src/main.ts index 524811e9..48c87dae 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import { BRAND_NAME } from './constants/branding'; import { supportsMCPBridge } from './utils/platform'; import { WasmEnsurer } from './utils/WasmEnsurer'; import type { PluginServices } from './types/plugin/PluginTypes'; +import type { RouterState } from './settings/SettingsRouter'; // MCPConnector type for desktop-only dynamic import type MCPConnectorType = import('./connector').MCPConnector; @@ -211,6 +212,14 @@ export default class NexusPlugin extends Plugin { // Service manager cleanup handled by lifecycle manager } + /** + * Open Settings on the Nexus tab at the requested route, e.g. `{ tab: 'providers' }`. + * Returns false when settings cannot be opened yet. + */ + public openNexusSettings(route: Partial): boolean { + return this.lifecycleManager?.openNexusSettings(route) ?? false; + } + /** * Get service manager for direct access if needed */ diff --git a/src/settings/SettingsView.ts b/src/settings/SettingsView.ts index e1789f9c..c57d0b3b 100644 --- a/src/settings/SettingsView.ts +++ b/src/settings/SettingsView.ts @@ -74,6 +74,9 @@ export class SettingsView extends PluginSettingTab { private prefetchedWorkspaces: IndividualWorkspace[] | null = null; private isPrefetching = false; + // Route requested from outside settings, applied on the next display() + private pendingRoute: Partial | undefined; + constructor( app: App, plugin: Plugin, @@ -188,6 +191,24 @@ export class SettingsView extends PluginSettingTab { } } + /** + * Ask settings to show a particular tab (and optionally a detail view). + * Applied immediately when this tab is already on screen, otherwise on + * the next display(), which Obsidian runs when the tab is opened. + */ + openTo(route: Partial): void { + if (this.tabs && this.containerEl.isConnected) { + this.applyRoute(route); + return; + } + this.pendingRoute = route; + } + + private applyRoute(route: Partial): void { + if (route.tab) this.router.setTab(route.tab); + if (route.view === 'detail' && route.detailId) this.router.showDetail(route.detailId); + } + /** * Main display method - renders the settings UI */ @@ -196,6 +217,15 @@ export class SettingsView extends PluginSettingTab { containerEl.empty(); containerEl.addClass('nexus-settings'); + const pendingRoute = this.pendingRoute; + this.pendingRoute = undefined; + if (pendingRoute) { + // Navigate before the tab strip exists, so no stale listener renders the old panes. + this.unsubscribeRouter?.(); + this.unsubscribeRouter = undefined; + this.applyRoute(pendingRoute); + } + this.getStartedTab?.destroy(); this.getStartedAccordion?.unload(); diff --git a/src/ui/chat/ChatView.ts b/src/ui/chat/ChatView.ts index 93846469..ca89f725 100644 --- a/src/ui/chat/ChatView.ts +++ b/src/ui/chat/ChatView.ts @@ -643,8 +643,8 @@ export class ChatView extends ItemView { onSidebarToggled: (_visible) => { /* Sidebar toggled */ } }; this.uiStateController = new UIStateController(this.containerEl, uiStateEvents, this); - this.uiStateController.setOpenSettingsCallback(() => { - void this.openChatSettingsModal(); + this.uiStateController.setOpenProviderSettingsCallback(() => { + this.openProviderSettings(); }); this.streamingController = new StreamingController(this.containerEl, this.app, this); this.nexusLoadingController = new NexusLoadingController(this.containerEl); @@ -1140,6 +1140,14 @@ export class ChatView extends ItemView { }, this).open(); } + /** The no-provider welcome sends the user to Settings → Nexus → Providers. */ + private openProviderSettings(): void { + const plugin = getNexusPlugin(this.app); + if (!plugin?.openNexusSettings({ tab: 'providers' })) { + new Notice('Open Settings → Nexus → Providers to add a provider.'); + } + } + private async ensurePreservationServiceAndCompact(): Promise { await this.ensurePreservationService(); await this.sendCoordinator.compactCurrentConversation(); diff --git a/src/ui/chat/controllers/UIStateController.ts b/src/ui/chat/controllers/UIStateController.ts index cba064cf..eced4779 100644 --- a/src/ui/chat/controllers/UIStateController.ts +++ b/src/ui/chat/controllers/UIStateController.ts @@ -11,7 +11,7 @@ export interface UIStateControllerEvents { export class UIStateController { private sidebarVisible = false; - private onOpenSettings?: () => void; + private onOpenProviderSettings?: () => void; private timeouts: ManagedTimeoutTracker; constructor( @@ -27,10 +27,11 @@ export class UIStateController { } /** - * Set callback for opening settings + * Set the callback for the no-provider welcome button, which opens + * Settings → Nexus → Providers (not the per-chat settings modal). */ - setOpenSettingsCallback(callback: () => void): void { - this.onOpenSettings = callback; + setOpenProviderSettingsCallback(callback: () => void): void { + this.onOpenProviderSettings = callback; } /** @@ -107,9 +108,7 @@ export class UIStateController { setIcon(settingsBtnIcon, 'settings'); settingsBtn.insertBefore(settingsBtnIcon, settingsBtn.firstChild); const settingsHandler = () => { - if (this.onOpenSettings) { - this.onOpenSettings(); - } + this.onOpenProviderSettings?.(); }; this.registerClickHandler(settingsBtn, settingsHandler); } diff --git a/tests/unit/ChatWelcomeOpenSettings.test.ts b/tests/unit/ChatWelcomeOpenSettings.test.ts new file mode 100644 index 00000000..32023251 --- /dev/null +++ b/tests/unit/ChatWelcomeOpenSettings.test.ts @@ -0,0 +1,132 @@ +/** + * The no-provider chat welcome shows "Open settings". With no provider there + * is nothing to configure in the per-chat Chat settings modal, so the button + * must open Settings → Nexus → Providers instead. It used to call + * ChatView.openChatSettingsModal(). + * + * Real pieces: the ChatView controller wiring (initializeControllers), the + * UIStateController welcome markup and its click registration, and the + * SettingsTabManager route hand-off. Faked: DOM elements (the node test + * environment has none) and Obsidian's private app.setting modal. The + * SettingsView side of the hand-off is covered in SettingsViewOpenTo.test.ts. + */ + +// SettingsTabManager constructs SettingsView only in initializeSettingsTab(), +// which these tests do not call; the obsidian mock has no PluginSettingTab. +jest.mock('../../src/settings/SettingsView', () => ({ SettingsView: jest.fn() })); + +jest.mock( + 'streaming-markdown', + () => ({ + default_renderer: () => ({}), + parser: () => ({}), + parser_write: () => undefined, + parser_end: () => undefined, + }), + { virtual: true } +); + +import type { WorkspaceLeaf } from 'obsidian'; +import { SettingsTabManager } from '../../src/core/settings/SettingsTabManager'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +interface FakeEl { + cls: string; + text: string; + children: FakeEl[]; + [key: string]: any; +} + +function fakeEl(cls = '', text = ''): FakeEl { + const el: FakeEl = { + cls, + text, + children: [], + empty: () => { el.children = []; }, + addClass: () => undefined, + createDiv: (opts?: string | { cls?: string; text?: string }) => add(opts), + createEl: (_tag: string, opts?: { cls?: string; text?: string }) => add(opts), + createSpan: (opts?: string | { cls?: string; text?: string }) => add(opts), + insertBefore: () => undefined, + firstChild: null + }; + function add(opts?: string | { cls?: string; text?: string }): FakeEl { + const child = typeof opts === 'string' ? fakeEl(opts) : fakeEl(opts?.cls ?? '', opts?.text ?? ''); + el.children.push(child); + return child; + } + return el; +} + +function find(root: FakeEl, text: string): FakeEl | undefined { + if (root.text === text) return root; + for (const child of root.children) { + const hit = find(child, text); + if (hit) return hit; + } + return undefined; +} + +async function buildView(openNexusSettings: jest.Mock) { + const { ChatView } = await import('../../src/ui/chat/ChatView'); + const plugin = { openNexusSettings, getServiceIfReady: jest.fn(() => null), getService: jest.fn(async () => null) }; + const app = { plugins: { getPlugin: (id: string) => (id === 'nexus' ? plugin : null) } }; + const view = new ChatView({ app } as unknown as WorkspaceLeaf, null as never) as any; + view.app = app; + const messageDisplay = fakeEl('message-display-container'); + view.containerEl = { querySelector: (selector: string) => selector === '.message-display-container' ? messageDisplay : null }; + const clicks = new Map void>(); + view.registerDomEvent = (element: unknown, type: string, handler: () => void) => { + if (type === 'click') clicks.set(element, handler); + }; + view.openChatSettingsModal = jest.fn(async () => undefined); + view.initializeControllers(); + return { view, messageDisplay, clicks }; +} + +describe('chat welcome "Open settings" with no provider', () => { + it('opens Nexus settings on the Providers tab, not the chat settings modal', async () => { + const openNexusSettings = jest.fn(() => true); + const { view, messageDisplay, clicks } = await buildView(openNexusSettings); + + view.uiStateController.showWelcomeState(false); + const button = find(messageDisplay, 'Open settings'); + expect(button).toBeDefined(); + expect(find(messageDisplay, 'Configure a provider to start chatting.')).toBeDefined(); + const click = clicks.get(button); + if (!click) throw new Error('Open settings button has no click handler'); + click(); + + expect(openNexusSettings).toHaveBeenCalledWith({ tab: 'providers' }); + expect(view.openChatSettingsModal).not.toHaveBeenCalled(); + }); +}); + +describe('SettingsTabManager.openNexusSettings', () => { + function manager(settingsTab: { openTo: jest.Mock } | undefined, setting?: { open: jest.Mock; openTabById: jest.Mock }) { + const tabManager = new SettingsTabManager({ + plugin: { manifest: { id: 'nexus' } }, + app: setting ? { setting } : {}, + } as never); + (tabManager as any).settingsTab = settingsTab; + return tabManager; + } + + it('hands the route to the settings view before opening the Nexus tab', () => { + const order: string[] = []; + const settingsTab = { openTo: jest.fn(() => { order.push('route'); }) }; + const setting = { + open: jest.fn(() => { order.push('open'); }), + openTabById: jest.fn(() => { order.push('tab'); }) + }; + expect(manager(settingsTab, setting).openNexusSettings({ tab: 'providers' })).toBe(true); + expect(settingsTab.openTo).toHaveBeenCalledWith({ tab: 'providers' }); + expect(setting.openTabById).toHaveBeenCalledWith('nexus'); + expect(order).toEqual(['route', 'open', 'tab']); + }); + + it('reports failure when the settings modal is unavailable', () => { + expect(manager({ openTo: jest.fn() }).openNexusSettings({ tab: 'providers' })).toBe(false); + }); +}); diff --git a/tests/unit/SettingsViewOpenTo.test.ts b/tests/unit/SettingsViewOpenTo.test.ts new file mode 100644 index 00000000..0c441a82 --- /dev/null +++ b/tests/unit/SettingsViewOpenTo.test.ts @@ -0,0 +1,113 @@ +/** + * SettingsView.openTo(route) lets code outside settings (the chat welcome's + * "Open settings") land on a specific Nexus tab. Failure this buys: if the + * route were dropped, or applied after the tab strip was built, settings would + * open on the last-used tab (Defaults) and the user would not see Providers. + * + * Real pieces: SettingsView.display(), SettingsRouter and the tab dispatch. + * Faked: Obsidian's PluginSettingTab base, UnifiedTabs (records the tab it is + * built with) and the tab implementations (record that they were rendered). + */ +jest.mock('obsidian', () => { + class PluginSettingTab { + app: unknown; + plugin: unknown; + containerEl: HTMLElement; + + constructor(app: unknown, plugin: unknown) { + this.app = app; + this.plugin = plugin; + this.containerEl = {} as HTMLElement; + } + } + + return { + Events: jest.requireActual('../mocks/obsidian/core').Events, + App: jest.fn(), + Plugin: jest.fn(), + PluginSettingTab, + Notice: jest.fn(), + ButtonComponent: jest.fn(), + FileSystemAdapter: jest.fn(), + Platform: { isMobile: false, isDesktop: true }, + }; +}, { virtual: true }); + +const mockTab = () => jest.fn().mockImplementation(() => ({ destroy: jest.fn(), render: jest.fn() })); +const mockDefaultsTab = mockTab(); +const mockProvidersTab = mockTab(); + +jest.mock('../../src/utils/UpdateManager', () => ({ + UpdateManager: { isStoreAvailable: jest.fn().mockResolvedValue(true) } +})); +jest.mock('../../src/utils/platform', () => ({ supportsMCPBridge: jest.fn().mockReturnValue(false) })); +jest.mock('../../src/components/UnifiedTabs', () => ({ + UnifiedTabs: jest.fn().mockImplementation(() => ({ + getTabContent: jest.fn(() => ({ empty: jest.fn() })), + destroy: jest.fn(), + activateTab: jest.fn() + })) +})); +jest.mock('../../src/settings/tabs/DefaultsTab', () => ({ DefaultsTab: mockDefaultsTab })); +jest.mock('../../src/settings/tabs/WorkspacesTab', () => ({ WorkspacesTab: mockTab() })); +jest.mock('../../src/settings/tabs/InstructionsTab', () => ({ InstructionsTab: mockTab() })); +jest.mock('../../src/settings/tabs/ProvidersTab', () => ({ ProvidersTab: mockProvidersTab })); +jest.mock('../../src/settings/tabs/RemoteAgentsTab', () => ({ RemoteAgentsTab: mockTab() })); +jest.mock('../../src/settings/tabs/AppsTab', () => ({ AppsTab: mockTab() })); +jest.mock('../../src/settings/tabs/DataTab', () => ({ DataTab: mockTab() })); +// Get started is desktop-bridge only and not under test (supportsMCPBridge is false). +jest.mock('../../src/components/Accordion', () => ({ Accordion: jest.fn() })); + +import { createMockElement } from '../helpers/mockFactories'; +import { SettingsView } from '../../src/settings/SettingsView'; + +function buildView(): SettingsView { + const view = new SettingsView( + { vault: { configDir: '.obsidian' }, workspace: {} } as never, + { manifest: { id: 'nexus', version: 'test' } } as never, + { settings: { llmProviders: {} }, saveSettings: jest.fn() } as never + ); + view.containerEl = createMockElement('div') as unknown as HTMLElement; + return view; +} + +function unifiedTabs(): jest.Mock { + return jest.requireMock('../../src/components/UnifiedTabs').UnifiedTabs as jest.Mock; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('opens on the requested tab when the route is set before settings display', () => { + const view = buildView(); + view.openTo({ tab: 'providers' }); + view.display(); + + expect(unifiedTabs().mock.calls[0][0].defaultTab).toBe('providers'); + expect(mockProvidersTab).toHaveBeenCalledTimes(1); + expect(mockDefaultsTab).not.toHaveBeenCalled(); +}); + +it('consumes the route once, so a later display keeps the user on their own tab', () => { + const view = buildView(); + view.openTo({ tab: 'providers' }); + view.display(); + (view as unknown as { router: { setTab(tab: string): void } }).router.setTab('defaults'); + view.display(); + + expect(unifiedTabs().mock.calls[1][0].defaultTab).toBe('defaults'); +}); + +it('navigates at once when settings are already on screen', () => { + const view = buildView(); + view.display(); + expect(mockDefaultsTab).toHaveBeenCalledTimes(1); + (view.containerEl as unknown as { isConnected: boolean }).isConnected = true; + + view.openTo({ tab: 'providers' }); + + const tabs = unifiedTabs().mock.results[0].value as { activateTab: jest.Mock }; + expect(tabs.activateTab).toHaveBeenCalledWith('providers', false); + expect(mockProvidersTab).toHaveBeenCalledTimes(1); +}); From cab775ea08cb089b9ac625e44f1685b23f2c519d Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:58:41 -0400 Subject: [PATCH 11/65] fix(settings): preselect the prompt bound to a workspace on Defaults WorkspaceFormRenderer saves the workspace prompt binding as the top-level dedicatedAgentId, but ChatSettingsRenderer.syncWorkspacePrompt read only the deprecated context.dedicatedAgent.agentId, and DefaultsTab.loadWorkspaces passed only { id, name }. Choosing a workspace on Defaults therefore never selected its prompt. The renderer now uses WorkspacePromptResolver's precedence (top-level first, then the deprecated path), its workspace option type carries dedicatedAgentId, and Defaults passes dedicatedAgentId and context. The context-note Add and remove buttons now register clicks through the renderer's Component (registerDomEvent) instead of onclick assignments; the Component is unloaded on every redraw and on destroy. Co-Authored-By: Claude Opus 5.5 --- src/components/shared/ChatSettingsRenderer.ts | 53 +++++---- src/settings/tabs/DefaultsTab.ts | 9 +- ...hatSettingsRendererWorkspacePrompt.test.ts | 105 ++++++++++++++++++ 3 files changed, 142 insertions(+), 25 deletions(-) create mode 100644 tests/unit/ChatSettingsRendererWorkspacePrompt.test.ts diff --git a/src/components/shared/ChatSettingsRenderer.ts b/src/components/shared/ChatSettingsRenderer.ts index 02245877..82682a78 100644 --- a/src/components/shared/ChatSettingsRenderer.ts +++ b/src/components/shared/ChatSettingsRenderer.ts @@ -102,6 +102,8 @@ export interface ChatSettingsOptions { workspaces: Array<{ id: string; name: string; + /** Canonical prompt binding (name or ID); preferred over context.dedicatedAgent. */ + dedicatedAgentId?: string; context?: { workflows?: Array<{ id: string; name: string; when?: string }>; dedicatedAgent?: { @@ -165,7 +167,8 @@ export class ChatSettingsRenderer { private agentEffortSection?: HTMLElement; private contextNotesListEl?: HTMLElement; private settingsEventRef?: EventRef; - private sliderEvents?: Component; + // Owns DOM listeners for the current render; unloaded on every redraw and on destroy. + private domEvents?: Component; // Maps dropdown option value -> actual { provider, modelId } for merged model lists private modelOptionMap: Map = new Map(); private agentModelOptionMap: Map = new Map(); @@ -196,8 +199,8 @@ export class ChatSettingsRenderer { } destroy(): void { - this.sliderEvents?.unload(); - this.sliderEvents = undefined; + this.domEvents?.unload(); + this.domEvents = undefined; if (this.settingsEventRef) { LLMSettingsNotifier.unsubscribe(this.settingsEventRef); this.settingsEventRef = undefined; @@ -205,8 +208,8 @@ export class ChatSettingsRenderer { } render(): void { - this.sliderEvents?.unload(); - this.sliderEvents = undefined; + this.domEvents?.unload(); + this.domEvents = undefined; this.container.empty(); this.container.addClass('chat-settings-renderer'); @@ -422,11 +425,19 @@ export class ChatSettingsRenderer { } private registerLiveSliderInput(element: HTMLInputElement, update: () => void): void { - if (!this.sliderEvents) { - this.sliderEvents = new Component(); - this.sliderEvents.load(); + this.registerDomEvent(element, 'input', update); + } + + private registerDomEvent( + element: HTMLElement, + type: K, + handler: (event: HTMLElementEventMap[K]) => void + ): void { + if (!this.domEvents) { + this.domEvents = new Component(); + this.domEvents.load(); } - this.sliderEvents.registerDomEvent(element, 'input', update); + this.domEvents.registerDomEvent(element, type, handler); } private renderContextWindowControl(content: HTMLElement): void { @@ -1307,7 +1318,7 @@ export class ChatSettingsRenderer { notesHeader.createSpan().setText('Context notes'); const addBtn = notesHeader.createEl('button', { cls: 'csr-add-btn' }); addBtn.setText('Add'); - addBtn.onclick = () => this.openNotePicker(); + this.registerDomEvent(addBtn, 'click', () => { void this.openNotePicker(); }); this.contextNotesListEl = content.createDiv('csr-notes-list'); this.renderContextNotesList(); @@ -1317,15 +1328,15 @@ export class ChatSettingsRenderer { if (!workspaceId) return; const workspace = this.config.options.workspaces.find(w => w.id === workspaceId); - // dedicatedAgent field stored for backward compat, but contains prompt info - if (workspace?.context?.dedicatedAgent?.agentId) { - const promptId = workspace.context.dedicatedAgent.agentId; - const prompt = this.config.options.prompts.find(p => p.id === promptId || p.name === promptId); - if (prompt) { - this.settings.promptId = prompt.id; - this.notifyChange(); - this.render(); - } + // Same precedence as WorkspacePromptResolver: the top-level binding first, + // then the deprecated context.dedicatedAgent. Either holds a prompt name or ID. + const promptId = workspace?.dedicatedAgentId || workspace?.context?.dedicatedAgent?.agentId; + if (!promptId) return; + const prompt = this.config.options.prompts.find(p => p.id === promptId || p.name === promptId); + if (prompt) { + this.settings.promptId = prompt.id; + this.notifyChange(); + this.render(); } } @@ -1343,11 +1354,11 @@ export class ChatSettingsRenderer { const item = contextNotesListEl.createDiv('csr-note-item'); item.createSpan({ cls: 'csr-note-path', text: notePath }); const removeBtn = item.createEl('button', { cls: 'csr-note-remove', text: '×' }); - removeBtn.onclick = () => { + this.registerDomEvent(removeBtn, 'click', () => { this.settings.contextNotes.splice(index, 1); this.notifyChange(); this.renderContextNotesList(); - }; + }); }); } diff --git a/src/settings/tabs/DefaultsTab.ts b/src/settings/tabs/DefaultsTab.ts index dcecd29e..5cf57246 100644 --- a/src/settings/tabs/DefaultsTab.ts +++ b/src/settings/tabs/DefaultsTab.ts @@ -15,7 +15,7 @@ import { Settings } from '../../settings'; import { DEFAULT_STORAGE_SETTINGS } from '../../types/plugin/PluginTypes'; import { WorkspaceService } from '../../services/WorkspaceService'; import { CustomPromptStorageService } from '../../agents/promptManager/services/CustomPromptStorageService'; -import { ChatSettingsRenderer, ChatSettings } from '../../components/shared/ChatSettingsRenderer'; +import { ChatSettingsRenderer, ChatSettings, ChatSettingsOptions } from '../../components/shared/ChatSettingsRenderer'; import { LLMProviderManager } from '../../services/llm/providers/ProviderManager'; import { getIngestCapabilityOptions, @@ -87,12 +87,13 @@ export class DefaultsTab { await this.render(workspaces, prompts); } - private async loadWorkspaces(): Promise> { + private async loadWorkspaces(): Promise { if (!this.services.workspaceService) return []; try { const workspaces = await this.services.workspaceService.getAllWorkspaces(); - return workspaces.map(w => ({ id: w.id, name: w.name })); + // The prompt binding travels with each workspace so choosing one can preselect its prompt. + return workspaces.map(w => ({ id: w.id, name: w.name, dedicatedAgentId: w.dedicatedAgentId, context: w.context })); } catch { return []; } @@ -203,7 +204,7 @@ export class DefaultsTab { * Main render method */ private async render( - workspaces: Array<{ id: string; name: string }>, + workspaces: ChatSettingsOptions['workspaces'], prompts: Array<{ id: string; name: string }> ): Promise { this.container.empty(); diff --git a/tests/unit/ChatSettingsRendererWorkspacePrompt.test.ts b/tests/unit/ChatSettingsRendererWorkspacePrompt.test.ts new file mode 100644 index 00000000..bd2e47d3 --- /dev/null +++ b/tests/unit/ChatSettingsRendererWorkspacePrompt.test.ts @@ -0,0 +1,105 @@ +/** + * Choosing a workspace in Defaults preselects the prompt bound to it. + * + * Failure this buys: WorkspaceFormRenderer saves the binding as the top-level + * `dedicatedAgentId`, and WorkspacePromptResolver treats that as canonical with + * `context.dedicatedAgent` deprecated. The renderer used to read only the + * deprecated path, and DefaultsTab passed only `{ id, name }`, so the prompt + * never followed the workspace there. + * + * Real pieces: ChatSettingsRenderer.renderContextSection and its workspace + * dropdown handler, DefaultsTab.loadWorkspaces. Faked: the dropdown and + * element mocks (they only capture the registered callbacks). + */ +import { Component } from 'obsidian'; +import { DropdownComponent } from '../mocks/obsidian/components'; +import { createMockElement } from '../helpers/mockFactories'; +import { ChatSettingsRenderer, type ChatSettingsOptions } from '../../src/components/shared/ChatSettingsRenderer'; +import { DefaultsTab } from '../../src/settings/tabs/DefaultsTab'; + +type RendererInternals = { + settings: { workspaceId: string | null; promptId: string | null; workflowId?: string | null; contextNotes: string[] }; + config: { options: ChatSettingsOptions; showWorkflowSelection?: boolean }; + notifyChange: jest.Mock; + render: jest.Mock; + renderContextSection(parent: HTMLElement): void; +}; + +function renderer(workspaces: ChatSettingsOptions['workspaces']): RendererInternals { + const r = Object.create(ChatSettingsRenderer.prototype) as RendererInternals; + r.settings = { workspaceId: null, promptId: null, contextNotes: [] }; + r.config = { + options: { + workspaces, + prompts: [{ id: 'Writer', name: 'Writer' }, { id: 'Editor', name: 'Editor' }] + } + }; + r.notifyChange = jest.fn(); + r.render = jest.fn(); + return r; +} + +/** Render the Context section and return the Workspace dropdown's change handler. */ +function workspaceDropdown(r: RendererInternals): (value: string) => void { + const handlers: Array<(value: string) => void> = []; + const onChange = jest.spyOn(DropdownComponent.prototype, 'onChange').mockImplementation(function (callback) { + handlers.push(callback); + return this; + }); + try { + r.renderContextSection(createMockElement('div') as unknown as HTMLElement); + } finally { + onChange.mockRestore(); + } + // Workspace is the first dropdown in the Context section. + return handlers[0]; +} + +it('selecting a workspace whose top-level dedicatedAgentId names a prompt selects that prompt', () => { + const r = renderer([{ id: 'ws1', name: 'Novel', dedicatedAgentId: 'Writer' }]); + workspaceDropdown(r)('ws1'); + expect(r.settings.workspaceId).toBe('ws1'); + expect(r.settings.promptId).toBe('Writer'); +}); + +it('the top-level binding wins over the deprecated context.dedicatedAgent', () => { + const r = renderer([{ + id: 'ws1', name: 'Novel', dedicatedAgentId: 'Writer', + context: { dedicatedAgent: { agentId: 'Editor', agentName: 'Editor' } } + }]); + workspaceDropdown(r)('ws1'); + expect(r.settings.promptId).toBe('Writer'); +}); + +it('falls back to the deprecated context.dedicatedAgent when no top-level binding exists', () => { + const r = renderer([{ id: 'ws1', name: 'Novel', context: { dedicatedAgent: { agentId: 'Editor', agentName: 'Editor' } } }]); + workspaceDropdown(r)('ws1'); + expect(r.settings.promptId).toBe('Editor'); +}); + +it('DefaultsTab hands the workspace prompt binding to the renderer', async () => { + const tab = Object.create(DefaultsTab.prototype) as { services: unknown; loadWorkspaces(): Promise }; + const context = { purpose: 'Write', dedicatedAgent: { agentId: 'Editor', agentName: 'Editor' } }; + tab.services = { + workspaceService: { + getAllWorkspaces: jest.fn(async () => [{ id: 'ws1', name: 'Novel', dedicatedAgentId: 'Writer', context, sessions: {} }]) + } + }; + expect(await tab.loadWorkspaces()).toEqual([{ id: 'ws1', name: 'Novel', dedicatedAgentId: 'Writer', context }]); +}); + +it('context note buttons register their clicks through a Component, not onclick', () => { + const r = renderer([]); + (r.settings as { contextNotes: string[] }).contextNotes = ['Notes/a.md']; + const registered: Array<{ type: string }> = []; + const register = jest.spyOn(Component.prototype, 'registerDomEvent').mockImplementation((_el, type) => { + registered.push({ type: String(type) }); + }); + try { + workspaceDropdown(r); + } finally { + register.mockRestore(); + } + // "Add" in the header plus one remove button for the single note. + expect(registered.filter(entry => entry.type === 'click')).toHaveLength(2); +}); From f0df0638d11b5d890e6cff2794ee206d8dac1533 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:00:39 -0400 Subject: [PATCH 12/65] fix(settings): keep Defaults ingestion rows ordered and through redraws Ingestion and Embeddings were appended after ChatSettingsRenderer.render(), which empties its container on every redraw (provider or model change, LLM settings change), so both sections vanished after the first redraw. They now render inside the renderer's renderAfterImageSection hook, next to Video and Voice, as an interim fix until the redesigned layout replaces the hook. Within Ingestion the toggle is created first and every dependent row, including auto-convert, lives in the container that is hidden while the toggle is off. Rows render synchronously; only the vision model dropdowns wait for the capability lookup, and a lookup failure no longer escapes as an unhandled rejection. Copy: the toggle is "File import" ("Drop PDFs or audio into chat to turn them into notes.") per the owner decision; auto-convert is "Convert new files automatically" and no longer refers to "the defaults below". Co-Authored-By: Claude Opus 5.5 --- src/settings/tabs/DefaultsTab.ts | 131 ++++++----- tests/unit/DefaultsTabIngestionOrder.test.ts | 220 +++++++++++++++++++ 2 files changed, 295 insertions(+), 56 deletions(-) create mode 100644 tests/unit/DefaultsTabIngestionOrder.test.ts diff --git a/src/settings/tabs/DefaultsTab.ts b/src/settings/tabs/DefaultsTab.ts index 5cf57246..4b3a6b22 100644 --- a/src/settings/tabs/DefaultsTab.ts +++ b/src/settings/tabs/DefaultsTab.ts @@ -84,7 +84,7 @@ export class DefaultsTab { const workspaces = await this.loadWorkspaces(); const prompts = this.loadPrompts(); - await this.render(workspaces, prompts); + this.render(workspaces, prompts); } private async loadWorkspaces(): Promise { @@ -203,10 +203,10 @@ export class DefaultsTab { /** * Main render method */ - private async render( + private render( workspaces: ChatSettingsOptions['workspaces'], prompts: Array<{ id: string; name: string }> - ): Promise { + ): void { this.container.empty(); if (!this.services.llmProviderSettings) { @@ -232,9 +232,15 @@ export class DefaultsTab { showVoiceSection: false, showTranscriptionSection: false, showWebSearch: false, + // Every Defaults-only section renders inside this hook: render() empties the + // container on each redraw, so anything appended outside it would vanish. renderAfterImageSection: (parent) => { this.renderVideoSection(parent); void this.renderVoiceSection(parent); + void this.renderIngestionSection(parent); + if (!Platform.isMobile) { + this.renderEmbeddingsSection(parent); + } }, callbacks: { onSettingsChange: (settings) => { @@ -248,31 +254,31 @@ export class DefaultsTab { }); this.renderer.render(); + } - await this.renderIngestionSection(rendererContainer); - - // Embeddings section (desktop only) - if (!Platform.isMobile) { - const embeddingsSection = createDiv({ cls: 'csr-section' }); - const embeddingsHeader = embeddingsSection.createDiv({ cls: 'csr-section-header' }); - embeddingsHeader.setText('Embeddings'); - const embeddingsContent = embeddingsSection.createDiv({ cls: 'csr-section-content' }); - - new Setting(embeddingsContent) - .setName('Enable') - .setDesc('Local embeddings for semantic search (~23 megabytes download). Restart to apply.') - .addToggle(toggle => { - toggle - .setValue(this.services.settings.settings.enableEmbeddings ?? true) - .onChange(async (value) => { - this.services.settings.settings.enableEmbeddings = value; - await this.services.settings.saveSettings(); - new Notice(`Embeddings ${value ? 'enabled' : 'disabled'}. Restart Obsidian to apply.`); - }); - }); + /** + * Render the embeddings toggle (desktop only). + */ + private renderEmbeddingsSection(parentEl: HTMLElement): void { + const embeddingsSection = createDiv({ cls: 'csr-section' }); + const embeddingsHeader = embeddingsSection.createDiv({ cls: 'csr-section-header' }); + embeddingsHeader.setText('Embeddings'); + const embeddingsContent = embeddingsSection.createDiv({ cls: 'csr-section-content' }); + + new Setting(embeddingsContent) + .setName('Enable') + .setDesc('Local embeddings for semantic search (~23 megabytes download). Restart to apply.') + .addToggle(toggle => { + toggle + .setValue(this.services.settings.settings.enableEmbeddings ?? true) + .onChange(async (value) => { + this.services.settings.settings.enableEmbeddings = value; + await this.services.settings.saveSettings(); + new Notice(`Embeddings ${value ? 'enabled' : 'disabled'}. Restart Obsidian to apply.`); + }); + }); - rendererContainer.appendChild(embeddingsSection); - } + parentEl.appendChild(embeddingsSection); } /** @@ -1039,55 +1045,49 @@ export class DefaultsTab { } /** - * Render the ingestion defaults section + * Render the ingestion defaults section. Rows are created synchronously in + * order (toggle first, dependent rows inside a container hidden while it is + * off); only the vision model dropdowns wait for the capability lookup. */ private async renderIngestionSection(parentEl: HTMLElement): Promise { const llmSettings = this.services.llmProviderSettings; if (!llmSettings) return; const pluginSettings = this.services.settings.settings; - const providerManager = new LLMProviderManager(llmSettings, this.services.app.vault); - const capabilities = await getIngestCapabilityOptions(providerManager); - const normalizedOcrSelection = normalizeIngestSelection( - capabilities.ocrProviders, - llmSettings.defaultOcrModel?.provider, - llmSettings.defaultOcrModel?.model - ); - - if (normalizedOcrSelection.provider && normalizedOcrSelection.model) { - llmSettings.defaultOcrModel = { - provider: normalizedOcrSelection.provider, - model: normalizedOcrSelection.model - }; - } - const section = createDiv({ cls: 'csr-section' }); const header = section.createDiv({ cls: 'csr-section-header' }); header.setText('Ingestion'); const content = section.createDiv({ cls: 'csr-section-content' }); - const ingestionSettingsContainer = content.createDiv(); + parentEl.appendChild(section); + const isEnabled = pluginSettings.enableIngestion !== false; + let ingestionSettingsContainer: HTMLElement | null = null; new Setting(content) - .setName('Enable ingestion') - .setDesc('Show PDF ingestion settings and enable drag-and-drop ingestion in chat.') + .setName('File import') + .setDesc('Drop PDFs or audio into chat to turn them into notes.') .addToggle(toggle => { toggle .setValue(isEnabled) .onChange(async (value) => { pluginSettings.enableIngestion = value; if (value) { - ingestionSettingsContainer.removeClass('nexus-ingest-confirm-hidden'); + ingestionSettingsContainer?.removeClass('nexus-ingest-confirm-hidden'); } else { - ingestionSettingsContainer.addClass('nexus-ingest-confirm-hidden'); + ingestionSettingsContainer?.addClass('nexus-ingest-confirm-hidden'); } await this.services.settings.saveSettings(); }); }); - new Setting(content) - .setName('Auto-convert new files') - .setDesc('When supported PDF files are added to the vault, automatically convert them to sibling Markdown files using the defaults below.') + ingestionSettingsContainer = content.createDiv(); + if (!isEnabled) { + ingestionSettingsContainer.addClass('nexus-ingest-confirm-hidden'); + } + + new Setting(ingestionSettingsContainer) + .setName('Convert new files automatically') + .setDesc('Adds a Markdown note next to each PDF, document or audio file added to the vault.') .addToggle(toggle => { toggle .setValue(pluginSettings.autoIngestion === true) @@ -1097,10 +1097,6 @@ export class DefaultsTab { }); }); - if (!isEnabled) { - ingestionSettingsContainer.addClass('nexus-ingest-confirm-hidden'); - } - // PDF processing mode let ocrSettingsContainer: HTMLElement | null = null; @@ -1132,6 +1128,31 @@ export class DefaultsTab { ocrSettingsContainer.addClass('nexus-ingest-confirm-hidden'); } + const providerManager = new LLMProviderManager(llmSettings, this.services.app.vault); + let capabilities: Awaited>; + try { + capabilities = await getIngestCapabilityOptions(providerManager); + } catch (error) { + console.error('[DefaultsTab] Failed to load ingestion models:', error); + ocrSettingsContainer.createDiv({ + cls: 'setting-item-description', + text: 'Vision models could not be loaded.' + }); + return; + } + const normalizedOcrSelection = normalizeIngestSelection( + capabilities.ocrProviders, + llmSettings.defaultOcrModel?.provider, + llmSettings.defaultOcrModel?.model + ); + + if (normalizedOcrSelection.provider && normalizedOcrSelection.model) { + llmSettings.defaultOcrModel = { + provider: normalizedOcrSelection.provider, + model: normalizedOcrSelection.model + }; + } + renderIngestModelDropdowns( ocrSettingsContainer, { @@ -1147,8 +1168,6 @@ export class DefaultsTab { } } ); - - parentEl.appendChild(section); } /** diff --git a/tests/unit/DefaultsTabIngestionOrder.test.ts b/tests/unit/DefaultsTabIngestionOrder.test.ts new file mode 100644 index 00000000..6407105d --- /dev/null +++ b/tests/unit/DefaultsTabIngestionOrder.test.ts @@ -0,0 +1,220 @@ +/** + * Defaults → Ingestion layout and survival across renderer redraws. + * + * Failures this buys (all three were live bugs): + * 1. The PDF mode and OCR rows rendered above the "enable" toggle that gates + * them, because their container was created before the toggle. + * 2. "Auto-convert" sat outside the gated container, so it stayed visible + * while ingestion was off. + * 3. Ingestion and Embeddings were appended after ChatSettingsRenderer.render(), + * which empties its container on every redraw (provider/model change, LLM + * settings change), so both sections vanished after the first redraw. + * + * The shared obsidian Setting mock neither attaches its element nor records a + * name, so order cannot be observed through it. This file swaps in a Setting + * that appends a named element to the container it is given, over a small + * element tree that keeps children in insertion order and honours empty(). + * + * Real pieces: DefaultsTab.render / renderIngestionSection / + * renderEmbeddingsSection and ChatSettingsRenderer.render (container emptying + * and the renderAfterImageSection hook). Stubbed: the renderer's own sections, + * Video and Voice, and the ingestion capability lookup. + */ + +interface FakeNode { + tag: string; + classes: Set; + text: string; + settingName?: string; + children: FakeNode[]; + parent: FakeNode | null; + [key: string]: unknown; +} + +function fakeNode(tag = 'div', info?: string | { cls?: string; text?: string }): FakeNode { + const opts = typeof info === 'string' ? { cls: info } : info ?? {}; + const node: FakeNode = { + tag, + classes: new Set((opts.cls ?? '').split(/\s+/).filter(Boolean)), + text: opts.text ?? '', + children: [], + parent: null + }; + const append = (child: FakeNode): FakeNode => { + child.parent = node; + node.children.push(child); + return child; + }; + Object.assign(node, { + createDiv: (o?: string | { cls?: string; text?: string }) => append(fakeNode('div', o)), + createEl: (t: string, o?: { cls?: string; text?: string }) => append(fakeNode(t, o)), + createSpan: (o?: string | { cls?: string; text?: string }) => append(fakeNode('span', o)), + appendChild: (child: FakeNode) => append(child), + empty: () => { node.children = []; }, + addClass: (cls: string) => { node.classes.add(cls); }, + removeClass: (cls: string) => { node.classes.delete(cls); }, + hasClass: (cls: string) => node.classes.has(cls), + setText: (text: string) => { node.text = text; } + }); + return node; +} + +const toggles = new Map Promise | void>(); + +jest.mock('obsidian', () => { + const actual = jest.requireActual('../mocks/obsidian'); + class RecordingSetting { + settingEl: FakeNode; + controlEl: FakeNode; + private name = ''; + constructor(containerEl: { createDiv(o?: unknown): FakeNode }) { + this.settingEl = containerEl.createDiv({ cls: 'setting-item' }); + this.controlEl = this.settingEl.createDiv({ cls: 'setting-item-control' }); + } + setName(name: string): this { this.name = name; this.settingEl.settingName = name; return this; } + setDesc(): this { return this; } + setClass(): this { return this; } + setTooltip(): this { return this; } + addToggle(cb: (toggle: unknown) => void): this { + const name = this.name; + const toggle = { + setValue() { return toggle; }, + onChange(handler: (value: boolean) => Promise | void) { toggles.set(name, handler); return toggle; } + }; + cb(toggle); + return this; + } + addDropdown(cb: (dropdown: unknown) => void): this { + const dropdown = { addOption() { return dropdown; }, setValue() { return dropdown; }, onChange() { return dropdown; } }; + cb(dropdown); + return this; + } + } + return { ...actual, Setting: RecordingSetting }; +}); + +jest.mock('../../src/agents/ingestManager/tools/services/IngestCapabilityService', () => ({ + getIngestCapabilityOptions: jest.fn(async () => ({ ocrProviders: [], transcriptionProviders: [] })), + normalizeIngestSelection: jest.fn(() => ({})) +})); +jest.mock('../../src/components/shared/IngestModelDropdownRenderer', () => ({ + renderIngestModelDropdowns: jest.fn() +})); + +import { ChatSettingsRenderer } from '../../src/components/shared/ChatSettingsRenderer'; +import { DefaultsTab } from '../../src/settings/tabs/DefaultsTab'; + +const INGEST_ROWS = ['File import', 'Convert new files automatically', 'Default PDF mode']; + +function settingNames(root: FakeNode): string[] { + const names: string[] = []; + const walk = (node: FakeNode): void => { + if (node.settingName) names.push(node.settingName); + node.children.forEach(walk); + }; + walk(root); + return names; +} + +function findSetting(root: FakeNode, name: string): FakeNode | undefined { + if (root.settingName === name) return root; + for (const child of root.children) { + const hit = findSetting(child, name); + if (hit) return hit; + } + return undefined; +} + +function isHidden(node: FakeNode | undefined): boolean { + for (let current = node ?? null; current; current = current.parent) { + if (current.classes.has('nexus-ingest-confirm-hidden')) return true; + } + return false; +} + +const flush = () => new Promise(resolve => setImmediate(resolve)); + +let restore: Array<() => void> = []; + +beforeEach(() => { + toggles.clear(); + const globals = globalThis as unknown as { createDiv: unknown }; + const originalCreateDiv = globals.createDiv; + globals.createDiv = (info?: string | { cls?: string; text?: string }) => fakeNode('div', info); + const sectionStubs = [ + 'renderModelSection', 'renderAgentModelSection', 'renderImageSection', + 'renderVoiceSection', 'renderTranscriptionSection', 'renderTemperatureSection', 'renderContextSection' + ].map(method => jest.spyOn(ChatSettingsRenderer.prototype as never, method as never).mockImplementation((() => undefined) as never)); + const tabStubs = ['renderVideoSection', 'renderVoiceSection'] + .map(method => jest.spyOn(DefaultsTab.prototype as never, method as never).mockImplementation((() => undefined) as never)); + restore = [ + () => { globals.createDiv = originalCreateDiv; }, + ...[...sectionStubs, ...tabStubs].map(spy => () => spy.mockRestore()) + ]; +}); + +afterEach(() => { + restore.forEach(fn => fn()); +}); + +async function renderDefaults(enableIngestion: boolean) { + const container = fakeNode(); + const saveSettings = jest.fn(async () => undefined); + const pluginSettings: Record = { enableIngestion, autoIngestion: false }; + const tab = Object.create(DefaultsTab.prototype) as { + container: unknown; services: unknown; settingsSaveQueue: Promise; + renderer: ChatSettingsRenderer | null; + render(workspaces: unknown[], prompts: unknown[]): void; + destroy(): void; + }; + tab.container = container; + tab.settingsSaveQueue = Promise.resolve(); + tab.services = { + app: { vault: {}, workspace: {} }, + settings: { settings: pluginSettings, saveSettings }, + llmProviderSettings: { providers: {}, defaultModel: { provider: '', model: '' }, defaultPdfMode: 'text' } + }; + tab.render([], []); + await flush(); + return { tab, container, pluginSettings }; +} + +it('renders the ingestion toggle before the rows it gates', async () => { + const { tab, container } = await renderDefaults(true); + try { + expect(settingNames(container).filter(name => INGEST_ROWS.includes(name))).toEqual(INGEST_ROWS); + } finally { + tab.destroy(); + } +}); + +it('hides auto-convert with the other dependent rows while ingestion is off', async () => { + const { tab, container, pluginSettings } = await renderDefaults(false); + try { + expect(isHidden(findSetting(container, 'File import'))).toBe(false); + expect(isHidden(findSetting(container, 'Convert new files automatically'))).toBe(true); + expect(isHidden(findSetting(container, 'Default PDF mode'))).toBe(true); + + await toggles.get('File import')?.(true); + expect(pluginSettings.enableIngestion).toBe(true); + expect(isHidden(findSetting(container, 'Convert new files automatically'))).toBe(false); + } finally { + tab.destroy(); + } +}); + +it('keeps ingestion and embeddings after the renderer redraws twice', async () => { + const { tab, container } = await renderDefaults(true); + try { + tab.renderer?.render(); + tab.renderer?.render(); + await flush(); + const names = settingNames(container); + for (const row of INGEST_ROWS) { + expect(names.filter(name => name === row)).toHaveLength(1); + } + expect(names.filter(name => name === 'Enable')).toHaveLength(1); + } finally { + tab.destroy(); + } +}); From a9c980645594db2cab753a615eb33f7f9054fd93 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:01:01 -0400 Subject: [PATCH 13/65] feat(llm): gate thinking by model capability at the request boundary Add resolveThinkingForModel, which keeps the stored thinking preference but returns enabled:false for registry models without supportsThinking, and use it in ModelAgentPromptContextAssembler so a hidden preference is no longer sent to non-reasoning models such as Mistral Large. Add getSupportedThinkingEfforts / normalizeThinkingEffortForModel, a pure helper for the Thinking dropdown (owner decision 8): it derives the levels a provider/model distinguishes from the adapters' own effort mappings and returns all five when there is no capability data. The Anthropic adaptive model predicate moves into ThinkingEffortMapper so the adapter and the helper share it. Co-Authored-By: Claude Opus 5.5 --- .../adapters/anthropic/AnthropicAdapter.ts | 5 +- .../llm/utils/ThinkingEffortMapper.ts | 9 ++ .../llm/utils/modelCapabilityLookup.ts | 56 +++++++++ .../llm/utils/resolveThinkingForModel.ts | 50 ++++++++ src/services/llm/utils/thinkingLevels.ts | 114 ++++++++++++++++++ .../ModelAgentPromptContextAssembler.ts | 12 +- tests/unit/PromptContextThinkingGate.test.ts | 71 +++++++++++ tests/unit/ResolveThinkingForModel.test.ts | 47 ++++++++ tests/unit/ThinkingLevels.test.ts | 85 +++++++++++++ 9 files changed, 444 insertions(+), 5 deletions(-) create mode 100644 src/services/llm/utils/modelCapabilityLookup.ts create mode 100644 src/services/llm/utils/resolveThinkingForModel.ts create mode 100644 src/services/llm/utils/thinkingLevels.ts create mode 100644 tests/unit/PromptContextThinkingGate.test.ts create mode 100644 tests/unit/ResolveThinkingForModel.test.ts create mode 100644 tests/unit/ThinkingLevels.test.ts diff --git a/src/services/llm/adapters/anthropic/AnthropicAdapter.ts b/src/services/llm/adapters/anthropic/AnthropicAdapter.ts index ee6f4d86..8d290248 100644 --- a/src/services/llm/adapters/anthropic/AnthropicAdapter.ts +++ b/src/services/llm/adapters/anthropic/AnthropicAdapter.ts @@ -19,7 +19,7 @@ import { import { WebSearchUtils } from '../../utils/WebSearchUtils'; import { extractStreamErrorMessage } from '../../streaming/streamErrorFrames'; import { ANTHROPIC_MODELS, ANTHROPIC_DEFAULT_MODEL } from './AnthropicModels'; -import { ThinkingEffortMapper, mapAnthropicAdaptiveEffort } from '../../utils/ThinkingEffortMapper'; +import { ThinkingEffortMapper, isAnthropicAdaptiveThinkingModel, mapAnthropicAdaptiveEffort } from '../../utils/ThinkingEffortMapper'; import { staticModelToModelInfo, getStaticModelPricing } from '../shared/StaticModelHelpers'; import type { AnthropicThinkingBlock, ThinkingEffort } from '../../../../types/llm/ProviderTypes'; import { TokenUsageExtractor } from '../../utils/TokenUsageExtractor'; @@ -569,8 +569,7 @@ export class AnthropicAdapter extends BaseAdapter { } private supportsAdaptiveThinking(modelId: string): boolean { - const normalized = this.normalizeModelId(modelId); - return /^claude-(?:(?:fable|mythos|opus|sonnet)-(?:5(?:-|$)|4-(?:6|7|8)(?:-|$))|haiku-5-5(?:-|$))/.test(normalized); + return isAnthropicAdaptiveThinkingModel(this.normalizeModelId(modelId)); } private captureThinkingBlock( diff --git a/src/services/llm/utils/ThinkingEffortMapper.ts b/src/services/llm/utils/ThinkingEffortMapper.ts index 74b4a210..af398f25 100644 --- a/src/services/llm/utils/ThinkingEffortMapper.ts +++ b/src/services/llm/utils/ThinkingEffortMapper.ts @@ -66,6 +66,15 @@ export function mapOpenAIThinkingEffort(effort: ThinkingEffort, modelId: string) return 'high'; } +/** + * Claude 4.6+ (and Haiku 5.5) take `thinking: adaptive` with an effort level; + * older Claude models take a manual token budget instead. + */ +export function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { + const normalized = modelId.replace(':1m', ''); + return /^claude-(?:(?:fable|mythos|opus|sonnet)-(?:5(?:-|$)|4-(?:6|7|8)(?:-|$))|haiku-5-5(?:-|$))/.test(normalized); +} + export function mapAnthropicAdaptiveEffort(effort: ThinkingEffort, modelId: string): ThinkingEffort { if (effort !== 'xhigh' && effort !== 'max') return effort; const model = modelId.replace(':1m', ''); diff --git a/src/services/llm/utils/modelCapabilityLookup.ts b/src/services/llm/utils/modelCapabilityLookup.ts new file mode 100644 index 00000000..0721be6f --- /dev/null +++ b/src/services/llm/utils/modelCapabilityLookup.ts @@ -0,0 +1,56 @@ +/** + * Registry-backed capability lookups for request boundaries. + * + * Every answer comes from the static model registry. A model that is not in + * the registry (Ollama, LM Studio, OpenAI-compatible endpoints, ids typed by + * hand) has no capability data, and each helper says so explicitly rather than + * guessing: `undefined` for tri-state answers, and the permissive answer for + * booleans that only ever restrict a request. + * + * Pure data access — no Node or Obsidian imports — so it is safe on mobile and + * in settings UI as well as the runtime paths. + */ + +import { ModelRegistry } from '../adapters/ModelRegistry'; +import type { ModelSpec } from '../adapters/modelTypes'; + +/** The registry entry for a provider/model pair, if one exists. */ +export function findRegistryModelSpec( + provider: string | null | undefined, + model: string | null | undefined +): ModelSpec | undefined { + if (!provider || !model) return undefined; + return ModelRegistry.findModel(provider, model); +} + +/** + * Whether the registry says this model can think. + * `undefined` means there is no registry entry, so nothing is known. + */ +export function registryModelSupportsThinking( + provider: string | null | undefined, + model: string | null | undefined +): boolean | undefined { + return findRegistryModelSpec(provider, model)?.capabilities.supportsThinking; +} + +/** + * Whether a request for this model may carry `temperature`. + * Only an explicit `supportsSamplingParams: false` in the registry says no; + * models without an entry keep the parameter, matching the adapters. + */ +export function modelAcceptsSamplingParams( + provider: string | null | undefined, + model: string | null | undefined +): boolean { + return findRegistryModelSpec(provider, model)?.supportsSamplingParams !== false; +} + +/** The advertised context window, when the registry knows the model. */ +export function getRegistryContextWindow( + provider: string | null | undefined, + model: string | null | undefined +): number | undefined { + const window = findRegistryModelSpec(provider, model)?.contextWindow; + return typeof window === 'number' && window > 0 ? window : undefined; +} diff --git a/src/services/llm/utils/resolveThinkingForModel.ts b/src/services/llm/utils/resolveThinkingForModel.ts new file mode 100644 index 00000000..ce5eda4e --- /dev/null +++ b/src/services/llm/utils/resolveThinkingForModel.ts @@ -0,0 +1,50 @@ +/** + * Gate a stored thinking preference at the request boundary. + * + * The user's thinking choice is stored per chat and per default, and it stays + * stored when they switch to a model that cannot reason — so switching back + * restores their level. What must not happen is that hidden preference being + * sent: a request for a non-reasoning model always goes out with thinking off. + * + * The answer is driven by the registry's `supportsThinking` flag. A model with + * no registry entry (discovered or custom models) has no capability data, so + * its preference passes through unchanged and the adapter decides, which is + * what every request did before this gate existed. + */ + +import type { ThinkingEffort, ThinkingSettings } from '../../../types/llm/ProviderTypes'; +import { registryModelSupportsThinking } from './modelCapabilityLookup'; + +export interface ThinkingPreference { + enabled?: boolean; + effort?: ThinkingEffort; +} + +export interface ResolveThinkingOptions { + /** + * Capability supplied by the caller when it knows better than the static + * registry. `undefined` falls back to the registry lookup. + */ + supportsThinking?: boolean; +} + +const DEFAULT_EFFORT: ThinkingEffort = 'medium'; + +/** + * Returns the thinking settings a request for `provider`/`model` may carry. + * The input object is never modified. + */ +export function resolveThinkingForModel( + provider: string | null | undefined, + model: string | null | undefined, + thinking: ThinkingPreference | null | undefined, + options: ResolveThinkingOptions = {} +): ThinkingSettings { + const effort = thinking?.effort ?? DEFAULT_EFFORT; + const enabled = thinking?.enabled === true; + if (!enabled) return { enabled: false, effort }; + + const supportsThinking = options.supportsThinking ?? registryModelSupportsThinking(provider, model); + if (supportsThinking === false) return { enabled: false, effort }; + return { enabled: true, effort }; +} diff --git a/src/services/llm/utils/thinkingLevels.ts b/src/services/llm/utils/thinkingLevels.ts new file mode 100644 index 00000000..c288391e --- /dev/null +++ b/src/services/llm/utils/thinkingLevels.ts @@ -0,0 +1,114 @@ +/** + * Which thinking levels a provider/model actually distinguishes. + * + * Nexus stores one of five efforts (Low, Medium, High, Extra high, Max), but + * most providers accept fewer and the adapters fold the rest onto a neighbour: + * Gemini stops at High, older Claude models treat Extra high and Max as High, + * GPT-5.2 to 5.5 treat Max as Extra high, and so on. A Thinking dropdown that + * offers every level would show choices that silently do the same thing. + * + * `getSupportedThinkingEfforts` returns only the levels that behave + * differently, using the same mapping functions the adapters use, so this + * list cannot drift from what is sent. When Nexus has no capability data for + * a provider or model it returns all five levels (owner decision: "if no + * capability data exists, show all six" — Off plus five levels). + * + * This answers "which levels", not "can it think at all": callers that also + * need the on/off capability use `registryModelSupportsThinking` or + * `resolveThinkingForModel`. + * + * Pure: no Node or Obsidian imports, safe for settings UI on mobile. + */ + +import type { ThinkingEffort } from '../../../types/llm/ProviderTypes'; +import { + clampThinkingEffortToHigh, + isAnthropicAdaptiveThinkingModel, + mapAnthropicAdaptiveEffort, + mapOpenAIThinkingEffort +} from './ThinkingEffortMapper'; + +/** Every effort Nexus can store, lowest first. */ +export const THINKING_EFFORTS: readonly ThinkingEffort[] = ['low', 'medium', 'high', 'xhigh', 'max']; + +/** For one provider/model, the level that a stored effort behaves as. */ +type EffortBehaviour = (effort: ThinkingEffort, model: string) => ThinkingEffort; + +const ONLY_HIGH: EffortBehaviour = () => 'high'; + +/** Mistral Large 4 takes a binary reasoning switch, sent as `high`. */ +function isMistralLarge4(model: string): boolean { + return model === 'mistral-large-4' || model === 'mistral-large-4-0' || model === 'mistral/mistral-large-4'; +} + +function getEffortBehaviour(provider: string, model: string): EffortBehaviour | null { + switch (provider) { + case 'anthropic': + // Manual-budget models (Claude 4.5 and older) lift Extra high and Max + // above High through the token budget, so every level is distinct. + return isAnthropicAdaptiveThinkingModel(model) ? mapAnthropicAdaptiveEffort : null; + case 'anthropic-claude-code': + // The CLI always passes `--effort` through the adaptive mapping. + return mapAnthropicAdaptiveEffort; + case 'openai': + case 'openai-codex': + return mapOpenAIThinkingEffort; + case 'google': + // Gemini 3 thinking levels and the legacy token budgets both stop at + // High; Gemini 3 Pro Preview has no Medium level. + return model.startsWith('gemini-3-pro-preview') + ? (effort: ThinkingEffort) => { + const level = clampThinkingEffortToHigh(effort); + return level === 'medium' ? 'high' : level; + } + : clampThinkingEffortToHigh; + case 'google-gemini-cli': + case 'groq': + return clampThinkingEffortToHigh; + case 'deepseek': + // DeepSeek accepts `high` and `max`. Low and Medium send `high`; High + // and above send `max`. Offer one level per behaviour: Medium and Max. + return (effort: ThinkingEffort) => (effort === 'low' || effort === 'medium' ? 'medium' : 'max'); + case 'mistral': + case 'requesty': + return isMistralLarge4(model) ? ONLY_HIGH : null; + case 'openrouter': + return model.replace(/:online$/, '') === 'mistralai/mistral-large-4-0' ? ONLY_HIGH : null; + default: + return null; + } +} + +/** + * The thinking levels this provider/model distinguishes, lowest first. + * All five when there is no capability data. + */ +export function getSupportedThinkingEfforts( + provider: string | null | undefined, + model: string | null | undefined +): ThinkingEffort[] { + const behaviour = provider && model ? getEffortBehaviour(provider, model) : null; + if (!behaviour || !model) return [...THINKING_EFFORTS]; + + const supported: ThinkingEffort[] = []; + for (const effort of THINKING_EFFORTS) { + const behavesAs = behaviour(effort, model); + if (!supported.includes(behavesAs)) supported.push(behavesAs); + } + return THINKING_EFFORTS.filter(effort => supported.includes(effort)); +} + +/** + * The supported level a stored effort behaves as for this provider/model, so a + * dropdown can show the value that will really be used without rewriting the + * stored preference. Unknown models return the effort unchanged. + */ +export function normalizeThinkingEffortForModel( + provider: string | null | undefined, + model: string | null | undefined, + effort: ThinkingEffort +): ThinkingEffort { + const behaviour = provider && model ? getEffortBehaviour(provider, model) : null; + if (!behaviour || !model) return effort; + return behaviour(effort, model); +} diff --git a/src/ui/chat/services/ModelAgentPromptContextAssembler.ts b/src/ui/chat/services/ModelAgentPromptContextAssembler.ts index 979e6b49..3c1f4d64 100644 --- a/src/ui/chat/services/ModelAgentPromptContextAssembler.ts +++ b/src/ui/chat/services/ModelAgentPromptContextAssembler.ts @@ -5,6 +5,7 @@ import type { CompactedContext } from '../../../services/chat/ContextCompactionS import type { CompactionFrontierRecord } from '../../../services/chat/CompactionFrontierService'; import { shouldPassToolSchemasToProvider } from '../../../services/llm/utils/ToolSchemaSupport'; import type { ThinkingSettings } from '../../../types/llm/ProviderTypes'; +import { resolveThinkingForModel } from '../../../services/llm/utils/resolveThinkingForModel'; import type { MessageEnhancement } from '../components/suggesters/base/SuggesterInterfaces'; import type { ModelOption } from '../types/SelectionTypes'; import type { @@ -110,6 +111,13 @@ export class ModelAgentPromptContextAssembler { snapshot: ModelAgentPromptContextSnapshot ): Promise { const sessionId = await this.deps.getSessionId(); + // The stored preference survives a switch to a non-reasoning model; it is + // only the request that must not carry it. + const thinking = resolveThinkingForModel( + snapshot.selectedModel?.providerId, + snapshot.selectedModel?.modelId, + snapshot.thinkingSettings + ); return { provider: snapshot.selectedModel?.providerId, @@ -117,13 +125,13 @@ export class ModelAgentPromptContextAssembler { systemPrompt: await this.buildSystemPrompt(snapshot) || undefined, workspaceId: snapshot.selectedWorkspaceId || undefined, sessionId, - enableThinking: snapshot.thinkingSettings.enabled, + enableThinking: thinking.enabled, webSearch: snapshot.webSearch && ( ['anthropic', 'openai', 'openrouter'].includes(snapshot.selectedModel?.providerId || '') || (snapshot.selectedModel?.providerId === 'google' && snapshot.selectedModel.modelId.startsWith('gemini-3')) ), - thinkingEffort: snapshot.thinkingSettings.effort, + thinkingEffort: thinking.effort, temperature: snapshot.temperature, imageProvider: snapshot.imageProvider, imageModel: snapshot.imageModel, diff --git a/tests/unit/PromptContextThinkingGate.test.ts b/tests/unit/PromptContextThinkingGate.test.ts new file mode 100644 index 00000000..5aba031e --- /dev/null +++ b/tests/unit/PromptContextThinkingGate.test.ts @@ -0,0 +1,71 @@ +/** + * The chat request boundary gates thinking by model capability. + * + * Defect this buys: Chat settings hide the Thinking control for models that + * cannot reason, but the stored `enabled: true` stayed in the snapshot and + * `buildMessageOptions` sent `enableThinking: true` to Mistral Large anyway. + * The model capability comes from the real registry, not a fixture. + */ +import { + ModelAgentPromptContextAssembler, + type ModelAgentPromptContextSnapshot +} from '../../src/ui/chat/services/ModelAgentPromptContextAssembler'; +import type { ModelOption } from '../../src/ui/chat/types/SelectionTypes'; + +function model(providerId: string, modelId: string): ModelOption { + return { providerId, providerName: providerId, modelId, modelName: modelId, contextWindow: 128_000 }; +} + +function snapshot(selectedModel: ModelOption): ModelAgentPromptContextSnapshot { + return { + selectedModel, + selectedWorkspaceId: null, + workspaceContext: null, + loadedWorkspaceData: null, + contextNotes: [], + messageEnhancement: null, + currentSystemPrompt: null, + thinkingSettings: { enabled: true, effort: 'high' }, + webSearch: false, + temperature: 0.5, + imageProvider: 'google', + imageModel: 'gemini-3.1-flash-image', + transcriptionProvider: null, + transcriptionModel: null, + contextTokenTracker: null, + compactionFrontier: [], + latestCompactionRecord: null + }; +} + +function createAssembler(): ModelAgentPromptContextAssembler { + return new ModelAgentPromptContextAssembler({ + systemPromptBuilder: { build: jest.fn().mockResolvedValue('system prompt') }, + getSessionId: jest.fn().mockResolvedValue('session-1') + }); +} + +describe('ModelAgentPromptContextAssembler thinking gate', () => { + it('does not send a stored thinking preference to a model that cannot think', async () => { + const input = snapshot(model('mistral', 'mistral-large-latest')); + const options = await createAssembler().buildMessageOptions(input); + + expect(options.enableThinking).toBe(false); + expect(options.thinkingEffort).toBe('high'); + // The stored preference is untouched, so switching back restores it. + expect(input.thinkingSettings).toEqual({ enabled: true, effort: 'high' }); + }); + + it('sends thinking for a model that can think', async () => { + const options = await createAssembler().buildMessageOptions(snapshot(model('mistral', 'magistral-medium-latest'))); + + expect(options.enableThinking).toBe(true); + expect(options.thinkingEffort).toBe('high'); + }); + + it('leaves discovered models without registry data to the adapter', async () => { + const options = await createAssembler().buildMessageOptions(snapshot(model('ollama', 'qwen3:8b'))); + + expect(options.enableThinking).toBe(true); + }); +}); diff --git a/tests/unit/ResolveThinkingForModel.test.ts b/tests/unit/ResolveThinkingForModel.test.ts new file mode 100644 index 00000000..37d8d9d3 --- /dev/null +++ b/tests/unit/ResolveThinkingForModel.test.ts @@ -0,0 +1,47 @@ +/** + * Thinking gate at the request boundary. + * + * Defect this buys: the stored thinking preference stays `enabled: true` while + * the user is on a model that cannot reason, and before the gate it was sent + * anyway. If `resolveThinkingForModel` stopped consulting the registry, the + * Mistral Large case below would go red. + */ +import { resolveThinkingForModel } from '../../src/services/llm/utils/resolveThinkingForModel'; +import { ModelRegistry } from '../../src/services/llm/adapters/ModelRegistry'; + +describe('resolveThinkingForModel', () => { + it('turns thinking off for a registry model that cannot think, keeping the effort', () => { + // Guard the fixture: the registry really says Mistral Large Latest cannot think. + expect(ModelRegistry.findModel('mistral', 'mistral-large-latest')?.capabilities.supportsThinking).toBe(false); + + const stored = { enabled: true, effort: 'high' as const }; + const resolved = resolveThinkingForModel('mistral', 'mistral-large-latest', stored); + + expect(resolved).toEqual({ enabled: false, effort: 'high' }); + expect(stored).toEqual({ enabled: true, effort: 'high' }); + }); + + it('passes thinking through for a model that can think', () => { + expect(ModelRegistry.findModel('anthropic', 'claude-opus-5-5')?.capabilities.supportsThinking).toBe(true); + expect(resolveThinkingForModel('anthropic', 'claude-opus-5-5', { enabled: true, effort: 'max' })) + .toEqual({ enabled: true, effort: 'max' }); + }); + + it('passes the preference through when the registry has no entry (no capability data)', () => { + expect(resolveThinkingForModel('ollama', 'qwen3:8b', { enabled: true, effort: 'low' })) + .toEqual({ enabled: true, effort: 'low' }); + expect(resolveThinkingForModel('openai', 'not-a-real-model', { enabled: true, effort: 'low' })) + .toEqual({ enabled: true, effort: 'low' }); + }); + + it('lets a caller-supplied capability override the registry', () => { + expect(resolveThinkingForModel('ollama', 'llama3', { enabled: true, effort: 'low' }, { supportsThinking: false })) + .toEqual({ enabled: false, effort: 'low' }); + }); + + it('keeps thinking off when it was off, and defaults a missing effort to medium', () => { + expect(resolveThinkingForModel('anthropic', 'claude-opus-5-5', { enabled: false, effort: 'low' })) + .toEqual({ enabled: false, effort: 'low' }); + expect(resolveThinkingForModel('anthropic', 'claude-opus-5-5', null)).toEqual({ enabled: false, effort: 'medium' }); + }); +}); diff --git a/tests/unit/ThinkingLevels.test.ts b/tests/unit/ThinkingLevels.test.ts new file mode 100644 index 00000000..70b638c1 --- /dev/null +++ b/tests/unit/ThinkingLevels.test.ts @@ -0,0 +1,85 @@ +/** + * Thinking levels a provider/model distinguishes (owner decision 8: the + * Thinking dropdown hides levels the model or provider does not support, and + * shows all of them when there is no capability data). + * + * Defect this buys: a dropdown offering Extra high and Max for Gemini, which + * the adapter silently sends as High. The expectations are cross-checked + * against the adapters' own mapping functions, so a level list that drifted + * from what is actually sent goes red. + */ +import { + THINKING_EFFORTS, + getSupportedThinkingEfforts, + normalizeThinkingEffortForModel +} from '../../src/services/llm/utils/thinkingLevels'; +import { + clampThinkingEffortToHigh, + mapAnthropicAdaptiveEffort, + mapOpenAIThinkingEffort +} from '../../src/services/llm/utils/ThinkingEffortMapper'; +import type { ThinkingEffort } from '../../src/types/llm/ProviderTypes'; + +describe('getSupportedThinkingEfforts', () => { + it.each([ + ['anthropic', 'claude-opus-5-5', ['low', 'medium', 'high', 'xhigh', 'max']], + ['anthropic', 'claude-opus-4-6', ['low', 'medium', 'high', 'max']], + ['anthropic', 'claude-opus-4-6:1m', ['low', 'medium', 'high', 'max']], + ['anthropic-claude-code', 'claude-opus-4-6', ['low', 'medium', 'high', 'max']], + ['openai', 'gpt-5.4', ['low', 'medium', 'high', 'xhigh']], + ['openai', 'gpt-5.6', ['low', 'medium', 'high', 'xhigh', 'max']], + ['openai', 'gpt-5', ['low', 'medium', 'high']], + ['openai-codex', 'gpt-5.4', ['low', 'medium', 'high', 'xhigh']], + ['google', 'gemini-3.1-pro-preview', ['low', 'medium', 'high']], + ['google', 'gemini-3-pro-preview', ['low', 'high']], + ['google-gemini-cli', 'gemini-3.5-flash', ['low', 'medium', 'high']], + ['groq', 'openai/gpt-oss-120b', ['low', 'medium', 'high']], + ['deepseek', 'deepseek-v4-pro-thinking', ['medium', 'max']], + ['mistral', 'mistral-large-4', ['high']], + ['requesty', 'mistral/mistral-large-4', ['high']], + ['openrouter', 'mistralai/mistral-large-4-0:online', ['high']] + ])('%s %s offers %j', (provider, model, expected) => { + expect(getSupportedThinkingEfforts(provider, model)).toEqual(expected); + }); + + it('keeps every level for a manual-budget Claude model, whose budget lifts Extra high and Max', () => { + expect(getSupportedThinkingEfforts('anthropic', 'claude-sonnet-4-5-20250929')).toEqual([...THINKING_EFFORTS]); + }); + + it('shows all five levels when there is no capability data', () => { + expect(getSupportedThinkingEfforts('ollama', 'qwen3:8b')).toEqual([...THINKING_EFFORTS]); + expect(getSupportedThinkingEfforts('openrouter', 'anthropic/claude-opus-5.5')).toEqual([...THINKING_EFFORTS]); + expect(getSupportedThinkingEfforts('my-openai-compatible', 'local-model')).toEqual([...THINKING_EFFORTS]); + expect(getSupportedThinkingEfforts(undefined, undefined)).toEqual([...THINKING_EFFORTS]); + }); + + it('agrees with the adapter mappings: every offered level is sent as itself', () => { + const cases: Array<[string, string, (effort: ThinkingEffort) => string]> = [ + ['anthropic', 'claude-opus-4-6', effort => mapAnthropicAdaptiveEffort(effort, 'claude-opus-4-6')], + ['anthropic-claude-code', 'claude-sonnet-4-6', effort => mapAnthropicAdaptiveEffort(effort, 'claude-sonnet-4-6')], + ['openai', 'gpt-5.4', effort => mapOpenAIThinkingEffort(effort, 'gpt-5.4')], + ['google', 'gemini-3.1-pro-preview', clampThinkingEffortToHigh] + ]; + for (const [provider, model, send] of cases) { + const offered = getSupportedThinkingEfforts(provider, model); + for (const effort of offered) expect(send(effort)).toBe(effort); + // Every hidden level behaves as one that is offered. + for (const effort of THINKING_EFFORTS) { + expect(offered).toContain(normalizeThinkingEffortForModel(provider, model, effort)); + } + } + }); +}); + +describe('normalizeThinkingEffortForModel', () => { + it('shows the level a stored effort really behaves as', () => { + expect(normalizeThinkingEffortForModel('google', 'gemini-3.1-pro-preview', 'max')).toBe('high'); + expect(normalizeThinkingEffortForModel('openai', 'gpt-5.4', 'max')).toBe('xhigh'); + expect(normalizeThinkingEffortForModel('deepseek', 'deepseek-v4-pro-thinking', 'high')).toBe('max'); + expect(normalizeThinkingEffortForModel('deepseek', 'deepseek-v4-pro-thinking', 'low')).toBe('medium'); + }); + + it('returns the stored effort unchanged when there is no capability data', () => { + expect(normalizeThinkingEffortForModel('ollama', 'qwen3:8b', 'xhigh')).toBe('xhigh'); + }); +}); From 7c1114acb531a9fd171ec1f6732634ef19ca09dc Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:02:15 -0400 Subject: [PATCH 14/65] fix(chat): sentence-case the provider settings fallback notice Co-Authored-By: Claude Opus 5.5 --- src/ui/chat/ChatView.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/chat/ChatView.ts b/src/ui/chat/ChatView.ts index ca89f725..12455b7b 100644 --- a/src/ui/chat/ChatView.ts +++ b/src/ui/chat/ChatView.ts @@ -1144,7 +1144,7 @@ export class ChatView extends ItemView { private openProviderSettings(): void { const plugin = getNexusPlugin(this.app); if (!plugin?.openNexusSettings({ tab: 'providers' })) { - new Notice('Open Settings → Nexus → Providers to add a provider.'); + new Notice('Could not open settings. Add a provider in the Nexus settings.'); } } From a799853de18f762af376eb4aacbfa03157cc5b0b Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:02:29 -0400 Subject: [PATCH 15/65] feat(subagents): agent model precedence and subagent Creativity/window settings Add SubagentModelResolver, the single precedence for agent runs (subagents and executePrompt): explicit tool parameter, the chat's subagent model, the default subagent model, a cloud chat model, then the first cloud provider. resolveAgentRunSettings combines thinking (gated by model capability), Creativity (dropped for models with supportsSamplingParams false) and the context budget (clamped to the model window). LLMService.getAgentModel now honours a configured agentModel whatever the chat model is, exposes getAgentDefaults(), and executePrompt forwards enableThinking/thinkingEffort to the adapter. New optional settings, no migration: llmProviders.agentTemperature and llmProviders.agentContextWindowOverrides, and per-chat chatSettings.agentTemperature / agentContextWindow carried through the defaults resolver, ModelAgentManager restore/save and the settings store. The conversation_updated event stores the whole chatSettings object and the applier replays it verbatim, so the new fields survive a cache rebuild. Co-Authored-By: Claude Opus 5.5 --- src/services/llm/core/LLMService.ts | 54 +++-- .../llm/utils/SubagentModelResolver.ts | 202 ++++++++++++++++++ src/types/chat/ChatTypes.ts | 4 + src/types/llm/ProviderTypes.ts | 20 +- .../ModelAgentConversationSettingsStore.ts | 4 + .../services/ModelAgentDefaultsResolver.ts | 20 ++ src/ui/chat/services/ModelAgentManager.ts | 55 +++++ tests/unit/LLMServiceAgentModel.test.ts | 72 +++++++ .../ModelAgentManagerAgentSettings.test.ts | 145 +++++++++++++ tests/unit/SubagentModelResolver.test.ts | 119 +++++++++++ 10 files changed, 666 insertions(+), 29 deletions(-) create mode 100644 src/services/llm/utils/SubagentModelResolver.ts create mode 100644 tests/unit/LLMServiceAgentModel.test.ts create mode 100644 tests/unit/ModelAgentManagerAgentSettings.test.ts create mode 100644 tests/unit/SubagentModelResolver.test.ts diff --git a/src/services/llm/core/LLMService.ts b/src/services/llm/core/LLMService.ts index 588a5227..d6e8c671 100644 --- a/src/services/llm/core/LLMService.ts +++ b/src/services/llm/core/LLMService.ts @@ -16,6 +16,7 @@ import { VaultOperations } from '../../../core/VaultOperations'; import { CacheManager, VaultAdapter } from '../utils/CacheManager'; import { Logger } from '../utils/Logger'; import { LLMSettingsNotifier } from '../LLMSettingsNotifier'; +import { resolveAgentModel, type AgentDefaults } from '../utils/SubagentModelResolver'; export interface LLMExecutionOptions extends GenerateOptions { provider?: string; @@ -119,36 +120,31 @@ export class LLMService { } /** - * Get the model to use for agent operations (executePrompt). - * If the default model is a local provider (webllm, ollama, lmstudio), - * returns the configured agentModel or falls back to first available API provider. + * Get the model to use for agent runs (subagents and executePrompt). + * + * A configured `agentModel` wins whatever the chat model is. Without one, + * agent runs use the chat model if it is a cloud model, otherwise the first + * available cloud provider (model left empty for the caller to pick). The + * precedence lives in `resolveAgentModel` so every agent path agrees. */ getAgentModel(): { provider: string; model: string } { - const LOCAL_PROVIDERS = ['webllm', 'ollama', 'lmstudio']; - const defaultModel = this.settings.defaultModel; - - // If default is not a local provider, use it - if (!LOCAL_PROVIDERS.includes(defaultModel.provider)) { - return defaultModel; - } - - // If agentModel is configured, use it - if (this.settings.agentModel?.provider && this.settings.agentModel?.model) { - return this.settings.agentModel; - } - - // Fall back to first available API provider - const availableProviders = this.getAvailableProviders(); - const apiProviders = availableProviders.filter(p => !LOCAL_PROVIDERS.includes(p)); - - if (apiProviders.length > 0) { - // Return first API provider with its default model - // This is a best-effort fallback - the caller should handle model selection - return { provider: apiProviders[0], model: '' }; - } + const resolved = resolveAgentModel({ + defaultAgent: this.settings.agentModel, + chat: this.settings.defaultModel, + availableProviders: this.getAvailableProviders() + }); + return { provider: resolved.provider, model: resolved.model }; + } - // No API providers available - return default anyway (will likely fail) - return defaultModel; + /** Plugin-wide defaults for agent runs (Settings → Defaults → Subagents). */ + getAgentDefaults(): AgentDefaults { + return { + model: this.settings.agentModel, + thinking: this.settings.agentThinking, + agentTemperature: this.settings.agentTemperature, + defaultTemperature: this.settings.defaultTemperature, + contextWindowOverrides: this.settings.agentContextWindowOverrides + }; } /** Execute a prompt with the specified or default provider/model */ @@ -219,7 +215,9 @@ export class LLMService { frequencyPenalty: options.frequencyPenalty, presencePenalty: options.presencePenalty, stopSequences: options.stopSequences, - webSearch: options.webSearch + webSearch: options.webSearch, + enableThinking: options.enableThinking, + thinkingEffort: options.thinkingEffort }; const result: LLMResponse = await adapter.generate(fullPrompt, generateOptions); diff --git a/src/services/llm/utils/SubagentModelResolver.ts b/src/services/llm/utils/SubagentModelResolver.ts new file mode 100644 index 00000000..05c4142c --- /dev/null +++ b/src/services/llm/utils/SubagentModelResolver.ts @@ -0,0 +1,202 @@ +/** + * Which model, thinking, temperature and context budget an agent run uses. + * + * "Agent runs" are subagents and executePrompt (saved prompts and + * automations). Their settings live under Settings → Defaults → Subagents and + * in each chat's settings, and this module is the single place that decides + * how those layers combine. LLMService, the subagent path and PromptExecutor + * all call it, so the three cannot disagree. + * + * Model precedence (first match wins): + * 1. an explicit tool parameter (`explicit`) + * 2. the conversation's subagent provider/model (`conversation`) + * 3. the default subagent model (`llmProviders.agentModel`) + * 4. the chat model, if it is a cloud model (behaviour before this existed) + * 5. the first available cloud provider (the old local-model fallback) + * + * A configured agent model (levels 2 and 3) on a provider that is no longer + * available is skipped rather than sent to a request that cannot succeed. + * + * Pure: no Node or Obsidian imports. + */ + +import type { ThinkingSettings } from '../../../types/llm/ProviderTypes'; +import { getContextWindowOverrideKey, resolveContextWindowLimit } from '../../../ui/chat/utils/ContextWindowSettings'; +import { getRegistryContextWindow, modelAcceptsSamplingParams } from './modelCapabilityLookup'; +import { resolveThinkingForModel, type ThinkingPreference } from './resolveThinkingForModel'; + +/** Providers that run on this device. Agent runs prefer cloud models. */ +export const LOCAL_PROVIDERS: readonly string[] = ['webllm', 'ollama', 'lmstudio']; + +export function isLocalProvider(provider: string | null | undefined): boolean { + return !!provider && LOCAL_PROVIDERS.includes(provider); +} + +export interface ModelRef { + provider?: string | null; + model?: string | null; +} + +export type AgentModelSource = + | 'explicit' + | 'conversation' + | 'default-agent' + | 'chat' + | 'cloud-fallback' + | 'unresolved'; + +export interface ResolveAgentModelInput { + explicit?: ModelRef | null; + conversation?: ModelRef | null; + defaultAgent?: ModelRef | null; + chat?: ModelRef | null; + /** + * Providers that can serve a request right now. When given, configured + * agent models and the chat model on other providers are skipped, and the + * cloud fallback picks from this list. When omitted nothing is skipped. + */ + availableProviders?: readonly string[]; + /** + * Model to use with a cloud-fallback provider. Without it the fallback + * leaves the model empty and the caller picks one. + */ + defaultModelFor?: (provider: string) => string | undefined; +} + +export interface ResolvedAgentModel { + provider: string; + /** Empty when only the provider is known; the caller picks a model. */ + model: string; + source: AgentModelSource; +} + +function isComplete(ref: ModelRef | null | undefined): ref is { provider: string; model: string } { + return !!ref?.provider && !!ref.model; +} + +export function resolveAgentModel(input: ResolveAgentModelInput): ResolvedAgentModel { + const available = input.availableProviders; + const isAvailable = (provider: string): boolean => !available || available.includes(provider); + + const explicit = input.explicit; + if (explicit?.provider) { + // A provider without a model borrows the model of a lower layer on the + // same provider, as executePrompt always has. + const sameProvider = [input.conversation, input.defaultAgent, input.chat] + .find(ref => isComplete(ref) && ref.provider === explicit.provider); + return { + provider: explicit.provider, + model: explicit.model || sameProvider?.model || '', + source: 'explicit' + }; + } + + if (isComplete(input.conversation) && isAvailable(input.conversation.provider)) { + return { provider: input.conversation.provider, model: input.conversation.model, source: 'conversation' }; + } + + if (isComplete(input.defaultAgent) && isAvailable(input.defaultAgent.provider)) { + return { provider: input.defaultAgent.provider, model: input.defaultAgent.model, source: 'default-agent' }; + } + + const chat = input.chat; + if (isComplete(chat) && !isLocalProvider(chat.provider) && isAvailable(chat.provider)) { + return { provider: chat.provider, model: chat.model, source: 'chat' }; + } + + const cloudProvider = available?.find(provider => !isLocalProvider(provider)); + if (cloudProvider) { + return { provider: cloudProvider, model: input.defaultModelFor?.(cloudProvider) || '', source: 'cloud-fallback' }; + } + + // Nothing usable. Return the chat model so the caller reports a real error. + return { provider: chat?.provider || '', model: chat?.model || '', source: 'unresolved' }; +} + +/** Per-conversation agent settings, as stored in chat settings. */ +export interface ConversationAgentSettings { + provider?: string | null; + model?: string | null; + thinking?: ThinkingPreference | null; + /** Creativity for agent runs in this chat. Null or absent inherits the default. */ + temperature?: number | null; + /** Context budget for agent runs in this chat. Null or absent inherits the default. */ + contextWindow?: number | null; +} + +/** The plugin-wide defaults that agent runs fall back to. */ +export interface AgentDefaults { + model?: ModelRef | null; + thinking?: ThinkingPreference | null; + /** `llmProviders.agentTemperature`. */ + agentTemperature?: number; + /** `llmProviders.defaultTemperature`, used when no agent temperature is set. */ + defaultTemperature?: number; + /** `llmProviders.agentContextWindowOverrides`. */ + contextWindowOverrides?: Record; +} + +export interface AgentRunSettingsInput { + provider: string; + model: string; + conversation?: ConversationAgentSettings | null; + defaults?: AgentDefaults | null; + /** A window the caller already knows (e.g. a discovered local model). */ + advertisedContextWindow?: number; +} + +export interface AgentRunSettings { + /** Already gated: `enabled` is false for models that cannot think. */ + thinking: ThinkingSettings; + /** Undefined when the model rejects sampling parameters. */ + temperature?: number; + /** Undefined when neither a limit nor the model's window is known. */ + contextWindow?: number; +} + +export const DEFAULT_AGENT_TEMPERATURE = 0.5; + +function finiteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +/** The Creativity an agent run would use before model gating. */ +export function resolveAgentTemperature( + conversation?: Pick | null, + defaults?: Pick | null +): number { + const value = finiteNumber(conversation?.temperature) + ?? finiteNumber(defaults?.agentTemperature) + ?? finiteNumber(defaults?.defaultTemperature) + ?? DEFAULT_AGENT_TEMPERATURE; + return Math.max(0, Math.min(1, value)); +} + +export function resolveAgentRunSettings(input: AgentRunSettingsInput): AgentRunSettings { + const { provider, model, conversation, defaults } = input; + + const thinking = resolveThinkingForModel( + provider, + model, + conversation?.thinking ?? defaults?.thinking ?? { enabled: false } + ); + + const temperature = modelAcceptsSamplingParams(provider, model) + ? resolveAgentTemperature(conversation, defaults) + : undefined; + + const advertised = finiteNumber(input.advertisedContextWindow) ?? getRegistryContextWindow(provider, model); + const override = finiteNumber(conversation?.contextWindow) + ?? (provider && model + ? finiteNumber(defaults?.contextWindowOverrides?.[getContextWindowOverrideKey(provider, model)]) + : undefined); + + let contextWindow: number | undefined; + if (advertised && advertised > 0) { + contextWindow = resolveContextWindowLimit(advertised, override); + } else if (override !== undefined && override > 0) { + contextWindow = Math.floor(override); + } + + return { thinking, temperature, contextWindow }; +} diff --git a/src/types/chat/ChatTypes.ts b/src/types/chat/ChatTypes.ts index ddb045ac..e700e872 100644 --- a/src/types/chat/ChatTypes.ts +++ b/src/types/chat/ChatTypes.ts @@ -177,6 +177,10 @@ export interface Conversation { enabled: boolean; effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; }; + /** Subagent Creativity for this chat (0-1). Absent: the defaults apply. */ + agentTemperature?: number; + /** Subagent context budget for this chat. Absent or null: the subagent model's default. */ + agentContextWindow?: number | null; }; promptId?: string; workflowId?: string; diff --git a/src/types/llm/ProviderTypes.ts b/src/types/llm/ProviderTypes.ts index a375be49..c25ff96d 100644 --- a/src/types/llm/ProviderTypes.ts +++ b/src/types/llm/ProviderTypes.ts @@ -132,8 +132,26 @@ export interface LLMProviderSettings { defaultModel: DefaultModelSettings; /** User-chosen context limits, keyed by JSON-encoded [provider, model]. */ contextWindowOverrides?: Record; - agentModel?: DefaultModelSettings; // Model for executePrompt (API-only, used when chat model is local) + /** + * Default model for subagents and executePrompt. When set it wins over the + * chat model; when absent, agent runs use the chat model if it is a cloud + * model, otherwise the first available cloud provider. + */ + agentModel?: DefaultModelSettings; agentThinking?: DefaultThinkingSettings; // Thinking settings for agent model (separate from chat model) + /** + * Default Creativity (temperature, 0.0-1.0) for subagents and executePrompt. + * Absent means `defaultTemperature ?? 0.5`. Not sent to models whose + * registry entry has `supportsSamplingParams: false`. + */ + agentTemperature?: number; + /** + * Subagent context limits keyed by `getContextWindowOverrideKey(provider, model)`. + * Kept apart from `contextWindowOverrides` so the chat and subagent sliders + * for the same model never write the same key. Absent means the model's + * advertised window. + */ + agentContextWindowOverrides?: Record; defaultImageModel?: DefaultImageModelSettings; // Default image generation model defaultVideoModel?: DefaultVideoModelSettings; // Default video generation model defaultThinking?: DefaultThinkingSettings; // Default thinking settings for chat model diff --git a/src/ui/chat/services/ModelAgentConversationSettingsStore.ts b/src/ui/chat/services/ModelAgentConversationSettingsStore.ts index 95b228f2..5255edde 100644 --- a/src/ui/chat/services/ModelAgentConversationSettingsStore.ts +++ b/src/ui/chat/services/ModelAgentConversationSettingsStore.ts @@ -25,6 +25,10 @@ export interface ConversationSettingsMetadata { agentProvider?: string | null; agentModel?: string | null; agentThinking?: ThinkingSettings; + /** Subagent Creativity for this chat (0-1). */ + agentTemperature?: number; + /** Subagent context budget for this chat; null uses the subagent model's default. */ + agentContextWindow?: number | null; imageProvider?: 'google' | 'openrouter' | 'openai'; imageModel?: string; speechProvider?: DefaultSpeechModelSettings['provider'] | null; diff --git a/src/ui/chat/services/ModelAgentDefaultsResolver.ts b/src/ui/chat/services/ModelAgentDefaultsResolver.ts index 95b85bc8..9275408e 100644 --- a/src/ui/chat/services/ModelAgentDefaultsResolver.ts +++ b/src/ui/chat/services/ModelAgentDefaultsResolver.ts @@ -12,6 +12,8 @@ import { ModelSelectionUtility } from '../utils/ModelSelectionUtility'; import type { ModelOption, PromptOption } from '../types/SelectionTypes'; import type { ModelAgentWorkspaceContextService, ModelAgentWorkspaceState } from './ModelAgentWorkspaceContextService'; import { buildOpenAICompatibleModels, OPENAI_COMPATIBLE_CONTEXT_WINDOW } from '../../../services/llm/adapters/openai-compatible/OpenAICompatibleConfig'; +import { resolveAgentTemperature } from '../../../services/llm/utils/SubagentModelResolver'; +import { getContextWindowOverrideKey } from '../utils/ContextWindowSettings'; interface PluginWithSettings { settings?: { @@ -22,6 +24,8 @@ interface PluginWithSettings { defaultTemperature?: number; agentModel?: { provider: string; model: string }; agentThinking?: ThinkingSettings; + agentTemperature?: number; + agentContextWindowOverrides?: Record; defaultImageModel?: { provider: 'google' | 'openrouter' | 'openai'; model: string }; defaultTranscriptionModel?: { provider: string; model: string }; defaultSpeechModel?: DefaultSpeechModelSettings; @@ -44,6 +48,10 @@ export interface ModelAgentDefaultState { agentProvider: string | null; agentModel: string | null; agentThinkingSettings: ThinkingSettings; + /** Subagent Creativity a new chat starts with. */ + agentTemperature: number; + /** Subagent context budget a new chat starts with; null uses the model's default. */ + agentContextWindow: number | null; imageProvider: 'google' | 'openrouter' | 'openai'; imageModel: string; speechProvider: DefaultSpeechModelSettings['provider'] | null; @@ -113,6 +121,8 @@ export class ModelAgentDefaultsResolver { agentProvider: llmProviders?.agentModel?.provider || null, agentModel: llmProviders?.agentModel?.model || null, agentThinkingSettings, + agentTemperature: resolveAgentTemperature(null, llmProviders), + agentContextWindow: this.resolveAgentContextWindowDefault(llmProviders), imageProvider: llmProviders?.defaultImageModel?.provider || 'google', imageModel: llmProviders?.defaultImageModel?.model || 'gemini-3.1-flash-image', speechProvider: llmProviders?.defaultSpeechModel?.provider || null, @@ -184,6 +194,16 @@ export class ModelAgentDefaultsResolver { }; } + /** The saved subagent limit for the default subagent model, if any. */ + private resolveAgentContextWindowDefault( + llmProviders: { agentModel?: { provider: string; model: string }; agentContextWindowOverrides?: Record } | undefined + ): number | null { + const agentModel = llmProviders?.agentModel; + if (!agentModel?.provider || !agentModel.model) return null; + const value = llmProviders?.agentContextWindowOverrides?.[getContextWindowOverrideKey(agentModel.provider, agentModel.model)]; + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : null; + } + private getPlugin(): PluginWithSettings | null { if (this.deps.getPlugin) { return this.deps.getPlugin(); diff --git a/src/ui/chat/services/ModelAgentManager.ts b/src/ui/chat/services/ModelAgentManager.ts index 88706dc4..d4e46823 100644 --- a/src/ui/chat/services/ModelAgentManager.ts +++ b/src/ui/chat/services/ModelAgentManager.ts @@ -49,6 +49,15 @@ import { } from '../../../services/chat/CompactionFrontierService'; import { ConversationData } from '../../../types/chat/ChatTypes'; import type { App, Plugin } from 'obsidian'; +import { + DEFAULT_AGENT_TEMPERATURE, + type ConversationAgentSettings +} from '../../../services/llm/utils/SubagentModelResolver'; + +/** A positive whole-token budget, or null for "use the model's default". */ +function normalizeAgentContextWindow(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : null; +} interface NexusPluginWithLLMSettings extends Plugin { settings?: { settings?: { llmProviders?: LLMProviderSettings } }; @@ -111,6 +120,10 @@ export class ModelAgentManager { private agentProvider: string | null = null; private agentModel: string | null = null; private agentThinkingSettings: ThinkingSettings = { enabled: false, effort: 'medium' }; + /** Subagent Creativity for this chat (0-1). */ + private agentTemperature: number = DEFAULT_AGENT_TEMPERATURE; + /** Subagent context budget for this chat; null uses the subagent model's default. */ + private agentContextWindow: number | null = null; private imageProvider: 'google' | 'openrouter' | 'openai' = 'google'; private imageModel = 'gemini-3.1-flash-image'; private speechProvider: DefaultSpeechModelSettings['provider'] | null = null; @@ -313,6 +326,14 @@ export class ModelAgentManager { }; } + // Restore subagent Creativity and context budget + if ('agentTemperature' in settings && typeof settings.agentTemperature === 'number' && Number.isFinite(settings.agentTemperature)) { + this.agentTemperature = Math.max(0, Math.min(1, settings.agentTemperature)); + } + if ('agentContextWindow' in settings) { + this.agentContextWindow = normalizeAgentContextWindow(settings.agentContextWindow); + } + // Restore image model if ('imageProvider' in settings && settings.imageProvider) { this.imageProvider = settings.imageProvider; @@ -395,6 +416,8 @@ export class ModelAgentManager { this.agentProvider = defaultState.agentProvider; this.agentModel = defaultState.agentModel; this.agentThinkingSettings = { ...defaultState.agentThinkingSettings }; + this.agentTemperature = defaultState.agentTemperature ?? DEFAULT_AGENT_TEMPERATURE; + this.agentContextWindow = normalizeAgentContextWindow(defaultState.agentContextWindow); this.imageProvider = defaultState.imageProvider; this.imageModel = defaultState.imageModel; this.speechProvider = defaultState.speechProvider; @@ -851,6 +874,36 @@ export class ModelAgentManager { this.agentThinkingSettings = { ...settings }; } + /** Subagent Creativity for this chat (0-1). */ + getAgentTemperature(): number { + return this.agentTemperature; + } + + setAgentTemperature(temperature: number): void { + if (!Number.isFinite(temperature)) return; + this.agentTemperature = Math.max(0, Math.min(1, temperature)); + } + + /** Subagent context budget for this chat; null uses the subagent model's default. */ + getAgentContextWindow(): number | null { + return this.agentContextWindow; + } + + setAgentContextWindow(contextWindow: number | null): void { + this.agentContextWindow = normalizeAgentContextWindow(contextWindow); + } + + /** Everything an agent run (subagent, executePrompt) takes from this chat. */ + getAgentSettings(): ConversationAgentSettings { + return { + provider: this.agentProvider, + model: this.agentModel, + thinking: { ...this.agentThinkingSettings }, + temperature: this.agentTemperature, + contextWindow: this.agentContextWindow + }; + } + /** * Get image provider */ @@ -1242,6 +1295,8 @@ export class ModelAgentManager { agentProvider: this.agentProvider, agentModel: this.agentModel, agentThinking: this.agentThinkingSettings, + agentTemperature: this.agentTemperature, + agentContextWindow: this.agentContextWindow, imageProvider: this.imageProvider, imageModel: this.imageModel, speechProvider: this.speechProvider, diff --git a/tests/unit/LLMServiceAgentModel.test.ts b/tests/unit/LLMServiceAgentModel.test.ts new file mode 100644 index 00000000..a9a11d25 --- /dev/null +++ b/tests/unit/LLMServiceAgentModel.test.ts @@ -0,0 +1,72 @@ +/** + * LLMService.getAgentModel honours the configured subagent model. + * + * Defect this buys: getAgentModel returned the chat default whenever it was a + * cloud model, so the subagent model saved in Defaults only mattered for + * local chat models. The adapter registry is mocked only for provider + * availability; the precedence under test is the real resolver. + */ +const mockAvailableProviders: string[] = []; + +jest.mock('../../src/services/llm/core/AdapterRegistry', () => ({ + AdapterRegistry: jest.fn().mockImplementation(() => ({ + initialize: jest.fn(), + updateSettings: jest.fn(), + setOnSettingsDirty: jest.fn(), + getAdapter: jest.fn(), + getAvailableProviders: jest.fn(() => mockAvailableProviders), + isProviderAvailable: jest.fn((provider: string) => mockAvailableProviders.includes(provider)), + waitForInit: jest.fn(), + clear: jest.fn(), + })), +})); + +jest.mock('../../src/services/llm/core/ModelDiscoveryService', () => ({ + ModelDiscoveryService: jest.fn().mockImplementation(() => ({ + getAvailableModels: jest.fn(async () => []), + })), +})); + +import { LLMService } from '../../src/services/llm/core/LLMService'; +import type { LLMProviderSettings } from '../../src/types/llm/ProviderTypes'; + +function service(settings: Partial): LLMService { + return new LLMService({ providers: {}, defaultModel: { provider: 'openai', model: 'gpt-5.4' }, ...settings }); +} + +describe('LLMService agent model', () => { + beforeEach(() => { + mockAvailableProviders.splice(0, mockAvailableProviders.length, 'openai', 'anthropic', 'ollama'); + }); + + it('uses a configured agentModel even when the chat model is a cloud model', () => { + const llm = service({ agentModel: { provider: 'anthropic', model: 'claude-haiku-5-5' } }); + expect(llm.getAgentModel()).toEqual({ provider: 'anthropic', model: 'claude-haiku-5-5' }); + }); + + it('keeps the cloud chat model when no agentModel is set', () => { + expect(service({}).getAgentModel()).toEqual({ provider: 'openai', model: 'gpt-5.4' }); + }); + + it('falls back to the first cloud provider when the chat model is local', () => { + const llm = service({ defaultModel: { provider: 'ollama', model: 'llama3' } }); + expect(llm.getAgentModel()).toEqual({ provider: 'openai', model: '' }); + }); + + it('exposes the agent defaults the subagent and prompt paths share', () => { + const llm = service({ + agentModel: { provider: 'anthropic', model: 'claude-haiku-5-5' }, + agentThinking: { enabled: true, effort: 'high' }, + agentTemperature: 0.3, + defaultTemperature: 0.7, + agentContextWindowOverrides: { k: 32_000 } + }); + expect(llm.getAgentDefaults()).toEqual({ + model: { provider: 'anthropic', model: 'claude-haiku-5-5' }, + thinking: { enabled: true, effort: 'high' }, + agentTemperature: 0.3, + defaultTemperature: 0.7, + contextWindowOverrides: { k: 32_000 } + }); + }); +}); diff --git a/tests/unit/ModelAgentManagerAgentSettings.test.ts b/tests/unit/ModelAgentManagerAgentSettings.test.ts new file mode 100644 index 00000000..66f1c144 --- /dev/null +++ b/tests/unit/ModelAgentManagerAgentSettings.test.ts @@ -0,0 +1,145 @@ +/** + * Per-chat subagent Creativity and context window (chatSettings.agentTemperature + * / agentContextWindow) and their defaults (llmProviders.agentTemperature / + * agentContextWindowOverrides). + * + * Defect this buys: a value the user sets for a chat's subagents that is not + * written to chat settings, not restored on reopen, or not seeded from the + * defaults for a new chat. The replay case feeds the real conversation event + * applier, which is what rebuilds the SQLite cache from JSONL. + */ +import { ModelAgentManager } from '../../src/ui/chat/services/ModelAgentManager'; +import { ModelAgentDefaultsResolver } from '../../src/ui/chat/services/ModelAgentDefaultsResolver'; +import { ConversationEventApplier } from '../../src/database/sync/ConversationEventApplier'; +import { getContextWindowOverrideKey } from '../../src/ui/chat/utils/ContextWindowSettings'; +import type { App } from 'obsidian'; + +function createEvents() { + return { onModelChanged: jest.fn(), onPromptChanged: jest.fn(), onSystemPromptChanged: jest.fn() }; +} + +function createResolver(llmProviders: Record): ModelAgentDefaultsResolver { + return new ModelAgentDefaultsResolver({ + app: {} as App, + staticModelsService: { findModel: jest.fn() }, + workspaceContextService: { + restoreWorkspace: jest.fn(), + createEmptyState: jest.fn(() => ({ selectedWorkspaceId: null, workspaceContext: null, loadedWorkspaceData: null })) + }, + getAvailableModels: jest.fn(async () => []), + getAvailablePrompts: jest.fn(async () => []), + getPlugin: () => ({ settings: { settings: { llmProviders: llmProviders as never } } }) + }); +} + +describe('ModelAgentDefaultsResolver subagent defaults', () => { + it('seeds a new chat from agentTemperature and the subagent model window override', async () => { + const key = getContextWindowOverrideKey('anthropic', 'claude-haiku-5-5'); + const state = await createResolver({ + agentModel: { provider: 'anthropic', model: 'claude-haiku-5-5' }, + agentTemperature: 0.2, + defaultTemperature: 0.9, + agentContextWindowOverrides: { [key]: 64_000 } + }).resolveDefaultState(); + + expect(state.agentTemperature).toBe(0.2); + expect(state.agentContextWindow).toBe(64_000); + }); + + it('falls back to defaultTemperature, then 0.5, and to no window override', async () => { + expect((await createResolver({ defaultTemperature: 0.9 }).resolveDefaultState()).agentTemperature).toBe(0.9); + const bare = await createResolver({}).resolveDefaultState(); + expect(bare.agentTemperature).toBe(0.5); + expect(bare.agentContextWindow).toBeNull(); + }); + + it('ignores a chat-model window override for the subagent window', async () => { + const key = getContextWindowOverrideKey('anthropic', 'claude-haiku-5-5'); + const state = await createResolver({ + agentModel: { provider: 'anthropic', model: 'claude-haiku-5-5' }, + contextWindowOverrides: { [key]: 50_000 } + }).resolveDefaultState(); + expect(state.agentContextWindow).toBeNull(); + }); +}); + +describe('ModelAgentManager subagent chat settings', () => { + it('saves the subagent Creativity and window into chat settings', async () => { + const conversationService = { + getConversation: jest.fn().mockResolvedValue({ metadata: { chatSettings: { sessionId: 's1' } } }), + updateConversationMetadata: jest.fn().mockResolvedValue(undefined) + }; + const manager = new ModelAgentManager({}, createEvents(), conversationService); + manager.setAgentTemperature(0.3); + manager.setAgentContextWindow(32_000); + + await manager.saveToConversation('conv_1'); + + expect(conversationService.updateConversationMetadata).toHaveBeenCalledWith('conv_1', { + chatSettings: expect.objectContaining({ agentTemperature: 0.3, agentContextWindow: 32_000 }) + }); + }); + + it('restores them from chat settings and exposes them as agent settings', async () => { + const conversationService = { + getConversation: jest.fn().mockResolvedValue({ + metadata: { + chatSettings: { + agentProvider: 'anthropic', + agentModel: 'claude-haiku-5-5', + agentThinking: { enabled: true, effort: 'high' }, + agentTemperature: 0.3, + agentContextWindow: 32_000 + } + } + }) + }; + const manager = new ModelAgentManager({}, createEvents(), conversationService); + jest.spyOn(manager, 'getAvailableModels').mockResolvedValue([]); + jest.spyOn(manager, 'getAvailablePrompts').mockResolvedValue([]); + + await manager.initializeFromConversation('conv_1'); + + expect(manager.getAgentTemperature()).toBe(0.3); + expect(manager.getAgentContextWindow()).toBe(32_000); + expect(manager.getAgentSettings()).toEqual({ + provider: 'anthropic', + model: 'claude-haiku-5-5', + thinking: { enabled: true, effort: 'high' }, + temperature: 0.3, + contextWindow: 32_000 + }); + }); + + it('clears the window with null and rejects nonsense values', () => { + const manager = new ModelAgentManager({}, createEvents()); + manager.setAgentContextWindow(32_000); + manager.setAgentContextWindow(null); + expect(manager.getAgentContextWindow()).toBeNull(); + manager.setAgentContextWindow(-5); + expect(manager.getAgentContextWindow()).toBeNull(); + manager.setAgentTemperature(4); + expect(manager.getAgentTemperature()).toBe(1); + }); +}); + +describe('subagent chat settings survive a cache rebuild', () => { + it('replays conversation_updated with the new chatSettings fields into metadataJson', async () => { + // Replay is how "Rebuild cache" repopulates SQLite from the JSONL events. + const sqliteCache = { run: jest.fn(async (_sql: string, _params: unknown[]) => undefined) }; + const applier = new ConversationEventApplier(sqliteCache); + const settings = { + chatSettings: { sessionId: 's1', agentProvider: 'anthropic', agentTemperature: 0.3, agentContextWindow: 32_000 } + }; + + await applier.apply({ + id: 'evt-1', type: 'conversation_updated', deviceId: 'device-1', timestamp: 1, + conversationId: 'conv_1', data: { updated: 1, settings } + }); + + const [sql, values] = sqliteCache.run.mock.calls[0]; + expect(sql).toContain('metadataJson = ?'); + const metadataJson = values.find(value => typeof value === 'string' && value.includes('chatSettings')) as string; + expect(JSON.parse(metadataJson).chatSettings).toEqual(expect.objectContaining({ agentTemperature: 0.3, agentContextWindow: 32_000 })); + }); +}); diff --git a/tests/unit/SubagentModelResolver.test.ts b/tests/unit/SubagentModelResolver.test.ts new file mode 100644 index 00000000..c88f96c5 --- /dev/null +++ b/tests/unit/SubagentModelResolver.test.ts @@ -0,0 +1,119 @@ +/** + * Agent-run precedence (plan Decisions: "Subagent settings take effect"). + * + * Defect this buys: the saved subagent model was ignored unless the chat model + * was local, and the subagent tool always inherited the chat model. If the + * precedence below regressed to "chat model first", the conversation and + * default-agent cases go red. Sampling and thinking capabilities come from the + * real registry (Claude Haiku 5.5 rejects temperature; Opus 4.6 accepts it; + * Mistral Large cannot think). + */ +import { + resolveAgentModel, + resolveAgentRunSettings, + resolveAgentTemperature +} from '../../src/services/llm/utils/SubagentModelResolver'; +import { ModelRegistry } from '../../src/services/llm/adapters/ModelRegistry'; +import { getContextWindowOverrideKey } from '../../src/ui/chat/utils/ContextWindowSettings'; + +const chatCloud = { provider: 'openai', model: 'gpt-5.4' }; +const chatLocal = { provider: 'ollama', model: 'llama3' }; +const conversationAgent = { provider: 'anthropic', model: 'claude-haiku-5-5' }; +const defaultAgent = { provider: 'google', model: 'gemini-3.5-flash' }; +const available = ['ollama', 'openai', 'anthropic', 'google']; + +describe('resolveAgentModel', () => { + it.each([ + ['an explicit tool parameter wins', { explicit: { provider: 'google', model: 'gemini-3.8-flash' }, conversation: conversationAgent, defaultAgent, chat: chatCloud }, { provider: 'google', model: 'gemini-3.8-flash', source: 'explicit' }], + ['the conversation subagent model beats the default and the chat', { conversation: conversationAgent, defaultAgent, chat: chatCloud }, { ...conversationAgent, source: 'conversation' }], + ['the default subagent model beats a cloud chat model', { defaultAgent, chat: chatCloud }, { ...defaultAgent, source: 'default-agent' }], + ['no subagent model and a cloud chat model: the chat model (today)', { chat: chatCloud }, { ...chatCloud, source: 'chat' }], + ['no subagent model and a local chat model: the first cloud provider', { chat: chatLocal }, { provider: 'openai', model: '', source: 'cloud-fallback' }] + ])('%s', (_label, input, expected) => { + expect(resolveAgentModel({ ...input, availableProviders: available })).toEqual(expected); + }); + + it('borrows a lower layer model when the explicit parameter names only a provider', () => { + expect(resolveAgentModel({ explicit: { provider: 'anthropic' }, defaultAgent: conversationAgent, chat: chatCloud, availableProviders: available })) + .toEqual({ provider: 'anthropic', model: 'claude-haiku-5-5', source: 'explicit' }); + }); + + it('skips a configured subagent model whose provider is no longer available', () => { + expect(resolveAgentModel({ conversation: conversationAgent, defaultAgent, chat: chatCloud, availableProviders: ['openai', 'google'] })) + .toEqual({ ...defaultAgent, source: 'default-agent' }); + }); + + it('uses the supplied default model for a cloud fallback provider', () => { + expect(resolveAgentModel({ chat: chatLocal, availableProviders: available, defaultModelFor: p => (p === 'openai' ? 'gpt-5.4' : undefined) })) + .toEqual({ provider: 'openai', model: 'gpt-5.4', source: 'cloud-fallback' }); + }); + + it('falls back to the chat model when nothing else can run', () => { + expect(resolveAgentModel({ chat: chatLocal, availableProviders: ['ollama'] })) + .toEqual({ ...chatLocal, source: 'unresolved' }); + }); +}); + +describe('resolveAgentRunSettings', () => { + it('drops temperature for a model that rejects sampling, and keeps thinking', () => { + expect(ModelRegistry.findModel('anthropic', 'claude-haiku-5-5')?.supportsSamplingParams).toBe(false); + + const settings = resolveAgentRunSettings({ + provider: 'anthropic', + model: 'claude-haiku-5-5', + conversation: { thinking: { enabled: true, effort: 'high' }, temperature: 0.3 } + }); + + expect(settings.temperature).toBeUndefined(); + expect(settings.thinking).toEqual({ enabled: true, effort: 'high' }); + }); + + it('passes temperature for a model that accepts sampling', () => { + expect(ModelRegistry.findModel('anthropic', 'claude-opus-4-6')?.supportsSamplingParams).not.toBe(false); + expect(resolveAgentRunSettings({ provider: 'anthropic', model: 'claude-opus-4-6', conversation: { temperature: 0.3 } }).temperature).toBe(0.3); + }); + + it('gates thinking for a model that cannot think', () => { + expect(resolveAgentRunSettings({ + provider: 'mistral', + model: 'mistral-large-latest', + defaults: { thinking: { enabled: true, effort: 'low' } } + }).thinking).toEqual({ enabled: false, effort: 'low' }); + }); + + it('prefers the conversation context window, then the subagent default, then the model window', () => { + const key = getContextWindowOverrideKey('anthropic', 'claude-opus-4-6'); + const advertised = ModelRegistry.findModel('anthropic', 'claude-opus-4-6')?.contextWindow ?? 0; + expect(advertised).toBeGreaterThan(64_000); + + expect(resolveAgentRunSettings({ + provider: 'anthropic', model: 'claude-opus-4-6', + conversation: { contextWindow: 32_000 }, + defaults: { contextWindowOverrides: { [key]: 64_000 } } + }).contextWindow).toBe(32_000); + expect(resolveAgentRunSettings({ + provider: 'anthropic', model: 'claude-opus-4-6', + defaults: { contextWindowOverrides: { [key]: 64_000 } } + }).contextWindow).toBe(64_000); + expect(resolveAgentRunSettings({ provider: 'anthropic', model: 'claude-opus-4-6' }).contextWindow).toBe(advertised); + }); + + it('clamps a stored budget to the model window and keeps a budget for unknown models', () => { + expect(resolveAgentRunSettings({ + provider: 'mistral', model: 'magistral-medium-latest', + conversation: { contextWindow: 5_000_000 } + }).contextWindow).toBe(ModelRegistry.findModel('mistral', 'magistral-medium-latest')?.contextWindow); + expect(resolveAgentRunSettings({ provider: 'ollama', model: 'llama3', conversation: { contextWindow: 16_000 } }).contextWindow).toBe(16_000); + expect(resolveAgentRunSettings({ provider: 'ollama', model: 'llama3' }).contextWindow).toBeUndefined(); + }); +}); + +describe('resolveAgentTemperature', () => { + it('uses chat, then agent default, then default temperature, then 0.5', () => { + expect(resolveAgentTemperature({ temperature: 0.1 }, { agentTemperature: 0.2, defaultTemperature: 0.3 })).toBe(0.1); + expect(resolveAgentTemperature(null, { agentTemperature: 0.2, defaultTemperature: 0.3 })).toBe(0.2); + expect(resolveAgentTemperature({ temperature: null }, { defaultTemperature: 0.3 })).toBe(0.3); + expect(resolveAgentTemperature(null, null)).toBe(0.5); + expect(resolveAgentTemperature({ temperature: 7 }, null)).toBe(1); + }); +}); From da3f08a5240ea45522cba4fc10587253d3679225 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:04:57 -0400 Subject: [PATCH 16/65] feat(subagents): make subagent settings reach subagent and executePrompt runs Subagents no longer inherit the chat model and thinking unconditionally. SubagentController.buildSubagentContext resolves provider, model, thinking, Creativity and context window with the shared agent precedence, using this chat's subagent settings (new SubagentContextProvider.getAgentSettings) and the defaults. SubagentExecutor forwards thinking and temperature to the streaming generator, which passes them to generateResponseStream, and uses the context window as a budget for the inherited workspace data and context files: whole items beyond it are dropped and named in one prompt line. executePrompt (PromptExecutor) now sends the agent temperature (omitted for models that reject sampling) and gated agent thinking, and picks its model with the same precedence. A chat's own subagent settings apply when that chat calls the tool: the tool batch executor passes its runtime identity to tools that opt in via acceptsToolRuntimeContext, and ExecutePromptsTool matches it against the chat view's conversation, as the subagent tool does. MCP calls and other conversations use the defaults. Co-Authored-By: Claude Opus 5.5 --- src/agents/promptManager/promptManager.ts | 12 +- .../tools/executePrompts/executePrompts.ts | 30 +++- .../executePrompts/services/PromptExecutor.ts | 53 ++++++- .../executePrompts/types/executeTypes.ts | 13 ++ src/agents/promptManager/tools/subagent.ts | 18 ++- .../services/ToolBatchExecutionService.ts | 27 ++-- src/services/chat/SubagentExecutor.ts | 87 ++++++++--- src/services/chat/subagentContextBudget.ts | 84 +++++++++++ src/types/branch/BranchTypes.ts | 15 +- src/types/tools/ToolRuntimeContext.ts | 26 ++++ src/ui/chat/controllers/SubagentController.ts | 88 +++++++---- .../chat/services/ChatSubagentIntegration.ts | 5 +- .../unit/PromptExecutorAgentSettings.test.ts | 119 +++++++++++++++ tests/unit/SubagentContextBudget.test.ts | 111 ++++++++++++++ ...SubagentControllerForwardsSettings.test.ts | 140 ++++++++++++++++++ 15 files changed, 749 insertions(+), 79 deletions(-) create mode 100644 src/services/chat/subagentContextBudget.ts create mode 100644 src/types/tools/ToolRuntimeContext.ts create mode 100644 tests/unit/PromptExecutorAgentSettings.test.ts create mode 100644 tests/unit/SubagentContextBudget.test.ts create mode 100644 tests/unit/SubagentControllerForwardsSettings.test.ts diff --git a/src/agents/promptManager/promptManager.ts b/src/agents/promptManager/promptManager.ts index 474f20de..bd6e20e6 100644 --- a/src/agents/promptManager/promptManager.ts +++ b/src/agents/promptManager/promptManager.ts @@ -87,6 +87,11 @@ export class PromptManagerAgent extends BaseAgent { */ private subagentTool: SubagentTool; + /** + * Prompt execution tool - stored so the chat view can supply its subagent settings + */ + private executePromptsTool: ExecutePromptsTool; + /** * Create a new PromptManagerAgent with dependency injection * @param settings Settings instance for prompt storage @@ -163,13 +168,14 @@ export class PromptManagerAgent extends BaseAgent { }); // Register unified prompt execution tool (handles single and batch) - eager (complex dependencies) - this.registerTool(new ExecutePromptsTool( + this.executePromptsTool = new ExecutePromptsTool( this.app, this.providerManager.getLLMService(), this.providerManager, this.parentAgentManager, this.storageService - )); + ); + this.registerTool(this.executePromptsTool); // Register image generation tool only if an image-capable provider key is configured const llmProviders = settings.settings.llmProviders; @@ -452,6 +458,8 @@ export class PromptManagerAgent extends BaseAgent { ): void { this.subagentTool.setSubagentExecutor(executor); this.subagentTool.setContextProvider(contextProvider); + // Saved prompts run from this chat use the same subagent settings. + this.executePromptsTool.setAgentContextProvider(() => contextProvider()); } /** diff --git a/src/agents/promptManager/tools/executePrompts/executePrompts.ts b/src/agents/promptManager/tools/executePrompts/executePrompts.ts index e8033732..37642a55 100644 --- a/src/agents/promptManager/tools/executePrompts/executePrompts.ts +++ b/src/agents/promptManager/tools/executePrompts/executePrompts.ts @@ -1,4 +1,6 @@ import { JSONSchema } from '../../../../types/schema/JSONSchemaTypes'; +import type { ToolRuntimeContext } from '../../../../types/tools/ToolRuntimeContext'; +import type { ConversationAgentSettings } from '../../../../services/llm/utils/SubagentModelResolver'; import { App } from 'obsidian'; import { BaseTool } from '../../../baseTool'; import { getErrorMessage } from '../../../../utils/errorUtils'; @@ -52,7 +54,16 @@ function addBatchRecommendations( * - Coordinate specialized services * - Handle high-level error management */ +/** The chat view's current conversation and its subagent settings. */ +export type AgentConversationContextProvider = () => { + conversationId: string; + conversationAgent?: ConversationAgentSettings; +} | null | undefined; + export class ExecutePromptsTool extends BaseTool { + /** Receives the caller's runtime identity so a chat's subagent settings apply. */ + readonly acceptsToolRuntimeContext = true as const; + private agentContextProvider: AgentConversationContextProvider | null = null; // Core services (injected) private obsidianApp: App | null = null; private llmService: LLMService | null = null; @@ -167,7 +178,23 @@ export class ExecutePromptsTool extends BaseTool { + /** + * Supply the chat view's subagent settings. They are used only for calls + * whose runtime identity is that same conversation in internal chat. + */ + setAgentContextProvider(provider: AgentConversationContextProvider): void { + this.agentContextProvider = provider; + } + + private resolveConversationAgentSettings(runtimeContext?: ToolRuntimeContext): ConversationAgentSettings | undefined { + if (!runtimeContext || runtimeContext.source !== 'internal' || !runtimeContext.conversationId) { + return undefined; + } + const view = this.agentContextProvider?.(); + return view && view.conversationId === runtimeContext.conversationId ? view.conversationAgent : undefined; + } + + async execute(params: BatchExecutePromptParams, runtimeContext?: ToolRuntimeContext): Promise { try { // Try to wait for dependencies if not available if (!this.llmService || !this.providerManager || !this.promptStorage) { @@ -219,6 +246,7 @@ export class ExecutePromptsTool extends BaseTool; allResults: InternalExecutionResult[]; } @@ -163,6 +171,11 @@ export interface PromptExecutionParams { model?: string; workspace?: string; sessionId?: string; + /** Agent Creativity; omitted for models that reject sampling parameters. */ + temperature?: number; + /** Agent thinking, already gated for models that cannot think. */ + enableThinking?: boolean; + thinkingEffort?: ThinkingEffort; } /** diff --git a/src/agents/promptManager/tools/subagent.ts b/src/agents/promptManager/tools/subagent.ts index daccc8e6..bd25adbd 100644 --- a/src/agents/promptManager/tools/subagent.ts +++ b/src/agents/promptManager/tools/subagent.ts @@ -16,6 +16,7 @@ import { getCommonResultSchema, createResult } from '../../../utils/schemaUtils' import { CommonParameters, CommonResult } from '../../../types'; import type { RemoteAgentJobService } from '../../../services/remoteAgents/RemoteAgentJobService'; import type { SubagentExecutor } from '../../../services/chat/SubagentExecutor'; +import type { ConversationAgentSettings } from '../../../services/llm/utils/SubagentModelResolver'; export interface SubagentToolParams extends CommonParameters { /** Clear description of what the subagent should accomplish */ @@ -64,7 +65,8 @@ export interface SubagentToolContext { sessionId?: string; source?: 'internal' | 'mcp'; isSubagentBranch?: boolean; - // Inherited model settings + // Model for the subagent run, resolved with the agent precedence + // (this chat's subagent model, the default, then a cloud chat model) provider?: string; model?: string; // Inherited agent settings @@ -74,9 +76,15 @@ export interface SubagentToolContext { workspaceData?: Record; // Full comprehensive workspace data (sessions, states, files, etc.) // Inherited context notes (file paths - subagent will read content) contextNotes?: string[]; - // Inherited thinking settings + // Subagent thinking, already gated for the resolved model thinkingEnabled?: boolean; thinkingEffort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + /** Subagent Creativity; undefined when the model rejects sampling parameters. */ + temperature?: number; + /** Token budget for the context the subagent inherits (notes, workspace). */ + contextWindow?: number; + /** This chat's raw subagent settings, for other agent runs such as executePrompt. */ + conversationAgent?: ConversationAgentSettings; } export class SubagentTool extends BaseTool { @@ -229,7 +237,7 @@ To continue a subagent that hit max iterations, provide continueBranchId.`, sessionId: context.sessionId, maxIterations: params.maxIterations, continueBranchId: params.continueBranchId, - // Inherit parent's model settings + // Subagent model settings (resolved by the chat view) provider: context.provider, model: context.model, // Inherit parent's agent settings @@ -238,9 +246,11 @@ To continue a subagent that hit max iterations, provide continueBranchId.`, // Inherit parent's workspace data workspaceData: context.workspaceData, // Context notes already merged above via contextFiles - // Inherit parent's thinking settings + // Subagent thinking, Creativity and context budget thinkingEnabled: context.thinkingEnabled, thinkingEffort: context.thinkingEffort, + temperature: context.temperature, + contextWindow: context.contextWindow, }); const isContinuing = !!params.continueBranchId; diff --git a/src/agents/toolManager/services/ToolBatchExecutionService.ts b/src/agents/toolManager/services/ToolBatchExecutionService.ts index ab355c30..85d9cfb0 100644 --- a/src/agents/toolManager/services/ToolBatchExecutionService.ts +++ b/src/agents/toolManager/services/ToolBatchExecutionService.ts @@ -11,9 +11,15 @@ import { WorkspaceService } from '../../../services/WorkspaceService'; import { matchWorkspaces } from '../../memoryManager/services/WorkspaceMatcher'; import { toKebabCase } from './ToolCliNormalizer'; import type { ToolExecutionOrigin } from '../../../types/tools/ToolOperationTypes'; +import { acceptsToolRuntimeContext, type ToolRuntimeContext } from '../../../types/tools/ToolRuntimeContext'; import { ToolOperationService } from './ToolOperationService'; import { getRegisteredToolExecutionPolicy } from '../../policy/ToolExecutionPolicyCatalog'; +/** A tool that opted into receiving its caller's runtime identity. */ +type RuntimeContextTool = { + execute(params: Record, runtimeContext: ToolRuntimeContext): Promise; +}; + /** * The slice of NexusPlugin this service needs. Declared structurally to avoid * importing the plugin class (circular) — but unlike an inline cast it names @@ -465,16 +471,19 @@ export class ToolBatchExecutionService { } try { + const runtimeContext: ToolRuntimeContext = { + conversationId: options.conversationId || '', + messageId: options.messageId || '', + workspaceId: context.workspaceId, + sessionId: context.sessionId, + source: options.operationOrigin === 'external-mcp' || !options.operationOrigin ? 'mcp' : 'internal', + isSubagentBranch: options.operationOrigin === 'subagent', + }; const toolResult = toolInstance instanceof SubagentTool - ? await toolInstance.execute(params as unknown as SubagentToolParams, { - conversationId: options.conversationId || '', - messageId: options.messageId || '', - workspaceId: context.workspaceId, - sessionId: context.sessionId, - source: options.operationOrigin === 'external-mcp' || !options.operationOrigin ? 'mcp' : 'internal', - isSubagentBranch: options.operationOrigin === 'subagent', - }) - : await toolInstance.execute(params || {}) as CommonResult; + ? await toolInstance.execute(params as unknown as SubagentToolParams, runtimeContext) + : acceptsToolRuntimeContext(toolInstance) + ? await (toolInstance as unknown as RuntimeContextTool).execute(params || {}, runtimeContext) + : await toolInstance.execute(params || {}) as CommonResult; const result: ToolCallResult = { agent: agentName, diff --git a/src/services/chat/SubagentExecutor.ts b/src/services/chat/SubagentExecutor.ts index 2013d59f..fa9b76fe 100644 --- a/src/services/chat/SubagentExecutor.ts +++ b/src/services/chat/SubagentExecutor.ts @@ -33,6 +33,12 @@ import type { DirectToolExecutor } from './DirectToolExecutor'; import { formatWorkspaceDataForPrompt } from '../../utils/WorkspaceDataFormatter'; import { isTextOnlyProvider } from '../llm/utils/ToolSchemaSupport'; import type { ChatRuntimeEvent } from '../llm/runtime/ChatRuntimeEvent'; +import type { ThinkingEffort } from '../../types/llm/ProviderTypes'; +import { + fitInheritedContext, + formatOmittedContextLine, + type InheritedContextItem +} from './subagentContextBudget'; import { createInitialChatTurnState, reduceChatTurn, @@ -54,6 +60,9 @@ export interface SubagentExecutorDependencies { sessionId?: string; operationOrigin?: import('../../types/tools/ToolOperationTypes').ToolExecutionOrigin; operationScopeId?: string; + enableThinking?: boolean; + thinkingEffort?: ThinkingEffort; + temperature?: number; } ) => AsyncGenerator; // Tool list service for pre-fetching schemas @@ -259,15 +268,18 @@ export class SubagentExecutor { // 2. Read context files if provided (inherited from parent + tool params) // These go into the SYSTEM PROMPT so the subagent has full context - let contextFilesContent = ''; - if (params.contextFiles?.length) { - contextFilesContent = await this.readContextFiles(params.contextFiles); - } + const contextFiles = params.contextFiles?.length + ? await this.readContextFiles(params.contextFiles) + : []; + + // 3. Fit the inherited context (workspace data, then files) into the + // subagent's context window, dropping whole items that do not fit. + const inheritedContext = this.assembleInheritedContext(params, contextFiles); - // 3. Build system prompt WITH tool schemas + context files included - const systemPrompt = this.buildSystemPrompt(params, toolSchemasText, contextFilesContent); + // 4. Build system prompt WITH tool schemas + inherited context included + const systemPrompt = this.buildSystemPrompt(params, toolSchemasText, inheritedContext); - // 4. Build initial user message (just task + any additional context string) + // 5. Build initial user message (just task + any additional context string) const initialMessage = this.buildInitialMessage(params.task, params.context || ''); // 5. Add initial messages to branch @@ -370,6 +382,9 @@ export class SubagentExecutor { sessionId: params.sessionId, provider: params.provider, model: params.model, + enableThinking: params.thinkingEnabled, + thinkingEffort: params.thinkingEffort, + temperature: params.temperature, operationOrigin: 'subagent', operationScopeId: branchId, })) { @@ -500,16 +515,43 @@ export class SubagentExecutor { }; } + /** + * Decide which inherited context goes into the subagent's system prompt. + * Without a context window everything is kept, as before. + */ + private assembleInheritedContext( + params: SubagentParams, + contextFiles: InheritedContextItem[] + ): { workspaceContext?: string; contextFilesContent?: string; omittedLine?: string } { + const items: InheritedContextItem[] = []; + const workspaceText = params.workspaceData ? this.formatWorkspaceData(params.workspaceData) : ''; + const workspaceItem: InheritedContextItem | null = workspaceText + ? { label: 'workspace context', text: workspaceText } + : null; + if (workspaceItem) items.push(workspaceItem); + items.push(...contextFiles); + + const fitted = fitInheritedContext(items, params.contextWindow); + const includedFiles = fitted.included.filter(item => item !== workspaceItem); + return { + workspaceContext: workspaceItem && fitted.included.includes(workspaceItem) ? workspaceItem.text : undefined, + contextFilesContent: includedFiles.length > 0 ? includedFiles.map(item => item.text).join('\n\n') : undefined, + omittedLine: fitted.omitted.length > 0 && params.contextWindow !== undefined + ? formatOmittedContextLine(fitted.omitted, params.contextWindow) + : undefined + }; + } + /** * Build system prompt for subagent * @param params Subagent parameters * @param toolSchemas Pre-fetched tool schemas to include (optional) - * @param contextFilesContent Content from context files to include (optional) + * @param inherited Workspace context, context files and the omitted-items line, already budgeted */ private buildSystemPrompt( params: SubagentParams, toolSchemas?: string, - contextFilesContent?: string + inherited: { workspaceContext?: string; contextFilesContent?: string; omittedLine?: string } = {} ): string { // Determine if tools were pre-loaded by parent const hasPreloadedTools = toolSchemas && toolSchemas.length > 0; @@ -549,17 +591,16 @@ ${params.task} 5. **Use thinking/reasoning internally** - Deliberate in your thinking, not in your text output.`); - // 3. Add workspace context if available - if (params.workspaceData) { - const workspaceContext = this.formatWorkspaceData(params.workspaceData); - if (workspaceContext) { - promptParts.push(`## Workspace Context\n${workspaceContext}`); - } + // 3. Add workspace context if available (and within the context budget) + if (inherited.workspaceContext) { + promptParts.push(`## Workspace Context\n${inherited.workspaceContext}`); } // 4. Add context files content if available (from parent's notes + tool params) - if (contextFilesContent) { - promptParts.push(`## Reference Files\nThe following files have been provided as context:\n\n${contextFilesContent}`); + if (inherited.contextFilesContent) { + promptParts.push(`## Reference Files\nThe following files have been provided as context:\n\n${inherited.contextFilesContent}${inherited.omittedLine ? `\n\n${inherited.omittedLine}` : ''}`); + } else if (inherited.omittedLine) { + promptParts.push(`## Reference Files\n${inherited.omittedLine}`); } // 5. Add tool section based on whether tools were pre-loaded @@ -668,8 +709,8 @@ BEGIN - Start by calling getTools to discover available tools.`); /** * Read context files */ - private async readContextFiles(files: string[]): Promise { - const contents: string[] = []; + private async readContextFiles(files: string[]): Promise { + const contents: InheritedContextItem[] = []; for (const file of files) { try { @@ -683,16 +724,16 @@ BEGIN - Start by calling getTools to discover available tools.`); const result = results[0]; if (result?.success && (result.result as { content?: string })?.content) { - contents.push(`--- ${file} ---\n${(result.result as { content: string }).content}`); + contents.push({ label: file, text: `--- ${file} ---\n${(result.result as { content: string }).content}` }); } else { - contents.push(`--- ${file} --- (failed to read)`); + contents.push({ label: file, text: `--- ${file} --- (failed to read)` }); } } catch { - contents.push(`--- ${file} --- (error reading file)`); + contents.push({ label: file, text: `--- ${file} --- (error reading file)` }); } } - return contents.join('\n\n'); + return contents; } /** diff --git a/src/services/chat/subagentContextBudget.ts b/src/services/chat/subagentContextBudget.ts new file mode 100644 index 00000000..3d9f5032 --- /dev/null +++ b/src/services/chat/subagentContextBudget.ts @@ -0,0 +1,84 @@ +/** + * Fit a subagent's inherited context into its context window. + * + * A subagent inherits the parent chat's workspace data and context notes, and + * those go into its system prompt in full. With a subagent context window set + * (Settings → Defaults → Subagents, or per chat), that inherited context must + * not crowd out the task: whole items are dropped once the budget is spent and + * the prompt says which ones, so the subagent knows to read them itself if it + * needs them. Items are never cut in half. There is no mid-run compaction. + */ + +export interface InheritedContextItem { + /** Shown in the omitted line, e.g. a note path or "workspace context". */ + label: string; + text: string; +} + +export interface FittedInheritedContext { + included: InheritedContextItem[]; + omitted: string[]; + budgetTokens: number; +} + +/** + * Share of the window kept free for the subagent's instructions, tool + * schemas, its own tool results and the reply. + */ +const RESERVED_SHARE = 0.25; +const MIN_RESERVED_TOKENS = 4096; + +/** Same estimate the context budget service uses: about four characters a token. */ +export function estimateInheritedTokens(text: string): number { + return text ? Math.ceil(text.length / 4) : 0; +} + +/** Tokens the inherited context may use inside a window of `contextWindow`. */ +export function inheritedContextBudget(contextWindow: number): number { + if (!Number.isFinite(contextWindow) || contextWindow <= 0) return 0; + const reserved = Math.max(MIN_RESERVED_TOKENS, Math.floor(contextWindow * RESERVED_SHARE)); + return Math.max(0, Math.floor(contextWindow) - reserved); +} + +/** + * Keep items in order while they fit; skip any item that does not, and keep + * trying the later (possibly smaller) ones. `contextWindow` undefined means + * no budget, so everything is kept. + */ +export function fitInheritedContext( + items: InheritedContextItem[], + contextWindow: number | undefined +): FittedInheritedContext { + if (contextWindow === undefined) { + return { included: [...items], omitted: [], budgetTokens: Number.POSITIVE_INFINITY }; + } + + const budgetTokens = inheritedContextBudget(contextWindow); + let remaining = budgetTokens; + const included: InheritedContextItem[] = []; + const omitted: string[] = []; + for (const item of items) { + const cost = estimateInheritedTokens(item.text); + if (cost <= remaining) { + included.push(item); + remaining -= cost; + } else { + omitted.push(item.label); + } + } + return { included, omitted, budgetTokens }; +} + +/** "32K", "200K", "1M" — the same short form the settings readout uses. */ +export function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000) { + return `${Number((tokens / 1_000_000).toFixed(1))}M`; + } + if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`; + return String(tokens); +} + +/** The single line that tells the subagent what was left out. */ +export function formatOmittedContextLine(omitted: string[], contextWindow: number): string { + return `Omitted to fit this subagent's ${formatTokenCount(contextWindow)} context window: ${omitted.join(', ')}. Read them with tools if the task needs them.`; +} diff --git a/src/types/branch/BranchTypes.ts b/src/types/branch/BranchTypes.ts index e83d0fe3..555f6ab4 100644 --- a/src/types/branch/BranchTypes.ts +++ b/src/types/branch/BranchTypes.ts @@ -96,9 +96,9 @@ export interface SubagentParams { sessionId?: string; maxIterations?: number; continueBranchId?: string; - // Inherited from parent conversation - Model settings - provider?: string; // LLM provider (inherits parent's model) - model?: string; // LLM model (inherits parent's model) + // Model settings, resolved with the agent precedence (subagent model, then a cloud chat model) + provider?: string; // LLM provider for the subagent run + model?: string; // LLM model for the subagent run // Inherited from parent conversation - Agent settings agentPrompt?: string; // Custom agent's full system prompt (merged into subagent prompt) agentName?: string; // Custom agent name for reference @@ -106,9 +106,16 @@ export interface SubagentParams { workspaceData?: Record; // Full comprehensive workspace data (sessions, states, files, etc.) // Inherited from parent conversation - Context notes (file paths) inheritedContextNotes?: string[]; // Note paths from parent's context notes - // Inherited from parent conversation - Thinking settings + // Subagent thinking settings (already gated for the resolved model) thinkingEnabled?: boolean; thinkingEffort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + /** Subagent Creativity; undefined when the model rejects sampling parameters. */ + temperature?: number; + /** + * Token budget for the inherited context (context files and workspace data) + * assembled into the subagent's system prompt. Undefined means no budget. + */ + contextWindow?: number; } /** diff --git a/src/types/tools/ToolRuntimeContext.ts b/src/types/tools/ToolRuntimeContext.ts new file mode 100644 index 00000000..fa1b94fe --- /dev/null +++ b/src/types/tools/ToolRuntimeContext.ts @@ -0,0 +1,26 @@ +/** + * Runtime identity of a tool call, supplied by the tool batch executor. + * + * Most tools only see their parameters. A tool that needs to know which + * conversation called it, and whether the call came from internal chat or an + * external MCP client, opts in with `acceptsToolRuntimeContext` and receives + * this object as the second argument to `execute`. + */ +export interface ToolRuntimeContext { + conversationId: string; + messageId: string; + workspaceId?: string; + sessionId?: string; + source: 'internal' | 'mcp'; + isSubagentBranch: boolean; +} + +export interface ToolRuntimeContextConsumer { + readonly acceptsToolRuntimeContext: true; +} + +export function acceptsToolRuntimeContext(tool: unknown): tool is ToolRuntimeContextConsumer { + return typeof tool === 'object' + && tool !== null + && (tool as { acceptsToolRuntimeContext?: unknown }).acceptsToolRuntimeContext === true; +} diff --git a/src/ui/chat/controllers/SubagentController.ts b/src/ui/chat/controllers/SubagentController.ts index e2d8e629..fa86c6cd 100644 --- a/src/ui/chat/controllers/SubagentController.ts +++ b/src/ui/chat/controllers/SubagentController.ts @@ -32,6 +32,28 @@ import type { ConversationData, ToolCall, ChatMessage } from '../../../types/cha import type { StreamingController } from './StreamingController'; import type { ToolEventCoordinator } from '../coordinators/ToolEventCoordinator'; import { isSubagentMetadata } from '../../../types/branch/BranchTypes'; +import type { ThinkingEffort } from '../../../types/llm/ProviderTypes'; +import { + resolveAgentModel, + resolveAgentRunSettings, + type ConversationAgentSettings +} from '../../../services/llm/utils/SubagentModelResolver'; +import { DEFAULT_MODELS } from '../../../services/llm/adapters/ModelRegistry'; + +/** Options the subagent executor passes to each streaming turn. */ +interface SubagentStreamOptions { + provider?: string; + model?: string; + systemPrompt?: string; + abortSignal?: AbortSignal; + workspaceId?: string; + sessionId?: string; + operationOrigin?: import('../../../types/tools/ToolOperationTypes').ToolExecutionOrigin; + operationScopeId?: string; + enableThinking?: boolean; + thinkingEffort?: ThinkingEffort; + temperature?: number; +} interface SubagentResultPayload { success?: boolean; @@ -60,12 +82,14 @@ export interface SubagentControllerDependencies { */ export interface SubagentContextProvider { getCurrentConversation: () => ConversationData | null; - getSelectedModel: () => { providerId?: string; modelId?: string } | null; + getSelectedModel: () => { providerId?: string; modelId?: string; contextWindow?: number } | null; getSelectedPrompt: () => { name?: string; systemPrompt?: string } | null; getLoadedWorkspaceData: () => Record | null; getContextNotes: () => string[]; getThinkingSettings: () => { enabled?: boolean; effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' } | null; getSelectedWorkspaceId: () => string | null; + /** This chat's subagent settings (model, thinking, Creativity, context window). */ + getAgentSettings?: () => ConversationAgentSettings | null; } /** @@ -208,7 +232,7 @@ export class SubagentController { // Wire up to PromptManagerAgent deps.promptManagerAgent.setSubagentExecutor( this.subagentExecutor, - () => this.buildSubagentContext(contextProvider) + () => this.buildSubagentContext(contextProvider, deps.llmService) ); // Initialize status menu if a container is provided. The optional @@ -317,31 +341,13 @@ export class SubagentController { directToolExecutor: DirectToolExecutor ): ( messages: ChatMessage[], - options: { - provider?: string; - model?: string; - systemPrompt?: string; - abortSignal?: AbortSignal; - workspaceId?: string; - sessionId?: string; - operationOrigin?: import('../../../types/tools/ToolOperationTypes').ToolExecutionOrigin; - operationScopeId?: string; - } + options: SubagentStreamOptions ) => AsyncGenerator { const isOpenAITool = this.isOpenAITool.bind(this); return async function* ( messages: ChatMessage[], - options: { - provider?: string; - model?: string; - systemPrompt?: string; - abortSignal?: AbortSignal; - workspaceId?: string; - sessionId?: string; - operationOrigin?: import('../../../types/tools/ToolOperationTypes').ToolExecutionOrigin; - operationScopeId?: string; - } + options: SubagentStreamOptions ) { try { const tools = shouldPassToolSchemasToProvider(options?.provider) @@ -355,6 +361,9 @@ export class SubagentController { workspaceId: options?.workspaceId, operationOrigin: options?.operationOrigin, operationScopeId: options?.operationScopeId, + enableThinking: options?.enableThinking, + thinkingEffort: options?.thinkingEffort, + temperature: options?.temperature, tools, }; @@ -452,7 +461,7 @@ export class SubagentController { /** * Build context for subagent execution from current state */ - private buildSubagentContext(contextProvider: SubagentContextProvider) { + private buildSubagentContext(contextProvider: SubagentContextProvider, llmService: LLMService) { const currentConversation = contextProvider.getCurrentConversation(); const messages = currentConversation?.messages || []; const lastMessage = messages[messages.length - 1]; @@ -462,7 +471,27 @@ export class SubagentController { const selectedPrompt = contextProvider.getSelectedPrompt(); const workspaceData = contextProvider.getLoadedWorkspaceData(); const contextNotes = contextProvider.getContextNotes() || []; - const thinkingSettings = contextProvider.getThinkingSettings(); + const conversationAgent = contextProvider.getAgentSettings?.() ?? undefined; + const agentDefaults = llmService.getAgentDefaults(); + + // Subagents use the subagent model: this chat's, then the default, then + // the chat model when it is a cloud model, then any cloud provider. + const agentModel = resolveAgentModel({ + conversation: conversationAgent, + defaultAgent: agentDefaults.model, + chat: { provider: selectedModel?.providerId, model: selectedModel?.modelId }, + availableProviders: llmService.getAvailableProviders(), + defaultModelFor: provider => DEFAULT_MODELS[provider] + }); + const isChatModel = agentModel.provider === selectedModel?.providerId + && agentModel.model === selectedModel?.modelId; + const runSettings = resolveAgentRunSettings({ + provider: agentModel.provider, + model: agentModel.model, + conversation: conversationAgent, + defaults: agentDefaults, + advertisedContextWindow: isChatModel ? selectedModel?.contextWindow : undefined + }); return { conversationId: currentConversation?.id || 'unknown', @@ -471,14 +500,17 @@ export class SubagentController { sessionId, source: 'internal' as const, isSubagentBranch: false, - provider: selectedModel?.providerId, - model: selectedModel?.modelId, + provider: agentModel.provider || undefined, + model: agentModel.model || undefined, agentPrompt: selectedPrompt?.systemPrompt, agentName: selectedPrompt?.name, workspaceData: workspaceData ?? undefined, contextNotes, - thinkingEnabled: thinkingSettings?.enabled, - thinkingEffort: thinkingSettings?.effort, + thinkingEnabled: runSettings.thinking.enabled, + thinkingEffort: runSettings.thinking.effort, + temperature: runSettings.temperature, + contextWindow: runSettings.contextWindow, + conversationAgent, }; } diff --git a/src/ui/chat/services/ChatSubagentIntegration.ts b/src/ui/chat/services/ChatSubagentIntegration.ts index c3bb58d4..b92f4d34 100644 --- a/src/ui/chat/services/ChatSubagentIntegration.ts +++ b/src/ui/chat/services/ChatSubagentIntegration.ts @@ -17,6 +17,7 @@ import { type SubagentControllerEvents, } from '../controllers/SubagentController'; import type { ConversationData } from '../../../types/chat/ChatTypes'; +import type { ConversationAgentSettings } from '../../../services/llm/utils/SubagentModelResolver'; interface ConversationManagerLike { getCurrentConversation(): ConversationData | null; @@ -24,12 +25,13 @@ interface ConversationManagerLike { } interface ModelAgentManagerLike { - getSelectedModel(): { providerId?: string; modelId?: string } | null; + getSelectedModel(): { providerId?: string; modelId?: string; contextWindow?: number } | null; getSelectedPrompt(): { name?: string; systemPrompt?: string } | null; getLoadedWorkspaceData(): Record | null; getContextNotes(): string[]; getThinkingSettings(): { enabled?: boolean; effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' } | null; getSelectedWorkspaceId(): string | null; + getAgentSettings?(): ConversationAgentSettings; } interface NavigationTarget { @@ -123,6 +125,7 @@ export class ChatSubagentIntegration { getContextNotes: () => this.deps.getModelAgentManager()?.getContextNotes() || [], getThinkingSettings: () => this.deps.getModelAgentManager()?.getThinkingSettings() ?? null, getSelectedWorkspaceId: () => this.deps.getModelAgentManager()?.getSelectedWorkspaceId() ?? null, + getAgentSettings: () => this.deps.getModelAgentManager()?.getAgentSettings?.() ?? null, }; } diff --git a/tests/unit/PromptExecutorAgentSettings.test.ts b/tests/unit/PromptExecutorAgentSettings.test.ts new file mode 100644 index 00000000..3991e692 --- /dev/null +++ b/tests/unit/PromptExecutorAgentSettings.test.ts @@ -0,0 +1,119 @@ +/** + * executePrompt uses the subagent settings. + * + * Defect this buys: PromptExecutor sent no temperature and no thinking, and + * picked the chat model whenever it was a cloud model, so the Subagents + * settings ("Used for: subagents, saved prompts and automations") had no + * effect on saved prompts. The full chain runs for real — tool batch executor + * → ExecutePromptsTool → PromptExecutor → LLMService.executePrompt — with + * only the LLM service stubbed. Sampling support comes from the registry: + * Claude Haiku 5.5 rejects temperature, Opus 4.6 accepts it. + */ +import { ToolBatchExecutionService } from '../../src/agents/toolManager/services/ToolBatchExecutionService'; +import { ExecutePromptsTool } from '../../src/agents/promptManager/tools/executePrompts/executePrompts'; +import type { IAgent } from '../../src/agents/interfaces/IAgent'; +import type { LLMService } from '../../src/services/llm/core/LLMService'; +import type { AgentDefaults, ConversationAgentSettings } from '../../src/services/llm/utils/SubagentModelResolver'; + +function createLlmService(defaults: AgentDefaults) { + const executePrompt = jest.fn(async (options: { provider?: string; model?: string }) => ({ + success: true, + response: 'ok', + provider: options.provider, + model: options.model + })); + const llmService = { + executePrompt, + getAvailableProviders: jest.fn(() => ['openai', 'anthropic']), + getAvailableModels: jest.fn(async () => [ + { provider: 'openai', id: 'gpt-5.4' }, + { provider: 'anthropic', id: 'claude-haiku-5-5' }, + { provider: 'anthropic', id: 'claude-opus-4-6' } + ]), + getAgentDefaults: jest.fn(() => defaults), + getAgentModel: jest.fn(() => defaults.model), + getDefaultModel: jest.fn(() => ({ provider: 'openai', model: 'gpt-5.4' })) + }; + return { llmService: llmService as unknown as LLMService, executePrompt }; +} + +function createService(tool: ExecutePromptsTool): ToolBatchExecutionService { + const agent: IAgent = { + name: 'promptManager', + description: '', + version: '1.0.0', + getTools: () => [tool], + getTool: (slug: string) => (slug === tool.slug ? tool : undefined), + initialize: jest.fn().mockResolvedValue(undefined), + executeTool: jest.fn(), + setAgentManager: jest.fn() + }; + return new ToolBatchExecutionService({} as never, new Map([['promptManager', agent]])); +} + +const DEFAULTS: AgentDefaults = { + model: { provider: 'anthropic', model: 'claude-opus-4-6' }, + thinking: { enabled: true, effort: 'high' }, + agentTemperature: 0.3, + defaultTemperature: 0.9 +}; + +const CHAT_AGENT: ConversationAgentSettings = { + provider: 'anthropic', + model: 'claude-haiku-5-5', + thinking: { enabled: true, effort: 'low' }, + temperature: 0.6 +}; + +async function runPrompt(options: { + prompt?: Record; + origin?: { operationOrigin?: 'native-chat'; conversationId?: string }; + viewConversationId?: string; +}) { + const { llmService, executePrompt } = createLlmService(DEFAULTS); + const tool = new ExecutePromptsTool(undefined, llmService, {} as never, undefined, {} as never); + tool.setAgentContextProvider(() => ({ conversationId: options.viewConversationId ?? 'conv_1', conversationAgent: CHAT_AGENT })); + const result = await createService(tool).execute({ + context: { workspaceId: 'default', sessionId: 's', memory: 'm', goal: 'g' }, + calls: [{ agent: 'promptManager', tool: 'execute', params: { prompts: [{ type: 'text', prompt: 'Hi', ...options.prompt }] } }] + }, options.origin); + expect(result.success).toBe(true); + expect(executePrompt).toHaveBeenCalledTimes(1); + return executePrompt.mock.calls[0][0] as Record; +} + +describe('executePrompt agent settings', () => { + it('uses the default subagent model, Creativity and thinking (not the cloud chat model)', async () => { + const sent = await runPrompt({}); + + expect(sent).toEqual(expect.objectContaining({ + provider: 'anthropic', + model: 'claude-opus-4-6', + temperature: 0.3, + enableThinking: true, + thinkingEffort: 'high' + })); + }); + + it("uses this chat's subagent settings when internal chat calls it, without a temperature for Haiku 5.5", async () => { + const sent = await runPrompt({ origin: { operationOrigin: 'native-chat', conversationId: 'conv_1' } }); + + expect(sent).toEqual(expect.objectContaining({ + provider: 'anthropic', + model: 'claude-haiku-5-5', + enableThinking: true, + thinkingEffort: 'low' + })); + expect(sent.temperature).toBeUndefined(); + }); + + it("ignores the chat view's settings for another conversation", async () => { + const sent = await runPrompt({ origin: { operationOrigin: 'native-chat', conversationId: 'conv_2' } }); + expect(sent).toEqual(expect.objectContaining({ model: 'claude-opus-4-6' })); + }); + + it('lets an explicit tool parameter choose the model', async () => { + const sent = await runPrompt({ prompt: { provider: 'openai', model: 'gpt-5.4' } }); + expect(sent).toEqual(expect.objectContaining({ provider: 'openai', model: 'gpt-5.4' })); + }); +}); diff --git a/tests/unit/SubagentContextBudget.test.ts b/tests/unit/SubagentContextBudget.test.ts new file mode 100644 index 00000000..69e5ef74 --- /dev/null +++ b/tests/unit/SubagentContextBudget.test.ts @@ -0,0 +1,111 @@ +/** + * The subagent context window budgets inherited context. + * + * Defect this buys: a subagent inherited every context note and the whole + * workspace into its system prompt regardless of its context window, so a + * small subagent model could be handed more context than it can hold. Whole + * items beyond the budget must be dropped and named in one line. + */ +import { SubagentExecutor } from '../../src/services/chat/SubagentExecutor'; +import { + fitInheritedContext, + formatTokenCount, + inheritedContextBudget +} from '../../src/services/chat/subagentContextBudget'; +import type { ChatMessage } from '../../src/types/chat/ChatTypes'; +import type { SubagentParams } from '../../src/types/branch/BranchTypes'; + +const FILES: Record = { + 'small-a.md': 'A'.repeat(8_000), // ~2,000 tokens + 'huge-b.md': 'B'.repeat(40_000), // ~10,000 tokens + 'small-c.md': 'C'.repeat(4_000) // ~1,000 tokens +}; + +async function systemPromptFor(params: Partial): Promise { + const messages: ChatMessage[] = []; + const executor = new SubagentExecutor({ + branchService: { + createSubagentBranch: jest.fn(async () => 'branch_1'), + addMessageToBranch: jest.fn(async (_id: string, message: ChatMessage) => { messages.push(message); }), + getBranch: jest.fn(async () => ({ branch: { messages: [...messages] }, parentMessageId: 'm' })), + updateMessageInBranch: jest.fn(async () => undefined), + updateBranchState: jest.fn(async () => undefined) + } as never, + messageQueueService: { enqueue: jest.fn(async () => undefined) } as never, + directToolExecutor: { + executeToolCalls: jest.fn(async (calls: Array<{ function: { arguments: string } }>) => { + const { filePath } = JSON.parse(calls[0].function.arguments) as { filePath: string }; + return [{ success: true, result: { content: FILES[filePath] } }]; + }) + } as never, + streamingGenerator: async function* () { + yield { type: 'turn.completed' as const }; + } + }); + const finished = new Promise(resolve => executor.setEventHandlers({ onSubagentComplete: () => resolve() })); + await executor.executeSubagent({ + task: 'Do the task', + parentConversationId: 'conv_1', + parentMessageId: 'msg_1', + contextFiles: Object.keys(FILES), + ...params + }); + await finished; + const system = messages.find(message => message.role === 'system'); + return system?.content ?? ''; +} + +describe('subagent inherited context budget', () => { + it('drops whole files beyond the context window and names them', async () => { + const prompt = await systemPromptFor({ contextWindow: 8_192 }); + + expect(prompt).toContain(FILES['small-a.md']); + expect(prompt).toContain(FILES['small-c.md']); + expect(prompt).not.toContain('BBBB'); + expect(prompt).toContain("Omitted to fit this subagent's 8K context window: huge-b.md."); + }); + + it('keeps everything when no context window is set', async () => { + const prompt = await systemPromptFor({}); + + expect(prompt).toContain(FILES['huge-b.md']); + expect(prompt).not.toContain('Omitted to fit'); + }); + + it('drops the workspace context whole when it does not fit', async () => { + const prompt = await systemPromptFor({ + contextWindow: 8_192, + contextFiles: [], + workspaceData: { name: 'Big workspace', context: { purpose: 'P'.repeat(60_000) } } + }); + + expect(prompt).not.toContain('## Workspace Context'); + expect(prompt).toContain('Omitted to fit'); + expect(prompt).toContain('workspace context'); + }); +}); + +describe('fitInheritedContext', () => { + it('reserves a quarter of the window, at least 4,096 tokens', () => { + expect(inheritedContextBudget(8_192)).toBe(4_096); + expect(inheritedContextBudget(200_000)).toBe(150_000); + expect(inheritedContextBudget(2_048)).toBe(0); + }); + + it('keeps order and tries later, smaller items after a skip', () => { + const fitted = fitInheritedContext([ + { label: 'a', text: 'x'.repeat(8_000) }, + { label: 'b', text: 'x'.repeat(40_000) }, + { label: 'c', text: 'x'.repeat(4_000) } + ], 8_192); + expect(fitted.included.map(item => item.label)).toEqual(['a', 'c']); + expect(fitted.omitted).toEqual(['b']); + }); + + it('formats token counts like the settings readout', () => { + expect(formatTokenCount(32_000)).toBe('32K'); + expect(formatTokenCount(1_000_000)).toBe('1M'); + expect(formatTokenCount(1_048_576)).toBe('1M'); + expect(formatTokenCount(1_500_000)).toBe('1.5M'); + }); +}); diff --git a/tests/unit/SubagentControllerForwardsSettings.test.ts b/tests/unit/SubagentControllerForwardsSettings.test.ts new file mode 100644 index 00000000..ff3f61ba --- /dev/null +++ b/tests/unit/SubagentControllerForwardsSettings.test.ts @@ -0,0 +1,140 @@ +/** + * Subagent settings reach the request. + * + * Defect this buys: the subagent tool inherited the parent chat's provider, + * model and thinking, never read the subagent settings, and SubagentExecutor + * dropped thinking on the floor before the streaming generator. This test runs + * the real chain — SubagentController context → SubagentTool → SubagentExecutor + * → the controller's streaming generator — and asserts on what + * `llmService.generateResponseStream` receives. Only storage, the tool + * executor and the LLM call itself are stubbed. Sampling support comes from the + * real registry: Claude Haiku 5.5 rejects temperature, Opus 4.6 accepts it. + */ +import { SubagentController, type SubagentContextProvider } from '../../src/ui/chat/controllers/SubagentController'; +import { SubagentExecutor } from '../../src/services/chat/SubagentExecutor'; +import { SubagentTool, type SubagentToolContext } from '../../src/agents/promptManager/tools/subagent'; +import type { ConversationAgentSettings } from '../../src/services/llm/utils/SubagentModelResolver'; +import type { LLMService } from '../../src/services/llm/core/LLMService'; +import type { App, Component } from 'obsidian'; + +type StreamOptions = Record; + +function createLlmService(defaults: ReturnType = {}) { + const calls: StreamOptions[] = []; + const llmService = { + generateResponseStream: jest.fn(async function* (_messages: unknown, options: StreamOptions) { + calls.push(options); + yield { type: 'assistant.delta', text: 'done' }; + yield { type: 'turn.completed' }; + }), + getAgentDefaults: jest.fn(() => defaults), + getAvailableProviders: jest.fn(() => ['openai', 'anthropic']) + }; + return { llmService: llmService as unknown as LLMService, calls }; +} + +function createBranchService() { + const messages: unknown[] = []; + return { + createSubagentBranch: jest.fn(async () => 'branch_1'), + addMessageToBranch: jest.fn(async (_branchId: string, message: unknown) => { messages.push(message); }), + getBranch: jest.fn(async () => ({ branch: { messages: [...messages] }, parentMessageId: 'msg_1' })), + updateMessageInBranch: jest.fn(async () => undefined), + updateBranchState: jest.fn(async () => undefined), + messages + }; +} + +function contextProvider(agent: ConversationAgentSettings | null): SubagentContextProvider { + return { + getCurrentConversation: () => ({ id: 'conv_1', title: 'T', created: 0, updated: 0, messages: [] }), + getSelectedModel: () => ({ providerId: 'openai', modelId: 'gpt-5.4', contextWindow: 400_000 }), + getSelectedPrompt: () => null, + getLoadedWorkspaceData: () => null, + getContextNotes: () => [], + // The parent chat's own thinking must not leak into the subagent. + getThinkingSettings: () => ({ enabled: false, effort: 'low' }), + getSelectedWorkspaceId: () => null, + getAgentSettings: () => agent + }; +} + +async function runSubagent(agent: ConversationAgentSettings | null, llm = createLlmService()) { + const controller = new SubagentController({} as App, {} as Component, { + onStreamingUpdate: jest.fn(), onToolCallsDetected: jest.fn(), onStatusChanged: jest.fn() + }); + const internals = controller as unknown as { + buildSubagentContext(provider: SubagentContextProvider, llmService: LLMService): SubagentToolContext; + createStreamingGenerator(llmService: LLMService, directToolExecutor: unknown): ConstructorParameters[0]['streamingGenerator']; + }; + const directToolExecutor = { getAvailableTools: jest.fn(async () => []), executeToolCalls: jest.fn(async () => []) }; + const branchService = createBranchService(); + const executor = new SubagentExecutor({ + branchService: branchService as never, + messageQueueService: { enqueue: jest.fn(async () => undefined) } as never, + directToolExecutor: directToolExecutor as never, + streamingGenerator: internals.createStreamingGenerator(llm.llmService, directToolExecutor) + }); + const finished = new Promise((resolve, reject) => { + executor.setEventHandlers({ onSubagentComplete: () => resolve(), onSubagentError: (_id, error) => reject(new Error(error)) }); + }); + + const tool = new SubagentTool(); + tool.setSubagentExecutor(executor); + tool.setContextProvider(() => internals.buildSubagentContext(contextProvider(agent), llm.llmService)); + const result = await tool.execute({ task: 'Summarise the notes' } as never); + expect(result.success).toBe(true); + await finished; + + expect(llm.calls).toHaveLength(1); + return llm.calls[0]; +} + +describe('subagent settings reach the streaming request', () => { + it('uses the subagent model and thinking, and sends no temperature to Haiku 5.5', async () => { + const options = await runSubagent({ + provider: 'anthropic', + model: 'claude-haiku-5-5', + thinking: { enabled: true, effort: 'high' }, + temperature: 0.3 + }); + + expect(options).toEqual(expect.objectContaining({ + provider: 'anthropic', + model: 'claude-haiku-5-5', + enableThinking: true, + thinkingEffort: 'high' + })); + expect(options.temperature).toBeUndefined(); + }); + + it('passes temperature to a model that accepts it (Opus 4.6)', async () => { + const options = await runSubagent({ + provider: 'anthropic', + model: 'claude-opus-4-6', + thinking: { enabled: false, effort: 'medium' }, + temperature: 0.3 + }); + + expect(options).toEqual(expect.objectContaining({ + provider: 'anthropic', + model: 'claude-opus-4-6', + enableThinking: false, + temperature: 0.3 + })); + }); + + it('falls back to the default subagent model, then to the cloud chat model', async () => { + const withDefault = await runSubagent(null, createLlmService({ + model: { provider: 'anthropic', model: 'claude-opus-4-6' }, + thinking: { enabled: true, effort: 'low' }, + agentTemperature: 0.2 + })); + expect(withDefault).toEqual(expect.objectContaining({ + provider: 'anthropic', model: 'claude-opus-4-6', enableThinking: true, thinkingEffort: 'low', temperature: 0.2 + })); + + const inherited = await runSubagent(null); + expect(inherited).toEqual(expect.objectContaining({ provider: 'openai', model: 'gpt-5.4', enableThinking: false })); + }); +}); From 504e81cc06d4d78b801648ca0d70a56acd82658e Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:11:42 -0400 Subject: [PATCH 17/65] feat(storage): add clearEmbeddingIndex for deleting vectors only Drops and recreates the three vec0 tables with the schema DDL and empties the embedding metadata tables and the conversation backfill cursor, in one transaction, then saves. Conversations, messages, traces, the notes index and the learned retrieval adapter file are untouched. Co-Authored-By: Claude Opus 5.5 --- src/database/storage/SQLiteCacheManager.ts | 9 ++ .../storage/SQLiteMaintenanceService.ts | 45 ++++++ tests/unit/ClearEmbeddingIndex.test.ts | 149 ++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 tests/unit/ClearEmbeddingIndex.test.ts diff --git a/src/database/storage/SQLiteCacheManager.ts b/src/database/storage/SQLiteCacheManager.ts index c74fbd54..2775549a 100644 --- a/src/database/storage/SQLiteCacheManager.ts +++ b/src/database/storage/SQLiteCacheManager.ts @@ -752,6 +752,15 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager await this.getMaintenanceService().clearAllData(); } + /** + * Delete all embedding vectors (notes, traces, conversations) and save. + * Used by "Delete index" while semantic search is turned off. + */ + async clearEmbeddingIndex(): Promise { + await this.getMaintenanceService().clearEmbeddingIndex(); + await this.save(); + } + async rebuildFTSIndexes(): Promise { await this.getMaintenanceService().rebuildFTSIndexes(); } diff --git a/src/database/storage/SQLiteMaintenanceService.ts b/src/database/storage/SQLiteMaintenanceService.ts index 7ef63965..1adc1149 100644 --- a/src/database/storage/SQLiteMaintenanceService.ts +++ b/src/database/storage/SQLiteMaintenanceService.ts @@ -67,6 +67,24 @@ interface SQLiteMaintenanceServiceOptions { blobStore?: CacheBlobStore; } +/** + * The vec0 tables that hold embedding vectors. Each is created in + * schema.ts as `USING vec0(embedding float[384])`; a test holds the two in step. + */ +export const EMBEDDING_VECTOR_TABLES = [ + 'note_embeddings', + 'trace_embeddings', + 'conversation_embeddings' +] as const; + +/** Row tables that describe the vectors, plus the conversation backfill cursor. */ +export const EMBEDDING_METADATA_TABLES = [ + 'embedding_metadata', + 'trace_embedding_metadata', + 'conversation_embedding_metadata', + 'embedding_backfill_state' +] as const; + export class SQLiteMaintenanceService { private readonly app: App; private dbPath: string; @@ -117,6 +135,33 @@ export class SQLiteMaintenanceService { }); } + /** + * Delete every stored embedding vector and its metadata, and nothing else. + * + * Vectors are derived cache data: turning semantic search back on rebuilds + * them from the vault and the conversation history. The three vec0 tables + * are dropped and recreated rather than DELETEd, so the space they held is + * released, with the same DDL the schema uses (EMBEDDING_VECTOR_TABLES). + * The metadata tables keep their schema and lose their rows, and the + * conversation backfill cursor is cleared so the next pass starts over. + * + * Conversations, messages, traces, the notes index and the learned retrieval + * adapter (a file, not a table) are untouched. + */ + async clearEmbeddingIndex(): Promise { + await this.transaction(() => { + const db = this.getDb(); + for (const table of EMBEDDING_VECTOR_TABLES) { + this.bridge.exec(db, `DROP TABLE IF EXISTS ${table}`); + this.bridge.exec(db, `CREATE VIRTUAL TABLE IF NOT EXISTS ${table} USING vec0(embedding float[384])`); + } + for (const table of EMBEDDING_METADATA_TABLES) { + this.bridge.exec(db, `DELETE FROM ${table}`); + } + return Promise.resolve(); + }); + } + async rebuildFTSIndexes(): Promise { await this.transaction(() => { const db = this.getDb(); diff --git a/tests/unit/ClearEmbeddingIndex.test.ts b/tests/unit/ClearEmbeddingIndex.test.ts new file mode 100644 index 00000000..349e8abf --- /dev/null +++ b/tests/unit/ClearEmbeddingIndex.test.ts @@ -0,0 +1,149 @@ +/** + * "Delete index" removes embedding vectors and nothing else. + * + * Before this there was no delete path at all: "Rebuild cache" drops only the + * conversation vectors, and note and trace vectors survived everything + * (SQLiteMaintenanceService.clearAllData). The settings fold needs a way to + * free that space while semantic search is off. + * + * What is real: SQLiteMaintenanceService and the schema SQL. What is faked: + * the SQLite bridge, which records the statements instead of running them. + * This repo's Jest lanes have no runnable sqlite-vec (the bundled WASM build + * refuses to start under Node), so the statements are checked for shape and + * the recreated vec0 DDL is checked against the schema it has to match. The + * real round trip is an in-app step (see the plan's Phase 4 verification). + * + * If the delete forgot a table, touched a non-embedding table, ran outside a + * transaction, or recreated a vec0 table with a different shape than the + * schema creates, this goes red. + */ + +import type { App } from 'obsidian'; + +jest.mock('@dao-xyz/sqlite3-vec/wasm', () => jest.fn(), { virtual: true }); + +import { + SQLiteMaintenanceService, + EMBEDDING_VECTOR_TABLES, + EMBEDDING_METADATA_TABLES +} from '../../src/database/storage/SQLiteMaintenanceService'; +import type { SQLiteWasmBridge, SQLiteDatabaseHandle } from '../../src/database/storage/SQLiteWasmBridge'; +import { SCHEMA_SQL } from '../../src/database/schema/schema'; + +/** Every table that holds embedding data, by name, in the current schema. */ +const ALL_EMBEDDING_TABLES = [ + 'note_embeddings', + 'embedding_metadata', + 'trace_embeddings', + 'trace_embedding_metadata', + 'conversation_embeddings', + 'conversation_embedding_metadata', + 'embedding_backfill_state' +]; + +function normalizeSql(sql: string): string { + return sql.replace(/\s+/g, ' ').replace(/\(\s+/g, '(').replace(/\s+\)/g, ')').trim(); +} + +function schemaVecDefinition(table: string): string { + const match = new RegExp( + `CREATE VIRTUAL TABLE IF NOT EXISTS ${table} USING vec0\\(([^)]*)\\)` + ).exec(SCHEMA_SQL); + if (!match) { + throw new Error(`schema.ts has no vec0 definition for ${table}`); + } + return normalizeSql(match[0]); +} + +function createService() { + const statements: string[] = []; + let inTransaction = false; + const outsideTransaction: string[] = []; + + const bridge = { + exec: (_db: SQLiteDatabaseHandle, sql: string) => { + statements.push(sql); + if (!inTransaction) outsideTransaction.push(sql); + } + } as unknown as SQLiteWasmBridge; + + const service = new SQLiteMaintenanceService({ + app: {} as App, + dbPath: 'cache.db', + bridge, + getDb: () => ({}) as SQLiteDatabaseHandle, + queryOne: async () => null, + transaction: async (fn: () => Promise): Promise => { + inTransaction = true; + try { + return await fn(); + } finally { + inTransaction = false; + } + } + }); + + return { service, statements, outsideTransaction }; +} + +describe('clearEmbeddingIndex', () => { + it('empties all seven embedding tables inside one transaction', async () => { + const { service, statements, outsideTransaction } = createService(); + + await service.clearEmbeddingIndex(); + + expect(outsideTransaction).toEqual([]); + for (const table of ALL_EMBEDDING_TABLES) { + const emptied = statements.some(sql => + sql === `DELETE FROM ${table}` || sql === `DROP TABLE IF EXISTS ${table}` + ); + expect({ table, emptied }).toEqual({ table, emptied: true }); + } + }); + + it('recreates every dropped vec0 table with the schema definition, so it accepts inserts again', async () => { + const { service, statements } = createService(); + + await service.clearEmbeddingIndex(); + + const dropped = statements + .filter(sql => sql.startsWith('DROP TABLE IF EXISTS ')) + .map(sql => sql.slice('DROP TABLE IF EXISTS '.length)); + expect(dropped.sort()).toEqual([...EMBEDDING_VECTOR_TABLES].sort()); + + for (const table of dropped) { + const dropAt = statements.indexOf(`DROP TABLE IF EXISTS ${table}`); + const create = statements.findIndex( + (sql, index) => index > dropAt && sql.startsWith(`CREATE VIRTUAL TABLE IF NOT EXISTS ${table} `) + ); + expect(create).toBeGreaterThan(dropAt); + expect(normalizeSql(statements[create])).toBe(schemaVecDefinition(table)); + } + }); + + it('touches no table that is not embedding data', async () => { + const { service, statements } = createService(); + + await service.clearEmbeddingIndex(); + + const touched = new Set( + statements.map(sql => /(?:FROM|EXISTS)\s+(\w+)/.exec(sql)?.[1]).filter(Boolean) + ); + for (const table of touched) { + expect(ALL_EMBEDDING_TABLES).toContain(table); + } + expect([...EMBEDDING_METADATA_TABLES].every(table => touched.has(table))).toBe(true); + }); + + it('names every embedding table the schema defines', () => { + // A new embedding table added to schema.ts without being added here would + // survive "Delete index". The schema is the authority. + const vecTables = [...SCHEMA_SQL.matchAll(/CREATE VIRTUAL TABLE IF NOT EXISTS (\w+) USING vec0/g)] + .map(match => match[1]); + expect([...EMBEDDING_VECTOR_TABLES].sort()).toEqual(vecTables.sort()); + + const metadataTables = [...SCHEMA_SQL.matchAll(/CREATE TABLE IF NOT EXISTS (\w*embedding\w*)/g)] + .map(match => match[1]); + expect([...EMBEDDING_METADATA_TABLES].sort()).toEqual(metadataTables.sort()); + }); +}); From 5ba87bb579365e80c3af00200b4372fe252188a7 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:11:42 -0400 Subject: [PATCH 18/65] feat(embeddings): turn semantic search off and on at runtime EmbeddingManager.setEnabled() starts and stops the engine, watchers, queue, status bar and dream cycle without a restart (the dream interval and the dream-now command are now undone on stop). A shared teardown backs both setEnabled(false) and shutdown(), and a run generation stops a background chain between phases once it is torn down. New public surface for the settings fold: getStatus(), getProgress(), onStateChange() (start, stop, delete and every queue progress event), pauseIndexing()/resumeIndexing(), and getStats() that reads stored counts from the database even while off, plus a distinct conversation count. deleteIndex() clears the vectors only while off and keeps the AdapterStore. IndexingQueue gains pruneMissingNotes() and pruneMissingConversations(), run before the first phase on every pass, so vectors of notes deleted or renamed and chats deleted while search was off are removed. Co-Authored-By: Claude Opus 5.5 --- src/services/embeddings/EmbeddingManager.ts | 362 ++++++++++++++--- src/services/embeddings/IndexingQueue.ts | 102 +++++ tests/unit/EmbeddingManagerHotToggle.test.ts | 372 ++++++++++++++++++ tests/unit/EmbeddingManagerPhaseOrder.test.ts | 22 +- .../EmbeddingManagerShutdownTimer.test.ts | 22 +- tests/unit/EmbeddingPrune.test.ts | 167 ++++++++ 6 files changed, 985 insertions(+), 62 deletions(-) create mode 100644 tests/unit/EmbeddingManagerHotToggle.test.ts create mode 100644 tests/unit/EmbeddingPrune.test.ts diff --git a/src/services/embeddings/EmbeddingManager.ts b/src/services/embeddings/EmbeddingManager.ts index 8cf19867..a83c3074 100644 --- a/src/services/embeddings/EmbeddingManager.ts +++ b/src/services/embeddings/EmbeddingManager.ts @@ -7,19 +7,22 @@ * - Lazy initialization (3-second delay on startup) * - Coordinates EmbeddingEngine, EmbeddingService, EmbeddingWatcher, * ConversationEmbeddingWatcher, IndexingQueue, and StatusBar + * - Can be turned off and on at runtime (setEnabled) without a restart + * - Publishes its status and indexing progress (onStateChange, getStatus) + * - Deletes the stored vectors on request while turned off (deleteIndex) * - Graceful shutdown with cleanup * * Relationships: - * - Called by PluginLifecycleManager for initialization + * - Called by PluginLifecycleManager for initialization and hot-apply * - Manages all embedding system components */ -import { App, Plugin, Platform, Notice } from 'obsidian'; +import { App, Plugin, Platform, Notice, Events, type EventRef } from 'obsidian'; import { EmbeddingEngine } from './EmbeddingEngine'; import { EmbeddingService } from './EmbeddingService'; import { EmbeddingWatcher } from './EmbeddingWatcher'; import { ConversationEmbeddingWatcher } from './ConversationEmbeddingWatcher'; -import { IndexingQueue } from './IndexingQueue'; +import { IndexingQueue, type IndexingProgress } from './IndexingQueue'; import { EmbeddingStatusBar } from './EmbeddingStatusBar'; import { createRetrievalDreamService, RetrievalDream } from './adapter/createRetrievalDreamService'; import type { SQLiteCacheManager } from '../../database/storage/SQLiteCacheManager'; @@ -36,6 +39,43 @@ interface PluginWithSettings { /** How often a dream-consolidation cycle runs (ms). */ const DREAM_INTERVAL_MS = 45 * 60 * 1000; +/** Command id of the manual "dream now" command; removed again on stop. */ +const DREAM_COMMAND_ID = 'consolidate-retrieval-memory'; + +/** Event name used on the manager's private Events bus. */ +const STATE_EVENT = 'state-change'; + +/** + * Snapshot of the embedding runtime, for status surfaces. + * + * `enabled` is whether semantic search is turned on for this device (always + * false on mobile). `initialized` is whether the engine, watchers and queue are + * running. `progress` is the latest indexing progress event, or null when + * nothing has been reported since the last start. + */ +export interface EmbeddingRuntimeStatus { + enabled: boolean; + initialized: boolean; + indexing: boolean; + paused: boolean; + progress: IndexingProgress | null; +} + +/** Stored index sizes plus runtime state. Counts are read even while turned off. */ +export interface EmbeddingStats { + enabled: boolean; + initialized: boolean; + noteCount: number; + traceCount: number; + conversationChunkCount: number; + /** Distinct conversations that have at least one embedded chunk. */ + conversationCount: number; + indexingInProgress: boolean; + indexingPaused: boolean; +} + +export type EmbeddingStateListener = (status: EmbeddingRuntimeStatus) => void; + /** * Embedding system manager * @@ -67,6 +107,28 @@ export class EmbeddingManager { private backgroundIndexingWork: Promise | null = null; private shuttingDown = false; + /** + * The periodic dream-cycle interval. Kept so a runtime stop can clear it; + * registerInterval alone only clears it at plugin unload. + */ + private dreamInterval: number | null = null; + private dreamCommandRegistered = false; + + /** + * Bumped by every teardown. A background indexing chain captures the value + * it started under and stops between phases once it changes, so turning + * semantic search off cannot let the next phase start against a torn-down + * queue. + */ + private runGeneration = 0; + + /** Serializes setEnabled/deleteIndex so rapid toggles apply in order. */ + private transition: Promise = Promise.resolve(); + + private readonly events = new Events(); + private queueProgressRef: EventRef | null = null; + private lastProgress: IndexingProgress | null = null; + constructor( app: App, plugin: Plugin, @@ -99,6 +161,13 @@ export class EmbeddingManager { this.watcher = new EmbeddingWatcher(this.app, this.service); this.queue = new IndexingQueue(this.app, this.service, this.db); this.statusBar = new EmbeddingStatusBar(this.plugin, this.queue); + this.lastProgress = null; + + // Relay queue progress to status subscribers (the settings fold). + this.queueProgressRef = this.queue.on('progress', (...data: unknown[]) => { + this.lastProgress = (data[0] as IndexingProgress | undefined) ?? null; + this.emitState(); + }); // Initialize status bar (desktop only) this.statusBar.init(); @@ -134,6 +203,68 @@ export class EmbeddingManager { console.error('[EmbeddingManager] Initialization failed:', error); // Don't throw - embeddings are optional functionality } + + this.emitState(); + } + + /** + * Turn semantic search on or off for this session, without a restart. + * + * Turning on builds fresh components and schedules indexing; the index kept + * on this device means only notes that changed are re-embedded, and the + * prune pass drops vectors of notes deleted or renamed while it was off. + * Turning off stops the watchers, the queue, the status bar, the dream cycle + * and the engine, and leaves the stored vectors in place (see deleteIndex). + * + * Idempotent, a no-op on mobile and after shutdown(), and serialized: two + * quick toggles apply in the order they were made. + */ + setEnabled(enabled: boolean): Promise { + const next = this.transition.then(async () => { + if (this.shuttingDown || Platform.isMobile) { + return; + } + if (enabled === this.isEnabled) { + return; + } + + if (enabled) { + this.isEnabled = true; + this.initialize(); + return; + } + + this.isEnabled = false; + await this.teardown(); + this.emitState(); + }); + this.transition = next.catch((error) => { + console.error('[EmbeddingManager] Failed to apply the semantic search setting:', error); + }); + return this.transition; + } + + /** + * Delete the stored note, trace and conversation vectors on this device. + * + * Allowed only while semantic search is turned off, so it can never race the + * indexing queue. The learned retrieval adapter (AdapterStore) is kept: it is + * a separate file, and it stays valid for the same embedding model. + * + * @throws Error when semantic search is on. + */ + async deleteIndex(): Promise { + const run = this.transition.then(async () => { + if (this.isEnabled || this.isInitialized) { + throw new Error('Turn semantic search off before deleting its index.'); + } + await this.db.clearEmbeddingIndex(); + this.lastProgress = null; + }); + // A failed delete must not wedge later toggles behind a rejected promise. + this.transition = run.catch(() => undefined); + await run; + this.emitState(); } /** @@ -142,21 +273,53 @@ export class EmbeddingManager { */ async shutdown(): Promise { this.shuttingDown = true; - // Cleared before the isEnabled bail-out on purpose: this timer is the one - // thing that can still fire after shutdown() returns, so it is cancelled + // Cleared before anything else on purpose: this timer is the one thing + // that can still fire after shutdown() returns, so it is cancelled // unconditionally rather than behind a flag that may have changed. if (this.backgroundIndexingTimer !== null) { window.clearTimeout(this.backgroundIndexingTimer); this.backgroundIndexingTimer = null; } - if (!this.isEnabled) { - return; + try { + // Let an in-flight toggle or delete finish before tearing down, so the + // two never interleave against the same components. + await this.transition; + await this.teardown(); + } catch (error) { + console.error('[EmbeddingManager] Shutdown failed:', error); + } finally { + // Even a failing watcher or engine cleanup must not leave the accepted + // background phase writing after the lifecycle closes its cache. + await this.backgroundIndexingWork; } + } + + /** + * Stop every running component and drop it. Shared by shutdown() and by + * turning semantic search off. Safe to call when nothing is running. + * + * Order matters: cancel and unsubscribe first, then wait for the indexing + * phase that was already running to persist its final progress, and only + * then dispose the engine it may still be using. + */ + private async teardown(): Promise { + this.runGeneration++; + + if (this.backgroundIndexingTimer !== null) { + window.clearTimeout(this.backgroundIndexingTimer); + this.backgroundIndexingTimer = null; + } + + this.stopRetrievalLearning(); try { // Cancel indexing and remove all listeners if (this.queue) { + if (this.queueProgressRef) { + this.queue.offref(this.queueProgressRef); + this.queueProgressRef = null; + } this.queue.destroy(); } @@ -170,7 +333,7 @@ export class EmbeddingManager { await this.conversationWatcher.shutdown(); } - // Clean up status bar (removes progress listener) + // Clean up status bar (removes progress listener and the item itself) if (this.statusBar) { this.statusBar.destroy(); } @@ -183,15 +346,15 @@ export class EmbeddingManager { if (this.engine) { await this.engine.dispose(); } - - this.isInitialized = false; - - } catch (error) { - console.error('[EmbeddingManager] Shutdown failed:', error); } finally { - // Even a failing watcher or engine cleanup must not leave the accepted - // background phase writing after the lifecycle closes its cache. - await this.backgroundIndexingWork; + this.engine = null; + this.service = null; + this.watcher = null; + this.conversationWatcher = null; + this.queue = null; + this.statusBar = null; + this.isInitialized = false; + this.lastProgress = null; } } @@ -260,39 +423,105 @@ export class EmbeddingManager { } /** - * Get statistics about the embedding system + * Whether semantic search is turned on for this device, whether or not the + * engine has finished starting. Always false on mobile. + */ + isTurnedOn(): boolean { + return this.isEnabled; + } + + /** Synchronous snapshot for a first paint; subscribe for updates. */ + getStatus(): EmbeddingRuntimeStatus { + return { + enabled: this.isEnabled, + initialized: this.isInitialized, + indexing: this.queue?.isIndexing() ?? false, + paused: this.queue?.isIndexingPaused() ?? false, + progress: this.lastProgress + }; + } + + /** + * Current indexing progress: the queue's own view while it is running, + * otherwise the last event it reported, otherwise null. + */ + getProgress(): IndexingProgress | null { + if (this.queue?.isIndexing()) { + return this.queue.getProgress(); + } + return this.lastProgress; + } + + /** Pause the running indexing pass. A no-op when nothing is indexing. */ + pauseIndexing(): void { + this.queue?.pause(); + this.emitState(); + } + + /** Resume a paused indexing pass. */ + resumeIndexing(): void { + this.queue?.resume(); + this.emitState(); + } + + /** + * Subscribe to state changes: start, stop, index deletion and every + * indexing progress event. + * + * @returns A function that removes the listener. */ - async getStats(): Promise<{ - enabled: boolean; - initialized: boolean; - noteCount: number; - traceCount: number; - conversationChunkCount: number; - indexingInProgress: boolean; - }> { - if (!this.isEnabled || !this.service) { - return { - enabled: false, - initialized: false, - noteCount: 0, - traceCount: 0, - conversationChunkCount: 0, - indexingInProgress: false - }; + onStateChange(listener: EmbeddingStateListener): () => void { + const ref = this.events.on(STATE_EVENT, (...data: unknown[]) => { + listener(data[0] as EmbeddingRuntimeStatus); + }); + return () => this.events.offref(ref); + } + + private emitState(): void { + try { + this.events.trigger(STATE_EVENT, this.getStatus()); + } catch (error) { + console.error('[EmbeddingManager] A state listener failed:', error); } + } - const stats = await this.service.getStats(); + /** + * Get statistics about the embedding system. + * + * The counts come straight from the cache database, so they are reported + * while semantic search is turned off as well: the off state shows how much + * index is being kept. Each count answers 0 on its own when its table cannot + * be read. + */ + async getStats(): Promise { + const [noteCount, traceCount, conversationChunkCount, conversationCount] = await Promise.all([ + this.countOrZero('SELECT COUNT(*) as count FROM embedding_metadata'), + this.countOrZero('SELECT COUNT(*) as count FROM trace_embedding_metadata'), + this.countOrZero('SELECT COUNT(*) as count FROM conversation_embedding_metadata'), + this.countOrZero('SELECT COUNT(DISTINCT conversationId) as count FROM conversation_embedding_metadata') + ]); return { enabled: this.isEnabled, initialized: this.isInitialized, - noteCount: stats.noteCount, - traceCount: stats.traceCount, - conversationChunkCount: stats.conversationChunkCount, - indexingInProgress: this.queue?.isIndexing() ?? false + noteCount, + traceCount, + conversationChunkCount, + conversationCount, + indexingInProgress: this.queue?.isIndexing() ?? false, + indexingPaused: this.queue?.isIndexingPaused() ?? false }; } + private async countOrZero(sql: string): Promise { + try { + const row = await this.db.queryOne<{ count: number }>(sql); + return Number(row?.count ?? 0) || 0; + } catch { + return 0; + } + } + /** * Set up the self-improving retrieval adapter: load any persisted adapter, * schedule periodic dream cycles, and register a manual "dream now" command. @@ -317,24 +546,51 @@ export class EmbeddingManager { console.error('[EmbeddingManager] Failed to load retrieval adapter:', error); }); - // Periodic consolidation, unless explicitly disabled. + // Periodic consolidation, unless explicitly disabled. The id is kept so + // turning semantic search off can clear it; registerInterval still + // covers plugin unload. if (pluginSettings?.embeddings?.retrievalLearning !== false) { const interval = window.setInterval(() => { void this.runDreamCycle(false); }, DREAM_INTERVAL_MS); + this.dreamInterval = interval; this.plugin.registerInterval(interval); } this.plugin.addCommand({ - id: 'consolidate-retrieval-memory', + id: DREAM_COMMAND_ID, name: 'Consolidate retrieval memory (dream now)', callback: () => { void this.runDreamCycle(true); } }); + this.dreamCommandRegistered = true; } catch (error) { console.error('[EmbeddingManager] Failed to initialize retrieval learning:', error); } } + /** + * Undo initRetrievalLearning: clear the dream interval and remove the + * command, so a stopped manager leaves nothing scheduled or advertised. + * The persisted adapter file is not touched. + */ + private stopRetrievalLearning(): void { + if (this.dreamInterval !== null) { + window.clearInterval(this.dreamInterval); + this.dreamInterval = null; + } + + if (this.dreamCommandRegistered) { + this.dreamCommandRegistered = false; + try { + this.plugin.removeCommand(DREAM_COMMAND_ID); + } catch (error) { + console.error('[EmbeddingManager] Failed to remove the dream command:', error); + } + } + + this.retrievalDream = null; + } + /** * Run a single dream-consolidation cycle. Notifies the user only when an * improved adapter is actually promoted (or on explicit manual trigger). @@ -377,11 +633,23 @@ export class EmbeddingManager { } private async runIndexingPhases(): Promise { - if (!this.queue) { + const queue = this.queue; + if (!queue) { return; } + const generation = this.runGeneration; + const stopped = (): boolean => this.shuttingDown || generation !== this.runGeneration; try { + // Prune first. Vectors of notes deleted or renamed while semantic search + // was off, and of conversations deleted meanwhile, are otherwise never + // removed: the passes below only walk what still exists. Running it on + // every pass covers startup, turning search back on and a cache rebuild. + await queue.pruneMissingNotes(); + if (stopped()) return; + await queue.pruneMissingConversations(); + if (stopped()) return; + // Cheapest-first, and deliberately so. // // The three phases are independent - conversations read conversations/ @@ -396,15 +664,15 @@ export class EmbeddingManager { // in seconds and the long vault walk runs behind them. // Phase 1: Backfill existing conversations (idempotent, resumable) - await this.queue.startConversationIndex(); - if (this.shuttingDown) return; + await queue.startConversationIndex(); + if (stopped()) return; // Phase 2: Backfill existing traces (from migration) - await this.queue.startTraceIndex(); - if (this.shuttingDown) return; + await queue.startTraceIndex(); + if (stopped()) return; // Phase 3: Index all notes - longest by far, so it goes last - await this.queue.startFullIndex(); + await queue.startFullIndex(); } catch (error) { console.error('[EmbeddingManager] Background indexing failed:', error); } diff --git a/src/services/embeddings/IndexingQueue.ts b/src/services/embeddings/IndexingQueue.ts index 51692727..a524ec7b 100644 --- a/src/services/embeddings/IndexingQueue.ts +++ b/src/services/embeddings/IndexingQueue.ts @@ -278,6 +278,108 @@ export class IndexingQueue extends Events { } } + // --------------------------------------------------------------------------- + // Prune passes + // --------------------------------------------------------------------------- + + /** + * Remove the vectors of notes that no longer exist in the vault. + * + * The watcher removes a vector when a note is deleted or renamed, but only + * while semantic search is running. A note deleted or renamed while it was + * off keeps its old vector forever otherwise: the note walk only visits + * files that exist, so nothing ever asks about the old path again. + * + * Refuses to prune when the vault reports no markdown files at all. That + * is far more likely to be a vault that has not finished loading than one + * whose every note was deleted, and pruning then would throw away the + * whole index. + * + * @returns The number of note vectors removed. + */ + async pruneMissingNotes(): Promise { + if (this.destroyed || !this.embeddingService.isServiceEnabled()) { + return 0; + } + + const rows = await this.db.query<{ notePath: string }>( + 'SELECT notePath FROM embedding_metadata' + ); + if (rows.length === 0 || this.app.vault.getMarkdownFiles().length === 0) { + return 0; + } + + let removed = 0; + for (const row of rows) { + if (this.destroyed) break; + if (this.app.vault.getAbstractFileByPath(row.notePath)) continue; + try { + await this.embeddingService.removeEmbedding(row.notePath); + removed++; + } catch (error) { + console.error(`[IndexingQueue] Failed to prune the vector for ${row.notePath}:`, error); + } + } + + if (removed > 0) { + await this.saveAfterPrune('notes'); + } + return removed; + } + + /** + * Remove the vectors of conversations that no longer exist in the cache. + * + * Same reasoning as pruneMissingNotes: a conversation deleted while + * semantic search was off never reached onConversationDeleted. Skipped when + * the conversations table is empty, which means the cache has not been + * replayed yet rather than that every chat was deleted. + * + * @returns The number of conversations whose vectors were removed. + */ + async pruneMissingConversations(): Promise { + if (this.destroyed || !this.embeddingService.isServiceEnabled()) { + return 0; + } + + const known = await this.db.queryOne<{ count: number }>( + 'SELECT COUNT(*) as count FROM conversations' + ); + if (!known || Number(known.count) === 0) { + return 0; + } + + const rows = await this.db.query<{ conversationId: string }>( + `SELECT DISTINCT conversationId FROM conversation_embedding_metadata + WHERE conversationId NOT IN (SELECT id FROM conversations)` + ); + + let removed = 0; + for (const row of rows) { + if (this.destroyed) break; + try { + await this.embeddingService.removeConversationEmbeddings(row.conversationId); + removed++; + } catch (error) { + console.error(`[IndexingQueue] Failed to prune vectors for conversation ${row.conversationId}:`, error); + } + } + + if (removed > 0) { + await this.saveAfterPrune('conversations'); + } + return removed; + } + + /** Persist a prune. Never throws: the rows are gone from memory either way. */ + private async saveAfterPrune(kind: 'notes' | 'conversations'): Promise { + try { + await this.db.save(); + } catch (error) { + console.error(`[IndexingQueue] Saving after pruning ${kind} failed:`, error); + } + } + // --------------------------------------------------------------------------- // Queue controls // --------------------------------------------------------------------------- diff --git a/tests/unit/EmbeddingManagerHotToggle.test.ts b/tests/unit/EmbeddingManagerHotToggle.test.ts new file mode 100644 index 00000000..aeeb7fde --- /dev/null +++ b/tests/unit/EmbeddingManagerHotToggle.test.ts @@ -0,0 +1,372 @@ +/** + * Semantic search turns off and on without a restart. + * + * Before this, `enableEmbeddings` was read once at startup: initialize() + * returned early when it was off, and nothing reacted to a later change. The + * dream-cycle interval and the "dream now" command were registered in a way + * that could not be undone, and getStats() reported zeros while off, so the + * settings fold could not show how much index it was keeping. + * + * What is real: EmbeddingManager. What is faked: its components (engine, + * service, watchers, queue, status bar, dream service) as constructor spies + * that record what the manager asks of them, and a database stub that answers + * the four count queries. The manager's own state, ordering and teardown are + * what is measured. + */ + +interface FakeComponent { + id: number; + [key: string]: unknown; +} + +const created: Record = { + engine: [], service: [], watcher: [], conversationWatcher: [], queue: [], statusBar: [] +}; + +function track(kind: string, make: (id: number) => T): T { + const component = make(created[kind].length + 1); + created[kind].push(component); + return component; +} + +jest.mock('../../src/services/embeddings/EmbeddingEngine', () => ({ + EmbeddingEngine: jest.fn().mockImplementation(() => track('engine', id => ({ + id, dispose: jest.fn().mockResolvedValue(undefined) + }))) +})); + +jest.mock('../../src/services/embeddings/EmbeddingService', () => ({ + EmbeddingService: jest.fn().mockImplementation(() => track('service', id => ({ + id, isServiceEnabled: jest.fn().mockReturnValue(true) + }))) +})); + +jest.mock('../../src/services/embeddings/EmbeddingWatcher', () => ({ + EmbeddingWatcher: jest.fn().mockImplementation(() => track('watcher', id => ({ + id, start: jest.fn(), stop: jest.fn() + }))) +})); + +jest.mock('../../src/services/embeddings/ConversationEmbeddingWatcher', () => ({ + ConversationEmbeddingWatcher: jest.fn().mockImplementation(() => track('conversationWatcher', id => ({ + id, start: jest.fn(), shutdown: jest.fn().mockResolvedValue(undefined) + }))) +})); + +jest.mock('../../src/services/embeddings/EmbeddingStatusBar', () => ({ + EmbeddingStatusBar: jest.fn().mockImplementation(() => track('statusBar', id => ({ + id, init: jest.fn(), destroy: jest.fn() + }))) +})); + +jest.mock('../../src/services/embeddings/IndexingQueue', () => { + // A real Events bus, so progress relayed by the manager is the queue's own. + const { Events } = jest.requireActual('obsidian'); + return { + IndexingQueue: jest.fn().mockImplementation(() => track('queue', id => { + const bus = new Events(); + let paused = false; + return Object.assign(bus, { + id, + pruneMissingNotes: jest.fn().mockResolvedValue(0), + pruneMissingConversations: jest.fn().mockResolvedValue(0), + startConversationIndex: jest.fn().mockResolvedValue(undefined), + startTraceIndex: jest.fn().mockResolvedValue(undefined), + startFullIndex: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn(), + cancel: jest.fn(), + isIndexing: jest.fn(() => true), + isIndexingPaused: jest.fn(() => paused), + pause: jest.fn(() => { paused = true; }), + resume: jest.fn(() => { paused = false; }), + getProgress: jest.fn(() => ({ + phase: paused ? 'paused' : 'indexing', totalNotes: 10, processedNotes: 4, + currentNote: null, estimatedTimeRemaining: 30 + })) + }); + })) + }; +}); + +const loadAndApply = jest.fn().mockResolvedValue(undefined); +jest.mock('../../src/services/embeddings/adapter/createRetrievalDreamService', () => ({ + createRetrievalDreamService: jest.fn(() => ({ loadAndApply, dream: { runDreamCycle: jest.fn() } })) +})); + +import { EmbeddingManager, type EmbeddingRuntimeStatus } from '../../src/services/embeddings/EmbeddingManager'; +import type { App, Plugin } from 'obsidian'; +import type { SQLiteCacheManager } from '../../src/database/storage/SQLiteCacheManager'; +import type { MessageRepository } from '../../src/database/repositories/MessageRepository'; + +const COUNTS: Record = { + 'SELECT COUNT(*) as count FROM embedding_metadata': 4512, + 'SELECT COUNT(*) as count FROM trace_embedding_metadata': 77, + 'SELECT COUNT(*) as count FROM conversation_embedding_metadata': 2400, + 'SELECT COUNT(DISTINCT conversationId) as count FROM conversation_embedding_metadata': 318 +}; + +async function flushMicrotasks(): Promise { + for (let i = 0; i < 20; i++) await Promise.resolve(); +} + +function createManager(enabled = true) { + const app = { vault: { adapter: { exists: jest.fn(), read: jest.fn(), write: jest.fn() }, configDir: '.obsidian' } } as unknown as App; + const plugin = { + settings: { embeddings: { retrievalLearning: true } }, + addCommand: jest.fn(), + removeCommand: jest.fn(), + registerInterval: jest.fn((id: number) => id) + }; + const db = { + queryOne: jest.fn(async (sql: string) => (sql in COUNTS ? { count: COUNTS[sql] } : null)), + clearEmbeddingIndex: jest.fn().mockResolvedValue(undefined) + }; + const manager = new EmbeddingManager( + app, + plugin as unknown as Plugin, + db as unknown as SQLiteCacheManager, + enabled, + {} as MessageRepository + ); + const states: EmbeddingRuntimeStatus[] = []; + manager.onStateChange(status => states.push(status)); + return { manager, plugin, db, app, states }; +} + +describe('EmbeddingManager hot toggle', () => { + let clearIntervalSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + for (const list of Object.values(created)) list.length = 0; + clearIntervalSpy = jest.spyOn(window, 'clearInterval'); + }); + + afterEach(() => { + clearIntervalSpy.mockRestore(); + jest.useRealTimers(); + }); + + it('setEnabled(false) stops every component, clears the dream cycle and reports it', async () => { + const h = createManager(); + h.manager.initialize(); + const dreamInterval = h.plugin.registerInterval.mock.calls[0][0]; + const [engine] = created.engine; + const [watcher] = created.watcher; + const [conversationWatcher] = created.conversationWatcher; + const [queue] = created.queue; + const [statusBar] = created.statusBar; + h.states.length = 0; + + await h.manager.setEnabled(false); + + expect(watcher.stop).toHaveBeenCalled(); + expect(conversationWatcher.shutdown).toHaveBeenCalled(); + expect(queue.destroy).toHaveBeenCalled(); + expect(statusBar.destroy).toHaveBeenCalled(); + expect(engine.dispose).toHaveBeenCalled(); + expect(clearIntervalSpy).toHaveBeenCalledWith(dreamInterval); + expect(h.plugin.removeCommand).toHaveBeenCalledWith('consolidate-retrieval-memory'); + expect(h.manager.getService()).toBeNull(); + expect(h.manager.isEmbeddingEnabled()).toBe(false); + expect(h.states.at(-1)).toMatchObject({ enabled: false, initialized: false }); + + // The deferred indexing kick-off was cancelled with everything else. + jest.advanceTimersByTime(10_000); + await flushMicrotasks(); + expect(queue.startConversationIndex).not.toHaveBeenCalled(); + }); + + it('setEnabled(true) builds fresh components and schedules indexing, prune first', async () => { + const h = createManager(); + h.manager.initialize(); + const firstService = h.manager.getService(); + await h.manager.setEnabled(false); + + await h.manager.setEnabled(true); + + expect(created.engine).toHaveLength(2); + expect(created.queue).toHaveLength(2); + expect(h.manager.getService()).not.toBeNull(); + expect(h.manager.getService()).not.toBe(firstService); + expect(h.manager.isEmbeddingEnabled()).toBe(true); + expect(h.plugin.addCommand).toHaveBeenCalledTimes(2); + expect(h.states.at(-1)).toMatchObject({ enabled: true, initialized: true }); + + const queue = created.queue[1]; + jest.advanceTimersByTime(3_000); + await flushMicrotasks(); + expect(queue.pruneMissingNotes).toHaveBeenCalled(); + expect(queue.startConversationIndex).toHaveBeenCalled(); + expect((queue.pruneMissingNotes as jest.Mock).mock.invocationCallOrder[0]) + .toBeLessThan((queue.startConversationIndex as jest.Mock).mock.invocationCallOrder[0]); + }); + + it('starts a manager that was constructed turned off', async () => { + const h = createManager(false); + h.manager.initialize(); + expect(created.engine).toHaveLength(0); + + await h.manager.setEnabled(true); + + expect(created.engine).toHaveLength(1); + expect(h.manager.isEmbeddingEnabled()).toBe(true); + }); + + it('is idempotent: repeating the current state rebuilds nothing', async () => { + const h = createManager(); + h.manager.initialize(); + + await h.manager.setEnabled(true); + await h.manager.setEnabled(true); + + expect(created.engine).toHaveLength(1); + }); + + it('stops a background chain between phases when turned off mid-run', async () => { + const h = createManager(); + h.manager.initialize(); + const [queue] = created.queue; + let finishConversations!: () => void; + (queue.startConversationIndex as jest.Mock).mockImplementationOnce( + () => new Promise(resolve => { finishConversations = resolve; }) + ); + jest.advanceTimersByTime(3_000); + await flushMicrotasks(); + expect(queue.startConversationIndex).toHaveBeenCalled(); + + const stopping = h.manager.setEnabled(false); + await flushMicrotasks(); + finishConversations(); + await stopping; + + expect(queue.startTraceIndex).not.toHaveBeenCalled(); + expect(queue.startFullIndex).not.toHaveBeenCalled(); + }); + + it('a later shutdown() after a toggle does not throw and blocks further starts', async () => { + const h = createManager(); + h.manager.initialize(); + await h.manager.setEnabled(false); + await h.manager.setEnabled(true); + + await expect(h.manager.shutdown()).resolves.toBeUndefined(); + await h.manager.setEnabled(false); + await h.manager.setEnabled(true); + + expect(created.engine).toHaveLength(2); + expect(h.manager.getService()).toBeNull(); + }); + + it('relays queue progress, and pause/resume, to state subscribers', async () => { + const h = createManager(); + h.manager.initialize(); + const [queue] = created.queue; + h.states.length = 0; + + const progress = { phase: 'indexing', totalNotes: 10, processedNotes: 4, currentNote: 'a.md', estimatedTimeRemaining: 30 }; + (queue as unknown as { trigger(name: string, data: unknown): void }).trigger('progress', progress); + expect(h.states.at(-1)?.progress).toEqual(progress); + + h.manager.pauseIndexing(); + expect(queue.pause).toHaveBeenCalled(); + expect(h.states.at(-1)?.paused).toBe(true); + + h.manager.resumeIndexing(); + expect(queue.resume).toHaveBeenCalled(); + expect(h.states.at(-1)?.paused).toBe(false); + + // After a stop the old queue's events no longer reach subscribers. + await h.manager.setEnabled(false); + h.states.length = 0; + (queue as unknown as { trigger(name: string, data: unknown): void }).trigger('progress', progress); + expect(h.states).toEqual([]); + }); + + it('unsubscribes a state listener', async () => { + const h = createManager(); + const seen: EmbeddingRuntimeStatus[] = []; + const unsubscribe = h.manager.onStateChange(status => seen.push(status)); + unsubscribe(); + + h.manager.initialize(); + await h.manager.setEnabled(false); + + expect(seen).toEqual([]); + }); +}); + +describe('EmbeddingManager stats and index deletion', () => { + beforeEach(() => { + jest.useFakeTimers(); + for (const list of Object.values(created)) list.length = 0; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('reports the stored index while turned off, including distinct chats', async () => { + const h = createManager(false); + + const stats = await h.manager.getStats(); + + expect(stats).toMatchObject({ + enabled: false, + initialized: false, + noteCount: 4512, + traceCount: 77, + conversationChunkCount: 2400, + conversationCount: 318, + indexingInProgress: false + }); + }); + + it('reports zero for a count the database cannot answer', async () => { + const h = createManager(false); + h.db.queryOne.mockRejectedValueOnce(new Error('Database not initialized')); + + const stats = await h.manager.getStats(); + + expect(stats.noteCount).toBe(0); + expect(stats.conversationCount).toBe(318); + }); + + it('refuses to delete the index while semantic search is on', async () => { + const h = createManager(); + h.manager.initialize(); + + await expect(h.manager.deleteIndex()).rejects.toThrow(/off/); + expect(h.db.clearEmbeddingIndex).not.toHaveBeenCalled(); + + // A refused delete must not wedge later toggles. + await h.manager.setEnabled(false); + expect(h.manager.getService()).toBeNull(); + }); + + it('deletes vectors only while off, keeps the adapter file, and reports the change', async () => { + const h = createManager(); + h.manager.initialize(); + await h.manager.setEnabled(false); + h.states.length = 0; + + await h.manager.deleteIndex(); + + expect(h.db.clearEmbeddingIndex).toHaveBeenCalledTimes(1); + const adapterFs = (h.app.vault as unknown as { adapter: Record }).adapter; + expect(adapterFs.write).not.toHaveBeenCalled(); + expect(h.states).toHaveLength(1); + expect(h.states[0]).toMatchObject({ enabled: false }); + }); + + it('waits for a pending turn-off before deleting', async () => { + const h = createManager(); + h.manager.initialize(); + + const off = h.manager.setEnabled(false); + const deleted = h.manager.deleteIndex(); + await off; + await expect(deleted).resolves.toBeUndefined(); + expect(h.db.clearEmbeddingIndex).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/EmbeddingManagerPhaseOrder.test.ts b/tests/unit/EmbeddingManagerPhaseOrder.test.ts index 182899f1..71dc8113 100644 --- a/tests/unit/EmbeddingManagerPhaseOrder.test.ts +++ b/tests/unit/EmbeddingManagerPhaseOrder.test.ts @@ -43,9 +43,14 @@ jest.mock('../../src/services/embeddings/IndexingQueue', () => ({ startFullIndex: jest.fn(async () => { calls.push('notes'); }), startTraceIndex: jest.fn(async () => { calls.push('traces'); }), startConversationIndex: jest.fn(async () => { calls.push('conversations'); }), + pruneMissingNotes: jest.fn(async () => { calls.push('prune-notes'); return 0; }), + pruneMissingConversations: jest.fn(async () => { calls.push('prune-conversations'); return 0; }), cancel, destroy: jest.fn(), - isIndexing: jest.fn(() => indexing) + on: jest.fn(() => ({})), + offref: jest.fn(), + isIndexing: jest.fn(() => indexing), + isIndexingPaused: jest.fn(() => false) })) })); @@ -84,11 +89,10 @@ describe('EmbeddingManager background indexing order', () => { manager.initialize(); jest.advanceTimersByTime(3_000); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); + for (let i = 0; i < 10; i++) await Promise.resolve(); - expect(calls).toEqual(['conversations', 'traces', 'notes']); + // Stale vectors are pruned before any phase, then cheapest-first. + expect(calls).toEqual(['prune-notes', 'prune-conversations', 'conversations', 'traces', 'notes']); }); it('re-derives embeddings after a cache rebuild', async () => { @@ -99,9 +103,9 @@ describe('EmbeddingManager background indexing order', () => { await manager.reindexAfterCacheRebuild(); - // Conversations first here too: they are the rows the rebuild actually - // destroyed, so they must not queue behind the vault walk. - expect(calls[0]).toBe('conversations'); + // Conversations first here too (after the prune): they are the rows the + // rebuild actually destroyed, so they must not queue behind the vault walk. + expect(calls.filter(call => !call.startsWith('prune-'))[0]).toBe('conversations'); expect(calls).toContain('traces'); expect(calls).toContain('notes'); }); @@ -118,6 +122,6 @@ describe('EmbeddingManager background indexing order', () => { // Without the cancel(), every phase would early-return on isRunning and the // rebuild would silently leave conversation embeddings at zero. expect(cancel).toHaveBeenCalled(); - expect(calls[0]).toBe('conversations'); + expect(calls.filter(call => !call.startsWith('prune-'))[0]).toBe('conversations'); }); }); diff --git a/tests/unit/EmbeddingManagerShutdownTimer.test.ts b/tests/unit/EmbeddingManagerShutdownTimer.test.ts index 33be2a48..fd697480 100644 --- a/tests/unit/EmbeddingManagerShutdownTimer.test.ts +++ b/tests/unit/EmbeddingManagerShutdownTimer.test.ts @@ -49,9 +49,14 @@ jest.mock('../../src/services/embeddings/IndexingQueue', () => ({ startFullIndex, startTraceIndex, startConversationIndex, + pruneMissingNotes: jest.fn().mockResolvedValue(0), + pruneMissingConversations: jest.fn().mockResolvedValue(0), destroy: queueDestroy, cancel: jest.fn(), - isIndexing: jest.fn().mockReturnValue(false) + on: jest.fn(() => ({})), + offref: jest.fn(), + isIndexing: jest.fn().mockReturnValue(false), + isIndexingPaused: jest.fn().mockReturnValue(false) })) })); @@ -63,6 +68,11 @@ import { EmbeddingManager } from '../../src/services/embeddings/EmbeddingManager import type { App, Plugin } from 'obsidian'; import type { SQLiteCacheManager } from '../../src/database/storage/SQLiteCacheManager'; +/** The prune passes run before the first phase, so reaching it takes several ticks. */ +async function flushMicrotasks(): Promise { + for (let i = 0; i < 10; i++) await Promise.resolve(); +} + function createManager() { const app = { vault: { adapter: {}, configDir: '.obsidian' } @@ -98,7 +108,7 @@ describe('EmbeddingManager deferred background indexing', () => { await manager.shutdown(); jest.advanceTimersByTime(10_000); - await Promise.resolve(); + await flushMicrotasks(); expect(queueDestroy).toHaveBeenCalled(); expect(startFullIndex).not.toHaveBeenCalled(); @@ -111,7 +121,7 @@ describe('EmbeddingManager deferred background indexing', () => { manager.initialize(); jest.advanceTimersByTime(3_000); - await Promise.resolve(); + await flushMicrotasks(); // Conversations are the first phase (cheapest-first ordering), so this is // the call that proves the timer fired. Asserting on the note phase would @@ -127,7 +137,7 @@ describe('EmbeddingManager deferred background indexing', () => { }); const manager = createManager(); manager.initialize(); jest.advanceTimersByTime(3_000); - await Promise.resolve(); + await flushMicrotasks(); let closed = false; const shutdown = manager.shutdown().then(() => { closed = true; }); await Promise.resolve(); expect(closed).toBe(false); @@ -135,14 +145,14 @@ describe('EmbeddingManager deferred background indexing', () => { finish(); await shutdown; expect(finalSave).toHaveBeenCalledTimes(1); expect(closed).toBe(true); expect(startTraceIndex).not.toHaveBeenCalled(); expect(startFullIndex).not.toHaveBeenCalled(); - manager.initialize(); jest.advanceTimersByTime(3_000); await Promise.resolve(); + manager.initialize(); jest.advanceTimersByTime(3_000); await flushMicrotasks(); expect(startConversationIndex).toHaveBeenCalledTimes(1); }); it('waits for the old background chain before starting cache-rebuild indexing again', async () => { let finish!: () => void; startConversationIndex.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); const manager = createManager(); manager.initialize(); - jest.advanceTimersByTime(3_000); await Promise.resolve(); + jest.advanceTimersByTime(3_000); await flushMicrotasks(); const rebuilt = manager.reindexAfterCacheRebuild(); await Promise.resolve(); expect(startConversationIndex).toHaveBeenCalledTimes(1); finish(); await rebuilt; diff --git a/tests/unit/EmbeddingPrune.test.ts b/tests/unit/EmbeddingPrune.test.ts new file mode 100644 index 00000000..28fee5f2 --- /dev/null +++ b/tests/unit/EmbeddingPrune.test.ts @@ -0,0 +1,167 @@ +/** + * Vectors of notes and chats removed while semantic search was off are pruned. + * + * The watcher removes a vector when a note is deleted or renamed, and the + * conversation service when a chat is deleted, but only while semantic search + * is running. The indexing passes walk only what still exists, so a note + * deleted while search was off kept its vector forever, and kept turning up + * as a semantic candidate. + * + * What is real: IndexingQueue. What is faked: the vault (a path set), the + * embedding service (records removals) and the cache database (two in-memory + * tables). The fake database answers the queue's SQL by computing it from + * those tables, including the NOT IN for conversations, so the assertions + * are about which rows the queue decides to remove, not about canned answers. + */ + +import { TFile } from 'obsidian'; +import { IndexingQueue } from '../../src/services/embeddings/IndexingQueue'; +import type { EmbeddingService } from '../../src/services/embeddings/EmbeddingService'; +import type { SQLiteCacheManager } from '../../src/database/storage/SQLiteCacheManager'; +import type { App } from 'obsidian'; + +interface Fixture { + vaultPaths: string[]; + embeddedNotes: string[]; + conversations: string[]; + embeddedConversations: string[]; +} + +function createQueue(fixture: Fixture) { + const files = new Map(fixture.vaultPaths.map(path => [path, new TFile(path.split('/').pop() ?? path, path)])); + const app = { + vault: { + getMarkdownFiles: () => [...files.values()], + getAbstractFileByPath: (path: string) => files.get(path) ?? null + } + } as unknown as App; + + const embeddedNotes = new Set(fixture.embeddedNotes); + const embeddedConversations = [...fixture.embeddedConversations]; + const conversations = new Set(fixture.conversations); + + const service = { + isServiceEnabled: () => true, + removeEmbedding: jest.fn(async (path: string) => { embeddedNotes.delete(path); }), + removeConversationEmbeddings: jest.fn(async (id: string) => { + for (let i = embeddedConversations.length - 1; i >= 0; i--) { + if (embeddedConversations[i] === id) embeddedConversations.splice(i, 1); + } + }) + }; + + const db = { + query: jest.fn(async (sql: string) => { + const normalized = sql.replace(/\s+/g, ' ').trim(); + if (normalized === 'SELECT notePath FROM embedding_metadata') { + return [...embeddedNotes].map(notePath => ({ notePath })); + } + if (normalized === 'SELECT DISTINCT conversationId FROM conversation_embedding_metadata WHERE conversationId NOT IN (SELECT id FROM conversations)') { + return [...new Set(embeddedConversations)] + .filter(id => !conversations.has(id)) + .map(conversationId => ({ conversationId })); + } + throw new Error(`Unexpected query: ${normalized}`); + }), + queryOne: jest.fn(async (sql: string) => { + if (sql === 'SELECT COUNT(*) as count FROM conversations') { + return { count: conversations.size }; + } + throw new Error(`Unexpected queryOne: ${sql}`); + }), + save: jest.fn(async () => undefined) + }; + + const queue = new IndexingQueue( + app, + service as unknown as EmbeddingService, + db as unknown as SQLiteCacheManager + ); + return { queue, service, db, embeddedNotes, embeddedConversations }; +} + +describe('IndexingQueue.pruneMissingNotes', () => { + it('removes vectors of deleted and renamed-away notes and keeps the rest', async () => { + const h = createQueue({ + vaultPaths: ['Kept.md', 'Folder/Renamed new.md'], + embeddedNotes: ['Kept.md', 'Deleted while off.md', 'Folder/Renamed old.md'], + conversations: [], + embeddedConversations: [] + }); + + const removed = await h.queue.pruneMissingNotes(); + + expect(removed).toBe(2); + expect([...h.embeddedNotes].sort()).toEqual(['Kept.md']); + expect(h.service.removeEmbedding).not.toHaveBeenCalledWith('Kept.md'); + expect(h.db.save).toHaveBeenCalledTimes(1); + }); + + it('removes nothing and does not save when every embedded note still exists', async () => { + const h = createQueue({ + vaultPaths: ['A.md', 'B.md'], + embeddedNotes: ['A.md', 'B.md'], + conversations: [], + embeddedConversations: [] + }); + + expect(await h.queue.pruneMissingNotes()).toBe(0); + expect(h.service.removeEmbedding).not.toHaveBeenCalled(); + expect(h.db.save).not.toHaveBeenCalled(); + }); + + it('refuses to prune against an empty vault, which is more likely not loaded than emptied', async () => { + const h = createQueue({ + vaultPaths: [], + embeddedNotes: ['A.md', 'B.md'], + conversations: [], + embeddedConversations: [] + }); + + expect(await h.queue.pruneMissingNotes()).toBe(0); + expect([...h.embeddedNotes].sort()).toEqual(['A.md', 'B.md']); + }); + + it('does nothing once the queue is destroyed', async () => { + const h = createQueue({ + vaultPaths: ['A.md'], + embeddedNotes: ['A.md', 'Gone.md'], + conversations: [], + embeddedConversations: [] + }); + h.queue.destroy(); + + expect(await h.queue.pruneMissingNotes()).toBe(0); + expect(h.embeddedNotes.has('Gone.md')).toBe(true); + }); +}); + +describe('IndexingQueue.pruneMissingConversations', () => { + it('removes vectors of deleted conversations and keeps existing ones', async () => { + const h = createQueue({ + vaultPaths: ['A.md'], + embeddedNotes: [], + conversations: ['conv-kept'], + embeddedConversations: ['conv-kept', 'conv-kept', 'conv-deleted', 'conv-deleted', 'conv-gone'] + }); + + const removed = await h.queue.pruneMissingConversations(); + + expect(removed).toBe(2); + expect(h.embeddedConversations).toEqual(['conv-kept', 'conv-kept']); + expect(h.service.removeConversationEmbeddings).not.toHaveBeenCalledWith('conv-kept'); + expect(h.db.save).toHaveBeenCalledTimes(1); + }); + + it('skips the pass when the conversations table is empty (cache not replayed yet)', async () => { + const h = createQueue({ + vaultPaths: ['A.md'], + embeddedNotes: [], + conversations: [], + embeddedConversations: ['conv-1', 'conv-2'] + }); + + expect(await h.queue.pruneMissingConversations()).toBe(0); + expect(h.embeddedConversations).toEqual(['conv-1', 'conv-2']); + }); +}); From 0e1ffc10336d31b516a8028b655c5bdf8c03c0ce Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:11:42 -0400 Subject: [PATCH 19/65] feat(settings): hot-apply synced enableEmbeddings instead of reloading A synced enableEmbeddings change no longer counts as needs-reload; it is adopted and applied through PluginLifecycleManager.setEmbeddingsEnabled(). ChatTraceService follows the manager's state, so traces stop going to a stopped embedding service and pick up the new one after a restart. Co-Authored-By: Claude Opus 5.5 --- src/core/PluginLifecycleManager.ts | 34 ++++-- src/main.ts | 7 ++ src/services/chat/ChatTraceService.ts | 8 +- src/settings.ts | 23 +++- .../SettingsSyncEmbeddingsHotApply.test.ts | 100 ++++++++++++++++++ 5 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 tests/unit/SettingsSyncEmbeddingsHotApply.test.ts diff --git a/src/core/PluginLifecycleManager.ts b/src/core/PluginLifecycleManager.ts index 36ba3dd9..8beffd2b 100644 --- a/src/core/PluginLifecycleManager.ts +++ b/src/core/PluginLifecycleManager.ts @@ -77,6 +77,7 @@ export class PluginLifecycleManager { private readAloudCommandManager: ReadAloudCommandManager; private vaultIngestionManager: VaultIngestionManager; private embeddingManager: EmbeddingManager | null = null; + private embeddingStateUnsubscribe: (() => void) | null = null; // Pending timer handles for cleanup on shutdown private pendingTimers: number[] = []; @@ -386,20 +387,35 @@ export class PluginLifecycleManager { }) ); - // Wire embedding service into ChatTraceService - const embeddingService = this.embeddingManager.getService(); - if (embeddingService) { - const chatTraceService = await this.serviceRegistrar.getService('chatTraceService'); - if (this.shuttingDown) return; - if (chatTraceService && typeof chatTraceService.setEmbeddingService === 'function') { - chatTraceService.setEmbeddingService(embeddingService); - } + // Wire the embedding service into ChatTraceService, and keep it + // wired as semantic search is turned off and on: each start builds + // a new service, and a stop must not leave the old one in use. + const chatTraceService = await this.serviceRegistrar.getService('chatTraceService'); + if (this.shuttingDown) return; + if (chatTraceService && typeof chatTraceService.setEmbeddingService === 'function') { + const syncTraceEmbeddings = (): void => { + chatTraceService.setEmbeddingService(manager.getService()); + }; + syncTraceEmbeddings(); + this.embeddingStateUnsubscribe = manager.onStateChange(syncTraceEmbeddings); } } catch (error) { console.error('[PluginLifecycleManager] Background embedding initialization failed:', error); } } + /** + * Apply the semantic search setting at runtime, without a restart. + * + * Called by the settings UI and for a change synced from another device. + * Before the embedding manager exists (the first seconds after load) this + * does nothing: the manager reads the setting when it is created. + */ + async setEmbeddingsEnabled(enabled: boolean): Promise { + if (this.shuttingDown || !this.embeddingManager) return; + await this.embeddingManager.setEnabled(enabled); + } + /** * Shutdown and cleanup */ @@ -449,6 +465,8 @@ export class PluginLifecycleManager { } // Shutdown embedding system first (before database closes) + this.embeddingStateUnsubscribe?.(); + this.embeddingStateUnsubscribe = null; if (this.embeddingManager) { try { await this.embeddingManager.shutdown(); diff --git a/src/main.ts b/src/main.ts index 524811e9..c86bdedd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -72,6 +72,13 @@ export default class NexusPlugin extends Plugin { if (this.unloading || !this.settings) return; try { const needsReload = await this.settings.reconcileExternalSettings(this.settingsSyncAbort.signal); + if (this.unloading) return; + // Semantic search is applied live rather than by reload. The call is + // idempotent, so it also catches a value adopted by an earlier read + // (the workflow scheduler reconciles synced settings too). + if (this.lifecycleManager) { + await this.lifecycleManager.setEmbeddingsEnabled(this.settings.settings.enableEmbeddings ?? true); + } if (needsReload && !this.unloading && !this.syncedSettingsNeedReload) { this.syncedSettingsNeedReload = true; new Notice(`${BRAND_NAME}: Settings changed on another device. Reload Nexus to apply changes outside AI providers.`); diff --git a/src/services/chat/ChatTraceService.ts b/src/services/chat/ChatTraceService.ts index 9df929d6..46009221 100644 --- a/src/services/chat/ChatTraceService.ts +++ b/src/services/chat/ChatTraceService.ts @@ -51,10 +51,12 @@ export class ChatTraceService { } /** - * Set the embedding service (can be set after construction) + * Set the embedding service (can be set after construction). Null clears it + * when semantic search is turned off, so traces stop being embedded through + * a stopped service. */ - setEmbeddingService(embeddingService: EmbeddingService): void { - this.embeddingService = embeddingService; + setEmbeddingService(embeddingService: EmbeddingService | null): void { + this.embeddingService = embeddingService ?? undefined; } /** diff --git a/src/settings.ts b/src/settings.ts index 219de180..8e0d9684 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -12,6 +12,15 @@ import { stripSecretsForPersist } from './services/secrets/SettingsSecrets'; +/** Assign an optional key, removing it rather than storing an explicit undefined. */ +function setOptionalKey(target: MCPSettings, key: K, value: MCPSettings[K]): void { + if (value === undefined) { + delete target[key]; + } else { + target[key] = value; + } +} + /** * Settings manager * Handles loading and saving plugin settings @@ -163,7 +172,13 @@ export class Settings { }); } - /** Refresh live providers only; storage roots and other service configuration require reload. */ + /** + * Refresh live providers and the semantic search switch; storage roots and + * other service configuration require reload. Returns whether a reload is + * needed. A synced `enableEmbeddings` change is adopted here and applied by + * the plugin at runtime (see NexusPlugin.onExternalSettingsChange), so it + * never asks for a reload. + */ async reconcileExternalSettings(signal?: AbortSignal): Promise { if (!this.loaded || signal?.aborted) return false; return pluginDataLock.acquire(async () => { @@ -179,8 +194,10 @@ export class Settings { const merged = mergeSettingsChanges(this.baseline, cloneSettings(this.settings), normalized); const next = cloneSettings(this.settings); next.llmProviders = merged.llmProviders; + setOptionalKey(next, 'enableEmbeddings', merged.enableEmbeddings); const requiresReload = !settingsEqual(this.reloadSettings(this.baseline), this.reloadSettings(normalized)); this.baseline.llmProviders = cloneSettings(normalized.llmProviders); + setOptionalKey(this.baseline, 'enableEmbeddings', normalized.enableEmbeddings); this.adoptSettings(next); return requiresReload; } @@ -192,11 +209,13 @@ export class Settings { return incoming; } - private reloadSettings(settings: MCPSettings): Omit { + /** The settings whose synced change needs a reload: everything not applied live. */ + private reloadSettings(settings: MCPSettings): Omit { const other = cloneSettings(settings); delete other.llmProviders; delete other.workflowScheduler; delete other.pluginStorage; + delete other.enableEmbeddings; return other; } diff --git a/tests/unit/SettingsSyncEmbeddingsHotApply.test.ts b/tests/unit/SettingsSyncEmbeddingsHotApply.test.ts new file mode 100644 index 00000000..45cfc117 --- /dev/null +++ b/tests/unit/SettingsSyncEmbeddingsHotApply.test.ts @@ -0,0 +1,100 @@ +/** + * A synced change to the semantic search switch is applied live, not by reload. + * + * Settings.reconcileExternalSettings treated every synced key outside + * `llmProviders` as "needs reload", so turning semantic search off on one + * device showed "Reload Nexus to apply changes" on every other device and + * changed nothing until then. + * + * What is real: NexusPlugin.onExternalSettingsChange and the Settings merge + * and reload decision. What is faked: data.json (an in-memory object) and the + * lifecycle manager, whose setEmbeddingsEnabled is a spy standing in for the + * EmbeddingManager hot toggle (covered by EmbeddingManagerHotToggle.test.ts). + */ + +import { App, Notice } from 'obsidian'; +import NexusPlugin from '../../src/main'; +import { Settings } from '../../src/settings'; + +jest.mock('obsidian', () => ({ ...jest.requireActual('obsidian'), Notice: jest.fn() })); +jest.mock('../../src/core/ServiceManager', () => ({ ServiceManager: jest.fn() })); +jest.mock('../../src/core/PluginLifecycleManager', () => ({ PluginLifecycleManager: jest.fn() })); +jest.mock('../../src/utils/WasmEnsurer', () => ({ WasmEnsurer: jest.fn() })); + +function fixture(initial: Record = {}) { + const disk: Record = { + llmProviders: { providers: {}, defaultTemperature: 0.5 }, + storage: { rootPath: 'Original' }, + enableEmbeddings: true, + ...initial + }; + const plugin = new NexusPlugin(new App(), { + id: 'nexus', name: 'Nexus', version: 'test', minAppVersion: '1.5.7', author: 'test', description: 'Test.', + }); + const loadData = jest.fn(async () => JSON.parse(JSON.stringify(disk)) as unknown); + const saveData = jest.fn(async (data: unknown) => { + for (const key of Object.keys(disk)) delete disk[key]; + Object.assign(disk, JSON.parse(JSON.stringify(data))); + }); + const setEmbeddingsEnabled = jest.fn(async () => undefined); + Object.assign(plugin, { loadData, saveData, lifecycleManager: { setEmbeddingsEnabled } }); + plugin.settings = new Settings(plugin); + return { plugin, disk, saveData, setEmbeddingsEnabled }; +} + +describe('synced enableEmbeddings change', () => { + beforeEach(() => jest.clearAllMocks()); + + it('does not require a reload', async () => { + const h = fixture(); + await h.plugin.settings.loadSettings(); + h.disk.enableEmbeddings = false; + + await expect(h.plugin.settings.reconcileExternalSettings()).resolves.toBe(false); + expect(h.plugin.settings.settings.enableEmbeddings).toBe(false); + }); + + it('is hot-applied through the lifecycle, with no reload notice and no echo write', async () => { + const h = fixture(); + await h.plugin.settings.loadSettings(); + h.disk.enableEmbeddings = false; + + await h.plugin.onExternalSettingsChange(); + + expect(h.setEmbeddingsEnabled).toHaveBeenLastCalledWith(false); + expect(Notice).not.toHaveBeenCalled(); + expect(h.saveData).not.toHaveBeenCalled(); + + h.disk.enableEmbeddings = true; + await h.plugin.onExternalSettingsChange(); + expect(h.setEmbeddingsEnabled).toHaveBeenLastCalledWith(true); + expect(h.plugin.settings.settings.enableEmbeddings).toBe(true); + }); + + it('still asks for a reload when another reload-only key changes alongside it', async () => { + const h = fixture(); + await h.plugin.settings.loadSettings(); + h.disk.enableEmbeddings = false; + h.disk.storage = { rootPath: 'Synced root' }; + + await h.plugin.onExternalSettingsChange(); + + expect(h.setEmbeddingsEnabled).toHaveBeenLastCalledWith(false); + expect(Notice).toHaveBeenCalledTimes(1); + expect(h.plugin.settings.settings.storage.rootPath).toBe('Original'); + }); + + it('keeps a later local save from reverting the synced value', async () => { + const h = fixture(); + await h.plugin.settings.loadSettings(); + h.disk.enableEmbeddings = false; + await h.plugin.onExternalSettingsChange(); + + h.plugin.settings.settings.defaultPromptId = 'local-edit'; + await h.plugin.settings.saveSettings(); + + expect(h.saveData).toHaveBeenCalledTimes(1); + expect(h.disk.defaultPromptId).toBe('local-edit'); + expect(h.disk.enableEmbeddings).toBe(false); + }); +}); From 0986137b5bbbb2028321039c61e70fea22e45d0e Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:11:42 -0400 Subject: [PATCH 20/65] feat(search): fall back to keyword search when semantic search is off search content keeps its semantic property (ToolCliNormalizer rejects unknown flags, so removing it would turn --semantic into an error) but re-describes it, drops the semantic pitch from the tool description, and answers semantic=true with keyword results and a note saying semantic search is turned off in Nexus settings. The description follows the setting live. With search on, the committed description is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/agents/searchManager/searchManager.ts | 10 +- .../searchManager/tools/searchContent.ts | 102 ++++++++-- src/services/embeddings/embeddingAccess.ts | 33 ++++ tests/unit/SearchContentSemanticOff.test.ts | 180 ++++++++++++++++++ 4 files changed, 312 insertions(+), 13 deletions(-) create mode 100644 tests/unit/SearchContentSemanticOff.test.ts diff --git a/src/agents/searchManager/searchManager.ts b/src/agents/searchManager/searchManager.ts index 6fa61fa9..a4b3ebb3 100644 --- a/src/agents/searchManager/searchManager.ts +++ b/src/agents/searchManager/searchManager.ts @@ -14,6 +14,7 @@ import { IStorageAdapter } from '../../database/interfaces/IStorageAdapter'; import { getNexusPlugin } from '../../utils/pluginLocator'; import { ServiceManager } from '../../core/ServiceManager'; import type { StorageAdapterResolver } from './services/ServiceAccessors'; +import { resolveSemanticSearchState } from '../../services/embeddings/embeddingAccess'; const noop = (): void => { return; @@ -130,8 +131,13 @@ export class SearchManagerAgent extends BaseAgent { const pluginOrFallback: Plugin = pluginRef || this.createMinimalPlugin(app); // Register ContentSearchTool (fuzzy + keyword + semantic search). The tool - // resolves the embedding service from the plugin on every semantic call. - this.searchContentTool = new SearchContentTool(pluginOrFallback); + // resolves the embedding service from the plugin on every semantic call, + // and reads the semantic search setting whenever it describes itself, so + // turning semantic search off stops it being offered without a reload. + this.searchContentTool = new SearchContentTool( + pluginOrFallback, + () => resolveSemanticSearchState(pluginOrFallback) + ); this.registerTool(this.searchContentTool); // Register focused search tools - lazy loaded diff --git a/src/agents/searchManager/tools/searchContent.ts b/src/agents/searchManager/tools/searchContent.ts index b976edbd..7de5b16c 100644 --- a/src/agents/searchManager/tools/searchContent.ts +++ b/src/agents/searchManager/tools/searchContent.ts @@ -4,7 +4,11 @@ import { getErrorMessage } from '../../../utils/errorUtils'; import { BRAND_NAME } from '../../../constants/branding'; import { isGlobPattern, globToRegex, normalizePath, isWithinPathScope } from '../../../utils/pathUtils'; import type { EmbeddingService } from '../../../services/embeddings/EmbeddingService'; -import { resolveEmbeddingService } from '../../../services/embeddings/embeddingAccess'; +import { + resolveEmbeddingService, + resolveSemanticSearchState, + type SemanticSearchState +} from '../../../services/embeddings/embeddingAccess'; import { CommonParameters } from '../../../types'; import type { ToolStatusTense } from '../../interfaces/ITool'; import { labelQuery, verbs } from '../../utils/toolStatusLabels'; @@ -53,9 +57,33 @@ export interface ContentSearchResult { content?: string; // Keyword search only matchType: SearchMatchType; }>; + /** Set when the search ran differently from what was asked, e.g. keyword instead of semantic. */ + note?: string; error?: string; } +/** Tool description while semantic search is available. Kept verbatim: the committed catalog holds it. */ +const DESCRIPTION_AVAILABLE = 'Search vault files. Set semantic=true for AI-powered conceptual search using local embeddings (best for concepts/related ideas), or semantic=false for keyword/fuzzy search (best for exact matches). Semantic search is desktop-only and becomes available once the embedding system initializes in the background (first run may take longer while the model downloads).'; + +/** Tool descriptions while semantic search cannot be used: no pitch for it. */ +const DESCRIPTION_KEYWORD_ONLY: Record, string> = { + off: 'Search vault files by keyword, with fuzzy filename matching (best for exact words and names). Semantic search is turned off in Nexus settings, so semantic=true also runs keyword search.', + unsupported: 'Search vault files by keyword, with fuzzy filename matching (best for exact words and names). Semantic search is not available on this device, so semantic=true also runs keyword search.' +}; + +/** The `semantic` property description for each state. The property itself always stays, so `--semantic` never becomes an unknown flag. */ +const SEMANTIC_PARAM_DESCRIPTION: Record = { + available: 'true = AI-powered conceptual search (desktop only, best for concepts/related ideas). false = keyword/fuzzy search (default, best for exact matches).', + off: 'Unavailable: semantic search is turned off in Nexus settings. true runs keyword search instead.', + unsupported: 'Unavailable on this device. true runs keyword search instead.' +}; + +/** The `note` returned when semantic=true fell back to keyword search. */ +export const SEMANTIC_FALLBACK_NOTES: Record, string> = { + off: 'Semantic search is turned off in Nexus settings, so this used keyword search.', + unsupported: 'Semantic search is not available on this device, so this used keyword search.' +}; + /** Score for a verbatim occurrence of the whole query. */ const EXACT_PHRASE_SCORE = 0.9; /** @@ -220,15 +248,43 @@ function scoreTextMatch( */ export class SearchContentTool extends BaseTool { private plugin: Plugin; + private readonly getSemanticState: () => SemanticSearchState; - constructor(plugin: Plugin) { - super( - 'content', - 'Content Search', - 'Search vault files. Set semantic=true for AI-powered conceptual search using local embeddings (best for concepts/related ideas), or semantic=false for keyword/fuzzy search (best for exact matches). Semantic search is desktop-only and becomes available once the embedding system initializes in the background (first run may take longer while the model downloads).', - '2.0.0' - ); + /** + * @param plugin The Nexus plugin (or a minimal stand-in carrying `app`). + * @param getSemanticState Reports whether semantic search can be offered. + * Defaults to reading the plugin's settings and platform. + */ + constructor(plugin: Plugin, getSemanticState?: () => SemanticSearchState) { + super('content', 'Content Search', DESCRIPTION_AVAILABLE, '2.0.0'); this.plugin = plugin; + this.getSemanticState = getSemanticState ?? (() => resolveSemanticSearchState(plugin)); + + // The description follows the semantic search setting, which can change + // at any time (settings tab, or synced from another device). BaseTool + // declares `description` as a field, so it is turned into an accessor + // here: discovery reads it directly, sometimes without calling + // getParameterSchema(), and it must never advertise a turned-off feature. + Object.defineProperty(this, 'description', { + get: (): string => this.describe(), + set: (): void => undefined, + enumerable: true, + configurable: true + }); + } + + /** Current semantic state; a failing provider reads as available. */ + private semanticState(): SemanticSearchState { + try { + return this.getSemanticState(); + } catch { + return 'available'; + } + } + + private describe(): string { + const state = this.semanticState(); + return state === 'available' ? DESCRIPTION_AVAILABLE : DESCRIPTION_KEYWORD_ONLY[state]; } getStatusLabel(params: Record | undefined, tense: ToolStatusTense): string | undefined { @@ -264,6 +320,15 @@ export class SearchContentTool extends BaseTool }).data ?? {}; + return this.prepareResult(true, { ...data, note }); + } + /** * Perform semantic (vector) search using embeddings */ @@ -287,7 +358,9 @@ export class SearchContentTool extends BaseTool; + note?: string; +} + +function createHarness(options: { enableEmbeddings?: boolean; managerReady?: boolean } = {}) { + const files = [ + new TFile('auth.md', 'Ideas/auth.md'), + new TFile('cooking.md', 'Life/cooking.md') + ]; + const bodies: Record = { + 'Ideas/auth.md': 'Notes on the authentication flow and tokens.', + 'Life/cooking.md': 'Pasta, mostly.' + }; + const service = { + isServiceEnabled: jest.fn(() => true), + getStats: jest.fn(async () => ({ noteCount: 2 })), + semanticSearch: jest.fn(async () => [{ notePath: 'Ideas/auth.md', distance: 0.1, score: 0.1 }]) + }; + const manager = { + isEmbeddingEnabled: () => options.managerReady ?? false, + getService: () => (options.managerReady ? service : null) + }; + + const settings: Record = { memory: {} }; + if (options.enableEmbeddings !== undefined) settings.enableEmbeddings = options.enableEmbeddings; + const plugin: Record = { + embeddingManager: manager, + getServiceContainer: () => ({ getServiceIfReady: () => null }), + settings: { settings } + }; + const app = { + plugins: { getPlugin: (id: string) => (id === PLUGIN_IDS.current ? plugin : null) }, + vault: { + getMarkdownFiles: () => files, + getAbstractFileByPath: (path: string) => files.find(file => file.path === path) ?? null, + read: async (file: TFile) => bodies[file.path] ?? '', + cachedRead: async (file: TFile) => bodies[file.path] ?? '' + }, + metadataCache: { getFileCache: () => null } + }; + plugin.app = app; + + const agent = new SearchManagerAgent(app as never, false, {} as never, {} as never); + const normalizer = new ToolCliNormalizer(new Map([['searchManager', agent as unknown as IAgent]])); + const tool = () => { + const found = agent.getTool('content'); + if (!found) throw new Error('content tool missing'); + return found; + }; + const run = async (semantic: boolean) => agent.executeTool('content', { + query: 'authentication flow', + semantic, + context: CONTEXT + }) as Promise<{ success: boolean; error?: string; data?: SearchData }>; + + return { agent, normalizer, tool, run, service, settings }; +} + +describe('search content with semantic search turned off', () => { + it('drops the semantic pitch from discovery, compact and full', () => { + const h = createHarness({ enableEmbeddings: false }); + + const compact = h.normalizer.buildCliSchema('searchManager', h.tool(), { compact: true }); + const full = h.normalizer.buildCliSchema('searchManager', h.tool()); + + expect(compact.description).not.toContain(SEMANTIC_PITCH); + expect(full.description).not.toContain(SEMANTIC_PITCH); + expect(full.description).toMatch(/turned off in Nexus settings/); + const semanticArg = full.arguments?.find(arg => arg.name === 'semantic'); + expect(semanticArg?.description).toMatch(/Unavailable: semantic search is turned off in Nexus settings/); + }); + + it('still parses --semantic, because unknown flags are rejected', () => { + const h = createHarness({ enableEmbeddings: false }); + + const calls = h.normalizer.normalizeExecutionCalls({ + ...CONTEXT, + tool: 'search content "authentication flow" --semantic' + } as never); + + expect(calls).toHaveLength(1); + expect(calls[0].params).toMatchObject({ query: 'authentication flow', semantic: true }); + expect(() => h.normalizer.normalizeExecutionCalls({ + ...CONTEXT, + tool: 'search content "x" --made-up-flag' + } as never)).toThrow(/Unknown flag/); + }); + + it('answers semantic=true with keyword results and a note, not an error', async () => { + const h = createHarness({ enableEmbeddings: false, managerReady: false }); + + const result = await h.run(true); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.data?.note).toBe(SEMANTIC_FALLBACK_NOTES.off); + expect(result.data?.results.map(r => r.filePath)).toContain('Ideas/auth.md'); + expect(result.data?.results.every(r => r.matchType !== 'semantic')).toBe(true); + expect(h.service.semanticSearch).not.toHaveBeenCalled(); + }); + + it('adds no note to an ordinary keyword search', async () => { + const h = createHarness({ enableEmbeddings: false }); + + const result = await h.run(false); + + expect(result.success).toBe(true); + expect(result.data?.note).toBeUndefined(); + }); + + it('follows the setting live: turning it back on restores the pitch without a new agent', () => { + const h = createHarness({ enableEmbeddings: false }); + expect(h.tool().description).not.toContain(SEMANTIC_PITCH); + + h.settings.enableEmbeddings = true; + + expect(h.tool().description).toContain(SEMANTIC_PITCH); + }); +}); + +describe('search content with semantic search turned on', () => { + it('keeps the committed description and says "still initializing" before the engine is up', async () => { + const h = createHarness({ enableEmbeddings: true, managerReady: false }); + + expect(h.tool().description).toContain(SEMANTIC_PITCH); + const result = await h.run(true); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/still initializing/); + }); + + it('runs semantic search once the engine is up, with no note', async () => { + const h = createHarness({ enableEmbeddings: true, managerReady: true }); + + const result = await h.run(true); + + expect(result.success).toBe(true); + expect(result.data?.note).toBeUndefined(); + expect(h.service.semanticSearch).toHaveBeenCalledTimes(1); + }); + + it('treats an absent setting as on (the default)', () => { + const h = createHarness({}); + expect(h.tool().description).toContain(SEMANTIC_PITCH); + }); +}); From 7cf208bf12bac671024480b4c350aed9a94d2553 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:13:58 -0400 Subject: [PATCH 21/65] test(embeddings): shut down managers left running by phase-order tests Their deferred 3s indexing timer stayed open after the test ended. Co-Authored-By: Claude Opus 5.5 --- tests/unit/EmbeddingManagerPhaseOrder.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/EmbeddingManagerPhaseOrder.test.ts b/tests/unit/EmbeddingManagerPhaseOrder.test.ts index 71dc8113..80e553f7 100644 --- a/tests/unit/EmbeddingManagerPhaseOrder.test.ts +++ b/tests/unit/EmbeddingManagerPhaseOrder.test.ts @@ -108,6 +108,7 @@ describe('EmbeddingManager background indexing order', () => { expect(calls.filter(call => !call.startsWith('prune-'))[0]).toBe('conversations'); expect(calls).toContain('traces'); expect(calls).toContain('notes'); + await manager.shutdown(); }); it('cancels the in-flight walk so the re-index is not swallowed by isRunning', async () => { @@ -123,5 +124,6 @@ describe('EmbeddingManager background indexing order', () => { // rebuild would silently leave conversation embeddings at zero. expect(cancel).toHaveBeenCalled(); expect(calls.filter(call => !call.startsWith('prune-'))[0]).toBe('conversations'); + await manager.shutdown(); }); }); From 0c798b674ab5a2bd4f6837bd5da90edf62ed2ec1 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:35:27 -0400 Subject: [PATCH 22/65] test(mocks): element tree for settings layout assertions The obsidian mock's Setting appended nothing and dropped its name, so no test could observe row order, descriptions, disabled state or whether a redraw replaced a node. FakeElement keeps an ordered tree, tracks isConnected, dispatches registered events and answers simple selectors. Given a FakeElement container, Setting renders Obsidian's markup and the Dropdown, Toggle, Slider, Button, ExtraButton and ProgressBar mocks render real-looking controls that react to change/click events. The Slider mock reproduces the native span.slider-value that Obsidian 1.13+ draws, with setDisplayFormat. SettingGroup and Modal.setTitle are added. Every other container keeps the old call-recording mock unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/helpers/settingsDom.ts | 106 ++++++++ tests/mocks/obsidian/components.ts | 381 ++++++++++++++++++++++---- tests/mocks/obsidian/dom.ts | 424 +++++++++++++++++++++++++++++ tests/mocks/obsidian/index.ts | 6 + tests/mocks/obsidian/views.ts | 5 + 5 files changed, 874 insertions(+), 48 deletions(-) create mode 100644 tests/helpers/settingsDom.ts create mode 100644 tests/mocks/obsidian/dom.ts diff --git a/tests/helpers/settingsDom.ts b/tests/helpers/settingsDom.ts new file mode 100644 index 00000000..6c4d7f8d --- /dev/null +++ b/tests/helpers/settingsDom.ts @@ -0,0 +1,106 @@ +/** + * Helpers for reading the settings layout out of a FakeElement tree + * (tests/mocks/obsidian/dom.ts). Everything here reads the rendered markup — + * class names, attributes and text — never renderer internals. + */ +import { FakeElement } from '../mocks/obsidian/dom'; + +/** Direct `.setting-item` rows of a `setting-items` list, in order. */ +export function rowsOf(itemsEl: FakeElement): FakeElement[] { + return itemsEl.children.filter(child => child.hasClass('setting-item')); +} + +export function rowName(row: FakeElement): string { + return row.querySelector('.setting-item-name')?.textContent ?? ''; +} + +export function rowDesc(row: FakeElement): string { + return row.querySelector('.setting-item-description')?.textContent ?? ''; +} + +export function rowNames(itemsEl: FakeElement): string[] { + return rowsOf(itemsEl).map(rowName); +} + +/** The heading-less top SettingGroup's list. */ +export function topItems(container: FakeElement): FakeElement { + const group = container.children.find(child => child.hasClass('setting-group')); + const items = group?.querySelector('.setting-items'); + if (!items) throw new Error('No top setting group'); + return items; +} + +export function accordions(container: FakeElement): FakeElement[] { + return container.children.filter(child => child.hasClass('nexus-accordion')); +} + +export function foldTitles(container: FakeElement): string[] { + return accordions(container).map(fold => fold.querySelector('.nexus-accordion-title')?.textContent ?? ''); +} + +export function fold(container: FakeElement, title: string): FakeElement { + const match = accordions(container).find(item => item.querySelector('.nexus-accordion-title')?.textContent === title); + if (!match) throw new Error(`No fold titled ${title}; have ${foldTitles(container).join(', ')}`); + return match; +} + +export function foldItems(container: FakeElement, title: string): FakeElement { + const items = fold(container, title).querySelector('.nexus-accordion-content .setting-items'); + if (!items) throw new Error(`Fold ${title} has no setting-items`); + return items; +} + +export function foldSummary(container: FakeElement, title: string): string { + return fold(container, title).querySelector('.nexus-accordion-summary')?.textContent ?? ''; +} + +export function foldToggle(container: FakeElement, title: string): FakeElement { + const toggle = fold(container, title).querySelector('.nexus-accordion-toggle'); + if (!toggle) throw new Error(`Fold ${title} has no toggle`); + return toggle; +} + +export function isFoldOpen(container: FakeElement, title: string): boolean { + return fold(container, title).hasClass('is-open'); +} + +export function findRow(itemsEl: FakeElement, name: string): FakeElement { + const row = rowsOf(itemsEl).find(item => rowName(item) === name); + if (!row) throw new Error(`No row named ${name}; have ${rowNames(itemsEl).join(', ')}`); + return row; +} + +/** An app stand-in with vault-scoped local storage, as FoldStateStore uses. */ +export function localStorageApp(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + return { + vault: {}, + workspace: {}, + store, + loadLocalStorage: jest.fn((key: string) => (store.has(key) ? JSON.parse(JSON.stringify(store.get(key))) : null)), + saveLocalStorage: jest.fn((key: string, value: unknown) => { store.set(key, JSON.parse(JSON.stringify(value))); }) + }; +} + +export const flush = (): Promise => new Promise(resolve => setImmediate(resolve)); + +/** The control element of a row's first dropdown/toggle/slider/button. */ +export function control(row: FakeElement, selector: 'select' | '.checkbox-container' | 'input.slider' | 'button' | '.extra-setting-button'): FakeElement { + const el = row.querySelector(`.setting-item-control ${selector}`) ?? row.querySelector(selector); + if (!el) throw new Error(`Row ${rowName(row)} has no ${selector}`); + return el; +} + +/** Pick a dropdown value as the user would (a 'change' event). */ +export function choose(select: FakeElement, value: string): void { + select.value = value; + select.dispatch('change'); +} + +export function optionValues(select: FakeElement): string[] { + return select.children.filter(child => child.tagName === 'OPTION').map(option => option.value); +} + +export function optionLabels(select: FakeElement): string[] { + return select.children.filter(child => child.tagName === 'OPTION').map(option => option.textContent); +} diff --git a/tests/mocks/obsidian/components.ts b/tests/mocks/obsidian/components.ts index bf848b84..df875b27 100644 --- a/tests/mocks/obsidian/components.ts +++ b/tests/mocks/obsidian/components.ts @@ -3,26 +3,61 @@ */ import { createMockElement } from './core'; +import { FakeElement, isFakeElement } from './dom'; -// Setting mock +// Element factory: a FakeElement subtree when the caller renders into a +// FakeElement (tests that observe structure), the call-recording mock otherwise. +function childOf(containerEl: unknown, tag: string, info?: Record | string): HTMLElement { + if (isFakeElement(containerEl)) { + return containerEl.createEl(tag, info as never) as unknown as HTMLElement; + } + return createMockElement(tag); +} + +function asFake(el: unknown): FakeElement | null { + return isFakeElement(el) ? el : null; +} + +// Setting mock. Given a FakeElement container it renders Obsidian's markup +// (setting-item > setting-item-info > name/description, setting-item-control) +// in place, so order, names, descriptions and disabled state can be asserted. export class Setting { settingEl: HTMLElement; + infoEl: HTMLElement; + nameEl: HTMLElement; + descEl: HTMLElement; controlEl: HTMLElement; + components: unknown[] = []; + /** Last value passed to setName/setDesc (strings only), for assertions. */ + name = ''; + desc = ''; - constructor(_containerEl: HTMLElement) { - this.settingEl = createMockElement('div'); - this.controlEl = createMockElement('div'); + constructor(containerEl: HTMLElement) { + this.settingEl = childOf(containerEl, 'div', 'setting-item'); + this.infoEl = childOf(this.settingEl, 'div', 'setting-item-info'); + this.nameEl = childOf(this.infoEl, 'div', 'setting-item-name'); + this.descEl = childOf(this.infoEl, 'div', 'setting-item-description'); + this.controlEl = childOf(this.settingEl, 'div', 'setting-item-control'); } - setClass(_cls: string): this { + setClass(cls: string): this { + asFake(this.settingEl)?.addClass(cls); return this; } - setName(_name: string): this { + setName(name: string | DocumentFragment): this { + if (typeof name === 'string') { + this.name = name; + asFake(this.nameEl)?.setText(name); + } return this; } - setDesc(_desc: string): this { + setDesc(desc: string | DocumentFragment): this { + if (typeof desc === 'string') { + this.desc = desc; + asFake(this.descEl)?.setText(desc); + } return this; } @@ -30,33 +65,82 @@ export class Setting { return this; } - addText(callback: (text: TextComponent) => void): this { - callback(new TextComponent(this.settingEl)); + setHeading(): this { + asFake(this.settingEl)?.addClass('setting-item-heading'); return this; } - addTextArea(callback: (textarea: TextAreaComponent) => void): this { - callback(new TextAreaComponent(this.settingEl)); + setDisabled(disabled: boolean): this { + asFake(this.settingEl)?.toggleClass('is-disabled', disabled); return this; } - addDropdown(callback: (dropdown: DropdownComponent) => void): this { - callback(new DropdownComponent(this.settingEl)); + then(callback: (setting: this) => unknown): this { + callback(this); return this; } - addToggle(callback: (toggle: ToggleComponent) => void): this { - callback(new ToggleComponent(this.settingEl)); + private add(component: T, callback: (component: T) => unknown): this { + this.components.push(component); + callback(component); return this; } + addText(callback: (text: TextComponent) => void): this { + return this.add(new TextComponent(this.controlEl), callback); + } + + addTextArea(callback: (textarea: TextAreaComponent) => void): this { + return this.add(new TextAreaComponent(this.controlEl), callback); + } + + addDropdown(callback: (dropdown: DropdownComponent) => void): this { + return this.add(new DropdownComponent(this.controlEl), callback); + } + + addToggle(callback: (toggle: ToggleComponent) => void): this { + return this.add(new ToggleComponent(this.controlEl), callback); + } + addButton(callback: (button: ButtonComponent) => void): this { - callback(new ButtonComponent(this.settingEl)); - return this; + return this.add(new ButtonComponent(this.controlEl), callback); + } + + addExtraButton(callback: (button: ExtraButtonComponent) => void): this { + return this.add(new ExtraButtonComponent(this.controlEl), callback); } addSlider(callback: (slider: SliderComponent) => void): this { - callback(new SliderComponent(this.settingEl)); + return this.add(new SliderComponent(this.controlEl), callback); + } + + addProgressBar(callback: (bar: ProgressBarComponent) => void): this { + return this.add(new ProgressBarComponent(this.controlEl), callback); + } +} + +// SettingGroup mock (Obsidian 1.11+): setting-group > setting-items, with the +// internal groupEl/listEl fields the real class carries. +export class SettingGroup { + groupEl: HTMLElement; + listEl: HTMLElement; + + constructor(containerEl: HTMLElement) { + this.groupEl = childOf(containerEl, 'div', 'setting-group'); + this.listEl = childOf(this.groupEl, 'div', 'setting-items'); + } + + setHeading(_text: string | DocumentFragment): this { + return this; + } + + addClass(cls: string): this { + asFake(this.groupEl)?.addClass(cls); + return this; + } + + addSetting(callback: (setting: Setting) => void): this { + callback(new Setting(this.listEl)); return this; } } @@ -67,8 +151,8 @@ export class TextComponent { private value = ''; private changeCallback?: (value: string) => void; - constructor(_containerEl: HTMLElement) { - this.inputEl = createMockElement('input') as HTMLInputElement; + constructor(containerEl: HTMLElement) { + this.inputEl = childOf(containerEl, 'input', { type: 'text' }) as HTMLInputElement; } setPlaceholder(_placeholder: string): this { @@ -77,6 +161,8 @@ export class TextComponent { setValue(value: string): this { this.value = value; + const fake = asFake(this.inputEl); + if (fake) fake.value = value; return this; } @@ -84,6 +170,10 @@ export class TextComponent { return this.value; } + setDisabled(_disabled: boolean): this { + return this; + } + onChange(callback: (value: string) => void): this { this.changeCallback = callback; return this; @@ -101,8 +191,8 @@ export class TextAreaComponent { inputEl: HTMLTextAreaElement; private value = ''; - constructor(_containerEl: HTMLElement) { - this.inputEl = createMockElement('textarea') as HTMLTextAreaElement; + constructor(containerEl: HTMLElement) { + this.inputEl = childOf(containerEl, 'textarea') as HTMLTextAreaElement; } setPlaceholder(_placeholder: string): this { @@ -123,44 +213,80 @@ export class TextAreaComponent { } } -// DropdownComponent mock +// DropdownComponent mock. With a FakeElement container, options are real +//