Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
]
}
10 changes: 6 additions & 4 deletions Packages/OsaurusCore/Managers/AgentManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Expand Down Expand Up @@ -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) }
}
}
Expand All @@ -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
Expand Down
52 changes: 28 additions & 24 deletions Packages/OsaurusCore/Managers/Plugin/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}

Expand All @@ -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)) {
Expand All @@ -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),
Expand All @@ -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())
}

Expand Down Expand Up @@ -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
}
}

Expand All @@ -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
}
}
}
Expand Down Expand Up @@ -942,3 +944,5 @@ final class PluginManager {
return nil
}
}

// swiftlint:enable opening_brace
79 changes: 69 additions & 10 deletions Packages/OsaurusCore/Managers/SkillManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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<String> = []
) 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
Expand All @@ -418,13 +448,42 @@ public final class SkillManager {
return sections.joined(separator: "\n\n")
}

private func orderedUniqueSkillNames(_ names: [String]) -> [String] {
var seen: Set<String> = []
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
}

Expand Down
Loading
Loading