-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
+ >✨ Assist
+
+
+
-
+
+
-
+
+
+
+
+
+
+
-
+
-
+
+
+
+
+
+
+ Generating...
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ assist.error.value }}
@@ -497,23 +522,178 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
\ No newline at end of file
+
+.stream-preview {
+ border: 1px solid var(--color-input-border);
+ border-radius: var(--radius-sm);
+ padding: 0.75rem;
+ background: var(--color-bg-card);
+ min-height: 200px;
+}
+
+.main-diff {
+ flex: 1;
+ min-height: 0;
+}
+
+/* Right sidebar */
+.note-sidebar {
+ width: 280px;
+ flex-shrink: 0;
+ border-left: 1px solid var(--color-border);
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Mobile accordion toggle (hidden on desktop) */
+.sidebar-toggle {
+ display: none;
+ width: 100%;
+ padding: 0.6rem 1rem;
+ background: var(--color-bg-secondary);
+ border: none;
+ border-bottom: 1px solid var(--color-border);
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ text-align: left;
+ font-family: inherit;
+}
+
+.sidebar-content {
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+}
+
+/* Sidebar field layout */
+.sb-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+.sb-label {
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.sb-select {
+ width: 100%;
+ padding: 0.35rem 0.5rem;
+ border: 1px solid var(--color-input-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-card);
+ color: var(--color-text);
+ font-size: 0.875rem;
+ font-family: inherit;
+ box-sizing: border-box;
+}
+.sb-select:focus { outline: none; border-color: var(--color-primary); }
+
+.sb-divider {
+ height: 1px;
+ background: var(--color-border);
+ margin: 0.15rem 0;
+}
+
+/* Tag suggest row inside sidebar */
+.tag-suggest-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+ align-items: center;
+}
+
+/* Writing Assistant section */
+.assist-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.assist-section-title {
+ font-size: 0.78rem;
+ font-weight: 700;
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* History button */
+.btn-history {
+ padding: 0.45rem 1rem;
+ background: none;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 0.875rem;
+ color: var(--color-text-secondary);
+ font-family: inherit;
+}
+.btn-history:hover {
+ border-color: var(--color-primary);
+ color: var(--color-primary);
+}
+
+/* Narrow screen: sidebar collapses */
+@media (max-width: 720px) {
+ .note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
+ .note-main { padding: 0.75rem 1rem 1rem; overflow-y: visible; }
+ .note-sidebar {
+ width: 100%;
+ border-left: none;
+ border-top: 1px solid var(--color-border);
+ overflow-y: visible;
+ }
+ .sidebar-toggle { display: block; }
+ .sidebar-content {
+ display: none;
+ padding: 0.75rem 1rem;
+ }
+ .sidebar-content.sidebar-open { display: flex; }
+}
+
diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py
index 07c3fbb..e7688d9 100644
--- a/src/fabledassistant/config.py
+++ b/src/fabledassistant/config.py
@@ -24,8 +24,9 @@ class Config:
)
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3:latest")
- # KV cache context window for generation. Lower = less VRAM, less throughput impact.
- OLLAMA_NUM_CTX: int = int(os.environ.get("OLLAMA_NUM_CTX", "16384"))
+ # KV cache context window for generation. Higher = more RAM usage but longer inputs/outputs.
+ # 131072 is the practical maximum for most models. Lower this on RAM-constrained hosts.
+ OLLAMA_NUM_CTX: int = int(os.environ.get("OLLAMA_NUM_CTX", "65536"))
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py
index f3155fa..a503d65 100644
--- a/src/fabledassistant/models/__init__.py
+++ b/src/fabledassistant/models/__init__.py
@@ -30,3 +30,5 @@ from fabledassistant.models.push_subscription import PushSubscription # noqa: E
from fabledassistant.models.event import Event # noqa: E402, F401
from fabledassistant.models.milestone import Milestone # noqa: E402, F401
from fabledassistant.models.task_log import TaskLog # noqa: E402, F401
+from fabledassistant.models.note_draft import NoteDraft # noqa: E402, F401
+from fabledassistant.models.note_version import NoteVersion # noqa: E402, F401
diff --git a/src/fabledassistant/models/note_draft.py b/src/fabledassistant/models/note_draft.py
new file mode 100644
index 0000000..c6eed29
--- /dev/null
+++ b/src/fabledassistant/models/note_draft.py
@@ -0,0 +1,39 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import DateTime, ForeignKey, Integer, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from fabledassistant.models import Base
+
+
+class NoteDraft(Base):
+ __tablename__ = "note_drafts"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ note_id: Mapped[int] = mapped_column(Integer, ForeignKey("notes.id", ondelete="CASCADE"))
+ user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
+ proposed_body: Mapped[str] = mapped_column(Text)
+ original_body: Mapped[str] = mapped_column(Text)
+ instruction: Mapped[str] = mapped_column(Text, default="")
+ scope: Mapped[str] = mapped_column(Text, default="document")
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
+ )
+
+ def to_dict(self) -> dict:
+ return {
+ "id": self.id,
+ "note_id": self.note_id,
+ "user_id": self.user_id,
+ "proposed_body": self.proposed_body,
+ "original_body": self.original_body,
+ "instruction": self.instruction,
+ "scope": self.scope,
+ "created_at": self.created_at.isoformat(),
+ "updated_at": self.updated_at.isoformat(),
+ }
diff --git a/src/fabledassistant/models/note_version.py b/src/fabledassistant/models/note_version.py
new file mode 100644
index 0000000..0afc091
--- /dev/null
+++ b/src/fabledassistant/models/note_version.py
@@ -0,0 +1,31 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import DateTime, ForeignKey, Integer, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from fabledassistant.models import Base
+
+
+class NoteVersion(Base):
+ __tablename__ = "note_versions"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ note_id: Mapped[int] = mapped_column(Integer, ForeignKey("notes.id", ondelete="CASCADE"))
+ user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
+ body: Mapped[str] = mapped_column(Text)
+ title: Mapped[str] = mapped_column(Text, default="")
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
+ )
+
+ def to_dict(self, include_body: bool = True) -> dict:
+ d: dict = {
+ "id": self.id,
+ "note_id": self.note_id,
+ "user_id": self.user_id,
+ "title": self.title,
+ "created_at": self.created_at.isoformat(),
+ }
+ if include_body:
+ d["body"] = self.body
+ return d
diff --git a/src/fabledassistant/routes/notes.py b/src/fabledassistant/routes/notes.py
index 5c4d835..4917409 100644
--- a/src/fabledassistant/routes/notes.py
+++ b/src/fabledassistant/routes/notes.py
@@ -30,6 +30,8 @@ from fabledassistant.services.notes import (
)
from fabledassistant.services.settings import get_setting
from fabledassistant.services.tag_suggestions import suggest_tags
+from fabledassistant.services.note_drafts import upsert_draft, get_draft, delete_draft
+from fabledassistant.services.note_versions import list_versions, get_version
logger = logging.getLogger(__name__)
@@ -285,11 +287,15 @@ async def assist_route():
target_section = data.get("target_section", "")
instruction = data.get("instruction", "")
- if not target_section or not instruction:
- return jsonify({"error": "target_section and instruction are required"}), 400
+ whole_doc = bool(data.get("whole_doc", False))
+
+ if not whole_doc and not target_section:
+ return jsonify({"error": "target_section is required for section mode"}), 400
+ if not instruction:
+ return jsonify({"error": "instruction is required"}), 400
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
- messages = build_assist_messages(body, target_section, instruction)
+ messages = build_assist_messages(body, target_section, instruction, whole_doc=whole_doc)
buf = create_assist_buffer(uid)
asyncio.create_task(run_assist_generation(buf, messages, model))
@@ -336,3 +342,63 @@ async def assist_stream_route():
"X-Accel-Buffering": "no",
},
)
+
+
+# ── Draft routes ─────────────────────────────────────────────────────────────
+
+@notes_bp.route("//draft", methods=["GET"])
+@login_required
+async def get_draft_route(note_id: int):
+ uid = get_current_user_id()
+ draft = await get_draft(uid, note_id)
+ if draft is None:
+ return jsonify({"error": "No draft found"}), 404
+ return jsonify(draft.to_dict())
+
+
+@notes_bp.route("//draft", methods=["PUT"])
+@login_required
+async def upsert_draft_route(note_id: int):
+ uid = get_current_user_id()
+ # Verify note ownership
+ note = await get_note(uid, note_id)
+ if note is None:
+ return jsonify({"error": "Note not found"}), 404
+ data = await request.get_json()
+ draft = await upsert_draft(
+ user_id=uid,
+ note_id=note_id,
+ proposed_body=data.get("proposed_body", ""),
+ original_body=data.get("original_body", ""),
+ instruction=data.get("instruction", ""),
+ scope=data.get("scope", "document"),
+ )
+ return jsonify(draft.to_dict()), 200
+
+
+@notes_bp.route("//draft", methods=["DELETE"])
+@login_required
+async def delete_draft_route(note_id: int):
+ uid = get_current_user_id()
+ await delete_draft(uid, note_id)
+ return "", 204
+
+
+# ── Version routes ────────────────────────────────────────────────────────────
+
+@notes_bp.route("//versions", methods=["GET"])
+@login_required
+async def list_versions_route(note_id: int):
+ uid = get_current_user_id()
+ versions = await list_versions(uid, note_id)
+ return jsonify({"versions": [v.to_dict(include_body=False) for v in versions]})
+
+
+@notes_bp.route("//versions/", methods=["GET"])
+@login_required
+async def get_version_route(note_id: int, version_id: int):
+ uid = get_current_user_id()
+ version = await get_version(uid, note_id, version_id)
+ if version is None:
+ return jsonify({"error": "Version not found"}), 404
+ return jsonify(version.to_dict(include_body=True))
diff --git a/src/fabledassistant/services/assist.py b/src/fabledassistant/services/assist.py
index a52e614..91dcb58 100644
--- a/src/fabledassistant/services/assist.py
+++ b/src/fabledassistant/services/assist.py
@@ -2,38 +2,51 @@ MAX_BODY_CHARS = 8000
def build_assist_messages(
- body: str, target_section: str, instruction: str
+ body: str, target_section: str, instruction: str, whole_doc: bool = False
) -> list[dict]:
- """Build Ollama messages for section-level assist.
+ """Build Ollama messages for writing assist.
- The full note body (truncated) is provided as read-only context in the
- system prompt. The target section + user instruction go in the user
- message. The model outputs only the replacement for the target section.
+ When whole_doc=True, the model revises the entire document.
+ When whole_doc=False (section mode), the full body is provided as read-only
+ context and the model outputs only the replacement for the target section.
"""
truncated_body = body[:MAX_BODY_CHARS]
if len(body) > MAX_BODY_CHARS:
truncated_body += "\n... (truncated)"
- system_content = (
- "You are an AI writing assistant integrated into a note-taking app. "
- "The user is editing a document. The full document is shown below for context.\n\n"
- f"--- Full Document ---\n{truncated_body}\n--- End Document ---\n\n"
- "The user will give you a specific section of the document and an instruction. "
- "Output ONLY the replacement text for that section. "
- "If the target section starts with a markdown heading (e.g. ## Heading), "
- "your output MUST also start with a heading at the same level. "
- "You may revise the heading text but do not remove it. "
- "Do not include other sections, explanatory text, or markdown code fences around the output. "
- "Match the document's existing tone and style. "
- "IMPORTANT: Preserve all markdown formatting exactly — including bullet lists (- item), "
- "numbered lists (1. item), nested/indented sub-items ( - sub), bold (**text**), "
- "italic (_text_), and code blocks. Never flatten nested lists into plain text."
- )
-
- user_content = (
- f"--- Target Section ---\n{target_section}\n--- End Target Section ---\n\n"
- f"Instruction: {instruction}"
- )
+ if whole_doc:
+ system_content = (
+ "You are a writing assistant. Revise the document per the instruction. "
+ "Output ONLY the complete revised document. Preserve all structure and "
+ "content not addressed by the instruction. "
+ "Preserve all markdown formatting exactly — including bullet lists (- item), "
+ "numbered lists (1. item), nested/indented sub-items ( - sub), bold (**text**), "
+ "italic (_text_), and code blocks. Never flatten nested lists into plain text."
+ )
+ user_content = (
+ f"--- Document ---\n{truncated_body}\n--- End Document ---\n\n"
+ f"Instruction: {instruction}"
+ )
+ else:
+ system_content = (
+ "You are an AI writing assistant integrated into a note-taking app. "
+ "The user is editing a document. The full document is shown below for context.\n\n"
+ f"--- Full Document ---\n{truncated_body}\n--- End Document ---\n\n"
+ "The user will give you a specific section of the document and an instruction. "
+ "Output ONLY the replacement text for that section. "
+ "If the target section starts with a markdown heading (e.g. ## Heading), "
+ "your output MUST also start with a heading at the same level. "
+ "You may revise the heading text but do not remove it. "
+ "Do not include other sections, explanatory text, or markdown code fences around the output. "
+ "Match the document's existing tone and style. "
+ "IMPORTANT: Preserve all markdown formatting exactly — including bullet lists (- item), "
+ "numbered lists (1. item), nested/indented sub-items ( - sub), bold (**text**), "
+ "italic (_text_), and code blocks. Never flatten nested lists into plain text."
+ )
+ user_content = (
+ f"--- Target Section ---\n{target_section}\n--- End Target Section ---\n\n"
+ f"Instruction: {instruction}"
+ )
return [
{"role": "system", "content": system_content},
diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py
index b58b4b0..5d0f1d4 100644
--- a/src/fabledassistant/services/generation_task.py
+++ b/src/fabledassistant/services/generation_task.py
@@ -425,7 +425,7 @@ async def run_assist_generation(
await asyncio.sleep(delay)
try:
buf.content_so_far = ""
- async for chunk in stream_chat(messages, model, options={"num_predict": 4096}):
+ async for chunk in stream_chat(messages, model, options={"num_predict": Config.OLLAMA_NUM_CTX}):
buf.content_so_far += chunk
buf.append_event("chunk", {"chunk": chunk})
diff --git a/src/fabledassistant/services/note_drafts.py b/src/fabledassistant/services/note_drafts.py
new file mode 100644
index 0000000..4f9513f
--- /dev/null
+++ b/src/fabledassistant/services/note_drafts.py
@@ -0,0 +1,66 @@
+from sqlalchemy import select, text
+
+from fabledassistant.models import async_session
+from fabledassistant.models.note_draft import NoteDraft
+
+
+async def upsert_draft(
+ user_id: int,
+ note_id: int,
+ proposed_body: str,
+ original_body: str,
+ instruction: str,
+ scope: str,
+) -> NoteDraft:
+ async with async_session() as session:
+ await session.execute(
+ text("""
+ INSERT INTO note_drafts (note_id, user_id, proposed_body, original_body, instruction, scope)
+ VALUES (:note_id, :user_id, :proposed_body, :original_body, :instruction, :scope)
+ ON CONFLICT (note_id, user_id) DO UPDATE SET
+ proposed_body = EXCLUDED.proposed_body,
+ original_body = EXCLUDED.original_body,
+ instruction = EXCLUDED.instruction,
+ scope = EXCLUDED.scope,
+ updated_at = NOW()
+ """).bindparams(
+ note_id=note_id,
+ user_id=user_id,
+ proposed_body=proposed_body,
+ original_body=original_body,
+ instruction=instruction,
+ scope=scope,
+ )
+ )
+ await session.commit()
+ result = await session.execute(
+ select(NoteDraft).where(
+ NoteDraft.note_id == note_id, NoteDraft.user_id == user_id
+ )
+ )
+ return result.scalars().first()
+
+
+async def get_draft(user_id: int, note_id: int) -> NoteDraft | None:
+ async with async_session() as session:
+ result = await session.execute(
+ select(NoteDraft).where(
+ NoteDraft.note_id == note_id, NoteDraft.user_id == user_id
+ )
+ )
+ return result.scalars().first()
+
+
+async def delete_draft(user_id: int, note_id: int) -> bool:
+ async with async_session() as session:
+ result = await session.execute(
+ select(NoteDraft).where(
+ NoteDraft.note_id == note_id, NoteDraft.user_id == user_id
+ )
+ )
+ draft = result.scalars().first()
+ if draft is None:
+ return False
+ await session.delete(draft)
+ await session.commit()
+ return True
diff --git a/src/fabledassistant/services/note_versions.py b/src/fabledassistant/services/note_versions.py
new file mode 100644
index 0000000..77bf4a2
--- /dev/null
+++ b/src/fabledassistant/services/note_versions.py
@@ -0,0 +1,51 @@
+from sqlalchemy import select, text
+
+from fabledassistant.models import async_session
+from fabledassistant.models.note_version import NoteVersion
+
+MAX_VERSIONS = 20
+
+
+async def create_version(user_id: int, note_id: int, body: str, title: str) -> NoteVersion:
+ async with async_session() as session:
+ version = NoteVersion(note_id=note_id, user_id=user_id, body=body, title=title)
+ session.add(version)
+ await session.commit()
+ await session.refresh(version)
+
+ # Prune versions beyond MAX_VERSIONS
+ await session.execute(
+ text("""
+ DELETE FROM note_versions
+ WHERE id IN (
+ SELECT id FROM note_versions
+ WHERE note_id = :note_id AND user_id = :user_id
+ ORDER BY created_at DESC
+ OFFSET :max_versions
+ )
+ """).bindparams(note_id=note_id, user_id=user_id, max_versions=MAX_VERSIONS)
+ )
+ await session.commit()
+ return version
+
+
+async def list_versions(user_id: int, note_id: int) -> list[NoteVersion]:
+ async with async_session() as session:
+ result = await session.execute(
+ select(NoteVersion)
+ .where(NoteVersion.note_id == note_id, NoteVersion.user_id == user_id)
+ .order_by(NoteVersion.created_at.desc())
+ )
+ return list(result.scalars().all())
+
+
+async def get_version(user_id: int, note_id: int, version_id: int) -> NoteVersion | None:
+ async with async_session() as session:
+ result = await session.execute(
+ select(NoteVersion).where(
+ NoteVersion.id == version_id,
+ NoteVersion.note_id == note_id,
+ NoteVersion.user_id == user_id,
+ )
+ )
+ return result.scalars().first()
diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py
index 7791fb1..3c719bd 100644
--- a/src/fabledassistant/services/notes.py
+++ b/src/fabledassistant/services/notes.py
@@ -194,6 +194,9 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
note = result.scalars().first()
if note is None:
return None
+ # Snapshot before changes for version creation
+ old_body = note.body
+ old_title = note.title
for key, value in fields.items():
if not hasattr(note, key):
continue
@@ -207,7 +210,13 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
- return note
+
+ # Create a version snapshot when body actually changes
+ if "body" in fields and fields["body"] != old_body:
+ from fabledassistant.services.note_versions import create_version
+ await create_version(user_id, note_id, old_body, old_title)
+
+ return note
async def delete_note(user_id: int, note_id: int) -> bool:
diff --git a/summary.md b/summary.md
index 219d2dc..43568e4 100644
--- a/summary.md
+++ b/summary.md
@@ -12,7 +12,25 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
-2026-03-05 — Task Work Log, writing assistant improvements, task editor UI overhaul.
+2026-03-05 — Note editor sidebar layout, full-document writing assist, persistent drafts, version history, context window bump.
+
+**Note editor sidebar layout:** `NoteEditorView.vue` fully refactored into two-column layout matching `TaskEditorView`. Right sidebar (280px) contains Project, Milestone, Tags + tag suggestions, and the Writing Assistant panel (always visible, no toggle). Mobile collapses to accordion. Removed `assistOpen` ref and `✨ Assist` toggle button. `InlineAssistPanel` removed from note editor.
+
+**Full-document writing assist:** `services/assist.py` — new `whole_doc: bool` param with a separate system prompt that instructs the model to output the complete revised document. `routes/notes.py` — `whole_doc` accepted from POST body; validation updated (section mode requires `target_section`, both modes require `instruction`). `composables/useAssist.ts` — added `scopeMode` ref (`'document' | 'section'`), `proposedFullBody` ref (full body after section replacement or whole-doc output), updated `diff` to always compare full document snapshots (`bodySnapshot → proposedFullBody`). Scope dropdown in sidebar drives `scopeMode` and `selectedSection`. `canSubmit` no longer requires a target in document mode. `selectSection()` and `selectTextRange()` now set `scopeMode = 'section'` automatically.
+
+**DiffView.vue:** New standalone component (`frontend/src/components/DiffView.vue`). Full-width, flex-fills available height. Sticky summary bar (insertions / deletions count). Scrollable monospace diff body. Replaces the editor main area during review state.
+
+**Main area state switching (NoteEditorView):** streaming → stream preview (rendered markdown); review → `DiffView` full-width; idle → normal Write/Preview/TiptapEditor.
+
+**Persistent drafts — backend:** Migration `0022_add_note_versions_and_drafts.py` — `note_drafts` table (UNIQUE per `note_id + user_id`; fields: `proposed_body`, `original_body`, `instruction`, `scope`) and `note_versions` table (max 20 per note; fields: `body`, `title`, `created_at`). Models `models/note_draft.py`, `models/note_version.py`. Added imports to `models/__init__.py`. Services `services/note_drafts.py` (`upsert_draft` via INSERT … ON CONFLICT, `get_draft`, `delete_draft`) and `services/note_versions.py` (`create_version` with auto-prune to 20, `list_versions`, `get_version`). `services/notes.py` `update_note()` — snapshots old body/title before applying changes; calls `create_version` when body actually changes. Routes in `routes/notes.py`: `GET/PUT/DELETE /api/notes//draft`, `GET /api/notes//versions`, `GET /api/notes//versions/`.
+
+**Persistent drafts — frontend:** `useAssist.ts` — accepts optional `noteId: Ref` second param; saves draft via `PUT /api/notes//draft` after `done` event; `loadDraft(draft)` restores `bodySnapshot`, `proposedFullBody`, `instruction`, `scopeMode`, and sets state to `'review'`; `accept()` and `reject()` call `DELETE /api/notes//draft`. `NoteEditorView.vue` — on mount, after note loads, fetches draft and calls `assist.loadDraft()` if one exists. `NoteDraft` interface exported from `useAssist.ts`; `NoteDraft` + `NoteVersion` interfaces added to `frontend/src/types/task.ts`.
+
+**Version history browser:** `HistoryPanel.vue` — wide modal (900px, 80vh), version list on left (lazy body loading), `DiffView` on right (selected version vs current body), Restore button emits body to parent. `NoteEditorView.vue` — "History" toolbar button, renders `` with restore handler (`body = $event; markDirty()`).
+
+**Context window + output token limits:** `OLLAMA_NUM_CTX` default raised from 16384 to 65536 in `config.py` (overridable via env var; comment updated to remove stale VRAM reference). `run_assist_generation` in `generation_task.py` — `num_predict` changed from hardcoded 4096 to `Config.OLLAMA_NUM_CTX`, so assist output budget always matches the configured context window.
+
+**Previous session (2026-03-05 earlier):** Fix writing assist: disable thinking mode, drop stuck-buffer 409.
**Task Work Log (backend):** New `task_logs` table (migration `0021_add_task_logs.py`) with FK to `notes(id)` CASCADE and `users(id)` CASCADE, indexes on `task_id` and `user_id`. SQLAlchemy model `models/task_log.py`. Service `services/task_logs.py` with `create_log`, `list_logs`, `update_log` (uses `_UNSET` sentinel so `None` can explicitly clear `duration_minutes`), `delete_log` — all ownership-checked. Blueprint `routes/task_logs.py` registered in `app.py` at `/api/tasks//logs` (GET, POST, PATCH `/`, DELETE `/`). LLM tool `log_work` added to `_CORE_TOOLS` in `services/tools.py`: resolves task by title, calls `create_log`, returns `{success, log, task}`.