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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user