Files
Termind/Frameworks/TermindApplication/FeatureModels/SkillLibraryModel.swift
T
Kaede Akino e9c269ab5e Replace starter skills with built-in skill helpers
Keep previously installed starter skills as ordinary user-owned records. Provide hidden read-only Skill Creator and Skill Installer instructions, use AgentKit 0.13.0 for reviewed complete GitHub package installation, and remove skill seeding and Reset.

Move Hermes Memory below MCP, use Enabled for its switch, and localize its Chinese title as persistent memory. Preserve existing user edits and synchronize user skill packages through the existing atomic store.

Validation: AgentKit 213 tests and iOS 452 tests passed. macOS app 1183 tests passed; shared frameworks passed 231/232 with the existing SpeechWaveformViewTests PNG byte-equality failure. Related skill, sync, localization and UI checks passed. Strict Swift formatting, Chinese localization checks, and git diff --check passed.
2026-09-17 08:34:10 +08:00

235 lines
8.8 KiB
Swift

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.
///
/// Settings and authorized skill tools share this atomic persistence boundary.
@Observable @MainActor
public final class SkillLibraryModel: AgentSkillLibraryManaging {
public private(set) var skills: [AgentSkill] = []
public private(set) var storageError: String?
private var persistence: (any SkillLibraryPersisting)?
public init(
skills: [AgentSkill] = [],
persistence: (any SkillLibraryPersisting)? = nil
) {
self.skills = skills
self.persistence = persistence
guard let persistence else { return }
do {
self.skills = try persistence.load()
} catch {
storageError = error.localizedDescription
}
}
// 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 AgentSkillLibrary.builtIns
+ skills.filter { $0.isEnabled && $0.isComplete && !conflicts.contains($0.id) }
}
/// Runtime-only inventory includes the immutable application skills.
public var inventory: [AgentSkill] { AgentSkillLibrary.builtIns + skills }
public var snapshot: AgentSkillLibrarySnapshot {
AgentSkillLibrarySnapshot(inventory, readOnlyNames: AgentSkillLibrary.reservedNames)
}
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
&& (AgentSkillLibrary.reservedNames.contains(name)
|| skills.contains { $0.id != id && $0.effectiveName == name })
}
public var conflictingSkillIDs: Set<UUID> {
var names = AgentSkillLibrary.reservedNames
var conflicts = Set<UUID>()
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
}
public func skillSnapshot() async throws -> AgentSkillLibrarySnapshot {
if let storageError { throw AgentToolError.invalidArguments(storageError) }
return snapshot
}
public func applySkillOperations(_ operations: [AgentSkillOperation], expectedRevision: String) async throws {
try Task.checkCancellation()
guard snapshot.revision == expectedRevision else {
throw AgentToolError.invalidArguments("The skill library changed after review. Read it again and retry.")
}
let updated = try AgentSkillOperation.applying(
operations, to: skills, readOnlyNames: AgentSkillLibrary.reservedNames)
try persistence?.save(updated)
skills = updated
storageError = nil
}
public func installSkill(_ skill: AgentSkill, expectedRevision: String) async throws {
try Task.checkCancellation()
guard snapshot.revision == expectedRevision else {
throw AgentToolError.invalidArguments("The skill library changed after review. Read it again and retry.")
}
_ = try snapshot.adding(skill)
let updated = skills + [skill]
try persistence?.save(updated)
skills = updated
storageError = nil
}
// MARK: - Writes
public func upsert(_ skill: AgentSkill) {
guard !AgentSkillLibrary.reservedNames.contains(skill.name) else {
storageError = String(localized: "This skill name is reserved for a built-in skill.")
return
}
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 }
}
@discardableResult
public func renameConflictingSkill(_ id: UUID, to name: String) -> Bool {
guard var skill = skill(id), hasNameConflict(skill.effectiveName, excluding: id),
AgentSkillNaming.isValid(name), !hasNameConflict(name, excluding: id)
else { return false }
skill.name = name
upsert(skill)
return storageError == nil
}
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
}
}
/// 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, package: document.package
)
upsert(skill)
return skill
}
private func availableName(basedOn name: String) -> String {
var candidate = name
var suffix = 2
while AgentSkillLibrary.reservedNames.contains(candidate)
|| 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 {
storageError = nil
return true
}
do {
try persistence.save(skills)
storageError = nil
return true
} catch {
skills = previous
storageError = error.localizedDescription
return false
}
}
}