File size: 4,041 Bytes
7b2dfc5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
import Foundation

enum AssistantPersistenceError: LocalizedError, Sendable {
  case applicationSupportDirectoryUnavailable
  case unsupportedSchemaVersion(Int)

  var errorDescription: String? {
    switch self {
    case .applicationSupportDirectoryUnavailable:
      "The app's Application Support directory is unavailable."
    case .unsupportedSchemaVersion(let version):
      "The saved assistant workspace uses unsupported schema version \(version)."
    }
  }
}

/// Owns the durable assistant workspace. All disk access is serialized by the
/// actor and writes use an atomic replacement so an interrupted save cannot
/// leave a partially written JSON document.
actor AssistantPersistence {
  private struct Envelope: Codable, Sendable {
    let schemaVersion: Int
    let workspace: AssistantWorkspace
  }

  private static let currentSchemaVersion = 5

  nonisolated let fileURL: URL

  init(fileURL: URL? = nil, fileManager: FileManager = .default) throws {
    if let fileURL {
      self.fileURL = fileURL
      return
    }

    guard
      let applicationSupport = fileManager.urls(
        for: .applicationSupportDirectory,
        in: .userDomainMask
      ).first
    else {
      throw AssistantPersistenceError.applicationSupportDirectoryUnavailable
    }

    self.fileURL = applicationSupport
      .appendingPathComponent("DolphinAssistant", isDirectory: true)
      .appendingPathComponent("workspace.json", isDirectory: false)
  }

  /// Returns an empty workspace on first launch. Decode and filesystem errors
  /// are surfaced to the caller so corrupt state is never silently discarded.
  func load() throws -> AssistantWorkspace {
    let fileManager = FileManager.default
    guard fileManager.fileExists(atPath: fileURL.path) else {
      return .empty
    }

    let data = try Data(contentsOf: fileURL, options: [.mappedIfSafe])
    let envelope = try Self.makeDecoder().decode(Envelope.self, from: data)
    switch envelope.schemaVersion {
    case Self.currentSchemaVersion:
      return envelope.workspace
    case 4, 3, 2:
      return envelope.workspace
    case 1:
      return Self.migrateSchemaOne(envelope.workspace)
    default:
      throw AssistantPersistenceError.unsupportedSchemaVersion(
        envelope.schemaVersion
      )
    }
  }

  func save(_ workspace: AssistantWorkspace) throws {
    let fileManager = FileManager.default
    let directoryURL = fileURL.deletingLastPathComponent()
    try fileManager.createDirectory(
      at: directoryURL,
      withIntermediateDirectories: true,
      attributes: nil
    )

    let envelope = Envelope(
      schemaVersion: Self.currentSchemaVersion,
      workspace: workspace
    )
    let data = try Self.makeEncoder().encode(envelope)
    try data.write(
      to: fileURL,
      options: [.atomic, .completeFileProtectionUnlessOpen]
    )
  }

  private static func makeEncoder() -> JSONEncoder {
    let encoder = JSONEncoder()
    // Numeric seconds preserve Date's full Codable precision for exact
    // workspace round trips (ISO-8601's default strategy drops fractions).
    encoder.dateEncodingStrategy = .secondsSince1970
    encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
    return encoder
  }

  private static func makeDecoder() -> JSONDecoder {
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .secondsSince1970
    return decoder
  }

  private static func migrateSchemaOne(
    _ legacyWorkspace: AssistantWorkspace
  ) -> AssistantWorkspace {
    var workspace = legacyWorkspace
    if workspace.settings.maxNewTokens
      == AgentSettings.legacyDefaultMaxNewTokens
    {
      workspace.settings.maxNewTokens = AgentSettings.defaultMaxNewTokens
    }
    workspace.messages = workspace.messages.compactMap { message in
      guard message.role == .assistant else { return message }
      var cleaned = message
      cleaned.content = AssistantChatText.cleaned(message.content)
      return cleaned.content.isEmpty ? nil : cleaned
    }
    return workspace
  }
}