Notes and drafts¶
Notes are Markdown documents in a folder tree: the firm's Library (notes
on no matter) and one private tree per matter, edited in one standalone
editor. Drafts are different: a draft is a .odt file in the matter's
Google Drive folder, opened in LibreOffice Writer, that a case AI
conversation is pinned to so the AI can read it and propose tracked
changes through a companion extension. Notes span apps/notes and
apps/case/notes; drafts are apps/drafts over apps/drive and the case
AI chat.
Where the code is¶
| Path | What it holds |
|---|---|
apps/notes/models.py |
Note, NoteFolder, NoteView |
apps/notes/views.py |
The Library tab, the editor and its partials, folder CRUD, moves, autosave |
apps/notes/access.py |
Who may reach a note or folder (note_for_user, visible_notes_q) |
apps/notes/api.py |
Token-authed JSON API for the MCP server |
apps/notes/tasks.py |
AI abstracts for library notes |
apps/case/notes/ |
The matter's Notes tab (list, filters, instant create, rename) |
apps/case/notes/markdown_ext.py |
Renders [[doc:id|label]] and [[hl:id|label]] references as links |
templates/notes/ |
editor.html (the page), editor-content.html (the note partial), editor-trees.html, main.html (the Library tab) |
static/js/notes-editor.js |
Editor entry point: builds the TipTap editor, wires the modules |
static/js/notes/ |
markdown.js, autosave.js, broadcast.js, conflict-lock.js, file-tree.js, tab-state.js, references.js, tables.js, palette.js, outline.js, search.js |
static/js/notes-library.js |
Drag-and-drop and context menus on the Library tab |
static/js/vendor/tiptap.bundle.js |
The TipTap bundle (see below) |
apps/notes/tests/js/ |
Node tests for the editor modules |
apps/drafts/models.py |
DraftLink, CompanionToken, CompanionRound |
apps/drafts/services.py |
Drive listing, link creation, the stale refresh |
apps/drafts/chat.py |
The draft section of the AI context and the edit rounds |
apps/drafts/companion.py |
The API the extension polls, and the .oxt build |
apps/drafts/companion_src/ |
The LibreOffice extension (kosmos_companion.py, description.xml, Addons.xcu) |
apps/drafts/views.py, templates/case/ai/draft-*.html |
The picker, the chip and the setup modal in the chat window |
apps/drive/convert.py |
ODT to Markdown through pandoc |
apps/drive/redline.py, apps/drive/uno_driver.py |
The headless redline driver, dormant (see below) |
Data model¶
NoteFolder:name,parent(SET_NULL),matter(CASCADE, nullable),depth.matternull is the general (Library) tree; set, it is that matter's private tree.clean()enforces four levels (depthup to 3), that a child is on the same matter as its parent, and no cycles;save()runs it unlessupdate_fieldsis given.Note:matter(CASCADE, nullable; null means Library),folder(SET_NULL),title,category,topic,content(Markdown),documentsandhighlightsas tracked sources,labels,importance,summaryandsummary_source_hash(the AI abstract),ai_write_until. History on every save. The invariant, stated on the field:folderis None orfolder.matter_id == matter_id. It is enforced in the views (validate_folder_move,note_move), not inclean(), because every move path saves withupdate_fields.NoteView: one row per (user, note),viewed_at, for recents andnotes_launch.DraftLink: one-to-one on a caseConversation(CASCADE),drive_file_id,name,doc_text(the Markdown facsimile the AI reads),doc_text_at,companion_seen.sibling_links()are the links to the same file by the same user;companion_activeis true when any sibling was polled in the last 15 seconds.CompanionToken: one per user, theX-Kosmos-Tokenkey the extension and the MCP server both send.CompanionRound: one AI edit round,edits(wire dicts fromapps.drive.redline.edit_to_dict),status(pending, applied, failed, expired),delivered_at,result,error,edit_index.
How it works¶
Trees, the Library and the matter tab¶
One NoteFolder.matter column unifies the two kinds of tree, so the
editor's left panel renders both from one query: get_editor_file_tree()
builds the Files tree (general folders and notes) and a Matters pane with
one tree per Open matter the user may see, plus the seven most recent
notes. Every node renders collapsed; expansion, the active pane and tree
scroll are per browser tab in sessionStorage (tab-state.js), so two
tabs never move each other's tree.
The Library tab (notes_index, templates/notes/main.html) is a
different surface over the same data: its folder sidebar filters the
table of general notes (get_notes_data, session filters under
standalone_notes_filter). notes-library.js adds drag-and-drop and
right-click menus to it as its own layer; it shares the server endpoints
(notes:folder-reparent, notes:note-move, notes:bulk-move) with the
editor tree but not its code, because the editor tree navigates and the
sidebar filters. The matter's Notes tab (apps/case/notes/views.py) is
the same table for one matter's notes, inside the Case shell, with
instant create (notes_add) and a rename modal; per-note editing always
goes to the editor.
A move never crosses matters. note_move accepts a destination folder
only on the note's own matter, note_folder_reparent goes through
validate_folder_move(), and the one path that changes a note's matter,
note_reassign_matter, resets folder to None and suffixes the title if
the target root already has one. A same-named sibling at a destination
gets a serial suffix (_next_untitled) rather than blocking the move.
The editor¶
note_view renders templates/notes/editor.html; switching notes swaps
editor-content.html in with HTMX (note_content_partial), which also
installs a fresh window.NOTE_DATA (ids, URLs, updatedAt, the AI-write
grant). notes-editor.js creates the TipTap editor from the modules it
imports and binds the toolbar, the trees, the Ctrl+K palette
(notes_search_palette, shared ranking with the API), the reference
picker (references.js, reference_search) and the outline.
notes_launch is where the Library's Editor button goes: the user's most
recently viewed note that is still within reach, else the newest visible
note. With no reachable note it renders launch-empty.html, a page with
a New note button (a POST to notes_add), rather than creating a note on
a GET. Background reloads call the partial with ?sync=1 so they do not
count as a view.
Storage is Markdown, and the round trip is the risk. The stored text
is plain text with Markdown marks; markdown.js turns it into editor HTML
(markdownToHtml) and back on every save (htmlToMarkdown), so anything
the first step mangles is written over the note two seconds later. The
loader escapes everything before it applies marks, lifts code spans,
reference chips and link addresses out first, and lets exactly one tag
through: a bare <br>, which is how a table cell breaks a line. What is
known to change shape, pinned by markdown_roundtrip.test.mjs: a
Markdown link becomes its text followed by the address in parentheses
(the editor has no link mark; an image reference  is not a
link and stays as typed), underscores inside a word are never
emphasis, and table cells flatten to one line with literal pipes escaped
(but not the pipe inside a [[doc:1|label]] chip). Tables are GFM pipe
tables with outer pipes; column alignment round-trips through the
separator colons; resizing and merged cells are not offered because the
storage cannot hold them (tables.js).
The TipTap bundle is built outside the repository. The esbuild
project (build.mjs, package.json, src/tiptap.js) was removed on
2026-02-28 and the bundle is committed as an artifact. The recipe is in
the commit "feat(notes): table support with pipe-markdown round-trip"
(2026-08-15): @tiptap/*@2.27.2, esbuild with format: esm, minify,
sourcemap, target es2020, an entry that re-exports every name imported
from the bundle (grep vendor/tiptap.bundle.js under static/js/: the
editor, references.js, conflict-lock.js, tables.js, search.js,
highlight-mark.js, plain-copy.js and prompt-editor.js). Rebuild in
a scratch project and commit the new bundle and map.
Saving and conflicts¶
autosave.js debounces two seconds, then POSTs content and
base_version, the exact updated_at isoformat string this tab last
received, to note_autosave. _version_conflict() in
apps/notes/views.py answers 409 when they differ: another tab, another
person or the AI saved since. note_title makes the same check. A 409
puts the tab into conflict (enterConflict): editing is paused behind
the banner in editor-content.html, every control that would change the
note (data-editing-only in editor.html: the format toolbar and its
overflow items, Import, Replace, the table bar) is hidden, and
ConflictLock (conflict-lock.js, a ProseMirror plugin) refuses every
transaction that changes the document, because setEditable(false) stops
typing but not programmatic commands. Reload (reloadNoteContent) is
the only way out. A failed save retries three times; pagehide flushes a
dirty buffer with keepalive.
Tabs talk over a BroadcastChannel (broadcast.js): note-saved
carries the note id and new updatedAt, and a clean sibling tab on the
same note reloads silently while a dirty one enters conflict;
tree-changed makes siblings re-fetch the trees; note-deleted sends a
tab with that note open to notes_launch. note_ai_write saves with
update_fields so toggling the grant does not bump updated_at and
raise a false conflict. A save's answer is applied only if
window.NOTE_DATA is still the object the save was sent for
(autosave_stale_response.test.mjs), so a switch mid-save cannot stamp
one note's version onto another.
The JSON API¶
apps/notes/api.py serves matters, note lists, one note, search and the
single write path, under kosmos_api_auth. api_note_write appends to
or replaces a note's Markdown only while Note.ai_write_until is in the
future; the editor's sparkles button (note_ai_write) grants 24 hours
and a second click clears it. The grant is per note and note-wide, since
token auth has no browser session to consult. The in-app AI's note
writes (apps/case/ai/note_blocks.py, fenced create-note and
edit-note blocks) are not gated by it. The API and the MCP server are
described in MCP.
Drafts: companion-first¶
Drafts exist only with AI configured: the link routes live under
/case/ai/ and the companion's setup, download and API under
/case/drafts/, and PermissionMiddleware answers 404 on both while
ai_enabled() is false (see
AI is optional).
The design, stated in apps/drafts/models.py: there is no server-side
working copy, no version chain and no publish step. The document in
Writer is the working copy, Ctrl+Z is version control, and saving the
file (which the user's Drive client syncs) is publishing.
- Link. The pen button in the chat window opens
draft_picker, which lists.odtfiles under the matter's Drive folder (services.list_matter_odt_files, walking subfolders toMAX_LIST_DEPTH, soft-failing to an empty list).services.create_link()refuses a file the picker would not have offered, fetches it, converts it withconvert.to_markdown()(pandoc) and stores the text on aDraftLink. - Context. When the conversation has a link, the chat worker
appends
chat.build_draft_section()to the system prompt: the preamble, the edit protocol and the draft text. First it callsservices.refresh_if_stale(), which re-fetches from Drive whendoc_text_atis older than five minutes and no companion is connected; with a companion connected the pushes are fresher than Drive, so it never re-fetches. A rename in Drive is propagated to every link on that file, because the extension pairs by file name. - Edits. The AI answers with one fenced
draft-editsblock of replace,delete_paragraphandinsert_afteroperations (optionally withoccurrence).chat.apply_edit_blocks()parses it (_parse_edit_block; a malformed block stays as text and changes nothing), refuses it with instructions when no companion is active, and otherwise_apply_via_companion()creates aCompanionRoundand blocks in_wait_for_companion(). Each block is replaced in the stored message by the outcome sentence. - The companion. The extension polls
api_opsevery 2.5 seconds;api_opshands a pending round out once (delivered_atset by a conditional update, never redelivered, because a redelivery could apply the same edits twice). Version 0.4.0 then claims it (api_resultwith{"claim": true}), applies it as tracked changes by "Kosmos AI" inside one undo context, and reports the outcome with the document as ODT bytes;_store_document()converts the push and writesdoc_texton the link and all its siblings.
Two clocks govern the wait, both in apps/drafts/chat.py: an
uncollected round expires after COMPANION_WAIT_SECONDS (30), and is
then never handed out, so "not applied" is true; a collected round gets
COMPANION_APPLY_SECONDS (90) from delivered_at, restarted by the
claim, with a hard ceiling of twice that. A report arriving after the
chat gave up is still recorded (the edits are in the document by then),
and the chat's wording distinguishes "not collected" from "collected,
not confirmed".
The extension is served as a personalized .oxt by companion_oxt:
companion_src/ zipped as is, plus a generated config.json carrying
PUBLIC_BASE_URL (or the request's host) and the user's
CompanionToken. EXTENSION_VERSION in companion.py and
description.xml must agree; test_companion checks it. Setup for
users and servers is in Drafting with
LibreOffice.
Two extension versions are in use. A user's copy changes only when
they download again, so 0.3.0 (collects and applies at once, no claim)
and 0.4.0 must both keep working: no path, method or key 0.3.0 uses may
change, and anything new must be optional for the client.
test_round_lifecycle.py replays the 0.3.0 call sequence against the
current server; test_companion_extension.py loads
kosmos_companion.py with UNO stubbed and drives its round handling
against the real views.
Retired designs¶
Recorded so nobody rebuilds them.
- Drafts v1 (2026-08-04 to 2026-08-06):
DraftSessionandDraftVersion, a server-side redline chain with PDF preview, two publish modes, a Drafts tab and a standalone window. Deleted in "refactor(drafts): fold drafting into case AI chat, companion-first" (2026-08-06) after live testing showed the companion made it dead weight; migration 0003 dropped the tables. The headless driver (redline.py,uno_driver.py) stays, dormant and tested, for a future Drive write-back path. - Canvas mode (branch
feat/drafting-canvas, 2026-08-07 to 08-09, never merged): a second draft surface whose text lived indoc_textitself with a TipTap pane in the chat window; pruned without merging. - The Drive notes mirror (retired 2026-08-11, "feat(notes): retire
Drive notes mirror, unlock imported notes"): notes synced from a
Notes/folder in Drive, read-only in the editor.Notelost its Drive fields;apps/drive/google.pystill skips that folder. - The AI-library flag on folders (2026-08-09 to 08-11): replaced by
get_library_notes(), every Library note is selectable for the AI and notes carry no AI knobs.
Background work¶
| Task | Queued by | On failure |
|---|---|---|
apps.notes.tasks.generate_note_summary |
note_autosave on a Library note (debounced two minutes through the cache), backfill_note_summaries |
logged; the summary stays stale and is retried on the next save |
apps.notes.tasks.queue_stale_library_summaries |
notes_bulk_move, through queue_library_summary_sweep(); backfill_note_summaries |
logged |
Both summary tasks, and queue_note_summary() and
queue_library_summary_sweep() that queue them, return at once when no
AI provider is configured (apps.settings.ai.ai_enabled()), so a server
without AI queues nothing. The edit round wait runs inside the AI chat
task on the worker, not as a task of its own. Drafts have no schedule; see
Scheduled jobs.
Access¶
The /notes/ routes are outside the middleware's /case/ matter check,
so every view that takes a note, folder or matter id goes through
apps/notes/access.py: a note or folder with no matter is the Library and
every signed-in user may see it; one on a matter needs
has_matter_access, and a refusal is a 404. visible_notes_q() scopes
recents, launch and search. The matter's Notes tab under /case/ is
covered by the middleware (note_id is in MATTER_LOOKUPS). The
companion API authenticates by token and re-checks matter membership on
every call (_user_links() for the listing, _get_link() for a link,
which answers 403 for a lost matter and 404 for an unlinked draft), since
a user taken off a matter keeps their token; the draft views under /case/ai/conversations/<conv_id>/draft/
rely on the middleware plus _get_conversation(), which excludes
matter-less conversations. The permission matrix is in the
permissions reference.
Things that bite¶
- Every move path saves with
update_fields, soclean()never runs. A new move or reparent endpoint must callvalidate_folder_move()or make the same-matter check itself, or a note can end up in another matter's folder. - Anything that touches
updated_aton a note someone has open raises a conflict banner. Save metadata withupdate_fieldsasnote_ai_writedoes; a content change should raise it, that is the point. markdown.jsis the schema. A new TipTap node or mark thathtmlToMarkdowncannot serialise is silently lost on the next autosave. Add the round trip and a case inmarkdown_roundtrip.test.mjsfirst.- The bundle's exports are the contract. Importing a name from
tiptap.bundle.jsthat the bundle does not export fails only at runtime, in the browser; the Node tests import the modules that avoid the bundle on purpose (autosave.js,markdown.js). - Rounds are never redelivered and
doc_textis shared across sibling links. A query onCompanionRoundorDraftLinkfor one file must go throughsibling_links(), or two conversations on one document disagree about what was applied. - The companion protocol only grows. A change that breaks the
0.3.0 sequence breaks installed extensions until every user downloads
again;
test_round_lifecycle.py's 0.3.0 class is the guard. - The Library summary task is a no-op for matter notes.
generate_note_summaryreturns whenmatter_idis set; matter notes feed their matter's context whole.
Related¶
- Guide: Notes, Drafts, AI chat
- Admin: Drafting with LibreOffice, Claude Desktop, Google Workspace
- Subsystems: MCP, AI context, Agent chat, Case building (the Drive mirror, labels, the matter Notes tab's shell)
- Conventions: HTMX and Alpine, Testing