This plugin is built so another Obsidian plugin can use its code as a library. You might want an editor for a different kind of writing, with its own categories, its own coach and its own files. You don't fork it. You add this repo as a dependency, import its modules and call one function with your own inputs.
There is no plugin-to-plugin API and nothing to register at runtime. Your plugin runs its own copy of the code.
Your plugin needs Obsidian 1.13.1 or later: the editor's confirmation dialogs use Obsidian's ConfirmationModal. Set minAppVersion in your manifest.json to at least 1.13.1.
The entry point#
src/lib.ts exports startEditor(plugin, options). Call it once from your plugin's onload. It loads the settings, builds the shared context, registers the marks, checks, spelling, coach, learning loop, progress, the panel (if you ask for it, see panel) and the settings tab, and registers the coach's role and tools with Model Hub. It returns the context.
If a step throws while it starts, it takes back everything it registered up to then (commands, views, events, the settings tab and its Model Hub tools), shows a notice that it couldn't start, and throws the error on. Your plugin doesn't need to clean up after it.
This plugin's own main.ts is one call to it with every option left out.
import { Plugin, PluginSettingTab, Setting } from 'obsidian';
import { DEFAULT_CATEGORIES, renderSettings, startEditor, type LEContext, type Settings } from 'learning-editor/src/lib';
import MY_PROMPT from './my-prompt.md';
interface MyData {
editor?: Partial<Settings>;
scores: { note: string; score: number }[];
}
export default class MyEditor extends Plugin {
data: MyData = { scores: [] };
ctx!: LEContext;
async onload() {
this.data = { scores: [], ...((await this.loadData()) as Partial<MyData> | null) };
this.ctx = await startEditor(this, {
pluginId: 'my-editor',
displayName: 'My Editor',
categories: [...DEFAULT_CATEGORIES, { id: 'series', label: 'Series', color: '#14b8a6', defaultMode: 'a', order: 6, description: 'Facts that drift between books' }],
dataDir: '.my-editor',
// The editor's settings live under one key of this plugin's data.json.
settings: {
load: async () => this.data.editor ?? null,
save: async (s) => {
this.data.editor = s;
await this.saveData(this.data);
},
},
settingsTab: false,
coach: {
prompt: MY_PROMPT,
finishFields: { score: { type: 'integer', minimum: 1, description: 'From 1 to 5' } },
finishRequired: ['score'],
onFinished: async ({ note, extra }) => {
this.data.scores.push({ note, score: extra.score as number });
await this.saveData(this.data);
},
},
});
this.addSettingTab(new MySettingTab(this));
}
}
class MySettingTab extends PluginSettingTab {
constructor(private my: MyEditor) {
super(my.app, my);
}
display() {
this.containerEl.empty();
new Setting(this.containerEl).setName('Scores').setHeading();
// ...this plugin's own controls...
new Setting(this.containerEl).setName('Editor').setHeading();
renderSettings(this.containerEl, this.my.ctx);
}
}
What you can pass in#
Everything is optional. Anything you leave out works the way it does in this plugin.
-
pluginId: the id the coach's role and tools are registered under with Model Hub. The tool names start with it (<pluginId>.submit_findings), the role is<pluginId>.coach, the tools only answer calls made as this id, and the panel, if you ask for it, is the view<pluginId>-panel. The default islearning-editor. Give each editor its own id: Model Hub replaces everything registered under an id with each new registration, and the first plugin to use a namespace owns it. Don't use an id your plugin registers its own Model Hub tools under, for the same reason. -
displayName: the name in notices ("My Editor: …"), in the messages when Model Hub is missing or too old, and in log lines. The default isLearning Editor. It's also the heading of your section in a bug report. -
support: where your plugin takes bug reports,{ email, repo }withrepoasowner/name. The editor registers a bug report section under your plugin's own id (from its manifest), so your users' reports name your plugin and go to your repo. Leave it out and only the email route shows. Learning Editor itself usesinsurgently/learning-editor. -
report: your plugin's own lines in that bug report section,{ collect, suspects, secrets, settings }, shaped like a report provider's in@insurgently/obsidian-ui. Your fields come after the editor's in the one section, and your suspects after its suspects. List the SecretStorage ids and settings keys that hold your keys insecrets, and passsettingsso their values can be masked. Report counts, durations, switches and error codes from a fixed list, never a note's text, a name, a path, a command line or an error's message. If yourcollectthrows or takes longer than 1.5 seconds, the section keeps the editor's lines and ends withhost.collect: failed(ortimeout).errorCode(e, known)andKNOWN_CODESare exported to turn an error into one of your known codes. -
categories: the categories findings may use. The marks, the strip, the commands, the settings tab, rule files and the coach's prompt and finish tool all read this list. A finding in any other category is dropped and logged. The starter habit rules and the repeat checks usecuts,lineandclarity, so keep those ids if you keep the local checks. -
dataDir: the folder for working files, such as the decision log. The default is.learning-editor. -
findingsPath(note, source): where one source's findings for a note are saved. The path has to end in.<source>.json, after a part that depends only on the note. The default is<dataDir>/findings/<note path>.<source>.json. -
dismissals:{ path, scope, cap }.scope: 'vault'keeps one list for every note, as{ "keys": [...] }.scope: 'note'gives each note its own list, as{ "notes": { "<note path>": [...] } }, and the list follows the note when you rename it.capis how many keys each list keeps. The default is one vault-wide list in<dataDir>/dismissed.json. -
coach:{ prompt, openingMessage, tools, finishFields, finishRequired, onFinished, sourceId, maxSteps, role }.promptis the coaching rules as Markdown, and the category list is added to the end. Tell the model to finish withsubmit_findings. It may be a string or a function that returns one (a getter on thecoachobject works too). Either is read at the start of every run, so you can change the rules without reloading the plugin.openingMessage(note, profile)writes the first message of the run.toolsadds read tools beside the built-in ones. Each extra tool gets the note, the profile and the run's stop signal, and it only answers inside a coach run. Name them with your plugin id first, like the built-in ones. The run loop, the budget, the checks on submitted findings and the retries stay the same.-
finishFieldsadds properties tosubmit_findings' input, as{ name: schema }in the small JSON Schema subset Model Hub checks (type,properties,required,items,enum,minimum). They're optional unless you list them infinishRequired. Model Hub checks the values before the tool sees them, and sends the model back if one is wrong.summaryandfindingscan't be replaced. A field calledscorereplaces the built-in score out of 10: its value is passed on and isn't saved with the findings. Say in your prompt what each field is for. -
sourceIdis the source id the coach registers and saves its findings under. Its files are<note path>.<sourceId>.json, its findings win overlaps (unless you setprecedence), and the progress report shows its last read. The default iscoach. -
maxStepsis the most steps one run may take. The default is 8. -
roleis the Model Hub role the run uses:{ name, tier, ceilingUsd, maxTokens }.namedefaults to<pluginId>.coachandtiertocareful.ceilingUsdis the most one call may cost, andmaxTokensthe most output tokens per call; both are left to Model Hub when you leave them out. If you register a role with that name yourself inextraRoles, the coach runs on yours and registers none of its own, and the other fields aren't used.coach: { prompt: () => this.rules, // read on every run sourceId: 'review', maxSteps: 12, role: { tier: 'fast', ceilingUsd: 0.25, maxTokens: 4000 }, }, -
onFinished(info)runs after a coach run has handed in its findings and they're saved.infois{ note, runId, findings, extra, spentUsd, score }: the note's path, the Model Hub run id, the findings as saved, yourfinishFieldsvalues the model gave, what the run spent, and the built-in score out of 10 (nullwhen the coach gave none). It isn't called when the writer stops the run, when the run fails, or when it runs out of steps or budget, even if some findings from an earlier try were kept. If it throws, the error is logged and shown in a notice, and the findings stay saved.
-
-
profileWrites:falsestops the plugin from writing the profile note. Proposals still show up for review. An accepted one goes to<dataDir>/accepted-proposals.jsonl, one JSON object per line, for another tool to fold in. Create profile and Import profile aren't offered then. -
settings:{ load, save }, where the settings are kept.load()returns what was saved, ornull, and missing fields get their defaults.save(settings)gets the whole settings object each time one changes. The default is your plugin'sloadDataandsaveData, which use the whole ofdata.json. If your plugin keeps its own data there, pass these so the two don't overwrite each other, as the example does. -
extraRolesandextraTools: your own Model Hub roles and tools, registered underpluginIdtogether with the coach's. Model Hub replaces everything under an id with each registration, so pass them here instead of callingregisterRolesorregisterToolsyourself. They're registered again each time Model Hub reloads. A name that's already taken is refused when the editor starts, except a role named like the coach's (seecoach.role), which the coach then uses instead of its own.extraRolescan also be a function that returns the roles. It's asked each time the roles are registered: when Model Hub loads and before each coach run. When what it returns has changed, the roles are registered again, so a role can follow one of your settings. To register a change right away, callconst hub = ctx.hub(); if (hub) ctx.hubRegistrations.ensure(hub);. A name the function gives that's already taken is logged and left out. If the function throws, its roles are left out and the error is logged.extraRoles: [{ name: 'my-editor.scorer', tier: 'fast', description: 'Scores a chapter' }], extraTools: [{ name: 'my-editor.word_count', description: 'Counts words', effect: 'read', input: { type: 'object' }, handler: async () => 1200 }], -
notePolicy(note, frontmatter): what one note allows, as{ apply, modeB, modeBCategories, applyAllCategories }.apply: falseshows the marks and suggestions but offers no apply or apply all, so the findings are read-only there.modeB: falsedraws underlines only in that note, whatever the categories' modes.modeBCategorieslists the categories that may draw inline diffs in that note; the others are underlines only, and only the listed ones get a diff switch in the strip.applyAllCategorieslists the categories apply all may change; a single apply still works on any. Anything you leave out is allowed, with the modes from settings. It's asked each time the note's findings reach the editor and when its frontmatter changes.notePolicy: (note, fm) => (fm.status === 'published' ? { apply: false } : { applyAllCategories: ['cuts', 'mechanics'] }), -
checksFor(note, frontmatter): whether the local habit checks and spelling run on a note, by themselves or from the check command. The coach isn't affected. The default is every note.checksFor: (note) => !note.startsWith('Archive/'), -
hideSource(id): sources whose findings files stay on disk but don't show as marks, such as test runs saved as<source>@<label>. The default hides none.hideSource: (id) => id.includes('@'), -
precedence: source ids, strongest first. Where two sources' findings overlap, the lower one's is hidden. Sources you don't list come after the ones you do, and between two of those the earlier finding wins. The default is the coach's source id alone, so the coach wins over the local checks.precedence: ['review', 'harper', 'habits'], -
settingsTab:falseleaves out the plugin's settings tab. Draw the same controls into your own tab withrenderSettings(containerEl, ctx), passing the contextstartEditorreturned. It adds them after whatever is already in the element and redraws them in place when it needs to. Pass{ redraw }as the third argument to redraw your whole tab instead. -
panel: whether the editor brings its own panel. The default istrueonly when you leave outpluginId(this plugin itself); with your ownpluginIdit'sfalseunless you passtrue.- With the panel, there's a view
<pluginId>-panel, a ribbon icon and an "Open the panel" command (open-panel). Obsidian's command palette gets eight commands, and the rest are buttons in the panel. - Without it, there's no view, no ribbon icon and no
open-panel, so you can register your own under that id. Every command is registered with Obsidian, as there's nowhere else to reach them. That includes Report a problem… (report-problem), which belongs in a panel's "⋯" menu: a host with no panel menu of its own keeps it in the palette until it has one, then moves it into that menu. "Open writing progress" (open-progress) shows the open note's progress in a dialog.
panel: true, // the panel, its ribbon icon and open-panel, in your plugin's namespace - With the panel, there's a view
Every option is in place when startEditor returns, before any findings reach an editor, so you never need to wrap anything afterwards.
Findings written by other tools#
A findings file another tool or a script writes shows on a note that's already open. You don't need to reopen the note. The plugin listens for changes to the open notes' findings files and, because Obsidian doesn't report every change inside dot folders like .learning-editor, it also checks those files every couple of seconds. It only reads a file again when its time or size changed. Its own writes don't trigger a reload. This works with a custom findingsPath too: a file belongs to a note when its path starts with the note's part of the layout.
Driving it from tests#
ctx.commands is the editor's command catalog: every command, with its id, name, area and icon, and which ones the palette lists.
ctx.automation lets a test or a script run the editor's commands without clicking. commands() lists every command. run(id, { target, answers }) runs one. state() returns what the panel shows for the open note (worked out the same way when you have no panel), and idle() waits until the coach and the checks are done. A local check that's waiting for the writer to stop typing runs at once in idle() rather than when its timer ends, so it writes its findings file a moment early. log() returns the notices and commands so far.
A command that writes stops at a plan ({ ok: true, plan }) unless answers.confirm is true. The plans for show-finding, apply-finding and dismiss-finding name the finding under the cursor as plan.markId. To confirm one, pass that id back as answers.markId. If the finding under the cursor is a different one by then, or you leave the id out, the run is refused with reason: 'failed' and nothing changes. The coach also costs money, so it needs a price. A priced coach plan needs Model Hub 0.13 or newer: the plan then carries costUsd (the estimate's high figure, which is an estimate and not a limit), and a confirmed run goes ahead if the budget covers it. The budget you approve is a hard cap on that run: the coach stops when it would spend more and saves nothing. With an older Hub, or when Model Hub refuses to price the run, run('coach-note') never starts a coach run: confirmed, it returns over-budget with why: 'no cost estimate'.
Learning Editor puts this on its own plugin as automation. With your own plugin, it's on the context startEditor returns, and your plugin decides whether to expose it.
Building it#
The code is TypeScript and isn't compiled ahead of time. Your bundler compiles it with your plugin. Four things to set up:
-
Harper's binary comes from a build plugin. The spelling and grammar check imports a module called
harper-wasm-gz, which isn't a package: it's Harper's WebAssembly, gzipped at build time. This repo'sscripts/harper-wasm.mjsmakes it, for esbuild and for vitest. Without it your build fails withCould not resolve "harper-wasm-gz".// esbuild.config.mjs import { harperWasmPlugin } from 'learning-editor/scripts/harper-wasm.mjs'; // ... esbuild.context({ plugins: [harperWasmPlugin()], /* ... */ });// vitest.config.ts, if your tests load the editor's Harper code import { harperWasmVitest } from 'learning-editor/scripts/harper-wasm.mjs'; export default defineConfig({ plugins: [harperWasmVitest()], /* ... */ });For
tsc, add the module's declaration to your tsconfig'sinclude:node_modules/learning-editor/src/sources/harper/harper-wasm-gz.d.ts. -
Markdown imports are text. The coach's prompt is imported from
prompt.md, so give.mdfiles a text loader (esbuild:loader: { '.md': 'text' }). -
Keep
obsidianand every@codemirror/*package external, as this repo'sesbuild.config.mjsdoes. Obsidian provides them. -
Ship a
styles.cssin your plugin's folder. Obsidian only loads the styles of the plugin itself, so without one the marks, cards, strip, panel and dialogs have no styling. Copy this repo's rootstyles.css. It's generated bynpm run build(the shared parts' CSS, thensrc/styles.css) and committed, so it's there without a build, and a test fails when it's out of date. Don't edit it; editsrc/styles.cssand build. Or write your own for thele-classes.
@insurgently/text (from npm) and harper.js come in as this repo's dependencies.
What is still shared#
Some things are the same for every editor built this way:
- The settings fields and their controls. You choose where they're stored and whether they get their own tab, but not what they are. The default profile note is
Learning Editor/profile.mdand the default rules folderLearning Editor/rules; the writer can change both. - The CSS classes, which all start with
le-. Two editors on the same code style their marks the same way. - The set of modules.
startEditorregisters all of them. If you need fewer, call theregister…functions yourself, the waystartEditordoes.
Commands get your plugin's id from Obsidian, so they never clash.