Files
oai-swift/oAI/Services/ConversationNotesService.swift
T
rune 3414e37e24 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.
2026-08-04 07:58:47 +02:00

93 lines
3.8 KiB
Swift

//
// 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")
}
}