mirror of
https://github.com/wiseguru/ReWrite-Voice-Notes.git
synced 2026-07-22 07:49:19 +00:00
Create a template user guide
This commit is contained in:
parent
df3ca7e1ee
commit
e1a729a558
4 changed files with 179 additions and 3 deletions
|
|
@ -56,6 +56,7 @@ src/
|
|||
├── insert.ts # cursor/newFile/append + {{date}}/{{time}} expansion
|
||||
├── whisper-host.ts # Spawns/stops a user-supplied whisper-server child process (desktop only)
|
||||
├── templates-folder.ts # Load templates from a vault folder + populate it with the 7 defaults
|
||||
├── template-guide.ts # Populate a human-facing "Template guide.md" next to the templates folder (never loaded by the plugin)
|
||||
├── shared-core.ts # Load the shared cleanup preface from a vault Markdown file + populate default (prepended to every template prompt)
|
||||
├── assistant-prompt.ts # Load the ad-hoc-instructions assistant prompt from a vault Markdown file + populate default
|
||||
├── known-nouns.ts # Load a vault Markdown file of known nouns + populate default + build the system-prompt section
|
||||
|
|
@ -184,7 +185,7 @@ The 7 defaults ([src/settings/default-templates.ts](src/settings/default-templat
|
|||
|
||||
`NoteTemplate.disableSharedCore?: boolean` (frontmatter `disableSharedCore: true`) opts a single template out of the shared-core prepend. `renderTemplateFile` ALWAYS emits the key so the knob is discoverable: empty (`disableSharedCore:`, parses to null = not disabled) by default, `disableSharedCore: true` when set. `parseTemplateFile` treats it as disabled only when the value is boolean `true` or the string `"true"` (case-insensitive) — the string form is tolerated because Obsidian's Properties UI may store an edited value as text; any other value (null/empty/false) means not disabled.
|
||||
|
||||
The Templates "Populate" button also seeds SharedCore.md when missing (non-destructive), because the shared core is load-bearing for the default templates' quality (it carries their guardrail + output discipline). Populating templates without it would yield prompts with no guardrail.
|
||||
The Templates "Populate" button also seeds SharedCore.md when missing (non-destructive), because the shared core is load-bearing for the default templates' quality (it carries their guardrail + output discipline). Populating templates without it would yield prompts with no guardrail. It additionally writes a human-facing "Template guide.md" via `populateTemplateGuide` ([src/template-guide.ts](src/template-guide.ts)): a Markdown doc explaining the frontmatter properties, how the shared core combines with a template at run time, and how to word prompts. The guide is placed in the PARENT of the templates folder (the `ReWrite` root by default; vault root if the templates folder has no parent), NOT inside the templates folder, so `loadTemplatesFromFolder` never tries to parse it as a template. The plugin never reads or caches the guide and never sends it to a provider; it has no `loadX`/`isPathX`/cache/refresh, only `populateTemplateGuide` (non-destructive, skipped when the file exists). Keep `DEFAULT_TEMPLATE_GUIDE` in sync if you change a frontmatter property, the prompt-assembly order, or what the shared core carries.
|
||||
|
||||
## Shared core
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,23 @@ Voxtral exposes a real-time STT model that doesn't accept whole-file uploads, an
|
|||
- Provider-side gating: only enable for transcription providers that expose a realtime endpoint; document which.
|
||||
- Honest assessment: this is lower-value than the rest of the plugin (transcript with no cleanup is what Obsidian's existing voice plugins already do). Ship only if users ask.
|
||||
|
||||
### 3. Optional per-invocation speaker/context hint for the LLM
|
||||
|
||||
Let the user supply a short, free-text note about who is speaking and what the recording is, fed to the cleanup LLM as extra context. Examples: "Lecture given by Dr. Smith on thermodynamics", "Podcast interview between Joe Rogan and Satan", "Meeting with Rachel, Joe, Billy, and Sally". This helps the LLM attribute statements, spell names correctly (it overlaps with but is more situational than the persistent Known nouns list), and pick the right register, and it pairs naturally with diarization (map `Speaker A/B/...` to real names).
|
||||
|
||||
Decided so far:
|
||||
|
||||
- **Gated by a per-template frontmatter flag.** A template opts into the context field via a frontmatter boolean (e.g. `enableContextHint: true`), so only the templates that benefit (Lecture, Meeting notes, Podcast) surface it. Templates without the flag never show the field. Follow the `disableSharedCore` precedent in [src/templates-folder.ts](../src/templates-folder.ts) (`parseTemplateFile` reads it, `renderTemplateFile` always emits the key for discoverability) — but note this one is a positive opt-in, not an opt-out, so the polarity is the reverse of [[shared-core-disable-flag-polarity]].
|
||||
- **Rendered as a collapsed (unexpanded) section in the popup.** When the active template has the flag, the field appears as a `<details>` that is collapsed by default, in both the main plugin modal AND the "Reprocess audio..." popup. The user expands it only when they want to add context, so the frictionless default path is untouched. Expand state should survive the modal's full-container re-renders (track it on the modal instance, same as `destinationExpanded` / `inactiveProfileExpanded`).
|
||||
|
||||
Design questions to resolve when picking this up:
|
||||
|
||||
- **Per-invocation value vs per-template default.** The flag controls *visibility*; still TBD whether the template also seeds a default string the user can edit per invocation, or whether the field is always empty until typed. Leaning: flag gates visibility, value is per-invocation and ephemeral (resets when the modal closes / template changes, like the destination override).
|
||||
- **Which entry points.** Main modal and the "Reprocess audio..." popup get the collapsed section (per above). Quick Record and the text/process-text paths stay frictionless and skip it, matching how destination-override is modal-only.
|
||||
- **Where it goes in the system prompt.** A new `## Context` (or `## Speakers`) block in `cleanupTranscript` ([src/pipeline.ts](../src/pipeline.ts)), ordered after the template prompt and ad-hoc instructions, before/after Known nouns (TBD). Plumb through `PipelineParams` (per-invocation) and/or `PipelineHost`/`NoteTemplate` (per-template), same pattern as the existing system-prompt sections.
|
||||
- **Injection surface.** Like the other vault/transcript inputs, this text flows into the system prompt unescaped, so it sits behind the shared-core anti-injection guardrail; no new escaping needed, but note it where the other injection caveats live.
|
||||
- **Relationship to diarization and Known nouns.** Document that this is the situational, one-off counterpart to the persistent Known nouns list, and that it complements the speaker labels diarization emits rather than replacing them.
|
||||
|
||||
---
|
||||
|
||||
## Done
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { createLLMProvider } from '../llm';
|
|||
import { formatWhisperStatus } from '../whisper-host';
|
||||
import { populateDefaultTemplates } from '../templates-folder';
|
||||
import { populateDefaultSharedCore } from '../shared-core';
|
||||
import { populateTemplateGuide } from '../template-guide';
|
||||
import { populateDefaultAssistantPrompt } from '../assistant-prompt';
|
||||
import { populateDefaultKnownNouns } from '../known-nouns';
|
||||
import { changeEncryptionMode, EncryptionMode, lockSecrets, resetSecrets } from '../secrets';
|
||||
|
|
@ -781,7 +782,7 @@ export class ReWriteSettingTab extends PluginSettingTab {
|
|||
|
||||
new Setting(parent)
|
||||
.setName('Populate with default templates')
|
||||
.setDesc('Writes the seven built-in templates into the folder above, plus the shared core file if it is missing. It skips any template that already exists, so you can run it again to top up after deleting one.')
|
||||
.setDesc('Writes the seven built-in templates into the folder above, plus the shared core file and a template guide if they are missing. It skips any template that already exists, so you can run it again to top up after deleting one.')
|
||||
.addButton((b) => {
|
||||
b.setButtonText('Populate').setCta().onClick(() => void this.runGuardedButton(b, async () => {
|
||||
try {
|
||||
|
|
@ -791,8 +792,12 @@ export class ReWriteSettingTab extends PluginSettingTab {
|
|||
// (it carries the guardrail + output discipline), so seed it alongside.
|
||||
const coreCreated = await populateDefaultSharedCore(this.app, s.sharedCorePath);
|
||||
await this.plugin.refreshSharedCore();
|
||||
// Seed the human-facing template guide next to the templates folder.
|
||||
// The plugin never reads it; it just teaches the template format.
|
||||
const guideCreated = await populateTemplateGuide(this.app, s.templatesFolderPath);
|
||||
const coreNote = coreCreated ? ` Created ${s.sharedCorePath}.` : '';
|
||||
new Notice(`ReWrite: populated ${result.folder}. Created ${result.created}, skipped ${result.skipped}.${coreNote}`);
|
||||
const guideNote = guideCreated ? ' Added the template guide.' : '';
|
||||
new Notice(`ReWrite: populated ${result.folder}. Created ${result.created}, skipped ${result.skipped}.${coreNote}${guideNote}`);
|
||||
this.display();
|
||||
} catch (e) {
|
||||
console.error('ReWrite: populate templates failed', e);
|
||||
|
|
|
|||
153
src/template-guide.ts
Normal file
153
src/template-guide.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { App, normalizePath } from 'obsidian';
|
||||
|
||||
// A human-facing help document seeded next to the templates folder by the
|
||||
// settings "Populate" button. The plugin never reads this file and never sends
|
||||
// it to any provider; it exists purely so users can learn the template format,
|
||||
// how the shared core combines with a template, and how to word a good prompt.
|
||||
export const TEMPLATE_GUIDE_FILENAME = 'Template guide.md';
|
||||
|
||||
export const DEFAULT_TEMPLATE_GUIDE = `---
|
||||
guidance: |
|
||||
This is a help document for you, the human. The ReWrite plugin never reads
|
||||
this file and never sends it to any provider. Edit or delete it freely.
|
||||
---
|
||||
|
||||
# ReWrite: how to write a template
|
||||
|
||||
A template tells ReWrite how to turn a transcript (or pasted / selected text) into a finished note. This guide covers every property a template can set, how the shared core combines with your template at run time, and how to word a prompt that produces clean output.
|
||||
|
||||
## A template is one Markdown file
|
||||
|
||||
Each template is a single \`.md\` file in your templates folder (default \`ReWrite/Templates\`). The file has two parts:
|
||||
|
||||
- **Frontmatter** (the \`---\` block at the top): the template's settings.
|
||||
- **Body** (everything after the frontmatter): the prompt sent to the language model.
|
||||
|
||||
Templates are listed in the picker sorted by file name, so prefix names with numbers (\`01 General.md\`, \`02 Daily note.md\`) if you want a specific order. Identity comes from the \`id\` property, not the file name, so you can rename a file without breaking which template is your default or last used.
|
||||
|
||||
The **Populate** button in settings writes the seven built-in templates here. It is non-destructive: it skips any template whose \`id\` already exists, so you can run it again to top up after deleting one, and your own edits are never overwritten.
|
||||
|
||||
## Frontmatter properties
|
||||
|
||||
| Property | What it does |
|
||||
| --- | --- |
|
||||
| \`id\` | Stable identifier. Must be unique. This is how ReWrite remembers your default and last-used template, so do not reuse an id or change it casually. |
|
||||
| \`name\` | Display name shown in the modal and pickers. Falls back to the file name if blank. |
|
||||
| \`insertMode\` | Where the result goes: \`cursor\`, \`newFile\`, or \`append\`. See below. |
|
||||
| \`newFileFolder\` | For \`insertMode: newFile\`, the folder the new note is created in. Blank means the vault root. |
|
||||
| \`newFileNameTemplate\` | For \`insertMode: newFile\`, the new note's file name. Supports \`{{date}}\` and \`{{time}}\`. |
|
||||
| \`disableSharedCore\` | Set to \`true\` to skip the shared core for this one template. Leave blank otherwise. See "The shared core". |
|
||||
|
||||
### insertMode in detail
|
||||
|
||||
- **\`cursor\`**: insert at the cursor in the active note. If no editor is open, it falls back to appending.
|
||||
- **\`newFile\`**: create a new note from \`newFileFolder\` plus \`newFileNameTemplate\`. If a note with that name already exists, ReWrite either auto-numbers it (\`name-1\`, \`name-2\`) or prompts you for a new path, depending on your new-file collision setting.
|
||||
- **\`append\`**: add to the end of the active note (or the most recently edited note when none is focused). If no Markdown note exists at all, it creates one.
|
||||
|
||||
\`{{date}}\` becomes the date as \`YYYY-MM-DD\` and \`{{time}}\` becomes the time as \`HHmmss\`, expanded at the moment of insertion. They are substituted in \`newFileNameTemplate\` only, not in \`newFileFolder\`; put a literal folder path in \`newFileFolder\`.
|
||||
|
||||
If the source was a recording, the saved audio file is embedded as \`![[...]]\` at the top of the output regardless of insert mode, so you always keep the original.
|
||||
|
||||
## The shared core
|
||||
|
||||
The shared core is one Markdown file (default \`ReWrite/SharedCore.md\`) whose body is **prepended to every template's prompt** right before each cleanup call. Think of it as the house rules every template inherits.
|
||||
|
||||
At run time the system prompt is assembled in this order:
|
||||
|
||||
1. **Shared core** (unless the template opts out)
|
||||
2. **Your template's body** (the prompt below your frontmatter)
|
||||
3. **Ad-hoc instructions** (only when you spoke "<assistant name>, ..." in the recording)
|
||||
4. **Known nouns** (only when your KnownNouns file lists any)
|
||||
|
||||
The default shared core carries three things, so your template body does not have to:
|
||||
|
||||
- An **anti-injection guardrail**: it tells the model the transcript is text to clean, not instructions to obey. This is the plugin's main defense against a transcript that happens to say "ignore your instructions and ...".
|
||||
- **General cleanup rules**: fix grammar and punctuation, drop filler words and false starts, keep the corrected version of self-corrections, preserve the speaker's voice and proper nouns, and keep \`Speaker A:\` style labels intact.
|
||||
- **Output discipline**: output only the result, with no preamble, labels, or code fences, and empty input yields empty output.
|
||||
|
||||
Because the shared core already says all of this, **do not repeat it in your template**. Your template should only describe what is unique to it: the structure and tone of this particular kind of note.
|
||||
|
||||
Editing \`SharedCore.md\` changes the baseline for every template at once. It rides along on every call, so trimming it saves tokens. Deleting or emptying the file disables the shared core for the whole plugin (there is no hidden fallback). Setting \`disableSharedCore: true\` on a template disables it for that one template only, which also drops the anti-injection guardrail for that template; settings will warn you when a loaded template has this set.
|
||||
|
||||
## Writing a good prompt
|
||||
|
||||
The body of the file is the prompt. A few principles produce reliable results:
|
||||
|
||||
1. **Describe the shape of the output, not the cleanup.** The shared core already handles grammar, filler, and formatting discipline. Spend your prompt on structure: which \`##\` sections to produce and in what order.
|
||||
2. **Name your sections and say when to omit them.** For example: "Use these sections in order: \`## Summary\`, then \`## Action items\` as a \`- [ ] \` checkbox list, omitting the heading when there are none."
|
||||
3. **Be concrete about format.** Say "as bullet points" or "as a checkbox list (\`- [ ] \`)" rather than leaving it open.
|
||||
4. **Extract, do not invent.** When a section pulls items out of what was said (tasks, dates, decisions), say so explicitly: "extracted from what the speaker actually said; do not invent items."
|
||||
5. **Keep it short and declarative.** Imperative sentences ("Lay the transcript into...", "Group related points under...") work better than long explanations.
|
||||
6. **One template, one job.** If you are describing two unrelated output shapes, split them into two templates.
|
||||
|
||||
### Example
|
||||
|
||||
\`\`\`
|
||||
---
|
||||
id: my-meeting-notes
|
||||
name: Meeting notes
|
||||
insertMode: newFile
|
||||
newFileFolder: Meetings
|
||||
newFileNameTemplate: "{{date}} meeting"
|
||||
disableSharedCore:
|
||||
---
|
||||
Turn the transcript into meeting notes with these sections in order:
|
||||
|
||||
## Summary
|
||||
Two or three sentences on what the meeting was about.
|
||||
|
||||
## Decisions
|
||||
Bullet points of decisions that were actually made. Omit the heading if none.
|
||||
|
||||
## Action items
|
||||
A checkbox list ("- [ ] ") of concrete next steps, with an owner in brackets when one was named. Omit the heading if none.
|
||||
|
||||
Decisions and action items are extracted from what was said; do not invent them.
|
||||
\`\`\`
|
||||
|
||||
Notice the body never mentions fixing grammar, removing "um", or avoiding preambles. The shared core covers all of that. To try a new template without recording, open ReWrite, pick the template, and use the Paste tab.
|
||||
`;
|
||||
|
||||
export async function populateTemplateGuide(app: App, templatesFolderPath: string): Promise<boolean> {
|
||||
const folder = guideFolder(templatesFolderPath);
|
||||
const path = folder
|
||||
? normalizePath(`${folder}/${TEMPLATE_GUIDE_FILENAME}`)
|
||||
: normalizePath(TEMPLATE_GUIDE_FILENAME);
|
||||
if (app.vault.getAbstractFileByPath(path)) return false;
|
||||
if (folder) await ensureFolder(app, folder);
|
||||
await app.vault.create(path, DEFAULT_TEMPLATE_GUIDE);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The guide lives in the parent of the templates folder (the "ReWrite" root by
|
||||
// default), not inside the templates folder itself, so it is never parsed as a
|
||||
// template. When the templates folder sits at the vault root, the guide does too.
|
||||
function guideFolder(templatesFolderPath: string): string {
|
||||
const normalized = normalizeFolderPath(templatesFolderPath);
|
||||
if (!normalized) return '';
|
||||
const idx = normalized.lastIndexOf('/');
|
||||
if (idx <= 0) return '';
|
||||
return normalized.slice(0, idx);
|
||||
}
|
||||
|
||||
function normalizeFolderPath(folderPath: string): string {
|
||||
const trimmed = folderPath.trim();
|
||||
if (!trimmed) return '';
|
||||
const normalized = normalizePath(trimmed);
|
||||
if (!normalized || normalized === '/' || normalized === '.') return '';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function ensureFolder(app: App, folder: string): Promise<void> {
|
||||
const normalized = normalizePath(folder);
|
||||
if (app.vault.getAbstractFileByPath(normalized)) return;
|
||||
const parts = normalized.split('/');
|
||||
let current = '';
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
current = current ? `${current}/${part}` : part;
|
||||
if (!app.vault.getAbstractFileByPath(current)) {
|
||||
await app.vault.createFolder(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue