From b0bc85eff0794762b4b29e2e2618a240538f11a9 Mon Sep 17 00:00:00 2001
From: WiseGuru <42100212+WiseGuru@users.noreply.github.com>
Date: Mon, 1 Jun 2026 18:21:25 -0700
Subject: [PATCH] Update max token suggestions
---
CLAUDE.md | 1 +
src/llm/anthropic.ts | 2 +-
src/settings/index.ts | 2 +-
src/settings/tab.ts | 57 +++++++++++++++++++++++++++++++++++++++++--
styles.css | 13 ++++++++++
5 files changed, 71 insertions(+), 4 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 59c1044..ae485e5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -261,6 +261,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma
- **`safeStorage` is lazy-required inside a `Platform.isDesktop` guard** in [src/secrets.ts](src/secrets.ts). Importing `electron` at module top would crash on mobile load (it's marked `external` in esbuild). Any failure is treated as "encryption unavailable", which is also the mobile path and the Linux-without-keyring path. The settings tab surfaces the active backend via `safeStorage.getSelectedStorageBackend()` so users can see why it failed (e.g. `basic_text` is Chromium's last-resort backend and counts as unencrypted).
- **`saveManyKeys` is a silent no-op when locked.** When `mode === 'passphrase'` and `unlockedKey === null`, `saveManyKeys` does nothing. This is deliberate: unrelated `plugin.saveSettings()` calls (e.g. user changes a model dropdown) would otherwise persist empty `apiKey` values for every profile, wiping the on-disk encrypted bag. The UI prevents this by disabling key fields and gating all pipeline entry points on `encryptionStatus.locked`. `saveKey` (single-key write) still throws so callers can react.
- **Keep zxcvbn lazy and the strength API async.** [src/passphrase-strength.ts](src/passphrase-strength.ts) pulls `@zxcvbn-ts/*` via dynamic `import()` specifically so the ~1.6 MB of dictionaries are not parsed/constructed at plugin load (measured: ~9 ms startup vs ~47 ms with a static import). Do not "simplify" it back to a top-level static import or a synchronous `evaluatePassphrase` — that re-adds the cost to every Obsidian launch on every device. The async ripples (modal `updateStrength` race guard, `await isPassphraseAcceptable` in `changeEncryptionMode`) are intentional. `warmPassphraseStrength()` is fired on passphrase-modal open to hide the one-time build behind the modal animation.
+- **`maxTokens` has two settings-tab views over one value.** `LLMConfig.maxTokens` (per profile, default `2560`) is the single source of truth. The normal-area "Maximum note length" dropdown (`renderNoteLength` in [src/settings/tab.ts](src/settings/tab.ts)) frames it in minutes via `NOTE_LENGTH_PRESETS` at `TOKENS_PER_MIN = 256` (≈150 wpm × ~1.3 tokens/word, padded ~20% for headings/bullets/`Speaker X:` labels; ~10 min → 2560). The Advanced "LLM max tokens" text field edits the same number raw; a value not matching a preset surfaces in the dropdown as a "Custom (N tokens, ~M min)" option. The dropdown calls `this.display()` on change so Advanced reflects the new number; the raw text field does not redraw (focus preservation), so the dropdown updates on the next full render. The Anthropic adapter's `config.maxTokens > 0 ? ... : 2560` fallback ([src/llm/anthropic.ts](src/llm/anthropic.ts)) must stay in sync with the default. The cap is on **output** tokens, so it bounds note length, not input.
- **No baked-in model defaults.** Both profiles ship with `model: ""`. The modal renders an inline setup card that blocks recording/paste until the active profile has a provider, model, key, and (for `openai-compatible`) base URL. If you add a provider, do not bake a default model string; surface it as placeholder hint text.
- **`openai-compatible` base URL asymmetry** (literal interpretation of the spec): transcription appends `/v1/audio/transcriptions` to a *root* URL (`http://localhost:8080`); LLM appends `/chat/completions` to a URL that *already includes* `/v1` (`http://localhost:11434/v1`). The settings UI hint text and setup card both guide users; do not "normalize" one to match the other.
- **`setHeading()` instead of manual `
`** inside settings tabs. `obsidianmd/settings-tab/no-manual-html-headings` forbids manual headings. Same applies anywhere else inside a settings tab that needs a section header. In [src/settings/tab.ts](src/settings/tab.ts), section headings go through the `sectionHeading(parent, name, icon)` helper, which builds the `setHeading()` Setting and prepends a Lucide icon (via `setIcon`) to its `nameEl` styled by `.rewrite-heading-icon`. It returns the `Setting` so callers that attach a status badge (profile, shared core) still reach `nameEl`. Add new headings via this helper, not a bare `new Setting(...).setHeading()`, so they keep an icon.
diff --git a/src/llm/anthropic.ts b/src/llm/anthropic.ts
index 74614ec..eeac218 100644
--- a/src/llm/anthropic.ts
+++ b/src/llm/anthropic.ts
@@ -29,7 +29,7 @@ export function createAnthropicLLM(): LLMProvider {
if (!config.model) throw new Error('anthropic: model is not configured');
const body = {
model: config.model,
- max_tokens: config.maxTokens > 0 ? config.maxTokens : 2048,
+ max_tokens: config.maxTokens > 0 ? config.maxTokens : 2560,
system: systemPrompt,
messages: [{ role: 'user', content: userMessage }],
};
diff --git a/src/settings/index.ts b/src/settings/index.ts
index 4849120..3c3ea29 100644
--- a/src/settings/index.ts
+++ b/src/settings/index.ts
@@ -20,7 +20,7 @@ const EMPTY_LLM_CONFIG: LLMConfig = {
apiKey: '',
baseUrl: '',
model: '',
- maxTokens: 2048,
+ maxTokens: 2560,
};
const DESKTOP_DEFAULT_PROFILE: EnvironmentProfile = {
diff --git a/src/settings/tab.ts b/src/settings/tab.ts
index a07e028..204fa01 100644
--- a/src/settings/tab.ts
+++ b/src/settings/tab.ts
@@ -27,6 +27,19 @@ import { PassphraseModal } from '../ui/passphrase-modal';
// Sentinel value for the "Custom..." entry in a model dropdown; never written to config.model.
const CUSTOM_MODEL_OPTION = '__rewrite_custom__';
+// Cleanup output runs ~256 tokens per minute of speech (≈150 wpm × ~1.3 tokens/word,
+// padded ~20% for headings, bullets, and Speaker labels in structured/diarized notes).
+// The "Maximum note length" dropdown maps these minute presets onto config.maxTokens;
+// the raw token count is editable in Advanced. ~10 min → 2560 is the default.
+const TOKENS_PER_MIN = 256;
+const NOTE_LENGTH_PRESETS: Array<{ minutes: number; tokens: number }> = [
+ { minutes: 5, tokens: 1280 },
+ { minutes: 10, tokens: 2560 },
+ { minutes: 20, tokens: 5120 },
+ { minutes: 30, tokens: 7680 },
+ { minutes: 60, tokens: 15360 },
+];
+
const TRANSCRIPTION_OPTIONS: Array<{ id: TranscriptionProviderID; label: string; desktopOnly?: boolean }> = [
{ id: 'openai', label: 'OpenAI Whisper' },
{ id: 'openai-compatible', label: 'OpenAI-compatible (local server)' },
@@ -468,11 +481,48 @@ export class ReWriteSettingTab extends PluginSettingTab {
await this.commit();
});
});
+
+ this.renderNoteLength(body, profile);
}
this.renderProfileAdvanced(body, profile);
}
+ // Normal-area control for cleanup output length, framed in minutes rather than raw
+ // tokens. Presets map onto config.maxTokens (the single source of truth); a non-preset
+ // value (set via the Advanced "LLM max tokens" field) surfaces as a "Custom (...)" option.
+ private renderNoteLength(parent: HTMLElement, profile: EnvironmentProfile): void {
+ const current = profile.llmConfig.maxTokens;
+ const matched = NOTE_LENGTH_PRESETS.some((p) => p.tokens === current);
+
+ const setting = new Setting(parent)
+ .setName('Maximum note length')
+ .setDesc(
+ 'How long a recording the cleaned note can hold before the response is cut off. ' +
+ 'Roughly 10 min of speech ≈ 2,560 tokens; structured or multi-speaker notes run longer. ' +
+ 'For an exact token count, use LLM max tokens under Advanced.',
+ )
+ .addDropdown((d) => {
+ for (const p of NOTE_LENGTH_PRESETS) {
+ d.addOption(String(p.tokens), `~${p.minutes} min (${p.tokens.toLocaleString()} tokens)`);
+ }
+ if (!matched) {
+ const mins = Math.max(1, Math.round(current / TOKENS_PER_MIN));
+ d.addOption(String(current), `Custom (${current.toLocaleString()} tokens, ~${mins} min)`);
+ }
+ d.setValue(String(current));
+ d.onChange(async (v) => {
+ const n = Number.parseInt(v, 10);
+ if (!Number.isFinite(n) || n <= 0) return;
+ profile.llmConfig.maxTokens = n;
+ await this.commit();
+ // Re-render so the Advanced raw-token field reflects the new value.
+ this.display();
+ });
+ });
+ setting.settingEl.addClass('rewrite-note-length');
+ }
+
private renderProfileAdvanced(parent: HTMLElement, profile: EnvironmentProfile): void {
if (profile.transcriptionProvider === 'none' && profile.llmProvider === 'none') return;
@@ -495,13 +545,16 @@ export class ReWriteSettingTab extends PluginSettingTab {
if (profile.llmProvider !== 'none') {
new Setting(details)
.setName('LLM max tokens')
- .setDesc('Maximum tokens for the cleanup response. Default 2048.')
+ .setDesc(
+ 'Exact token cap for the cleanup response, overriding the Maximum note length presets. ' +
+ '~256 tokens ≈ 1 min of cleaned speech. Default 2560.',
+ )
.addText((t) => {
t.inputEl.type = 'number';
t.setValue(String(profile.llmConfig.maxTokens));
t.onChange(async (v) => {
const n = Number.parseInt(v, 10);
- profile.llmConfig.maxTokens = Number.isFinite(n) && n > 0 ? n : 2048;
+ profile.llmConfig.maxTokens = Number.isFinite(n) && n > 0 ? n : 2560;
await this.commit();
});
});
diff --git a/styles.css b/styles.css
index 32d5569..f6b5398 100644
--- a/styles.css
+++ b/styles.css
@@ -200,6 +200,19 @@
margin-left: -15px;
}
+/* Give "Maximum note length" first-class emphasis vs the plainer fields around it. */
+.rewrite-settings .rewrite-note-length {
+ margin-top: 8px;
+ padding: 6px 12px;
+ border-left: 3px solid var(--interactive-accent);
+ background-color: var(--background-secondary);
+ border-radius: 4px;
+}
+
+.rewrite-settings .rewrite-note-length .setting-item-name {
+ font-weight: var(--font-semibold, 600);
+}
+
.rewrite-settings .rewrite-profile-active-badge {
display: inline-block;
background-color: var(--interactive-accent);