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
+7 -1
View File
@@ -31,6 +31,8 @@ struct Conversation: Identifiable, Codable {
var updatedAt: Date
var primaryModel: String? // Primary model used in this conversation
var folderId: UUID? // Folder this conversation is filed under, if any
var notesEnabled: Bool // Whether the per-conversation notes.md feature is on
var notesFilename: String? // Filename under Application Support/oAI/notes/, if notes have ever been created
nonisolated init(
id: UUID = UUID(),
@@ -39,7 +41,9 @@ struct Conversation: Identifiable, Codable {
createdAt: Date = Date(),
updatedAt: Date = Date(),
primaryModel: String? = nil,
folderId: UUID? = nil
folderId: UUID? = nil,
notesEnabled: Bool = false,
notesFilename: String? = nil
) {
self.id = id
self.name = name
@@ -48,6 +52,8 @@ struct Conversation: Identifiable, Codable {
self.updatedAt = updatedAt
self.primaryModel = primaryModel
self.folderId = folderId
self.notesEnabled = notesEnabled
self.notesFilename = notesFilename
}
var messageCount: Int {
@@ -285,6 +285,18 @@
<dd>Enable/disable write permissions</dd>
</dl>
<h3>Conversation Notes Commands</h3>
<dl class="commands">
<dt>/notes on</dt>
<dd>Enable a persistent notes.md file for this conversation. The AI reads it automatically every turn and can update it on its own, with no per-write approval — turning it on is the only consent step</dd>
<dt>/notes off</dt>
<dd>Disable automatic reading/writing of this conversation's notes (the file itself is kept)</dd>
<dt>/notes show</dt>
<dd>Display this conversation's current notes in the chat</dd>
</dl>
<h3>Shortcuts &amp; Skills Commands</h3>
<dl class="commands">
<dt>/shortcuts</dt>
@@ -0,0 +1,92 @@
//
// ConversationNotesService.swift
// Confab
//
// Manages per-conversation notes.md files in Application Support/oAI/notes/
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use including selling Confab or any part of
// it, standalone or bundled into another product or service
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import Foundation
import AppKit
/// Stores each conversation's notes.md as a single file under
/// `~/Library/Application Support/oAI/notes/`. The filename is a human-readable
/// courtesy for anyone browsing in Finder; the conversation's UUID is embedded in the
/// file content itself (a `**ID**:` header, same convention as `ConversationExport`) so
/// identity never depends on the filename surviving a conversation rename.
///
/// All operations are best-effort a missing or unreadable file is never an error,
/// since notes files are explicitly meant to tolerate being renamed, edited, or
/// deleted by hand outside the app.
final class ConversationNotesService {
nonisolated static let shared = ConversationNotesService()
private let baseDirectory: URL = {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first!
return appSupport.appendingPathComponent("oAI/notes", isDirectory: true)
}()
private func ensureDirectory() {
try? FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true)
}
/// A human-readable filename derived from the conversation's name, with a short
/// ID suffix so two same-named conversations never collide.
func makeFilename(conversationName: String, conversationId: UUID) -> String {
let base = conversationName.sanitizedForFilename().nonEmptyOrNil ?? "Untitled"
let suffix = conversationId.uuidString.prefix(4).lowercased()
return "\(base)-\(suffix).md"
}
/// Returns the notes body (with the embedded ID header stripped), or nil if the
/// file doesn't exist or can't be read.
func readBody(filename: String) -> String? {
let url = baseDirectory.appendingPathComponent(filename)
guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil }
return Self.stripIDHeader(from: content)
}
/// Writes the full notes body, prefixed with the conversation's embedded ID header.
func write(body: String, filename: String, conversationId: UUID) {
ensureDirectory()
let content = "**ID**: `\(conversationId.uuidString)`\n\n\(body)"
let url = baseDirectory.appendingPathComponent(filename)
try? content.write(to: url, atomically: true, encoding: .utf8)
}
func delete(filename: String) {
let url = baseDirectory.appendingPathComponent(filename)
try? FileManager.default.removeItem(at: url)
}
/// Opens the notes folder in Finder (Settings Advanced "Open Notes Folder").
func openNotesFolder() {
ensureDirectory()
NSWorkspace.shared.open(baseDirectory)
}
nonisolated static func stripIDHeader(from content: String) -> String {
guard content.hasPrefix("**ID**: `") else { return content }
var lines = content.components(separatedBy: "\n").dropFirst()
if lines.first?.isEmpty == true {
lines = lines.dropFirst()
}
return lines.joined(separator: "\n")
}
}
+55 -8
View File
@@ -36,6 +36,8 @@ struct ConversationRecord: Codable, FetchableRecord, PersistableRecord, Sendable
var updatedAt: String
var primaryModel: String?
var folderId: String?
var notesEnabled: Bool = false
var notesFilename: String?
}
struct FolderRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
@@ -392,6 +394,16 @@ final class DatabaseService: Sendable {
try db.execute(sql: "UPDATE folders SET updatedAt = createdAt WHERE updatedAt IS NULL")
}
migrator.registerMigration("v12") { db in
// Per-conversation notes.md: opt-in persistent memory file, auto-read/written by the
// model. notesFilename is a local lookup pointer only the file's embedded **ID**
// line is the actual source of truth (see ConversationNotesService).
try db.alter(table: "conversations") { t in
t.add(column: "notesEnabled", .boolean).notNull().defaults(to: false)
t.add(column: "notesFilename", .text)
}
}
return migrator
}
@@ -471,7 +483,9 @@ final class DatabaseService: Sendable {
createdAt: nowString,
updatedAt: nowString,
primaryModel: primaryModel,
folderId: folderId?.uuidString
folderId: folderId?.uuidString,
notesEnabled: false,
notesFilename: nil
)
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
@@ -585,7 +599,9 @@ final class DatabaseService: Sendable {
createdAt: createdAt,
updatedAt: updatedAt,
primaryModel: convRecord.primaryModel,
folderId: convRecord.folderId.flatMap { UUID(uuidString: $0) }
folderId: convRecord.folderId.flatMap { UUID(uuidString: $0) },
notesEnabled: convRecord.notesEnabled,
notesFilename: convRecord.notesFilename
)
return (conversation, messages)
@@ -628,7 +644,9 @@ final class DatabaseService: Sendable {
createdAt: createdAt,
updatedAt: lastDate,
primaryModel: primaryModel,
folderId: record.folderId.flatMap { UUID(uuidString: $0) }
folderId: record.folderId.flatMap { UUID(uuidString: $0) },
notesEnabled: record.notesEnabled,
notesFilename: record.notesFilename
)
conv.updatedAt = lastDate
return conv
@@ -768,6 +786,24 @@ final class DatabaseService: Sendable {
}
}
nonisolated func setNotesEnabled(id: UUID, enabled: Bool) throws {
try dbQueue.write { db in
try db.execute(
sql: "UPDATE conversations SET notesEnabled = ? WHERE id = ?",
arguments: [enabled, id.uuidString]
)
}
}
nonisolated func setNotesFilename(id: UUID, filename: String?) throws {
try dbQueue.write { db in
try db.execute(
sql: "UPDATE conversations SET notesFilename = ? WHERE id = ?",
arguments: [filename, id.uuidString]
)
}
}
// MARK: - Usage Statistics
nonisolated func getOverallUsageStats() throws -> UsageStats {
@@ -882,22 +918,33 @@ final class DatabaseService: Sendable {
nonisolated func deleteConversation(id: UUID) throws -> Bool {
Log.db.info("Deleting conversation \(id.uuidString)")
return try dbQueue.write { db in
let result = try dbQueue.write { db -> (Bool, String?) in
let notesFilename = try ConversationRecord.fetchOne(db, key: id.uuidString)?.notesFilename
try MessageRecord.filter(Column("conversationId") == id.uuidString).deleteAll(db)
return try ConversationRecord.deleteOne(db, key: id.uuidString)
let deleted = try ConversationRecord.deleteOne(db, key: id.uuidString)
return (deleted, notesFilename)
}
if let notesFilename = result.1 {
ConversationNotesService.shared.delete(filename: notesFilename)
}
return result.0
}
nonisolated func deleteConversation(name: String) throws -> Bool {
try dbQueue.write { db in
let result = try dbQueue.write { db -> (Bool, String?) in
guard let record = try ConversationRecord
.filter(Column("name") == name)
.fetchOne(db)
else { return false }
else { return (false, nil) }
try MessageRecord.filter(Column("conversationId") == record.id).deleteAll(db)
return try ConversationRecord.deleteOne(db, key: record.id)
let deleted = try ConversationRecord.deleteOne(db, key: record.id)
return (deleted, record.notesFilename)
}
if let notesFilename = result.1 {
ConversationNotesService.shared.delete(filename: notesFilename)
}
return result.0
}
nonisolated func updateConversation(id: UUID, name: String?, messages: [Message]?) throws -> Bool {
+1 -3
View File
@@ -852,9 +852,7 @@ class GitSyncService {
}
func sanitizeFilename(_ name: String) -> String {
// Remove invalid filename characters
let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|")
return name.components(separatedBy: invalid).joined(separator: "-")
name.sanitizedForFilename()
}
static func extractProvider(from url: String) -> String {
@@ -107,4 +107,12 @@ extension String {
let endIndex = index(startIndex, offsetBy: length - trailing.count)
return String(self[..<endIndex]) + trailing
}
// MARK: - Filename Sanitization
/// Replaces characters invalid in filenames (on macOS/most filesystems) with "-".
func sanitizedForFilename() -> String {
let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|")
return components(separatedBy: invalid).joined(separator: "-")
}
}
+159 -6
View File
@@ -150,6 +150,10 @@ class ChatViewModel {
var currentConversationName: String? = nil
private var savedMessageCount: Int = 0
// Per-conversation notes.md (see ConversationNotesService)
var notesEnabled: Bool = false
var notesFilename: String? = nil
var hasUnsavedChanges: Bool {
let chatCount = messages.filter { $0.role != .system }.count
return chatCount > 0 && chatCount != savedMessageCount
@@ -249,8 +253,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
settings.customPromptMode == .replace,
let customPrompt = settings.systemPrompt,
!customPrompt.isEmpty {
// BYOP: Use ONLY the custom prompt
return customPrompt
// BYOP: use ONLY the custom prompt, but conversation notes are a non-overridable
// instruction they must survive even when the user has replaced everything else.
return customPrompt + Self.buildNotesSection(body: currentNotesBody)
}
// Otherwise, build the prompt: default + conditional sections + custom (if append mode)
@@ -295,9 +300,83 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
}
// Append conversation notes (see ConversationNotesService). Deliberately outside the
// modelSupportsTools gate above fenced-block writes exist specifically so tool-incapable
// models (e.g. Apple On-Device) can still use this.
prompt += Self.buildNotesSection(body: currentNotesBody)
return prompt
}
/// The current conversation's notes body if notes are enabled, nil otherwise. Empty string
/// means notes are on but nothing has been written yet.
private var currentNotesBody: String? {
guard notesEnabled, let filename = notesFilename else { return nil }
return ConversationNotesService.shared.readBody(filename: filename) ?? ""
}
/// Builds the "## Conversation Notes" system prompt section. Pure/testable: nil body means
/// notes are off for this conversation and nothing is appended.
nonisolated static func buildNotesSection(body: String?) -> String {
guard let body else { return "" }
return """
---
## Conversation Notes
You maintain a persistent memory file for this specific conversation, saved outside the chat and re-read at the start of every turn. Use it for durable facts, preferences, or context worth keeping across the whole conversation — not a transcript, not every detail.
To update it, include this block anywhere in your reply — it is invisible to the user and will not appear in the chat:
```update-notes
<the complete new contents of the notes file>
```
Only include the block when you actually want to change the notes. Each one replaces the previous contents entirely, so include everything worth keeping, not just what changed. If the user directly asks you to add, change, or remove something from the notes, comply using this same mechanism.
Current notes:
\(body.isEmpty ? "(empty — nothing saved yet)" : body)
"""
}
/// Detects a ```update-notes fenced block in a finalized assistant message, strips it from
/// the text that will actually be displayed, and returns its body separately so it can be
/// persisted via ConversationNotesService. Only call this once a message is fully finalized
/// (never on in-flight streaming deltas) stripping mid-stream would flash the block and
/// then remove it.
nonisolated static func extractNotesUpdate(from content: String) -> (display: String, notesBody: String?) {
let pattern = #"```update-notes\s*\n([\s\S]*?)```"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)),
let bodyRange = Range(match.range(at: 1), in: content),
let fullRange = Range(match.range(at: 0), in: content)
else {
return (content, nil)
}
let body = String(content[bodyRange]).trimmingCharacters(in: .whitespacesAndNewlines)
var display = content
display.removeSubrange(fullRange)
display = display.trimmingCharacters(in: .whitespacesAndNewlines)
return (display, body)
}
/// Applies extractNotesUpdate at message finalization: writes any extracted notes body to
/// disk and returns the content with the fenced block stripped. No-op (returns content
/// unchanged) unless notes are enabled and the conversation has an ID and filename.
private func applyNotesUpdateIfNeeded(_ content: String) -> String {
guard notesEnabled, let conversationId = currentConversationId, let filename = notesFilename else {
return content
}
let (display, notesBody) = Self.extractNotesUpdate(from: content)
if let notesBody {
ConversationNotesService.shared.write(body: notesBody, filename: filename, conversationId: conversationId)
}
return display
}
// MARK: - Initialization
init() {
@@ -350,6 +429,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
currentConversationId = nil
currentConversationName = nil
savedMessageCount = 0
notesEnabled = false
notesFilename = nil
}
/// Re-sync local state from SettingsService (called when Settings sheet dismisses)
@@ -499,7 +580,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
private func performLoadConversation(_ conversation: Conversation) {
do {
guard let (_, loadedMessages) = try DatabaseService.shared.loadConversation(id: conversation.id) else {
guard let (loadedConversation, loadedMessages) = try DatabaseService.shared.loadConversation(id: conversation.id) else {
showSystemMessage("Could not load conversation '\(conversation.name)'")
return
}
@@ -513,6 +594,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
currentConversationId = conversation.id
currentConversationName = conversation.name
savedMessageCount = loadedMessages.filter { $0.role != .system }.count
notesEnabled = loadedConversation.notesEnabled
notesFilename = loadedConversation.notesFilename
// Rebuild session stats from loaded messages
for msg in loadedMessages {
@@ -664,6 +747,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
currentConversationId = saved.id
currentConversationName = details.name
savedMessageCount = chatMessages.count
notesEnabled = false
notesFilename = nil
showSystemMessage("Saved as \"\(details.name)\"")
Task { await GitSyncService.shared.autoSync() }
} catch {
@@ -794,6 +879,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
currentConversationId = saved.id
currentConversationName = name
savedMessageCount = chatMessages.count
notesEnabled = false
notesFilename = nil
showSystemMessage("Conversation saved as '\(name)'")
} catch {
showSystemMessage("Failed to save: \(error.localizedDescription)")
@@ -859,6 +946,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
case "/mcp":
handleMCPCommand(args: args)
case "/notes":
handleNotesCommand(args: args)
default:
// Check user-defined shortcuts
if let shortcut = settings.userShortcuts.first(where: { $0.command == cmd.lowercased() }) {
@@ -1045,7 +1135,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
let responseTime = Date().timeIntervalSince(startTime)
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = response.content
messages[index].content = applyNotesUpdateIfNeeded(response.content)
messages[index].isStreaming = false
messages[index].generatedImages = response.generatedImages
messages[index].responseTime = responseTime
@@ -1106,7 +1196,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
let responseTime = Date().timeIntervalSince(startTime)
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = fullContent
messages[index].content = applyNotesUpdateIfNeeded(fullContent)
messages[index].isStreaming = false
messages[index].responseTime = responseTime
messages[index].wasInterrupted = wasCancelled
@@ -1379,6 +1469,67 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
}
// MARK: - Notes Command Handling
private func handleNotesCommand(args: [String]) {
guard let sub = args.first?.lowercased() else {
showSystemMessage("Usage: /notes on|off|show")
return
}
switch sub {
case "on":
guard let conversationId = currentConversationId else {
showSystemMessage("Send a message first so this conversation is saved, then try /notes on")
return
}
do {
var filename = notesFilename
if filename == nil {
let newFilename = ConversationNotesService.shared.makeFilename(
conversationName: currentConversationName ?? "Untitled",
conversationId: conversationId
)
ConversationNotesService.shared.write(body: "", filename: newFilename, conversationId: conversationId)
try DatabaseService.shared.setNotesFilename(id: conversationId, filename: newFilename)
filename = newFilename
}
try DatabaseService.shared.setNotesEnabled(id: conversationId, enabled: true)
notesFilename = filename
notesEnabled = true
showSystemMessage("Notes enabled for this conversation")
} catch {
showSystemMessage("Failed to enable notes: \(error.localizedDescription)")
}
case "off":
guard let conversationId = currentConversationId else {
showSystemMessage("No active conversation")
return
}
do {
try DatabaseService.shared.setNotesEnabled(id: conversationId, enabled: false)
notesEnabled = false
showSystemMessage("Notes disabled for this conversation")
} catch {
showSystemMessage("Failed to disable notes: \(error.localizedDescription)")
}
case "show":
guard notesEnabled, let filename = notesFilename,
let body = ConversationNotesService.shared.readBody(filename: filename),
!body.isEmpty
else {
showSystemMessage("No notes yet for this conversation")
return
}
showSystemMessage("📝 Notes:\n\n\(body)")
default:
showSystemMessage("Usage: /notes on|off|show")
}
}
// MARK: - AI Response with Tool Calls
// MARK: - Images API Generation
@@ -1703,7 +1854,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
} else {
let assistantMessage = Message(
role: .assistant,
content: finalContent,
content: applyNotesUpdateIfNeeded(finalContent),
tokens: totalUsage?.completionTokens,
cost: nil,
timestamp: Date(),
@@ -2235,6 +2386,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
currentConversationId = saved.id
currentConversationName = details.name
savedMessageCount = chatMessages.count
notesEnabled = false
notesFilename = nil
showSystemMessage("Saved as \"\(details.name)\"")
Task { await GitSyncService.shared.autoSync() }
return true
+4
View File
@@ -54,6 +54,7 @@ struct InputBar: View {
"/memory on", "/memory off", "/online on", "/online off",
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
"/mcp write on", "/mcp write off",
"/notes on", "/notes off", "/notes show",
"/export md", "/export html", "/export pdf", "/export json",
]
@@ -303,6 +304,9 @@ struct CommandSuggestionsView: View {
("/mcp add", "Add folder for MCP"),
("/mcp write on", "Enable MCP write permissions"),
("/mcp write off", "Disable MCP write permissions"),
("/notes on", "Enable persistent notes for this conversation"),
("/notes off", "Disable persistent notes for this conversation"),
("/notes show", "Show this conversation's notes"),
]
static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
+20
View File
@@ -178,6 +178,26 @@ private let helpCategories: [CommandCategory] = [
examples: ["/mcp write on", "/mcp write off"]
),
]),
CommandCategory(name: "Conversation Notes", icon: "note.text", commands: [
CommandDetail(
command: "/notes on",
brief: "Enable notes for this conversation",
detail: "Turns on a persistent notes.md file for this specific conversation. Once on, the AI reads it automatically on every turn and can update it on its own — no approval needed per write. This is the only consent step; turning it on is always a deliberate, explicit action.",
examples: ["/notes on"]
),
CommandDetail(
command: "/notes off",
brief: "Disable notes for this conversation",
detail: "Turns off automatic reading and writing of this conversation's notes. The file itself isn't deleted — turning notes back on later picks up where it left off.",
examples: ["/notes off"]
),
CommandDetail(
command: "/notes show",
brief: "Show this conversation's notes",
detail: "Displays the current contents of this conversation's notes file in the chat, without leaving the app. Notes files also live in Settings > Advanced, where you can open the folder directly in Finder.",
examples: ["/notes show"]
),
]),
CommandCategory(name: "Integrations", icon: "server.rack", commands: [
CommandDetail(
command: "/jarvis",
+19
View File
@@ -1416,6 +1416,25 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
// Conversation Notes
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Conversation Notes")
formSection {
row("Notes Folder") {
Button("Open Notes Folder") {
ConversationNotesService.shared.openNotesFolder()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
Text("Each conversation can keep its own persistent notes.md file, read and written automatically by the AI once turned on with /notes on. Use this to browse or edit notes files directly in Finder.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
// Semantic Search
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Semantic Search")