import AgentKit import Foundation /// The skills the user has installed. /// /// Its own store rather than more fields on `AIConfigurationModel`: that one is /// about endpoints you spend a key at, and this is a library of documents. They /// share nothing but the Settings page they appear on. /// /// The only writer. A skill reaches this model from the editor or from an import /// the user chose, and from nowhere else — no tool, no MCP server and no fetched /// page can reach it. `LoadSkillTool` depends on that: it is why a skill body is /// the one tool result that is not framed as untrusted data. @Observable public final class SkillLibraryModel { public private(set) var skills: [AgentSkill] = [] public private(set) var storageError: String? private var persistence: (any SkillLibraryPersisting)? private let seedDefaults: UserDefaults? public init( skills: [AgentSkill] = [], persistence: (any SkillLibraryPersisting)? = nil, seedDefaults: UserDefaults? = nil ) { self.skills = skills self.persistence = persistence self.seedDefaults = seedDefaults guard let persistence else { return } do { self.skills = try persistence.load() } catch { storageError = error.localizedDescription } seedBuiltInSkills() } /// Copies in the starter skills a release added, once. /// /// The generation mark is the whole mechanism, and it is what makes the /// built-ins genuinely the user's: seeding only ever installs skills newer /// than the mark, so deleting one is permanent and editing one is never /// undone. A release that adds a starter skill raises /// `AgentSkillLibrary.currentGeneration` and only that skill arrives. /// /// Skipped entirely without `seedDefaults` — previews, tests and the /// screenshot build get the library they were handed and nothing else. private func seedBuiltInSkills() { guard let seedDefaults else { return } let seeded = seedDefaults.integer(forKey: SharedSettingsKey.seededSkillGeneration) guard seeded < AgentSkillLibrary.currentGeneration else { return } for skill in AgentSkillLibrary.builtIns(after: seeded) { var installed = skill installed.name = availableName(basedOn: skill.name) upsert(installed) } // Written even when nothing was installed, so a failed save is the only // thing that can make this run twice. guard storageError == nil else { return } seedDefaults.set( AgentSkillLibrary.currentGeneration, forKey: SharedSettingsKey.seededSkillGeneration ) } // MARK: - Reads /// What a run may be offered. Incomplete drafts are excluded rather than /// listed as names with nothing to match on — see `AgentSkill.isComplete`. public var enabledSkills: [AgentSkill] { let conflicts = conflictingSkillIDs return skills.filter { $0.isEnabled && $0.isComplete && !conflicts.contains($0.id) } } /// The snapshot a run is given. Empty when nothing is enabled, which is what /// removes the tool and the catalog block entirely rather than sending an /// empty list. public var catalog: AgentSkillCatalog { AgentSkillCatalog(enabledSkills) } public func skill(_ id: AgentSkill.ID) -> AgentSkill? { skills.first { $0.id == id } } public func skill(named name: String) -> AgentSkill? { skills.first { $0.effectiveName == name } } public func hasNameConflict(_ name: String, excluding id: AgentSkill.ID) -> Bool { !name.isEmpty && skills.contains { $0.id != id && $0.effectiveName == name } } public var conflictingSkillIDs: Set { var names = Set() var conflicts = Set() for skill in skills.sorted(by: { $0.id.uuidString < $1.id.uuidString }) { if !skill.effectiveName.isEmpty, !names.insert(skill.effectiveName).inserted { conflicts.insert(skill.id) } } return conflicts } /// Only the trusted persistence/sync composition root supplies this snapshot. public func applyRemoteSnapshot(_ values: [AgentSkill]) { skills = values storageError = nil } // MARK: - Writes public func upsert(_ skill: AgentSkill) { mutatePersistedState { var updated = skill updated.updatedAt = .now if let index = skills.firstIndex(where: { $0.id == skill.id }) { skills[index] = updated } else { skills.append(updated) } } } /// Locks a valid, unique derived name without changing the UUID identity. /// The same move `AIConfigurationModel.lockMCPNamespace` makes, and for the /// same reason: the model-facing identifier must stop moving once anything /// could have referred to it. @discardableResult public func lockName(for id: AgentSkill.ID) -> Bool { guard let index = skills.firstIndex(where: { $0.id == id }) else { return false } if !skills[index].name.isEmpty { return true } let name = skills[index].effectiveName guard AgentSkillNaming.isValid(name), !hasNameConflict(name, excluding: id) else { return false } return mutatePersistedState { skills[index].name = name } } public func delete(id: AgentSkill.ID) { mutatePersistedState { skills.removeAll { $0.id == id } } } public func setEnabled(_ enabled: Bool, for id: AgentSkill.ID) { mutatePersistedState { guard let index = skills.firstIndex(where: { $0.id == id }) else { return } skills[index].isEnabled = enabled skills[index].updatedAt = .now } } /// Puts the library back to exactly the skills Termind ships with. /// /// A replacement, not a merge: everything the user wrote, imported or edited /// is discarded, and what is left is the starter set as released. That is /// the destructive reading of "reset", and it is the one the caller has to /// confirm out loud — nothing here is recoverable afterwards, because a /// skill's only copy lives in this store. /// /// It exists because seeding installs each starter skill exactly once and a /// deleted one never returns on its own. Without this there is no way back /// to a known state, only forward from whatever the library became. @discardableResult public func resetToBuiltInSkills() -> Int { let shipped = AgentSkillLibrary.builtIns(after: 0) let changed = mutatePersistedState { skills = shipped } guard changed else { return 0 } // Nothing left for seeding to add, whatever generation this store was // written by. seedDefaults?.set( AgentSkillLibrary.currentGeneration, forKey: SharedSettingsKey.seededSkillGeneration ) return shipped.count } /// Adds an imported document under a name that is free. /// /// A collision is resolved rather than refused: someone importing a second /// `pdf-processing` from another collection wants both, and a numbered name /// is a smaller surprise than a silent overwrite of the one they had. @discardableResult public func install(_ document: AgentSkillDocument, importedFrom: String) -> AgentSkill { let skill = AgentSkill( name: availableName(basedOn: document.name), title: Self.title(of: document), summary: document.description, body: document.body, importedFrom: importedFrom ) upsert(skill) return skill } private func availableName(basedOn name: String) -> String { var candidate = name var suffix = 2 while skills.contains(where: { $0.effectiveName == candidate }) { let room = AgentSkillNaming.maximumLength - "-\(suffix)".count candidate = "\(name.prefix(room))-\(suffix)" suffix += 1 } return candidate } /// The document's first Markdown heading, which is where a skill written for /// the standard puts its human-facing name — the format itself has no title /// field. Falls back to the name. private static func title(of document: AgentSkillDocument) -> String { for line in document.body.split(separator: "\n", omittingEmptySubsequences: false) { let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.hasPrefix("# ") else { if trimmed.isEmpty { continue } break } return String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces) } return document.name } // MARK: - Persistence /// Mutate, save, and put it back if the save failed. Lifted from /// `AIConfigurationModel.mutatePersistedState`: observable state that /// survived a failed write is state the user would go on editing. @discardableResult private func mutatePersistedState(_ mutation: () -> Void) -> Bool { let previous = skills mutation() guard let persistence else { return true } do { try persistence.save(skills) storageError = nil return true } catch { skills = previous storageError = error.localizedDescription return false } } }