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
+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