Add per-conversation notes.md

Gives each conversation an opt-in, persistent memory file the model reads
automatically every turn and writes to on its own initiative via a fenced
```update-notes``` block in its reply — no per-write approval, matching the
Confab-as-CLAUDE.md-for-itself concept Rune wanted. /notes on|off|show,
files live in ~/Library/Application Support/oAI/notes/, embedded ID header
for future Git Sync compatibility. Adds DB migration v12.
This commit is contained in:
2026-08-04 07:58:47 +02:00
parent 32e6ce3c37
commit 3414e37e24
14 changed files with 581 additions and 18 deletions
@@ -104,4 +104,56 @@ struct ChatViewModelPureLogicTests {
func draftFingerprintEmptyIsStable() {
#expect(ChatViewModel.draftFingerprint(for: []) == ChatViewModel.draftFingerprint(for: []))
}
// MARK: - buildNotesSection
@Test("Nil body produces no notes section at all")
func buildNotesSectionNilBodyIsEmpty() {
#expect(ChatViewModel.buildNotesSection(body: nil) == "")
}
@Test("Empty body still produces a section, with a placeholder for the notes content")
func buildNotesSectionEmptyBodyShowsPlaceholder() {
let section = ChatViewModel.buildNotesSection(body: "")
#expect(section.contains("## Conversation Notes"))
#expect(section.contains("nothing saved yet"))
}
@Test("Non-empty body is included verbatim")
func buildNotesSectionIncludesBody() {
let section = ChatViewModel.buildNotesSection(body: "User prefers dark roast coffee.")
#expect(section.contains("User prefers dark roast coffee."))
}
// MARK: - extractNotesUpdate
@Test("No fenced block leaves content untouched and returns no notes body")
func extractNotesUpdateNoBlock() {
let (display, body) = ChatViewModel.extractNotesUpdate(from: "Just a normal reply.")
#expect(display == "Just a normal reply.")
#expect(body == nil)
}
@Test("A fenced update-notes block is stripped from the display text and its body extracted")
func extractNotesUpdateStripsBlock() {
let content = "Sure, noted!\n\n```update-notes\nUser prefers dark roast coffee.\n```"
let (display, body) = ChatViewModel.extractNotesUpdate(from: content)
#expect(display == "Sure, noted!")
#expect(body == "User prefers dark roast coffee.")
}
@Test("Text surrounding the fenced block on both sides is preserved")
func extractNotesUpdatePreservesSurroundingText() {
let content = "Before text.\n```update-notes\nRemember this.\n```\nAfter text."
let (display, body) = ChatViewModel.extractNotesUpdate(from: content)
#expect(display == "Before text.\n\nAfter text.")
#expect(body == "Remember this.")
}
@Test("A block with the language tag but an empty body extracts an empty string, not nil")
func extractNotesUpdateEmptyBody() {
let content = "```update-notes\n```"
let (_, body) = ChatViewModel.extractNotesUpdate(from: content)
#expect(body == "")
}
}
@@ -0,0 +1,65 @@
//
// ConversationNotesServiceTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("ConversationNotesService")
struct ConversationNotesServiceTests {
@Test("makeFilename sanitizes the conversation name and appends a short ID suffix")
func makeFilenameSanitizesAndSuffixes() {
let id = UUID(uuidString: "A3F2B1C0-0000-0000-0000-000000000000")!
let filename = ConversationNotesService.shared.makeFilename(conversationName: "My Recipe Chat", conversationId: id)
#expect(filename == "My Recipe Chat-a3f2.md")
}
@Test("makeFilename sanitizes invalid filename characters in the conversation name")
func makeFilenameSanitizesInvalidChars() {
let id = UUID(uuidString: "A3F2B1C0-0000-0000-0000-000000000000")!
let filename = ConversationNotesService.shared.makeFilename(conversationName: "Q&A: What/Why?", conversationId: id)
#expect(filename == "Q&A- What-Why--a3f2.md")
}
@Test("makeFilename falls back to Untitled for an empty conversation name")
func makeFilenameFallsBackForEmptyName() {
let id = UUID(uuidString: "A3F2B1C0-0000-0000-0000-000000000000")!
let filename = ConversationNotesService.shared.makeFilename(conversationName: "", conversationId: id)
#expect(filename == "Untitled-a3f2.md")
}
@Test("write then readBody round-trips the body with the ID header stripped")
func writeReadRoundTrip() {
let id = UUID()
let filename = "test-notes-\(UUID().uuidString).md"
defer { ConversationNotesService.shared.delete(filename: filename) }
ConversationNotesService.shared.write(body: "User prefers dark roast coffee.", filename: filename, conversationId: id)
let body = ConversationNotesService.shared.readBody(filename: filename)
#expect(body == "User prefers dark roast coffee.")
}
@Test("readBody on a nonexistent file returns nil, not an error")
func readBodyMissingFileReturnsNil() {
let body = ConversationNotesService.shared.readBody(filename: "definitely-does-not-exist-\(UUID().uuidString).md")
#expect(body == nil)
}
@Test("stripIDHeader removes the embedded ID line and following blank line")
func stripIDHeaderRemovesHeader() {
let content = "**ID**: `12345678-1234-1234-1234-123456789012`\n\nActual notes content."
#expect(ConversationNotesService.stripIDHeader(from: content) == "Actual notes content.")
}
@Test("stripIDHeader leaves content without the header untouched")
func stripIDHeaderLeavesUnheaderedContentUntouched() {
let content = "Just some content, no header."
#expect(ConversationNotesService.stripIDHeader(from: content) == content)
}
}
+58
View File
@@ -487,3 +487,61 @@ struct DatabaseServiceFolderTests {
#expect(conversations.first?.folderId == folder.id)
}
}
@Suite("DatabaseService per-conversation notes (v12), against a throwaway in-memory queue")
struct DatabaseServiceNotesTests {
@Test("conversations table has notesEnabled and notesFilename columns after v12")
func v12AddsNotesColumns() {
let db = DatabaseService.makeInMemory()
let columns = Set(db.columnNames(in: "conversations"))
#expect(columns.contains("notesEnabled"))
#expect(columns.contains("notesFilename"))
}
@Test("A newly saved conversation defaults to notes disabled with no filename")
func newConversationDefaultsToNotesDisabled() throws {
let db = DatabaseService.makeInMemory()
let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
#expect(saved.notesEnabled == false)
#expect(saved.notesFilename == nil)
let loaded = try db.loadConversation(id: saved.id)
#expect(loaded?.0.notesEnabled == false)
#expect(loaded?.0.notesFilename == nil)
}
@Test("setNotesEnabled persists and round-trips through loadConversation")
func setNotesEnabledRoundTrips() throws {
let db = DatabaseService.makeInMemory()
let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
try db.setNotesEnabled(id: saved.id, enabled: true)
let loaded = try db.loadConversation(id: saved.id)
#expect(loaded?.0.notesEnabled == true)
}
@Test("setNotesFilename persists and round-trips through loadConversation")
func setNotesFilenameRoundTrips() throws {
let db = DatabaseService.makeInMemory()
let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
try db.setNotesFilename(id: saved.id, filename: "Chat-a3f2.md")
let loaded = try db.loadConversation(id: saved.id)
#expect(loaded?.0.notesFilename == "Chat-a3f2.md")
}
@Test("listConversations reflects notesEnabled and notesFilename")
func listConversationsReflectsNotes() throws {
let db = DatabaseService.makeInMemory()
let saved = try db.saveConversation(name: "Chat", messages: [Message(role: .user, content: "hi")])
try db.setNotesEnabled(id: saved.id, enabled: true)
try db.setNotesFilename(id: saved.id, filename: "Chat-a3f2.md")
let conversations = try db.listConversations()
#expect(conversations.first?.notesEnabled == true)
#expect(conversations.first?.notesFilename == "Chat-a3f2.md")
}
}
+29
View File
@@ -0,0 +1,29 @@
//
// StringExtensionsTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
@testable import Confab
@Suite("String.sanitizedForFilename")
struct StringExtensionsTests {
@Test("Invalid filename characters are replaced with a dash")
func sanitizedForFilenameStripsInvalidChars() {
#expect("a/b\\c:d*e?f\"g<h>i|j".sanitizedForFilename() == "a-b-c-d-e-f-g-h-i-j")
}
@Test("A valid filename passes through unchanged")
func sanitizedForFilenameValidInput() {
#expect("My Chat 2026-01-15".sanitizedForFilename() == "My Chat 2026-01-15")
}
@Test("GitSyncService.sanitizeFilename delegates to the shared extension")
func gitSyncServiceDelegatesToExtension() {
let service = GitSyncService.shared
#expect(service.sanitizeFilename("a/b\\c:d*e?f\"g<h>i|j") == "a-b-c-d-e-f-g-h-i-j".sanitizedForFilename())
}
}