diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..42607b8ea --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,32 @@ +{ + "name": "Osaurus First-Party Skills", + "owner": { + "name": "Osaurus", + "url": "https://github.com/osaurus-ai/osaurus" + }, + "metadata": { + "description": "High-fidelity Osaurus skills that stay discoverable and load on demand.", + "version": "1.0.0", + "repository": "https://github.com/osaurus-ai/osaurus" + }, + "plugins": [ + { + "name": "high-fidelity-skills", + "description": "Portable first-party skills for development, research, documents, automation, and reliability workflows.", + "source": "./skills/first-party", + "strict": true, + "skills": [ + "./skills/first-party/osaurus-contributor", + "./skills/first-party/agent-loop-operator", + "./skills/first-party/plugin-tool-author", + "./skills/first-party/ci-release-debugger", + "./skills/first-party/performance-reliability-investigator", + "./skills/first-party/privacy-security-reviewer", + "./skills/first-party/research-citation-analyst", + "./skills/first-party/document-data-analyst", + "./skills/first-party/automation-watcher-designer", + "./skills/first-party/presentation-content-producer" + ] + } + ] +} diff --git a/Packages/OsaurusCore/Managers/AgentManager.swift b/Packages/OsaurusCore/Managers/AgentManager.swift index f361676f6..3fc6a9be0 100644 --- a/Packages/OsaurusCore/Managers/AgentManager.swift +++ b/Packages/OsaurusCore/Managers/AgentManager.swift @@ -64,7 +64,9 @@ public final class AgentManager: ObservableObject { guard let self else { return } let liveTools = ToolRegistry.shared.listDynamicTools().map(\.name) self.growEnabledToolNames(Set(liveTools)) - let liveSkills = SkillManager.shared.skills.map(\.name) + let liveSkills = SkillManager.shared.skills + .filter(\.isDefaultSelectedForAgents) + .map(\.name) self.growEnabledSkillNames(Set(liveSkills)) } } @@ -144,9 +146,9 @@ public final class AgentManager: ObservableObject { guard MasterKey.exists() else { return } let context = OsaurusIdentityContext.biometric() - var masterKeyData = try MasterKey.getPrivateKey(context: context) + var privateKeyData = try MasterKey.getPrivateKey(context: context) defer { - masterKeyData.withUnsafeMutableBytes { ptr in + privateKeyData.withUnsafeMutableBytes { ptr in if let base = ptr.baseAddress { memset(base, 0, ptr.count) } } } @@ -155,7 +157,7 @@ public final class AgentManager: ObservableObject { var nextIndex: UInt32 = 0 while usedIndices.contains(nextIndex) { nextIndex += 1 } - let address = try AgentKey.deriveAddress(masterKey: masterKeyData, index: nextIndex) + let address = try AgentKey.deriveAddress(masterKey: privateKeyData, index: nextIndex) var updated = agent updated.agentIndex = nextIndex diff --git a/Packages/OsaurusCore/Managers/Plugin/PluginManager.swift b/Packages/OsaurusCore/Managers/Plugin/PluginManager.swift index 6272e20e6..913b2c397 100644 --- a/Packages/OsaurusCore/Managers/Plugin/PluginManager.swift +++ b/Packages/OsaurusCore/Managers/Plugin/PluginManager.swift @@ -5,6 +5,9 @@ // Manages loading and lifecycle of external plugins. // +// SwiftFormat owns multiline condition layout here; SwiftLint's brace rule conflicts with it. +// swiftlint:disable opening_brace + import Foundation import Darwin import Combine @@ -268,11 +271,9 @@ final class PluginManager { let destinations = agents.map { $0.id } for agentId in destinations { - for (key, value) in legacySecrets { - // Only copy if the agent doesn't already have a value for this key. - if ToolSecretsKeychain.getSecret(id: key, for: pluginId, agentId: agentId) == nil { - ToolSecretsKeychain.saveSecret(value, id: key, for: pluginId, agentId: agentId) - } + for (key, value) in legacySecrets + where ToolSecretsKeychain.getSecret(id: key, for: pluginId, agentId: agentId) == nil { + ToolSecretsKeychain.saveSecret(value, id: key, for: pluginId, agentId: agentId) } } @@ -648,7 +649,11 @@ final class PluginManager { version: skill.version, author: skill.author, category: skill.category, + keywords: skill.keywords, enabled: skill.enabled, + discoverable: skill.isDiscoverable, + defaultSelectedForAgents: skill.isDefaultSelectedForAgents, + activation: skill.activationMode, instructions: skill.instructions, isBuiltIn: false, createdAt: skill.createdAt, @@ -714,8 +719,7 @@ final class PluginManager { guard url.hasDirectoryPath, let v = SemanticVersion.parse(url.lastPathComponent) else { return nil } return (v, url) } - .sorted { $0.0 > $1.0 } - .first?.1 + .max { $0.0 < $1.0 }?.1 } nonisolated static func toolsRootDirectory() -> URL { @@ -736,11 +740,11 @@ final class PluginManager { // MARK: - Plugin Quarantine - private nonisolated static func currentlyLoadingURL() -> URL { + nonisolated private static func currentlyLoadingURL() -> URL { toolsRootDirectory().appendingPathComponent(".currently_loading", isDirectory: false) } - private nonisolated static func quarantineURL() -> URL { + nonisolated private static func quarantineURL() -> URL { toolsRootDirectory().appendingPathComponent(".quarantine", isDirectory: false) } @@ -751,7 +755,7 @@ final class PluginManager { return Set(ids) } - private nonisolated static func addToQuarantine(_ pluginId: String) { + nonisolated private static func addToQuarantine(_ pluginId: String) { var ids = quarantinedPluginIds() ids.insert(pluginId) if let data = try? JSONEncoder().encode(Array(ids)) { @@ -767,7 +771,7 @@ final class PluginManager { /// If a `.currently_loading` marker was left behind by a crash during /// dlopen/init, quarantine that plugin so it is skipped on future launches. - private nonisolated static func promoteStaleLoadingMarker() { + nonisolated private static func promoteStaleLoadingMarker() { let markerURL = currentlyLoadingURL() guard let data = try? Data(contentsOf: markerURL), let pluginId = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -777,11 +781,11 @@ final class PluginManager { try? FileManager.default.removeItem(at: markerURL) } - private nonisolated static func writeLoadingMarker(pluginId: String) { + nonisolated private static func writeLoadingMarker(pluginId: String) { try? pluginId.data(using: .utf8)?.write(to: currentlyLoadingURL()) } - private nonisolated static func clearLoadingMarker() { + nonisolated private static func clearLoadingMarker() { try? FileManager.default.removeItem(at: currentlyLoadingURL()) } @@ -829,7 +833,7 @@ final class PluginManager { guard let v = SemanticVersion.parse(url.lastPathComponent) else { return nil } return (v, url) } - versionDir = versions.sorted(by: { $0.0 > $1.0 }).first?.1 + versionDir = versions.max { $0.0 < $1.0 }?.1 } } @@ -845,16 +849,14 @@ final class PluginManager { includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) { - for case let fileURL as URL in enumerator { - if fileURL.pathExtension == "dylib" { - foundDylib = true - let verifyResult = verifyDylibBeforeLoadWithError(fileURL) - switch verifyResult { - case .success: - dylibURLs.append(fileURL) - case .failure(let error): - failures[pluginId] = error.message - } + for case let fileURL as URL in enumerator where fileURL.pathExtension == "dylib" { + foundDylib = true + let verifyResult = verifyDylibBeforeLoadWithError(fileURL) + switch verifyResult { + case .success: + dylibURLs.append(fileURL) + case .failure(let error): + failures[pluginId] = error.message } } } @@ -942,3 +944,5 @@ final class PluginManager { return nil } } + +// swiftlint:enable opening_brace diff --git a/Packages/OsaurusCore/Managers/SkillManager.swift b/Packages/OsaurusCore/Managers/SkillManager.swift index 90669d5bf..aa40c9032 100644 --- a/Packages/OsaurusCore/Managers/SkillManager.swift +++ b/Packages/OsaurusCore/Managers/SkillManager.swift @@ -144,7 +144,11 @@ public final class SkillManager { version: skill.version, author: skill.author, category: skill.category, + keywords: skill.keywords, enabled: enabled, + discoverable: skill.isDiscoverable, + defaultSelectedForAgents: skill.isDefaultSelectedForAgents, + activation: skill.activationMode, instructions: skill.instructions, isBuiltIn: true, createdAt: skill.createdAt, @@ -180,6 +184,11 @@ public final class SkillManager { version: skill.version, author: skill.author, category: skill.category, + keywords: skill.keywords, + enabled: skill.enabled, + discoverable: skill.isDiscoverable, + defaultSelectedForAgents: skill.isDefaultSelectedForAgents, + activation: skill.activationMode, instructions: skill.instructions ) await SkillStore.save(skill) @@ -198,6 +207,11 @@ public final class SkillManager { version: skill.version, author: skill.author, category: skill.category, + keywords: skill.keywords, + enabled: skill.enabled, + discoverable: skill.isDiscoverable, + defaultSelectedForAgents: skill.isDefaultSelectedForAgents, + activation: skill.activationMode, instructions: skill.instructions ) await SkillStore.save(skill) @@ -218,6 +232,11 @@ public final class SkillManager { version: parsedSkill.version, author: parsedSkill.author, category: parsedSkill.category, + keywords: parsedSkill.keywords, + enabled: parsedSkill.enabled, + discoverable: parsedSkill.isDiscoverable, + defaultSelectedForAgents: parsedSkill.isDefaultSelectedForAgents, + activation: parsedSkill.activationMode, instructions: parsedSkill.instructions ) await SkillStore.save(skill) @@ -325,7 +344,11 @@ public final class SkillManager { version: skill.version, author: skill.author, category: skill.category, + keywords: skill.keywords, enabled: true, + discoverable: skill.isDiscoverable, + defaultSelectedForAgents: skill.isDefaultSelectedForAgents, + activation: skill.activationMode, instructions: skill.instructions, directoryName: skill.xplaceholder_agentSkillsNamex ) @@ -401,14 +424,21 @@ public final class SkillManager { return sections.joined(separator: "\n\n") } - /// Builds the combined skill instructions section for an agent's enabled skills, - /// regardless of tool selection mode. Returns nil when the agent has not been - /// seeded yet (legacy behaviour: skills only inject in Manual via the older - /// `manualSkillPromptSection`) or has no enabled skills. - public func enabledSkillPromptSection(for agentId: UUID) async -> String? { - guard let skillNames = AgentManager.shared.effectiveEnabledSkillNames(for: agentId), - !skillNames.isEmpty - else { return nil } + /// Builds instructions for startup-selected skills plus skills explicitly loaded + /// for the active session. On-demand skills are only honored through + /// `additionalSkillNames`, which keeps stale "all skills" allowlists from + /// re-inflating the prompt at chat start. + public func enabledSkillPromptSection( + for agentId: UUID, + additionalSkillNames: Set = [] + ) async -> String? { + let selectedSkillNames = AgentManager.shared.effectiveEnabledSkillNames(for: agentId) ?? [] + let startupSkillNames = selectedSkillNames.filter { name in + guard let skill = skill(named: name) else { return false } + return skill.activationMode == .selected + } + let skillNames = orderedUniqueSkillNames(startupSkillNames + additionalSkillNames.sorted()) + guard !skillNames.isEmpty else { return nil } let instructions = await loadInstructions(for: skillNames) guard !instructions.isEmpty else { return nil } let sections = skillNames.compactMap { name -> String? in @@ -418,13 +448,42 @@ public final class SkillManager { return sections.joined(separator: "\n\n") } + private func orderedUniqueSkillNames(_ names: [String]) -> [String] { + var seen: Set = [] + var result: [String] = [] + for name in names { + let key = name.lowercased() + guard seen.insert(key).inserted else { continue } + result.append(name) + } + return result + } + public func loadInstructions(for skillNames: [String]) async -> [String: String] { var result: [String: String] = [:] + var unresolvedNames: [String] = [] for name in skillNames { - if let skill = skill(named: name), skill.enabled { - result[name] = await buildFullInstructions(for: skill) + if let skill = skill(named: name) { + if skill.enabled { + result[name] = await buildFullInstructions(for: skill) + } + } else { + unresolvedNames.append(name) } } + + if !unresolvedNames.isEmpty { + // Skills can be installed or created outside the in-memory + // catalog's current snapshot. On-demand loads should resolve + // those fresh disk entries without requiring an app restart. + await refresh() + for name in unresolvedNames { + if let skill = skill(named: name), skill.enabled { + result[name] = await buildFullInstructions(for: skill) + } + } + } + return result } diff --git a/Packages/OsaurusCore/Models/Agent/Skill.swift b/Packages/OsaurusCore/Models/Agent/Skill.swift index e1359bba7..2c3ff95ee 100644 --- a/Packages/OsaurusCore/Models/Agent/Skill.swift +++ b/Packages/OsaurusCore/Models/Agent/Skill.swift @@ -7,6 +7,9 @@ // See: https://agentskills.io/specification // +// SwiftFormat owns multiline condition layout here; SwiftLint's brace rule conflicts with it. +// swiftlint:disable opening_brace + import Foundation /// Represents a file within a skill's references or assets directory @@ -23,6 +26,11 @@ public struct SkillFile: Codable, Identifiable, Sendable, Equatable { } } +public enum SkillActivation: String, Codable, Sendable, Equatable { + case selected + case onDemand = "on-demand" +} + /// A skill containing instructions/guidance for the AI /// Follows the Agent Skills specification: https://agentskills.io/specification public struct Skill: Codable, Identifiable, Sendable, Equatable { @@ -34,6 +42,13 @@ public struct Skill: Codable, Identifiable, Sendable, Equatable { public var category: String? public var keywords: [String] public var enabled: Bool + // Activation metadata is optional so older saved skills decode without a migration. + // swiftlint:disable:next discouraged_optional_boolean + public var discoverable: Bool? + // Activation metadata is optional so older saved skills decode without a migration. + // swiftlint:disable:next discouraged_optional_boolean + public var defaultSelectedForAgents: Bool? + public var activation: SkillActivation? public var instructions: String public let isBuiltIn: Bool public let createdAt: Date @@ -65,6 +80,9 @@ public struct Skill: Codable, Identifiable, Sendable, Equatable { category: String? = nil, keywords: [String] = [], enabled: Bool = true, + discoverable: Bool = true, + defaultSelectedForAgents: Bool = true, + activation: SkillActivation = .selected, instructions: String = "", isBuiltIn: Bool = false, createdAt: Date = Date(), @@ -82,6 +100,9 @@ public struct Skill: Codable, Identifiable, Sendable, Equatable { self.category = category self.keywords = keywords self.enabled = enabled + self.discoverable = discoverable + self.defaultSelectedForAgents = defaultSelectedForAgents + self.activation = activation self.instructions = instructions self.isBuiltIn = isBuiltIn self.createdAt = createdAt @@ -102,6 +123,12 @@ public struct Skill: Codable, Identifiable, Sendable, Equatable { totalFileCount > 0 } + public var isDiscoverable: Bool { discoverable ?? true } + public var isDefaultSelectedForAgents: Bool { defaultSelectedForAgents ?? true } + public var activationMode: SkillActivation { + activation ?? (isDefaultSelectedForAgents ? .selected : .onDemand) + } + // MARK: - Built-in Skills /// All built-in skills @@ -705,6 +732,14 @@ extension Skill { } else { keywords = [] } + let discoverable = + firstBool(frontmatter, keys: ["discoverable", "osaurus-discoverable"]) ?? true + let defaultSelected = + firstBool(frontmatter, keys: ["defaultSelectedForAgents", "osaurus-default-selected"]) ?? true + let activation = + firstString(frontmatter, keys: ["activation", "osaurus-activation"]) + .flatMap(SkillActivation.init(rawValue:)) + ?? (defaultSelected ? .selected : .onDemand) return Skill( id: id, @@ -715,6 +750,9 @@ extension Skill { category: frontmatter["category"] as? String, keywords: keywords, enabled: frontmatter["enabled"] as? Bool ?? true, + discoverable: discoverable, + defaultSelectedForAgents: defaultSelected, + activation: activation, instructions: body.trimmingCharacters(in: .whitespacesAndNewlines), isBuiltIn: false, createdAt: createdAt, @@ -743,6 +781,9 @@ extension Skill { yaml += "keywords: \"\(keywords.joined(separator: ", "))\"\n" } yaml += "enabled: \(enabled)\n" + yaml += "discoverable: \(isDiscoverable)\n" + yaml += "defaultSelectedForAgents: \(isDefaultSelectedForAgents)\n" + yaml += "activation: \"\(activationMode.rawValue)\"\n" if let pluginId = pluginId { yaml += "pluginId: \"\(escapeYamlString(pluginId))\"\n" } @@ -859,6 +900,33 @@ extension Skill { return v } + private static func firstString(_ values: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = values[key] as? String, !value.isEmpty { + return value + } + } + return nil + } + + // Missing activation booleans carry meaning; callers apply backward-compatible defaults. + // swiftlint:disable:next discouraged_optional_boolean + private static func firstBool(_ values: [String: Any], keys: [String]) -> Bool? { + for key in keys { + if let value = values[key] as? Bool { + return value + } + if let value = values[key] as? String { + switch value.lowercased() { + case "true": return true + case "false": return false + default: continue + } + } + } + return nil + } + /// Escape special characters for YAML string private func escapeYamlString(_ string: String) -> String { return @@ -868,6 +936,8 @@ extension Skill { } } +// swiftlint:enable opening_brace + // MARK: - Errors public enum SkillParseError: Error, LocalizedError { @@ -907,6 +977,9 @@ extension Skill { category: skill.category, keywords: skill.keywords, enabled: skill.enabled, + discoverable: skill.isDiscoverable, + defaultSelectedForAgents: skill.isDefaultSelectedForAgents, + activation: skill.activationMode, instructions: skill.instructions, isBuiltIn: false, createdAt: Date(), @@ -987,6 +1060,9 @@ extension Skill { if !keywords.isEmpty { yaml += " keywords: \"\(keywords.joined(separator: ", "))\"\n" } + yaml += " osaurus-discoverable: \(isDiscoverable)\n" + yaml += " osaurus-default-selected: \(isDefaultSelectedForAgents)\n" + yaml += " osaurus-activation: \"\(activationMode.rawValue)\"\n" yaml += "---\n\n" yaml += instructions @@ -1014,6 +1090,9 @@ extension Skill { var keywords: [String] = [] var osaurusId: UUID? var enabled = true + var discoverable = true + var defaultSelected = true + var activation: SkillActivation = .selected var pluginId: String? if let metadata = frontmatter["metadata"] as? [String: Any] { @@ -1031,6 +1110,12 @@ extension Skill { if let enabledValue = metadata["osaurus-enabled"] as? Bool { enabled = enabledValue } + discoverable = firstBool(metadata, keys: ["osaurus-discoverable"]) ?? true + defaultSelected = firstBool(metadata, keys: ["osaurus-default-selected"]) ?? true + activation = + firstString(metadata, keys: ["osaurus-activation"]) + .flatMap(SkillActivation.init(rawValue:)) + ?? (defaultSelected ? .selected : .onDemand) pluginId = metadata["osaurus-plugin-id"] as? String } @@ -1050,6 +1135,9 @@ extension Skill { category: category, keywords: keywords, enabled: enabled, + discoverable: discoverable, + defaultSelectedForAgents: defaultSelected, + activation: activation, instructions: body.trimmingCharacters(in: .whitespacesAndNewlines), isBuiltIn: false, createdAt: Date(), diff --git a/Packages/OsaurusCore/Models/Agent/SkillStore.swift b/Packages/OsaurusCore/Models/Agent/SkillStore.swift index 2e0d92030..3e18df2b0 100644 --- a/Packages/OsaurusCore/Models/Agent/SkillStore.swift +++ b/Packages/OsaurusCore/Models/Agent/SkillStore.swift @@ -6,6 +6,9 @@ // Directory structure: skills/{skill-name}/SKILL.md with optional references/ and assets/ // +// SwiftFormat owns multiline condition layout here; SwiftLint's brace rule conflicts with it. +// swiftlint:disable opening_brace + import Foundation public enum SkillStore { @@ -68,7 +71,11 @@ public enum SkillStore { version: builtIn.version, author: builtIn.author, category: builtIn.category, + keywords: builtIn.keywords, enabled: saved.enabled, + discoverable: saved.isDiscoverable, + defaultSelectedForAgents: saved.isDefaultSelectedForAgents, + activation: saved.activationMode, instructions: builtIn.instructions, isBuiltIn: true, createdAt: builtIn.createdAt, @@ -263,7 +270,11 @@ public enum SkillStore { version: parsed.version, author: parsed.author, category: parsed.category, + keywords: parsed.keywords, enabled: parsed.enabled, + discoverable: parsed.isDiscoverable, + defaultSelectedForAgents: parsed.isDefaultSelectedForAgents, + activation: parsed.activationMode, instructions: parsed.instructions, isBuiltIn: parsed.isBuiltIn, createdAt: parsed.createdAt, @@ -358,3 +369,5 @@ public enum SkillStore { } } } + +// swiftlint:enable opening_brace diff --git a/Packages/OsaurusCore/Services/Chat/SystemPromptComposer.swift b/Packages/OsaurusCore/Services/Chat/SystemPromptComposer.swift index 3ee382982..3fc12ce97 100644 --- a/Packages/OsaurusCore/Services/Chat/SystemPromptComposer.swift +++ b/Packages/OsaurusCore/Services/Chat/SystemPromptComposer.swift @@ -10,6 +10,9 @@ // resolves whether to use compact or full prompt variants via isLocalModel. // +// SwiftFormat owns multiline condition layout here; SwiftLint's brace rule conflicts with it. +// swiftlint:disable opening_brace + import Foundation // MARK: - SystemPromptComposer @@ -77,9 +80,9 @@ public struct SystemPromptComposer: Sendable { /// /// Pass `cachedPreflight` from a per-session `SessionToolState` to skip /// the LLM-based selection (it only ever needs to run on the first send - /// of a session). Pass `additionalToolNames` to merge tools the agent has - /// loaded mid-session via `capabilities_load`, so they survive across - /// subsequent composes. + /// of a session). Pass `additionalToolNames` / `additionalSkillNames` to + /// merge capabilities the agent has loaded mid-session via + /// `capabilities_load`, so they survive across subsequent composes. @MainActor static func composeChatContext( agentId: UUID, @@ -90,6 +93,7 @@ public struct SystemPromptComposer: Sendable { toolsDisabled: Bool = false, cachedPreflight: PreflightResult? = nil, additionalToolNames: Set = [], + additionalSkillNames: Set = [], frozenAlwaysLoadedNames: Set? = nil, trace: TTFTTrace? = nil ) async -> ComposedContext { @@ -103,6 +107,7 @@ public struct SystemPromptComposer: Sendable { toolsDisabled: toolsDisabled, cachedPreflight: cachedPreflight, additionalToolNames: additionalToolNames, + additionalSkillNames: additionalSkillNames, frozenAlwaysLoadedNames: frozenAlwaysLoadedNames, model: model, trace: trace @@ -143,6 +148,7 @@ public struct SystemPromptComposer: Sendable { toolsDisabled: Bool, cachedPreflight: PreflightResult? = nil, additionalToolNames: Set = [], + additionalSkillNames: Set = [], frozenAlwaysLoadedNames: Set? = nil, model: String? = nil, trace: TTFTTrace? = nil @@ -208,6 +214,19 @@ public struct SystemPromptComposer: Sendable { trace?.set("preflightSource", "skipped") } + // Skills inject in BOTH Auto and Manual modes — they're the user's + // explicitly-enabled set and aren't part of pre-flight (preflight only + // ranks tools). Per-item Enabled toggles in the capability picker are + // the single source of truth for what reaches the system prompt. + if !effectiveToolsOff, + let section = await SkillManager.shared.enabledSkillPromptSection( + for: agentId, + additionalSkillNames: additionalSkillNames + ) + { + comp.append(.dynamic(id: "skills", label: "Skills", content: section)) + } + trace?.mark("resolve_tools_start") let tools = resolveTools( agentId: agentId, @@ -237,7 +256,7 @@ public struct SystemPromptComposer: Sendable { tools.contains(where: { $0.function.name == "capabilities_load" }) else { return [] } let alreadySurfaced = Set(preflight.companions.compactMap(\.skill?.name)) - .union(additionalToolNames) + .union(additionalSkillNames) trace?.mark("skill_suggestions_start") let teasers = await PreflightCompanions.deriveSkillSuggestions( query: query, @@ -1059,3 +1078,5 @@ public struct SystemPromptComposer: Sendable { mergeSystemContent(content, into: &messages, prepend: false) } } + +// swiftlint:enable opening_brace diff --git a/Packages/OsaurusCore/Services/Context/PreflightCapabilitySearch.swift b/Packages/OsaurusCore/Services/Context/PreflightCapabilitySearch.swift index 997f4743a..7386fa1d2 100644 --- a/Packages/OsaurusCore/Services/Context/PreflightCapabilitySearch.swift +++ b/Packages/OsaurusCore/Services/Context/PreflightCapabilitySearch.swift @@ -148,6 +148,7 @@ struct PreflightDiagnostic: Sendable { struct SessionToolState: Sendable { var initialPreflight: PreflightResult var loadedToolNames: Set + var loadedSkillNames: Set /// Snapshot of always-loaded tool names from the FIRST compose of this /// session. On subsequent composes the resolver intersects the live /// always-loaded set against this snapshot so a tool that registers @@ -169,11 +170,13 @@ struct SessionToolState: Sendable { init( initialPreflight: PreflightResult, loadedToolNames: Set = [], + loadedSkillNames: Set = [], initialAlwaysLoadedNames: Set? = nil, sessionFingerprint: String? = nil ) { self.initialPreflight = initialPreflight self.loadedToolNames = loadedToolNames + self.loadedSkillNames = loadedSkillNames self.initialAlwaysLoadedNames = initialAlwaysLoadedNames self.sessionFingerprint = sessionFingerprint } diff --git a/Packages/OsaurusCore/Services/Context/SessionToolStateStore.swift b/Packages/OsaurusCore/Services/Context/SessionToolStateStore.swift index d8d478078..d4ce99855 100644 --- a/Packages/OsaurusCore/Services/Context/SessionToolStateStore.swift +++ b/Packages/OsaurusCore/Services/Context/SessionToolStateStore.swift @@ -14,8 +14,8 @@ import Foundation -/// Per-session record of the initial preflight selection plus every tool the -/// agent has loaded mid-session via `capabilities_load`. The composer uses +/// Per-session record of the initial preflight selection plus every tool/skill +/// the agent has loaded mid-session via `capabilities_load`. The composer uses /// this to skip the LLM-based preflight call after turn 1 and to keep the /// rendered system prompt + `` block byte-stable across turns /// (required for KV-cache reuse). @@ -108,6 +108,26 @@ actor SessionToolStateStore { states[sessionId] = entry } + /// Append skill names loaded mid-session via `capabilities_load`. + /// Loaded skills are prompt instructions, not tool specs, but they need + /// the same session persistence so follow-up turns keep the capability + /// without selecting it globally for the agent. + func appendLoadedSkills( + _ sessionId: String, + names: [String], + fallbackPreflight: PreflightResult, + fallbackAlwaysLoadedNames: Set? + ) { + var entry = + states[sessionId] + ?? SessionToolState( + initialPreflight: fallbackPreflight, + initialAlwaysLoadedNames: fallbackAlwaysLoadedNames + ) + for name in names { entry.loadedSkillNames.insert(name) } + states[sessionId] = entry + } + // MARK: - Cache fingerprint /// Record this send's cache-hint, emit a one-line `[Cache]` log entry, diff --git a/Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift b/Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift index 74dccb7a9..a3adc8b0e 100644 --- a/Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift +++ b/Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift @@ -7,6 +7,9 @@ // database (sandboxed SQLite), dispatch, inference, models, and HTTP access. // +// SwiftFormat owns multiline condition layout here; SwiftLint's brace rule conflicts with it. +// swiftlint:disable opening_brace + import Foundation import os @@ -23,7 +26,7 @@ final class PluginHostContext: @unchecked Sendable { // MARK: - Context Registry (thread-safe) - private nonisolated(unsafe) static var contexts: [String: PluginHostContext] = [:] + nonisolated(unsafe) private static var contexts: [String: PluginHostContext] = [:] private static let contextsLock = NSLock() static func getContext(for pluginId: String) -> PluginHostContext? { @@ -496,8 +499,19 @@ final class PluginHostContext: @unchecked Sendable { } // Skills inject in BOTH modes — see the matching block in // `SystemPromptComposer.compose` for the full rationale. + let loadedSkillNames: Set + if let sid = enriched.request.session_id, + let state = await SessionToolStateStore.shared.get(sid) + { + loadedSkillNames = state.loadedSkillNames + } else { + loadedSkillNames = [] + } if !agentToolsOff, - let section = await SkillManager.shared.enabledSkillPromptSection(for: resolvedAgentId) + let section = await SkillManager.shared.enabledSkillPromptSection( + for: resolvedAgentId, + additionalSkillNames: loadedSkillNames + ) { SystemPromptComposer.appendSystemContent(section, into: &enriched.request.messages) } @@ -671,6 +685,19 @@ final class PluginHostContext: @unchecked Sendable { } } + private static func recordSessionLoadedSkills(sessionId: String, names: [String]) { + guard !names.isEmpty else { return } + Task { + guard await SessionToolStateStore.shared.get(sessionId) != nil else { return } + await SessionToolStateStore.shared.appendLoadedSkills( + sessionId, + names: names, + fallbackPreflight: .empty, + fallbackAlwaysLoadedNames: nil + ) + } + } + private static func extractPreflightQuery(from messages: [ChatMessage]) -> String { messages.last(where: { $0.role == "user" })?.content ?? "" } @@ -854,6 +881,7 @@ final class PluginHostContext: @unchecked Sendable { case "capabilities_load": let newTools = await CapabilityLoadBuffer.shared.drain() + let newSkillNames = await CapabilityLoadBuffer.shared.drainSkillNames() let existing = Set((toolSpecs ?? []).map { $0.function.name }) let additions = newTools.filter { !existing.contains($0.function.name) } if !additions.isEmpty { @@ -868,6 +896,9 @@ final class PluginHostContext: @unchecked Sendable { ) } } + if !newSkillNames.isEmpty, let sid = prep.enriched.request.session_id { + recordSessionLoadedSkills(sessionId: sid, names: newSkillNames) + } return (result, nil) default: @@ -1718,6 +1749,8 @@ extension PluginHostContext { } } +// swiftlint:enable opening_brace + // MARK: - SSRF Protection extension PluginHostContext { @@ -2007,7 +2040,7 @@ extension PluginHostContext { /// Serialize a dictionary to a JSON string. Falls back to "{}" on encoding failure. static func jsonString(_ dict: [String: Any]) -> String { guard let data = try? JSONSerialization.data(withJSONObject: dict, options: []) else { return "{}" } - return String(decoding: data, as: UTF8.self) + return String(bytes: data, encoding: .utf8) ?? "{}" } /// Parse a JSON string back into a dictionary. @@ -2048,7 +2081,7 @@ extension PluginHostContext { /// have TLS set. Protected by `fallbackLock` to avoid data races under /// concurrent execution. TLS (option 1) is the authoritative mechanism. private static let fallbackLock = NSLock() - private nonisolated(unsafe) static var _lastDispatchedPluginId: String? + nonisolated(unsafe) private static var _lastDispatchedPluginId: String? private static var lastDispatchedPluginId: String? { get { fallbackLock.withLock { _lastDispatchedPluginId } } diff --git a/Packages/OsaurusCore/Services/Skill/SkillSearchService.swift b/Packages/OsaurusCore/Services/Skill/SkillSearchService.swift index 4f3c415e8..f42852d1f 100644 --- a/Packages/OsaurusCore/Services/Skill/SkillSearchService.swift +++ b/Packages/OsaurusCore/Services/Skill/SkillSearchService.swift @@ -95,6 +95,10 @@ public actor SkillSearchService { public func indexSkill(_ skill: Skill) async { guard let db = vectorDB else { return } + guard skill.isDiscoverable else { + await removeSkill(id: skill.id) + return + } do { let id = deterministicUUID(for: skill.id) let text = buildIndexText(for: skill) @@ -145,7 +149,7 @@ public actor SkillSearchService { return Array( matchedSkillIds.compactMap { skillId -> SkillSearchResult? in - guard let skill = skillById[skillId], skill.enabled else { return nil } + guard let skill = skillById[skillId], skill.enabled, skill.isDiscoverable else { return nil } let uuid = deterministicUUID(for: skillId) guard let score = scoreMap[uuid.uuidString] else { return nil } return SkillSearchResult(skill: skill, searchScore: score) @@ -167,7 +171,9 @@ public actor SkillSearchService { try await db.reset() reverseIdMap.removeAll() - let allSkills = await MainActor.run { SkillManager.shared.skills } + let allSkills = await MainActor.run { + SkillManager.shared.skills.filter(\.isDiscoverable) + } var texts: [String] = [] var ids: [UUID] = [] texts.reserveCapacity(allSkills.count) @@ -189,10 +195,28 @@ public actor SkillSearchService { // MARK: - Helpers private func buildIndexText(for skill: Skill) -> String { - if !skill.keywords.isEmpty { - return "\(skill.name) \(skill.keywords.joined(separator: " "))" - } - return "\(skill.name) \(skill.description)" + let headings = + skill.instructions + .components(separatedBy: .newlines) + .compactMap { line -> String? in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("#") else { return nil } + let heading = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "# ")) + return heading.isEmpty ? nil : heading + } + .prefix(8) + .joined(separator: " ") + + return [ + skill.name, + skill.description, + skill.category ?? "", + skill.keywords.joined(separator: " "), + headings, + ] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: " ") } private func deterministicUUID(for skillId: UUID) -> UUID { diff --git a/Packages/OsaurusCore/Tests/Chat/ContextBudgetPreviewTests.swift b/Packages/OsaurusCore/Tests/Chat/ContextBudgetPreviewTests.swift index afa837613..19ae98810 100644 --- a/Packages/OsaurusCore/Tests/Chat/ContextBudgetPreviewTests.swift +++ b/Packages/OsaurusCore/Tests/Chat/ContextBudgetPreviewTests.swift @@ -181,21 +181,30 @@ struct ContextBudgetPreviewTests { } } - // MARK: - Skills are load-on-demand only - - /// Regression for the 55k-token Skills bloat: skills MUST be - /// discovered via `capabilities_search` and pulled in via - /// `capabilities_load`, never auto-injected into the system prompt - /// at compose time. Both compose paths must omit the `skills` - /// section regardless of the agent's enabled-skills allowlist. - @Test("compose: no `skills` section, even when the agent has skills enabled") - func bagOfSkills_neverInjected() async { + // MARK: - On-demand skills stay out of startup prompts + + /// Regression for the 55k-token Skills bloat: skills that declare + /// `activation: on-demand` must be discoverable and loadable, but a + /// stale "all skills enabled" allowlist must not make them startup + /// prompt content. + @Test("compose: on-demand skills in the enabled allowlist stay out of startup prompts") + func onDemandSkillsInAllowlistAreNotInjectedAtStartup() async { await withAgent(toolSelectionMode: .auto) { agentId in - // Simulate the "all skills enabled" allowlist that the - // capability seeder used to write — exactly the state that - // produced the 55k Skills row in the original screenshot. + let marker = "OnDemandSkillMarker-\(UUID().uuidString)" + let skill = Skill( + name: "On Demand Preview Skill \(UUID().uuidString.prefix(6))", + description: "Loads only when requested", + enabled: true, + discoverable: true, + defaultSelectedForAgents: false, + activation: .onDemand, + instructions: marker + ) + await SkillStore.save(skill) + await SkillManager.shared.refresh() + AgentManager.shared.updateEnabledSkillNames( - SkillManager.shared.skills.map(\.name), + [skill.name], for: agentId ) @@ -212,6 +221,8 @@ struct ContextBudgetPreviewTests { #expect(sectionIds(preview).contains("skills") == false) #expect(real.manifest.sections.map(\.id).contains("skills") == false) + #expect(real.prompt.contains(marker) == false) + _ = await SkillManager.shared.delete(id: skill.id) } } diff --git a/Packages/OsaurusCore/Tests/Chat/SessionPreflightCacheTests.swift b/Packages/OsaurusCore/Tests/Chat/SessionPreflightCacheTests.swift index ba7d0be48..1efc60887 100644 --- a/Packages/OsaurusCore/Tests/Chat/SessionPreflightCacheTests.swift +++ b/Packages/OsaurusCore/Tests/Chat/SessionPreflightCacheTests.swift @@ -22,12 +22,79 @@ struct SessionPreflightCacheTests { initialPreflight: PreflightResult(toolSpecs: [], items: []) ) #expect(state.loadedToolNames.isEmpty) + #expect(state.loadedSkillNames.isEmpty) state.loadedToolNames.insert("pdf_extract") state.loadedToolNames.insert("pdf_render") state.loadedToolNames.insert("pdf_extract") // dedup + state.loadedSkillNames.insert("Document Data Analyst") + state.loadedSkillNames.insert("Document Data Analyst") #expect(state.loadedToolNames == ["pdf_extract", "pdf_render"]) + #expect(state.loadedSkillNames == ["Document Data Analyst"]) + } + + @Test + func seedEnabledCapabilities_excludesOnDemandSkillsFromDefaults() async { + await withSessionPreflightAgent { agentId in + let defaultSkill = Skill( + name: "Default Session Skill", + defaultSelectedForAgents: true, + instructions: "Default marker" + ) + let onDemandSkill = Skill( + name: "On Demand Session Skill", + defaultSelectedForAgents: false, + activation: .onDemand, + instructions: "On-demand marker" + ) + let defaultNames = [defaultSkill, onDemandSkill] + .filter(\.isDefaultSelectedForAgents) + .map(\.name) + + AgentManager.shared.seedEnabledCapabilitiesIfNeeded( + for: agentId, + defaultToolNames: [], + defaultSkillNames: defaultNames + ) + + let seeded = AgentManager.shared.agent(for: agentId)?.manualSkillNames ?? [] + #expect(seeded.contains(defaultSkill.name)) + #expect(seeded.contains(onDemandSkill.name) == false) + } + } + + @Test + func composeChatContext_includesLoadedSkillsButNotUnloadedOnDemandSkills() async { + await withSessionPreflightAgent { agentId in + let marker = "LoadedSkillMarker-\(UUID().uuidString)" + let skill = Skill( + name: "Loaded Session Skill \(UUID().uuidString.prefix(6))", + description: "Loaded only when requested", + category: "test", + enabled: true, + discoverable: true, + defaultSelectedForAgents: false, + activation: .onDemand, + instructions: marker + ) + await SkillStore.save(skill) + await SkillManager.shared.refresh() + + let withoutLoaded = await SystemPromptComposer.composeChatContext( + agentId: agentId, + executionMode: .none + ) + #expect(withoutLoaded.prompt.contains(marker) == false) + + let withLoaded = await SystemPromptComposer.composeChatContext( + agentId: agentId, + executionMode: .none, + additionalSkillNames: [skill.name] + ) + #expect(withLoaded.prompt.contains(marker)) + _ = await SkillManager.shared.delete(id: skill.id) + } } @Test diff --git a/Packages/OsaurusCore/Tests/Skill/FirstPartySkillPackTests.swift b/Packages/OsaurusCore/Tests/Skill/FirstPartySkillPackTests.swift new file mode 100644 index 000000000..a186cf5db --- /dev/null +++ b/Packages/OsaurusCore/Tests/Skill/FirstPartySkillPackTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing + +@testable import OsaurusCore + +struct FirstPartySkillPackTests { + + @Test + func firstPartySkillPackIsOnDemandAndParseable() throws { + let root = repoRoot() + let packDir = root.appendingPathComponent("skills/first-party", isDirectory: true) + let manifest = try loadMarketplace(from: root) + let manifestPaths = manifest.plugins.flatMap(\.skills) + let expectedPaths = try skillDirectories(in: packDir).map { + "./skills/first-party/\($0.lastPathComponent)" + } + + #expect(manifestPaths.isEmpty == false) + #expect(Set(manifestPaths) == Set(expectedPaths)) + + for dir in try skillDirectories(in: packDir) { + let skillURL = dir.appendingPathComponent("SKILL.md") + let markdown = try String(contentsOf: skillURL, encoding: .utf8) + let skill = try Skill.parseAnyFormat(from: markdown) + + #expect(skill.enabled) + #expect(skill.isDiscoverable) + #expect(skill.isDefaultSelectedForAgents == false) + #expect(skill.activationMode == .onDemand) + #expect(skill.keywords.count >= 5) + #expect(markdown.localizedCaseInsensitiveContains("Work Mode") == false) + } + } + + @Test + func firstPartyReferencesStayWithinPromptLimit() throws { + let root = repoRoot() + let packDir = root.appendingPathComponent("skills/first-party", isDirectory: true) + let referenceURLs = try FileManager.default.subpathsOfDirectory(atPath: packDir.path) + .filter { $0.contains("/references/") } + + #expect(referenceURLs.isEmpty == false) + + for relativePath in referenceURLs { + let url = packDir.appendingPathComponent(relativePath) + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + let size = attrs[.size] as? NSNumber + #expect((size?.intValue ?? 0) < 100_000) + } + } + + private struct Marketplace: Decodable { + let plugins: [Plugin] + + struct Plugin: Decodable { + let skills: [String] + } + } + + private func loadMarketplace(from root: URL) throws -> Marketplace { + let url = root.appendingPathComponent(".claude-plugin/marketplace.json") + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(Marketplace.self, from: data) + } + + private func skillDirectories(in packDir: URL) throws -> [URL] { + try FileManager.default.contentsOfDirectory( + at: packDir, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + .filter { url in + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + && isDirectory.boolValue + } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + private func repoRoot() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } +} diff --git a/Packages/OsaurusCore/Tests/Skill/SkillActivationMetadataTests.swift b/Packages/OsaurusCore/Tests/Skill/SkillActivationMetadataTests.swift new file mode 100644 index 000000000..e422667a3 --- /dev/null +++ b/Packages/OsaurusCore/Tests/Skill/SkillActivationMetadataTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing + +@testable import OsaurusCore + +struct SkillActivationMetadataTests { + + @Test + func parsesAgentSkillsActivationMetadata() throws { + let markdown = """ + --- + name: on-demand-helper + description: Helps only when loaded + metadata: + version: "1.2.3" + category: development + keywords: "helper, on demand, session" + osaurus-discoverable: true + osaurus-default-selected: false + osaurus-activation: "on-demand" + --- + + # On Demand Helper + """ + + let skill = try Skill.parseAnyFormat(from: markdown) + + #expect(skill.name == "On Demand Helper") + #expect(skill.version == "1.2.3") + #expect(skill.category == "development") + #expect(skill.keywords.contains("on demand")) + #expect(skill.isDiscoverable) + #expect(skill.isDefaultSelectedForAgents == false) + #expect(skill.activationMode == .onDemand) + } + + @Test + func parsesOsaurusActivationMetadata() throws { + let markdown = """ + --- + id: "00000001-0000-0000-0000-00000000ABCD" + name: "Local Helper" + description: "A local helper" + version: "1.0.0" + keywords: "local, helper" + enabled: true + discoverable: false + defaultSelectedForAgents: false + activation: "on-demand" + --- + + # Local Helper + """ + + let skill = try Skill.parseAnyFormat(from: markdown) + + #expect(skill.name == "Local Helper") + #expect(skill.isDiscoverable == false) + #expect(skill.isDefaultSelectedForAgents == false) + #expect(skill.activationMode == .onDemand) + } + + @Test + func agentSkillsExportIncludesActivationMetadata() { + let skill = Skill( + name: "Exported Helper", + description: "Exported helper", + keywords: ["exported", "helper"], + discoverable: true, + defaultSelectedForAgents: false, + activation: .onDemand, + instructions: "# Exported Helper" + ) + + let markdown = skill.toAgentSkillsFormat() + + #expect(markdown.contains("osaurus-discoverable: true")) + #expect(markdown.contains("osaurus-default-selected: false")) + #expect(markdown.contains("osaurus-activation: \"on-demand\"")) + } +} diff --git a/Packages/OsaurusCore/Tests/Tool/CapabilityToolsTests.swift b/Packages/OsaurusCore/Tests/Tool/CapabilityToolsTests.swift index 73e44c83b..e6e0bfff6 100644 --- a/Packages/OsaurusCore/Tests/Tool/CapabilityToolsTests.swift +++ b/Packages/OsaurusCore/Tests/Tool/CapabilityToolsTests.swift @@ -37,6 +37,19 @@ struct CapabilityLoadBufferTests { #expect(empty.isEmpty) } + @Test func drainReturnsAndClearsPendingSkillNames() async { + let buffer = CapabilityLoadBuffer() + + await buffer.addSkillName("Document Data Analyst") + await buffer.addSkillName("Research Citation Analyst") + + let drained = await buffer.drainSkillNames() + #expect(drained == ["Document Data Analyst", "Research Citation Analyst"]) + + let empty = await buffer.drainSkillNames() + #expect(empty.isEmpty) + } + @Test func drainOnEmptyBufferReturnsEmpty() async { let buffer = CapabilityLoadBuffer() let result = await buffer.drain() @@ -154,4 +167,26 @@ struct CapabilitiesLoadToolTests { let buffered = await CapabilityLoadBuffer.shared.drain() #expect(buffered.contains(where: { $0.function.name == "capabilities_search" })) } + + @Test func skillLoadBuffersSkillName() async throws { + try await SandboxTestLock.runWithStoragePaths { + let suffix = UUID().uuidString.prefix(6).lowercased() + let skill = await SkillManager.shared.create( + name: "Buffered Skill \(suffix)", + description: "A buffered skill", + instructions: "Buffered skill instructions" + ) + _ = await CapabilityLoadBuffer.shared.drainSkillNames() + + let tool = CapabilitiesLoadTool() + let result = try await tool.execute( + argumentsJSON: "{\"ids\": [\"skill/\(skill.name)\"]}" + ) + + #expect(result.lowercased().contains("## skill: \(skill.name.lowercased())")) + let buffered = await CapabilityLoadBuffer.shared.drainSkillNames() + #expect(buffered.map { $0.lowercased() }.contains(skill.name.lowercased())) + _ = await SkillManager.shared.delete(id: skill.id) + } + } } diff --git a/Packages/OsaurusCore/Tools/CapabilityTools.swift b/Packages/OsaurusCore/Tools/CapabilityTools.swift index 8e2cc0650..5299e4b9e 100644 --- a/Packages/OsaurusCore/Tools/CapabilityTools.swift +++ b/Packages/OsaurusCore/Tools/CapabilityTools.swift @@ -18,16 +18,27 @@ actor CapabilityLoadBuffer { static let shared = CapabilityLoadBuffer() private var pendingTools: [Tool] = [] + private var pendingSkillNames: [String] = [] func add(_ tool: Tool) { pendingTools.append(tool) } + func addSkillName(_ name: String) { + pendingSkillNames.append(name) + } + func drain() -> [Tool] { let tools = pendingTools pendingTools = [] return tools } + + func drainSkillNames() -> [String] { + let names = pendingSkillNames + pendingSkillNames = [] + return names + } } // MARK: - capabilities_search @@ -254,12 +265,13 @@ final class CapabilitiesLoadTool: OsaurusTool, @unchecked Sendable { } if !method.skillsUsed.isEmpty { - let skills: [(String, String)] = await MainActor.run { - method.skillsUsed.compactMap { name in - SkillManager.shared.skill(named: name).map { (name, $0.instructions) } - } + var skills: [(String, String)] = [] + for name in method.skillsUsed { + guard let skill = await resolveSkill(named: name), skill.enabled else { continue } + skills.append((name, skill.instructions)) } for (name, instructions) in skills { + await CapabilityLoadBuffer.shared.addSkillName(name) output += "\n## Skill: \(name)\n" output += instructions output += "\n\n" @@ -272,6 +284,17 @@ final class CapabilitiesLoadTool: OsaurusTool, @unchecked Sendable { } } + private func resolveSkill(named skillName: String) async -> Skill? { + if let skill = await MainActor.run(body: { SkillManager.shared.skill(named: skillName) }) { + return skill + } + + await SkillManager.shared.refresh() + return await MainActor.run { + SkillManager.shared.skill(named: skillName) + } + } + private func loadTool(_ toolId: String) async -> String { let (isEnabled, isBuiltIn, toolSpec) = await MainActor.run { ( @@ -293,12 +316,14 @@ final class CapabilitiesLoadTool: OsaurusTool, @unchecked Sendable { } private func loadSkill(_ skillName: String) async -> String { - let skill = await MainActor.run { - SkillManager.shared.skill(named: skillName) - } + let skill = await resolveSkill(named: skillName) guard let skill = skill else { return "Error: Skill '\(skillName)' not found.\n" } + guard skill.enabled else { + return "Error: Skill '\(skillName)' is disabled.\n" + } + await CapabilityLoadBuffer.shared.addSkillName(skill.name) var output = "## Skill: \(skill.name)\n" if !skill.description.isEmpty { output += "*\(skill.description)*\n\n" diff --git a/Packages/OsaurusCore/Views/Agent/AgentCapabilityManagerView.swift b/Packages/OsaurusCore/Views/Agent/AgentCapabilityManagerView.swift index 499365edc..f58e2a761 100644 --- a/Packages/OsaurusCore/Views/Agent/AgentCapabilityManagerView.swift +++ b/Packages/OsaurusCore/Views/Agent/AgentCapabilityManagerView.swift @@ -726,7 +726,9 @@ struct AgentCapabilityManagerView: View { private func seedIfNeeded() { guard case .live(let agentId) = source else { return } let liveToolNames = ToolRegistry.shared.listDynamicTools().map(\.name) - let liveSkillNames = SkillManager.shared.skills.map(\.name) + let liveSkillNames = SkillManager.shared.skills + .filter(\.isDefaultSelectedForAgents) + .map(\.name) agentManager.seedEnabledCapabilitiesIfNeeded( for: agentId, defaultToolNames: liveToolNames, diff --git a/Packages/OsaurusCore/Views/Agent/AgentsView.swift b/Packages/OsaurusCore/Views/Agent/AgentsView.swift index 8026137e0..54a690eef 100644 --- a/Packages/OsaurusCore/Views/Agent/AgentsView.swift +++ b/Packages/OsaurusCore/Views/Agent/AgentsView.swift @@ -1,5 +1,8 @@ import SwiftUI +// SwiftFormat owns multiline condition layout here; SwiftLint's brace rule conflicts with it. +// swiftlint:disable opening_brace + // MARK: - Shared Helpers func agentColorFor(_ name: String) -> Color { @@ -786,7 +789,7 @@ struct AgentDetailView: View { @State private var pluginInstructionsMap: [String: String] = [:] @State private var disableTools: Bool = false @State private var disableMemory: Bool = false - @State private var avatar: String? = nil + @State private var avatar: String? /// Drives the title-bar agent picker popover. Tapping the avatar / name in the /// header bar reveals the list of other custom agents so the user can jump /// between them without bouncing back to the Agents grid every time. @@ -3213,7 +3216,7 @@ private struct AgentDetailSection: View { let title: String let icon: String - var subtitle: String? = nil + var subtitle: String? @ViewBuilder let content: () -> Content var body: some View { @@ -3275,7 +3278,7 @@ private struct AgentEditorSheet: View { /// the suggested name in sync. Once the user types their own value, the /// name is theirs and presets stop touching it. @State private var nameUserEdited: Bool = false - @State private var selectedAvatar: String? = nil + @State private var selectedAvatar: String? @State private var systemPrompt: String = "" @State private var selectedModel: String? @State private var pickerItems: [ModelPickerItem] = [] @@ -3390,7 +3393,11 @@ private struct AgentEditorSheet: View { guard !draftSeeded else { return } draftSeeded = true draftToolNames = Set(ToolRegistry.shared.listDynamicTools().map(\.name)) - draftSkillNames = Set(SkillManager.shared.skills.map(\.name)) + draftSkillNames = Set( + SkillManager.shared.skills + .filter(\.isDefaultSelectedForAgents) + .map(\.name) + ) } // MARK: Form column @@ -3919,3 +3926,5 @@ private struct ThemeOptionCard: View { #Preview { AgentsView() } + +// swiftlint:enable opening_brace diff --git a/Packages/OsaurusCore/Views/Chat/ChatView.swift b/Packages/OsaurusCore/Views/Chat/ChatView.swift index 26a8dc071..95185fde5 100644 --- a/Packages/OsaurusCore/Views/Chat/ChatView.swift +++ b/Packages/OsaurusCore/Views/Chat/ChatView.swift @@ -5,6 +5,9 @@ // Created by Terence on 10/26/25. // +// SwiftFormat owns multiline condition layout here; SwiftLint's brace rule conflicts with it. +// swiftlint:disable opening_brace + import AppKit import Combine import LocalAuthentication @@ -50,7 +53,7 @@ final class ChatSession: ObservableObject { let expandedBlocksStore = ExpandedBlocksStore() @Published var input: String = "" @Published var pendingAttachments: [Attachment] = [] - @Published var selectedModel: String? = nil + @Published var selectedModel: String? @Published var pickerItems: [ModelPickerItem] = [] @Published var activeModelOptions: [String: ModelOptionValue] = [:] @Published var hasAnyModel: Bool = false @@ -1468,6 +1471,7 @@ final class ChatSession: ObservableObject { toolsDisabled: chatCfg.disableTools, cachedPreflight: cachedSession?.initialPreflight, additionalToolNames: cachedSession?.loadedToolNames ?? [], + additionalSkillNames: cachedSession?.loadedSkillNames ?? [], frozenAlwaysLoadedNames: cachedSession?.initialAlwaysLoadedNames, trace: ttftTrace ) @@ -1865,30 +1869,46 @@ final class ChatSession: ObservableObject { // etc.) so the model sees the rejection. } - // Hot-load tools injected by capabilities_load or sandbox_plugin_register. - // Skipped in manual mode — the user's explicit tool set is fixed. - if !isManualTools, - inv.toolName == "capabilities_load" - || inv.toolName == "sandbox_plugin_register" - { + // Hot-load capabilities injected by capabilities_load or sandbox_plugin_register. + let isCapabilityLoad = inv.toolName == "capabilities_load" + if isCapabilityLoad || inv.toolName == "sandbox_plugin_register" { let newTools = await CapabilityLoadBuffer.shared.drain() - for tool in newTools - where !toolSpecs.contains(where: { $0.function.name == tool.function.name }) { - toolSpecs.append(tool) + let newSkillNames = + isCapabilityLoad + ? await CapabilityLoadBuffer.shared.drainSkillNames() + : [] + + if !isManualTools { + for tool in newTools + where !toolSpecs.contains(where: { $0.function.name == tool.function.name }) { + toolSpecs.append(tool) + } } - // Persist names into the session's tool union - // so they survive the next compose call - // without re-running preflight. + // Persist names into the session's capability + // union so they survive the next compose call + // without re-running preflight or becoming + // globally selected for the agent. if let sid = sessionId { - let names = newTools.map { $0.function.name } let preflight = context.preflight let snapshot = context.alwaysLoadedNames - await SessionToolStateStore.shared.appendLoadedTools( - sessionStateKey(sid), - names: names, - fallbackPreflight: preflight, - fallbackAlwaysLoadedNames: snapshot - ) + let key = sessionStateKey(sid) + let names = isManualTools ? [] : newTools.map { $0.function.name } + if !names.isEmpty { + await SessionToolStateStore.shared.appendLoadedTools( + key, + names: names, + fallbackPreflight: preflight, + fallbackAlwaysLoadedNames: snapshot + ) + } + if !newSkillNames.isEmpty { + await SessionToolStateStore.shared.appendLoadedSkills( + key, + names: newSkillNames, + fallbackPreflight: preflight, + fallbackAlwaysLoadedNames: snapshot + ) + } } } @@ -2040,13 +2060,13 @@ struct ChatView: View { @State private var editText: String = "" @State private var userImagePreview: NSImage? // Bonjour agent connection - @State private var pendingDiscoveredAgent: DiscoveredAgent? = nil + @State private var pendingDiscoveredAgent: DiscoveredAgent? // Minimap @State private var activeMinimapTurnId: UUID? @State private var scrollToTurnId: UUID? @State private var scrollToTurnTrigger: Int = 0 // What's New modal - @State private var pendingWhatsNew: WhatsNewRelease? = nil + @State private var pendingWhatsNew: WhatsNewRelease? /// Convenience accessor for the window's theme private var theme: ThemeProtocol { windowState.theme } @@ -2116,6 +2136,7 @@ struct ChatView: View { } var body: some View { + // swiftlint:disable:next redundant_discardable_let let _ = ChatPerfTrace.shared.count("body.ChatView") chatModeContent .themedAlertScope(.chat(windowState.windowId)) @@ -2759,11 +2780,12 @@ private struct IsolatedThreadView: View { let onConfirmEdit: (() -> Void)? let onCancelEdit: (() -> Void)? let onUserImagePreview: ((String) -> Void)? - var onVisibleTopUserTurnChanged: ((UUID?) -> Void)? = nil - var scrollToTurnId: UUID? = nil + var onVisibleTopUserTurnChanged: ((UUID?) -> Void)? + var scrollToTurnId: UUID? var scrollToTurnTrigger: Int = 0 var body: some View { + // swiftlint:disable:next redundant_discardable_let let _ = ChatPerfTrace.shared.count("body.IsolatedThreadView") MessageThreadView( blocks: store.blocks, @@ -3030,7 +3052,7 @@ private struct PairingSheet: View { let onCancel: () -> Void @State private var isPairing = false - @State private var errorMessage: String? = nil + @State private var errorMessage: String? @Environment(\.theme) private var theme var body: some View { @@ -3136,17 +3158,17 @@ private enum PairingClient { let context = LAContext() context.touchIDAuthenticationAllowableReuseDuration = 300 - var masterKey = try MasterKey.getPrivateKey(context: context) + var privateKey = try MasterKey.getPrivateKey(context: context) defer { - masterKey.withUnsafeMutableBytes { ptr in + privateKey.withUnsafeMutableBytes { ptr in if let base = ptr.baseAddress { memset(base, 0, ptr.count) } } } - let connectorAddress = try PairingKey.deriveAddress(masterKey: masterKey) + let connectorAddress = try PairingKey.deriveAddress(masterKey: privateKey) let nonce = UUID().uuidString - let signature = try PairingKey.sign(payload: Data(nonce.utf8), masterKey: masterKey) + let signature = try PairingKey.sign(payload: Data(nonce.utf8), masterKey: privateKey) let hexSig = "0x" + signature.hexEncodedString let rawHost = agent.host ?? "" @@ -3185,3 +3207,5 @@ private enum PairingClient { // MARK: - Shared Header Components // HeaderActionButton, SettingsButton, CloseButton, PinButton are now in SharedHeaderComponents.swift + +// swiftlint:enable opening_brace diff --git a/Packages/OsaurusCore/Views/Skill/SkillEditorSheet.swift b/Packages/OsaurusCore/Views/Skill/SkillEditorSheet.swift index e737f126e..503b324ff 100644 --- a/Packages/OsaurusCore/Views/Skill/SkillEditorSheet.swift +++ b/Packages/OsaurusCore/Views/Skill/SkillEditorSheet.swift @@ -42,6 +42,11 @@ struct SkillEditorSheet: View { return nil } + private var existingSkill: Skill? { + if case .edit(let skill) = mode { return skill } + return nil + } + private var existingCreatedAt: Date? { if case .edit(let skill) = mode { return skill.createdAt } return nil @@ -465,7 +470,11 @@ struct SkillEditorSheet: View { version: version.trimmingCharacters(in: .whitespacesAndNewlines), author: author.isEmpty ? nil : author.trimmingCharacters(in: .whitespacesAndNewlines), category: category.isEmpty ? nil : category.trimmingCharacters(in: .whitespacesAndNewlines), + keywords: existingSkill?.keywords ?? [], enabled: enabled, + discoverable: existingSkill?.isDiscoverable ?? true, + defaultSelectedForAgents: existingSkill?.isDefaultSelectedForAgents ?? true, + activation: existingSkill?.activationMode ?? .selected, instructions: trimmedInstructions, isBuiltIn: false, createdAt: existingCreatedAt ?? Date(), diff --git a/README.md b/README.md index 6a19fb33d..681e35dba 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ osaurus status # Check status ## Agents -Agents are the core of Osaurus. Each one gets its own prompts, memory, and visual theme -- a research assistant, a coding partner, a file organizer, whatever you need. Tools and skills are automatically selected via RAG search based on the task at hand -- no manual configuration needed. Everything else in the harness exists to make agents smarter, faster, and more capable over time. +Agents are the core of Osaurus. Each one gets its own prompts, memory, and visual theme -- a research assistant, a coding partner, a file organizer, whatever you need. Tool preflight keeps the active schema focused, while skills stay lightweight: select them for an agent or let the agent discover and load them on demand. Everything else in the harness exists to make agents smarter, faster, and more capable over time. ### Agent Loop @@ -154,7 +154,7 @@ osaurus tools dev com.acme.my-plugin # Dev with hot reload ## More -**Skills & Methods** -- Skills import reusable AI capabilities from GitHub repos or files, compatible with [Agent Skills](https://agentskills.io/). Methods are learned workflows that agents save and reuse over time. Both are automatically selected via RAG search -- no manual configuration needed. See [Skills Guide](docs/SKILLS.md). +**Skills & Methods** -- Skills import reusable AI capabilities from GitHub repos or files, compatible with [Agent Skills](https://agentskills.io/). Methods are learned workflows that agents save and reuse over time. Both are discoverable through capability search, and larger skills can load on demand without bloating every startup prompt. See [Skills Guide](docs/SKILLS.md). **Automation** -- Schedules run recurring tasks in the background. Watchers monitor folders and trigger agents on file changes. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 1555c0073..112f59b28 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -368,7 +368,7 @@ See [INFERENCE_RUNTIME.md](./INFERENCE_RUNTIME.md) for the full runtime architec **Features:** - **Custom System Prompts** — Define unique instructions for each agent -- **Automated Capabilities** — Tools, skills, and methods are automatically selected via RAG search based on the task +- **Lightweight Capabilities** — Tool preflight keeps schemas focused, while methods and skills stay discoverable for on-demand loading - **Visual Themes** — Assign a custom theme that activates with the agent - **Generation Settings** — Configure default model, temperature, and max tokens - **Import/Export** — Share agents as JSON files for backup or sharing @@ -765,20 +765,25 @@ See [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) for the full reference. - **Reference Files** — Attach text files loaded into skill context - **Asset Files** — Support files for skills - **Categories** — Organize skills by type -- **Automated Selection** — Skills are automatically selected via RAG-based preflight search +- **Lightweight Activation** — Skills can be selected per agent or loaded on demand without adding prompt weight at startup +- **First-Party Skill Pack** — High-fidelity portable skills in `skills/first-party`, importable through `.claude-plugin/marketplace.json` **Skill Properties:** -| Property | Description | -| -------------- | ---------------------------------- | -| `name` | Display name (required) | -| `description` | Brief description | -| `instructions` | Full AI instructions (markdown) | -| `category` | Optional category for organization | -| `version` | Skill version | -| `author` | Skill author | -| `references/` | Text files loaded into context | -| `assets/` | Supporting files | +| Property | Description | +| --------------------------- | ----------------------------------------------------- | +| `name` | Display name (required) | +| `description` | Brief description | +| `instructions` | Full AI instructions (markdown) | +| `category` | Optional category for organization | +| `keywords` | Search terms folded into skill retrieval | +| `osaurus-discoverable` | Whether `capabilities_search` can surface the skill | +| `osaurus-default-selected` | Whether new agent capability sets include the skill | +| `osaurus-activation` | Activation policy, usually `on-demand` for large packs | +| `version` | Skill version | +| `author` | Skill author | +| `references/` | Text files loaded into context | +| `assets/` | Supporting files | **Storage:** `~/.osaurus/skills/{skill-name}/SKILL.md` @@ -838,9 +843,9 @@ Each time a method is used, a `MethodEvent` is recorded (`loaded`, `succeeded`, ### Context Management -**Purpose:** Automatically select and inject relevant capabilities (methods, tools, and skills) into each agent session via RAG search. +**Purpose:** Keep prompts lean while selecting relevant tools before a turn and exposing methods/skills for on-demand loading. -Context management replaces manual per-agent tool and skill configuration with a fully automated system. Before each agent loop, a preflight RAG search runs across all indexed methods, tools, and skills, injecting relevant context and tool definitions based on the user's query. +Context management keeps the chat prompt lean while letting agents discover more capability as needed. Before each agent loop, preflight search ranks dynamic tools for the active request. Methods and discoverable skills remain available through `capabilities_search` / `capabilities_load`, and selected or session-loaded skills are appended to the system prompt. **Components:** @@ -852,17 +857,17 @@ Context management replaces manual per-agent tool and skill configuration with a **Preflight Search Modes:** -| Mode | Methods | Tools | Skills | Use Case | -| ----------- | ------- | ----- | ------ | ------------------------------------- | -| `off` | 0 | 0 | 0 | Disable automatic selection | -| `narrow` | 1 | 2 | 1 | Minimal context, fastest responses | -| `balanced` | 3 | 5 | 2 | Default — good coverage, moderate cost| -| `wide` | 5 | 8 | 4 | Maximum coverage, larger prompts | +| Mode | Tools | Use Case | +| ----------- | ----- | ------------------------------------- | +| `off` | 0 | Disable automatic tool selection | +| `narrow` | 2 | Minimal context, fastest responses | +| `balanced` | 5 | Default tool coverage | +| `wide` | 15 | Larger tool surface for complex tasks | The preflight search produces a `PreflightResult` containing: -- **Tool specs** — Tool definitions merged into the active tool set (direct matches + tools cascaded from matched methods) -- **Context snippet** — Markdown-formatted method bodies and skill instructions injected into the system prompt +- **Tool specs** — Tool definitions merged into the active tool set +- **Companions** — Compact hints for related plugin tools or plugin skills the model can load on demand **Runtime Capability Tools:** @@ -871,9 +876,9 @@ For on-demand discovery during a session, agents can use: | Tool | Description | | --------------------- | ----------------------------------------------------------------- | | `capabilities_search` | Search methods, tools, and skills across all indexes in parallel | -| `capabilities_load` | Load a capability by ID into the active session (hot-loads tools) | +| `capabilities_load` | Load a capability by ID into the active session | -When `capabilities_load` is called, new tool specs are queued in a `CapabilityLoadBuffer` and drained into the active tool set after each invocation, allowing the agent to dynamically expand its capabilities mid-session. +When `capabilities_load` is called, new tool specs are queued in a `CapabilityLoadBuffer` and drained into the active tool set after each invocation. Loaded skill names are stored on `SessionToolStateStore.loadedSkillNames`, so follow-up turns keep the instructions without selecting the skill globally for the agent. **Search Infrastructure:** diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index bc9b21147..4c1033783 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -924,7 +924,7 @@ const res = await fetch(`${window.__osaurus.baseUrl}/api/widgets`); ### Plugin Skills (SKILL.md) -Plugins can bundle a `SKILL.md` file that provides AI-specific guidance for using the plugin's tools. When a plugin includes a skill, Osaurus automatically loads it and makes it available to the AI during conversations. This is the recommended way to teach the AI how to use your plugin effectively. +Plugins can bundle a `SKILL.md` file that provides AI-specific guidance for using the plugin's tools. When a plugin includes a skill, Osaurus registers and indexes it so users or agents can select it up front or load it on demand during a conversation. This is the recommended way to teach the AI how to use your plugin effectively without adding prompt weight to every chat. Skills follow the [Agent Skills](https://agentskills.io/specification) specification — a markdown file with YAML frontmatter. @@ -956,8 +956,11 @@ Detailed instructions for the AI... | `description` | string | Yes | Tells the AI when this skill applies. Max 1024 characters. | | `metadata.author` | string | No | Skill author name. | | `metadata.version` | string | No | Skill version (e.g., `"1.0.0"`). | +| `metadata.osaurus-discoverable` | bool | No | Whether `capabilities_search` can surface the skill. Defaults to `true`. | +| `metadata.osaurus-default-selected` | bool | No | Whether new agent capability sets include the skill. Defaults to `true` for compatibility. | +| `metadata.osaurus-activation` | string | No | Activation policy, usually `"on-demand"` for large guidance packs. | -The body after the frontmatter contains the full instructions in markdown. This is what the AI sees when the skill is active. +The body after the frontmatter contains the full instructions in markdown. This is what the AI sees when the skill is selected for the agent or loaded into the current session. **Packaging:** @@ -970,7 +973,8 @@ When using `osaurus tools dev`, only the root-level `SKILL.md` file is copied. F 1. When the plugin is installed, `SKILL.md` files are extracted to `/skills/`. 2. When the plugin loads, Osaurus parses each skill and registers it with the skill manager. 3. Plugin skills appear in the Skills UI with a "From: _plugin-name_" badge and are **read-only** — users cannot edit or delete them, but they can enable or disable them. -4. When the plugin is uninstalled, its skills are automatically unregistered and removed. +4. Discoverable plugin skills can be found with `capabilities_search` and loaded into the active session with `capabilities_load`; selected skills still inject at chat start. +5. When the plugin is uninstalled, its skills are automatically unregistered and removed. **Best Practices:** @@ -980,6 +984,7 @@ When using `osaurus tools dev`, only the root-level `SKILL.md` file is copied. F - **List limitations.** If elements can't be modified after creation or slides can't be reordered, say so up front — this prevents the AI from attempting unsupported operations. - **Add tool-specific tips.** Note quirks like "hex colors must omit the `#` prefix" or "the `layout` parameter is metadata only and does not auto-generate content." - **Keep it focused.** The skill is loaded into the AI's context window. Be thorough but concise — avoid repeating what the tool schemas already convey. +- **Prefer on-demand for large packs.** Set `metadata.osaurus-default-selected: false` and `metadata.osaurus-activation: "on-demand"` when the guidance should be discoverable but not always injected. **Example:** diff --git a/docs/SKILLS.md b/docs/SKILLS.md index 648ad29bb..6c86fda16 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -40,6 +40,8 @@ Import skills from any GitHub repository that includes a skills marketplace: Osaurus looks for `.claude-plugin/marketplace.json` in the repository to discover available skills. +Skill packs are portable by design: a repository can contain only `.claude-plugin/marketplace.json` plus skill directories, with no Osaurus app code. First-party high-fidelity skills in this repo follow that same layout under `skills/first-party/` so they can be maintained here or split into a dedicated skills repository later. + ### From Files Import skills from local files: @@ -159,6 +161,8 @@ Skills are stored as directories: └── template.md ``` +For reusable packs, keep each skill directory self-contained and list it in the repository's `.claude-plugin/marketplace.json`. + --- ## Reference Files @@ -180,55 +184,62 @@ Add context files that are automatically loaded when the skill is active: --- -## Automated Capability Selection +## Lightweight Capability Selection -Osaurus uses a RAG-based system to automatically select and inject relevant skills into each conversation. No manual configuration is needed -- the right skills are loaded based on what you're asking about. +Osaurus keeps startup prompts small. Tool preflight can select a relevant tool subset before a chat turn, but skill instructions are not automatically injected just because a skill exists. A skill reaches the system prompt only when it is selected for the agent or loaded on demand in the active session. -### How It Works +### Lifecycle -Before each agent loop, a **preflight capability search** runs across all indexed skills (as well as methods and tools). The search uses hybrid BM25 + vector matching to find capabilities relevant to your query, then injects matching skill instructions directly into the system prompt. +| State | Meaning | +|-------|---------| +| **Installed** | The skill exists on disk, in a plugin, or in a marketplace repository. | +| **Discoverable** | The skill is indexed for `capabilities_search`. | +| **Selected** | The user or agent configuration injects the skill at chat start. | +| **Loaded** | `capabilities_load` adds the skill to the current session for follow-up turns. | -### Search Modes +High-fidelity first-party skills use this metadata by default: -You can control how aggressively the system searches for capabilities: +```yaml +metadata: + osaurus-discoverable: true + osaurus-default-selected: false + osaurus-activation: "on-demand" +``` -| Mode | Skills Loaded | Description | -| ----------- | ------------- | ----------------------------------------- | -| `off` | 0 | Disable automatic selection | -| `narrow` | 1 | Minimal context, fastest responses | -| `balanced` | 2 | Default — good coverage, moderate cost | -| `wide` | 4 | Maximum coverage, larger prompts | +That means they are searchable and loadable, but they do not add prompt weight until the agent needs them. ### Runtime Discovery -During a conversation, the AI can also discover and load additional capabilities on demand: +During a conversation, the AI can discover and load additional capabilities on demand: -1. **`capabilities_search`** — Searches all indexed methods, tools, and skills in parallel -2. **`capabilities_load`** — Loads a specific capability into the active session +1. **`capabilities_search`** searches indexed methods, tools, and discoverable skills. +2. **`capabilities_load`** loads specific IDs returned by search. -This means the AI starts with automatically selected skills and can dynamically expand its capabilities as the conversation evolves. +Loaded tools are added to the callable schema for the session. Loaded skills are appended to the session instructions on later turns without becoming globally selected for the agent. -### Why This Matters +### Search Modes + +Preflight search modes control automatic tool selection: -- **Zero configuration** — Skills are selected based on relevance, not manual toggles -- **Better focus** — Only relevant skills are loaded, keeping context lean -- **Adaptive** — The AI can discover additional skills mid-conversation if the topic shifts -- **Works with Methods** — Learned workflows (methods) are searched alongside skills, so the AI benefits from past experience +| Mode | Tools Loaded | Description | +| ----------- | ------------ | ----------------------------------------- | +| `off` | 0 | Disable preflight selection | +| `narrow` | Up to 2 | Minimal tool injection | +| `balanced` | Up to 5 | Default tool coverage | +| `wide` | Up to 15 | Larger tool surface for complex tasks | + +Skills remain available through selected agent configuration and on-demand capability loading. --- ## Agent Integration -Skills are available to all agents automatically. The RAG-based preflight search selects relevant skills for each query regardless of which agent is active. - -**How it works with agents:** +Skills are available to agents in two lightweight ways: -- Each agent's system prompt guides its behavior and specialization -- When you send a message, the preflight search finds skills relevant to your query -- Matching skill instructions are injected into the agent's context -- The agent can discover additional skills at runtime via `capabilities_search` +- Selected skills are injected at chat start for that agent. +- Discoverable skills can be found with `capabilities_search` and loaded into the current session with `capabilities_load`. -No per-agent skill configuration is needed. The system automatically matches the right skills to the right tasks. +New high-fidelity skills can opt out of default agent selection with `osaurus-default-selected: false`. They still appear in search and can be loaded when the task calls for them. --- @@ -237,9 +248,10 @@ No per-agent skill configuration is needed. The system automatically matches the ### Skills not appearing in chat - Verify the skill is enabled (toggle is on) -- Check that the skill's description clearly describes its purpose (the RAG search uses this) +- Check whether the skill is selected for the agent, or ask the agent to use `capabilities_search` +- Check that the skill's description and keywords clearly describe its purpose - Start a new chat session -- Try setting a wider search mode in chat configuration +- For tools, try setting a wider preflight search mode in chat configuration ### GitHub import fails diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 000000000..76cf024ee --- /dev/null +++ b/skills/README.md @@ -0,0 +1,42 @@ +# Skill Packs + +This directory holds portable Agent Skills content. It is intentionally separate from `Packages/OsaurusCore` so skill instructions can evolve without app-code changes. + +## Maintenance Model + +- Each skill is a self-contained directory with `SKILL.md` and optional `references/` or `assets/`. +- `.claude-plugin/marketplace.json` is the import index Osaurus uses for GitHub skill import. +- Skill packs can move to a separate repository as long as the same marketplace file and relative skill paths are preserved. +- Adding, removing, or editing a skill should not require Swift changes unless the runtime capability system itself changes. + +## Development Alignment + +- Runtime changes for skills must follow the public layering guidance in `docs/CONTRIBUTING.md`: models stay data-only, services handle retrieval/loading behavior, managers own UI-facing state, and views stay presentation-focused. +- Skill content should target the single Chat/Agent loop documented in `docs/AGENT_LOOP.md`; do not add instructions for legacy separate-mode flows. +- Any new or changed capability tool behavior must preserve the JSON envelope and schema rules in `docs/TOOL_CONTRACT.md`. +- Skill-pack distribution should remain compatible with the GitHub import path in `docs/SKILLS.md` and plugin skill packaging guidance in `docs/PLUGIN_AUTHORING.md`. + +## Lightweight Defaults + +High-fidelity skills should use: + +```yaml +metadata: + osaurus-discoverable: true + osaurus-default-selected: false + osaurus-activation: "on-demand" +``` + +This keeps skills searchable and loadable while avoiding startup prompt weight. + +## Checks + +Run these from the repo root after changing skill content: + +```bash +jq empty .claude-plugin/marketplace.json +cd Packages/OsaurusCore +swift test --filter FirstPartySkillPackTests +``` + +The test validates that every skill directory is listed in the marketplace, every listed skill exists, metadata stays on-demand, and reference files remain under the prompt-size limit. diff --git a/skills/first-party/README.md b/skills/first-party/README.md new file mode 100644 index 000000000..9a3e504bf --- /dev/null +++ b/skills/first-party/README.md @@ -0,0 +1,20 @@ +# First-Party High-Fidelity Skills + +These skills are first-party Osaurus content packages, not built-in app code. They are meant to stay portable so they can be maintained here or split into a dedicated skills repository later. + +## Add A Skill + +1. Create `skills/first-party//SKILL.md`. +2. Use Agent Skills frontmatter with a clear description and retrieval-rich keywords. +3. Set `osaurus-default-selected: false` and `osaurus-activation: "on-demand"`. +4. Add optional small text references under `references/`. +5. Add `./skills/first-party/` to `.claude-plugin/marketplace.json`. +6. Run `swift test --filter FirstPartySkillPackTests`. + +## Authoring Guidelines + +- Keep `SKILL.md` concise enough to load into a chat turn when requested. +- Put stable background material in `references/`; keep each file under 100KB. +- Make descriptions and keywords specific because discovery relies on metadata. +- Target the single Chat/Agent loop, not legacy separate-mode flows. +- Do not require new app tools for prose-only workflow guidance. diff --git a/skills/first-party/agent-loop-operator/SKILL.md b/skills/first-party/agent-loop-operator/SKILL.md new file mode 100644 index 000000000..d649de535 --- /dev/null +++ b/skills/first-party/agent-loop-operator/SKILL.md @@ -0,0 +1,36 @@ +--- +name: agent-loop-operator +description: Operate the Osaurus Chat/Agent loop with lightweight planning, correct use of todo, clarify, complete, share_artifact, and on-demand capability loading. +metadata: + author: Osaurus + version: "1.0.0" + category: agent + keywords: "agent loop, todo, clarify, complete, share artifact, capabilities search, capabilities load, folder context, sandbox" + osaurus-discoverable: true + osaurus-default-selected: false + osaurus-activation: "on-demand" +--- + +# Agent Loop Operator + +Use this skill when a task needs disciplined execution inside an Osaurus chat session. + +## Loop Discipline + +- Use `todo` only when the request has three or more meaningful steps. +- Use `clarify` for one blocking decision only. For minor preferences, choose a sensible default. +- Use `complete` once at the end with what was done and how it was verified. +- Use `share_artifact` for generated files, reports, images, charts, or code blobs that the user should see. + +## Capability Loading + +- Start with the tools already available. +- Use `capabilities_search` when the current schema is missing a capability. +- Use `capabilities_load` with IDs from search results. Do not invent capability IDs. +- Load specialized skills on demand instead of carrying broad instruction packs in every prompt. + +## Execution Context + +- Use folder context for real repository edits. +- Use sandbox context for isolated scripts, package installation, scraping, and generated artifacts. +- Avoid switching execution style unless the task requires it. diff --git a/skills/first-party/automation-watcher-designer/SKILL.md b/skills/first-party/automation-watcher-designer/SKILL.md new file mode 100644 index 000000000..4e84874cb --- /dev/null +++ b/skills/first-party/automation-watcher-designer/SKILL.md @@ -0,0 +1,33 @@ +--- +name: automation-watcher-designer +description: Design Osaurus schedules, watchers, and background chat workflows with safe triggers, idempotent actions, auditability, and clear user control. +metadata: + author: Osaurus + version: "1.0.0" + category: automation + keywords: "automation, watcher, schedule, background task, trigger, monitor, idempotent, audit, notification" + osaurus-discoverable: true + osaurus-default-selected: false + osaurus-activation: "on-demand" +--- + +# Automation Watcher Designer + +Use this skill for recurring tasks, monitors, scheduled checks, and background chat sessions. + +## Design Rules + +- Make triggers explicit and narrow. +- Prefer idempotent actions that can safely run more than once. +- Include cancellation, pause, and audit paths. +- Keep writes, network calls, and notifications intentional. + +## Failure Handling + +- Define what happens on missing credentials, offline services, parse failures, and repeated errors. +- Avoid tight polling. Use backoff or event-driven triggers when available. + +## User Control + +- State what will run, when it runs, and what it can change. +- Surface outputs in the same session/audit trail when possible. diff --git a/skills/first-party/ci-release-debugger/SKILL.md b/skills/first-party/ci-release-debugger/SKILL.md new file mode 100644 index 000000000..f11cfc666 --- /dev/null +++ b/skills/first-party/ci-release-debugger/SKILL.md @@ -0,0 +1,34 @@ +--- +name: ci-release-debugger +description: Triage failing Osaurus checks, reproduce the smallest failing scope, separate flaky runner failures from code regressions, and prepare release-safe fixes. +metadata: + author: Osaurus + version: "1.0.0" + category: development + keywords: "CI, GitHub Actions, failing tests, swift test, release, regression, logs, flaky, root cause" + osaurus-discoverable: true + osaurus-default-selected: false + osaurus-activation: "on-demand" +--- + +# CI Release Debugger + +Use this skill when a pull request, branch, or release candidate has failing checks. + +## Triage Flow + +- Identify the exact failing job, test target, and first meaningful error. +- Distinguish infrastructure symptoms from source-level regressions. +- Reproduce locally with the smallest targeted command before broadening. +- Keep a clear map of fixed, still failing, skipped, and not reproduced checks. + +## Fix Flow + +- Fix the root cause rather than silencing the failing assertion. +- Add or update tests that would have caught the regression. +- Preserve unrelated worktree changes and avoid broad cleanup. + +## Reporting + +- State the failing check, root cause, changed files, and verification command. +- Call out residual risk when CI logs are non-diagnostic or runner-only. diff --git a/skills/first-party/document-data-analyst/SKILL.md b/skills/first-party/document-data-analyst/SKILL.md new file mode 100644 index 000000000..a31513835 --- /dev/null +++ b/skills/first-party/document-data-analyst/SKILL.md @@ -0,0 +1,33 @@ +--- +name: document-data-analyst +description: Analyze PDFs, CSVs, spreadsheets, tables, and structured attachments with careful extraction, validation, summaries, and chart-ready outputs. +metadata: + author: Osaurus + version: "1.0.0" + category: documents + keywords: "PDF, CSV, XLSX, spreadsheet, workbook, table, document analysis, extraction, chart, attachment" + osaurus-discoverable: true + osaurus-default-selected: false + osaurus-activation: "on-demand" +--- + +# Document Data Analyst + +Use this skill when working with attached documents, spreadsheets, reports, or tabular data. + +## Extraction + +- Identify file type and the most reliable parser before transforming content. +- Preserve sheet names, table headings, page numbers, and units where available. +- Validate row counts, column names, totals, and obvious type conversions. + +## Analysis + +- Separate raw extraction from interpretation. +- Use tables for comparisons and concise bullets for findings. +- Flag missing values, inconsistent units, or possible OCR/parser errors. + +## Outputs + +- Produce chart-ready data only after validating columns. +- Use `share_artifact` for generated reports, converted files, or chart specs the user should inspect. diff --git a/skills/first-party/osaurus-contributor/SKILL.md b/skills/first-party/osaurus-contributor/SKILL.md new file mode 100644 index 000000000..9aa33ef85 --- /dev/null +++ b/skills/first-party/osaurus-contributor/SKILL.md @@ -0,0 +1,35 @@ +--- +name: osaurus-contributor +description: Contribute to Osaurus code with repo-aware Swift architecture, clean Git hygiene, targeted verification, and post-PR-893 single Chat/Agent loop conventions. +metadata: + author: Osaurus + version: "1.0.0" + category: development + keywords: "osaurus, swift, architecture, contributor, codebase, tests, git, package build, chat agent loop" + osaurus-discoverable: true + osaurus-default-selected: false + osaurus-activation: "on-demand" +--- + +# Osaurus Contributor + +Use this skill when changing Osaurus itself. + +## Operating Rules + +- Work from a clean checkout and leave private user documents untouched. +- Treat the single Chat/Agent loop as the product architecture. Do not add a new mode, tab, or legacy planning surface. +- Keep changes inside the established layers: Models are pure data, Services own business logic, Managers own UI state, Views render feature UI, Tools expose capability contracts. +- Prefer focused changes that match nearby code over broad refactors. +- Preserve user or teammate edits in the worktree. + +## Verification + +- Prefer package-level Swift verification for OsaurusCore. +- Do not rely on workspace `xcodebuild` for local verification when private repo rules say external dependencies have known failures. +- Run targeted tests first, then broaden to package checks and CI-equivalent jobs. + +## Completion Standard + +- Explain what changed, how it was verified, and any remaining risk. +- When producing files for the user, surface them through the artifact flow used by the current chat system. diff --git a/skills/first-party/osaurus-contributor/references/osaurus-development-principles.md b/skills/first-party/osaurus-contributor/references/osaurus-development-principles.md new file mode 100644 index 000000000..7fad748c4 --- /dev/null +++ b/skills/first-party/osaurus-contributor/references/osaurus-development-principles.md @@ -0,0 +1,6 @@ +# Osaurus Development Principles + +- The chat window is the agent loop. +- Startup context should stay small; load specialized instructions only when they are selected or requested. +- Tool behavior must follow the JSON success/failure envelope in `docs/TOOL_CONTRACT.md`. +- New workflows should compose with folder context, sandbox context, `todo`, `clarify`, `complete`, and `share_artifact`. diff --git a/skills/first-party/performance-reliability-investigator/SKILL.md b/skills/first-party/performance-reliability-investigator/SKILL.md new file mode 100644 index 000000000..2b04146b3 --- /dev/null +++ b/skills/first-party/performance-reliability-investigator/SKILL.md @@ -0,0 +1,34 @@ +--- +name: performance-reliability-investigator +description: Investigate Osaurus startup, GPU, Metal, MLX, polling, and model-runtime reliability issues with measurement-first debugging. +metadata: + author: Osaurus + version: "1.0.0" + category: reliability + keywords: "performance, reliability, GPU, Metal, MLX, startup, polling, memory, hang, crash, issue 969" + osaurus-discoverable: true + osaurus-default-selected: false + osaurus-activation: "on-demand" +--- + +# Performance Reliability Investigator + +Use this skill for startup hangs, GPU saturation, memory spikes, slow prompts, or model-runtime regressions. + +## Investigation Pattern + +- Reproduce with a narrow scenario and record exact app state. +- Separate UI animation, polling, indexing, model loading, inference, and Metal work. +- Prefer instrumentation and logs over speculation. +- Check whether work starts too early at app open or should be deferred until user intent. + +## Fix Pattern + +- Gate expensive work behind explicit demand, idle scheduling, or cached state. +- Avoid adding retries or polling loops without a clear backoff and cancellation story. +- Preserve responsiveness of the main actor. + +## Acceptance + +- Verify the issue no longer reproduces in the narrow case. +- Add a regression test or diagnostic hook when direct UI performance testing is not practical. diff --git a/skills/first-party/plugin-tool-author/SKILL.md b/skills/first-party/plugin-tool-author/SKILL.md new file mode 100644 index 000000000..e56041f05 --- /dev/null +++ b/skills/first-party/plugin-tool-author/SKILL.md @@ -0,0 +1,35 @@ +--- +name: plugin-tool-author +description: Design Osaurus plugins and tools with strict schemas, JSON envelopes, safe sandbox registration, and on-demand discovery through the capability system. +metadata: + author: Osaurus + version: "1.0.0" + category: development + keywords: "plugin, tool, schema, ToolEnvelope, additionalProperties, sandbox plugin, MCP, capability load, plugin authoring" + osaurus-discoverable: true + osaurus-default-selected: false + osaurus-activation: "on-demand" +--- + +# Plugin Tool Author + +Use this skill when creating or reviewing Osaurus tools, plugins, or MCP-like integrations. + +## Tool Contracts + +- Return a JSON success or failure envelope for every tool call. +- Use focused tools with one clear action instead of broad mega-tools. +- Define JSON Schema with `additionalProperties: false`. +- Use enums, schema defaults, and precise descriptions to help small local models call tools correctly. + +## Plugin Shape + +- Put durable instructions in `SKILL.md`; put executable behavior in tools. +- Keep plugin capabilities discoverable through tool descriptions, skill descriptions, and marketplace metadata. +- Prefer read-only operations first. Add writes only when the user asked for them or the workflow clearly requires them. + +## Sandbox Creation + +- Use sandbox plugin creation for missing integrations when the agent has no existing tool that fits. +- Keep generated plugins text-only unless a signed distribution path explicitly supports binary assets. +- Treat secrets and network access as explicit requirements, not defaults. diff --git a/skills/first-party/presentation-content-producer/SKILL.md b/skills/first-party/presentation-content-producer/SKILL.md new file mode 100644 index 000000000..0291cfaa7 --- /dev/null +++ b/skills/first-party/presentation-content-producer/SKILL.md @@ -0,0 +1,33 @@ +--- +name: presentation-content-producer +description: Create high-quality presentation and content artifacts with clear structure, audience fit, visual asset planning, export checks, and concise speaker-ready copy. +metadata: + author: Osaurus + version: "1.0.0" + category: content + keywords: "presentation, slides, content, deck, narrative, speaker notes, visual assets, export, artifact" + osaurus-discoverable: true + osaurus-default-selected: false + osaurus-activation: "on-demand" +--- + +# Presentation Content Producer + +Use this skill for slide decks, narrative docs, launch content, and polished user-facing artifacts. + +## Structure + +- Start from audience, goal, and decision the artifact should support. +- Use a simple narrative arc: context, insight, evidence, recommendation, next step. +- Keep slide copy short and speaker-ready. + +## Visual Planning + +- Use real product, data, document, or workflow visuals when inspection matters. +- Avoid decorative filler when the user needs clarity. +- Make charts legible with units, labels, and a direct takeaway. + +## Delivery + +- Verify exported files render correctly before completion. +- Use `share_artifact` for final decks, reports, previews, or media. diff --git a/skills/first-party/privacy-security-reviewer/SKILL.md b/skills/first-party/privacy-security-reviewer/SKILL.md new file mode 100644 index 000000000..538594b26 --- /dev/null +++ b/skills/first-party/privacy-security-reviewer/SKILL.md @@ -0,0 +1,34 @@ +--- +name: privacy-security-reviewer +description: Review Osaurus changes for private document safety, secrets handling, sandbox boundaries, destructive operations, signing, and local-first trust. +metadata: + author: Osaurus + version: "1.0.0" + category: security + keywords: "privacy, security, private documents, secrets, sandbox, signing, destructive operations, access keys, local first" + osaurus-discoverable: true + osaurus-default-selected: false + osaurus-activation: "on-demand" +--- + +# Privacy Security Reviewer + +Use this skill when a change touches user files, credentials, networking, plugins, storage, or sandbox execution. + +## Review Focus + +- Never delete, move, or rewrite private documents unless the user explicitly requested it. +- Treat secrets as non-printable and non-loggable. +- Keep sandbox and host-folder permissions narrow. +- Prefer explicit user approval for destructive, networked, or credentialed actions. + +## Plugin And Tool Safety + +- Validate paths stay inside the intended root. +- Keep network and filesystem permissions opt-in. +- Require signed or verified distribution paths for installable code. + +## Output + +- Lead with concrete risks and affected files. +- Separate confirmed issues from assumptions and future hardening ideas. diff --git a/skills/first-party/research-citation-analyst/SKILL.md b/skills/first-party/research-citation-analyst/SKILL.md new file mode 100644 index 000000000..3b23ea1ed --- /dev/null +++ b/skills/first-party/research-citation-analyst/SKILL.md @@ -0,0 +1,35 @@ +--- +name: research-citation-analyst +description: Perform source-grounded research with citation hygiene, recency checks, source quality labels, synthesis, and confidence assessment. +metadata: + author: Osaurus + version: "1.0.0" + category: research + keywords: "research, citations, sources, fact check, recency, evidence, synthesis, confidence, primary sources" + osaurus-discoverable: true + osaurus-default-selected: false + osaurus-activation: "on-demand" +--- + +# Research Citation Analyst + +Use this skill for questions that need current facts, precise attribution, or careful synthesis. + +## Source Strategy + +- Prefer primary sources, official docs, papers, or direct records. +- Check publication dates and event dates separately. +- Use multiple independent sources for contested claims. +- Label uncertainty when evidence is incomplete. + +## Synthesis + +- Answer the user's actual question first. +- Separate findings, caveats, and recommendations. +- Do not overquote. Use short excerpts only when they materially help. + +## Citation Hygiene + +- Link sources close to the claim they support. +- Make clear when a statement is an inference from the sources. +- Avoid citing irrelevant sources just to pad confidence.