2.5.1 #11
@@ -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 & 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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "-")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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