Files
Termind/Frameworks/TermindApplication/FeatureModels/SkillLibraryModel.swift
T
Kaede Akino 8d591dd0f6 Add global Hermes memory and skill package tools
Add opt-in GRDB memory with frozen conversation snapshots, background learning, session search, and a user-only Destroy action on both platforms. Persist and synchronize complete skill packages with the new AgentKit management tools.

Pin signed AgentKit 0.12.3 and GRDB 7.11.1. Add the V18 package migration, regression coverage, bilingual UI snapshots, and architecture records.

Validation: AgentKit and iOS suites, focused macOS skills/memory/architecture/localization tests, Swift formatting, and Chinese localization checks passed. Full macOS runs exposed intermittent existing waveform-rendering and metrics-timing failures.
2026-09-17 07:54:04 +08:00

251 lines
9.9 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)?
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: SettingsKey.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: SettingsKey.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<UUID> {
var names = Set<String>()
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 AgentSkillLibrarySnapshot(skills)
}
public func applySkillOperations(_ operations: [AgentSkillOperation], expectedRevision: String) async throws {
guard AgentSkillLibrarySnapshot(skills).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)
try persistence?.save(updated)
skills = updated
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: SettingsKey.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, package: document.package
)
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
}
}
}