diff --git a/AGENTS.md b/AGENTS.md index 2c7db2d..9287893 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,10 +11,16 @@ ## currency - always update the README.md when major things change - new features, adjustments, etc -- Add docs to docs/ as necessary. Especially when adding new features, what would we want to tell someone about how to use them? +- Add docs to docs/ as necessary. Especially when adding new features, what would we want to tell someone about how to use them? - Add unit tests when introducing new function whenever practical - Run unit tests before commit and ensure that they pass. +## Definition of Done (Atomic Feature Check) +Before opening or updating a Pull Request, verify: +1. **Docs Currency:** Every new user-facing feature, slash command, configuration key, or CLI flag MUST have its corresponding entries added to `README.md` and reference guides in `docs/` in the same commit. Never wait for the user to ask if docs exist. +2. **Automated Verification:** Unit tests exist under `Tests/` and cover all newly added features and edge cases. +3. **Spec, Plan & Review Layout:** Design specs live in `docs/specs/YYYY-MM-DD-feature-name.md`, implementation plans in `docs/plans/YYYY-MM-DD-feature-name.md`, and reviews in `docs/reviews/YYYY-MM-DD-feature-name-review.md`. + ## Specs, Plans & Reviews Layout - **Design Specs:** Save to `docs/specs/YYYY-MM-DD-feature-name.md`. - **Implementation Plans:** Save to `docs/plans/YYYY-MM-DD-feature-name.md`. diff --git a/README.md b/README.md index 82d0b69..8269d31 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,9 @@ Iris runs in the background and can be summoned instantly over any other app by Iris supports in-app slash commands typed directly into the composer. Deterministic commands run instantly in-app without invoking the LLM (zero token cost): * **Harness & Models:** `/model [tier|name]` (inspect/switch active model), `/mcp [reload]` (inspect/reconnect MCP servers), `/tokens` (view token usage). -* **Skills & Rules:** `/skills [reload|show ]` (list, reload, or view SKILL.md body), `/rules [reload]` (inspect/reload custom rules from `~/.iris/rules/`). -* **Memory & Facts:** `/facts [search |probe ]` (query SQLite FactStore). +* **Skills & Bundles:** `/skills [new |reload|show |curate]` (scaffold, list, reload, view, or curate skills), `/bundle [save s1,s2||clear]` (manage or activate selective skill bundle filters). +* **Journey & Learning:** `/journey` (render chronological timeline of all learned memories, skills, and facts). +* **Memory & Rules:** `/rules [reload]` (inspect/reload custom rules), `/facts [search |probe ]` (query SQLite FactStore). * **Session Control:** `/new` (fresh chat), `/clear` (clear current buffer), `/stop` (cancel active goal/subagents), `/update` (check for GitHub releases). * **Autonomous Workflows:** `/goal ` (autonomous execution loop), `/reflect` (memory reflection), `/vibecop init` (generate security rules). diff --git a/Sources/iris/AppState.swift b/Sources/iris/AppState.swift index b463856..c65152d 100644 --- a/Sources/iris/AppState.swift +++ b/Sources/iris/AppState.swift @@ -370,6 +370,12 @@ class AppState { } else if trimmed == "/skills" || trimmed.hasPrefix("/skills ") { handleSkillsCommand(trimmed, convId: convId) return + } else if trimmed == "/bundle" || trimmed.hasPrefix("/bundle ") { + handleBundleCommand(trimmed, convId: convId) + return + } else if trimmed == "/journey" { + handleJourneyCommand(convId: convId) + return } else if trimmed == "/rules" || trimmed.hasPrefix("/rules ") { handleRulesCommand(trimmed, convId: convId) return @@ -761,7 +767,30 @@ class AppState { let args = trimmed.dropFirst(7).trimmingCharacters(in: .whitespacesAndNewlines) Task { [weak self] in guard let self = self else { return } - if args.hasPrefix("reload") { + if args.hasPrefix("curate") { + let report = await SkillCurator.shared.curateSkills() + let markdown = SkillCurator.shared.formatReportMarkdown(report) + self.emitCommandOutput(markdown, format: .markdown, to: convId) + } else if args.hasPrefix("new ") { + let name = String(args.dropFirst(4)).trimmingCharacters(in: .whitespacesAndNewlines) + let templateBody = """ + # Overview + Provide a clear summary of when to use this skill. + + # Steps + 1. Step one + 2. Step two + + # Pitfalls & Verification + - Step verification criteria + """ + let res = await ToolExecutor.shared.createSkill( + name: name, + description: "Scaffolded skill '\(name)'", + body: templateBody + ) + self.emitCommandOutput("✨ Scaffolded skill template for **\(name)**.\n\(res)", format: .markdown, to: convId) + } else if args.hasPrefix("reload") { let skillArg = args.dropFirst(6).trimmingCharacters(in: .whitespacesAndNewlines) await self.engine.invalidateSystemPrompt() let skills = await SkillManager.shared.listSkills() @@ -790,6 +819,63 @@ class AppState { } } + private func handleBundleCommand(_ trimmed: String, convId: UUID) { + let args = trimmed.dropFirst(7).trimmingCharacters(in: .whitespacesAndNewlines) + if args.hasPrefix("save ") { + let rest = String(args.dropFirst(5)).trimmingCharacters(in: .whitespacesAndNewlines) + let parts = rest.split(separator: " ", maxSplits: 1).map { String($0) } + guard parts.count == 2 else { + emitCommandOutput("Usage: `/bundle save skill1,skill2,...`", format: .markdown, to: convId) + return + } + let name = parts[0] + let skills = parts[1].split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + let bundle = SkillBundle(name: name, description: "Custom bundle '\(name)'", skillNames: skills) + do { + try SkillBundleManager.shared.saveBundle(bundle) + emitCommandOutput("Saved skill bundle **\(name)** (\(skills.joined(separator: ", "))).", format: .markdown, to: convId) + } catch { + emitCommandOutput("Failed to save bundle: \(error.localizedDescription)", format: .markdown, to: convId) + } + } else if !args.isEmpty { + if args == "clear" || args == "off" { + SkillBundleManager.shared.activeBundle = nil + Task { [weak self] in + await self?.engine.invalidateSystemPrompt() + self?.emitCommandOutput("Cleared active skill bundle filter. All registered skills are now loaded.", format: .markdown, to: convId) + } + } else if let bundle = SkillBundleManager.shared.getBundle(name: args) { + SkillBundleManager.shared.activeBundle = bundle + Task { [weak self] in + await self?.engine.invalidateSystemPrompt() + self?.emitCommandOutput("Activated skill bundle **\(bundle.name)** (\(bundle.skillNames.joined(separator: ", "))). Context filtered & prompt cache updated.", format: .markdown, to: convId) + } + } else { + emitCommandOutput("Bundle '\(args)' not found. Type `/bundle` to list saved bundles.", format: .markdown, to: convId) + } + } else { + let bundles = SkillBundleManager.shared.listBundles() + if bundles.isEmpty { + emitCommandOutput("No skill bundles defined. Use `/bundle save skill1,skill2` to create one.", format: .markdown, to: convId) + } else { + var body = "**Defined Skill Bundles (\(bundles.count))**\n\n" + for b in bundles { + body += "• **\(b.name)**: \(b.skillNames.joined(separator: ", "))\n" + } + emitCommandOutput(body, format: .markdown, to: convId) + } + } + } + + private func handleJourneyCommand(convId: UUID) { + Task { [weak self] in + guard let self = self else { return } + let items = await JourneyManager.shared.buildTimeline() + let markdown = JourneyManager.shared.formatTimelineMarkdown(items: items) + self.emitCommandOutput(markdown, format: .markdown, to: convId) + } + } + private func handleRulesCommand(_ trimmed: String, convId: UUID) { let args = trimmed.dropFirst(6).trimmingCharacters(in: .whitespacesAndNewlines) Task { [weak self] in diff --git a/Sources/iris/JourneyManager.swift b/Sources/iris/JourneyManager.swift new file mode 100644 index 0000000..d028d22 --- /dev/null +++ b/Sources/iris/JourneyManager.swift @@ -0,0 +1,120 @@ +import Foundation + +public struct JourneyItem: Identifiable, Sendable, Equatable { + public let id: UUID + public let title: String + public let category: String // "Skill", "Profile", "Soul", "Fact" + public let timestamp: Date + public let summary: String + public let path: String? + + public init(id: UUID = UUID(), title: String, category: String, timestamp: Date, summary: String, path: String? = nil) { + self.id = id + self.title = title + self.category = category + self.timestamp = timestamp + self.summary = summary + self.path = path + } +} + +public final class JourneyManager: Sendable { + public static let shared = JourneyManager() + + private init() {} + + public func buildTimeline() async -> [JourneyItem] { + await buildTimeline(paths: .default) + } + + func buildTimeline(paths: IrisPaths) async -> [JourneyItem] { + var items: [JourneyItem] = [] + let fileManager = FileManager.default + let isoFormatter = ISO8601DateFormatter() + + // 1. Scan USER.md + if fileManager.fileExists(atPath: paths.userMd.path), + let content = try? String(contentsOf: paths.userMd, encoding: .utf8) { + let (desc, date) = parseFrontmatterDetails(content: content, fallbackDate: getFileModDate(paths.userMd)) + items.append(JourneyItem(title: "User Profile Updated", category: "Profile", timestamp: date, summary: desc, path: paths.userMd.path)) + } + + // 2. Scan SOUL.md + if fileManager.fileExists(atPath: paths.soulMd.path), + let content = try? String(contentsOf: paths.soulMd, encoding: .utf8) { + let (desc, date) = parseFrontmatterDetails(content: content, fallbackDate: getFileModDate(paths.soulMd)) + items.append(JourneyItem(title: "Persona / SOUL Evolved", category: "Soul", timestamp: date, summary: desc, path: paths.soulMd.path)) + } + + // 3. Scan Skills + if let skillFolders = try? fileManager.contentsOfDirectory(atPath: paths.skillsDir.path) { + for folder in skillFolders { + let skillFile = paths.skillsDir.appendingPathComponent("\(folder)/SKILL.md") + if fileManager.fileExists(atPath: skillFile.path), + let content = try? String(contentsOf: skillFile, encoding: .utf8) { + let (desc, date) = parseFrontmatterDetails(content: content, fallbackDate: getFileModDate(skillFile)) + items.append(JourneyItem(title: "Learned Skill: \(folder)", category: "Skill", timestamp: date, summary: desc, path: skillFile.path)) + } + } + } + + // 4. Scan FactStore + if let facts = try? FactStoreManager.shared.search(query: "", limit: 20) { + for fact in facts { + let date = Date(timeIntervalSince1970: TimeInterval(fact.createdAt)) + items.append(JourneyItem(title: "Fact Saved [\(fact.category)]", category: "Fact", timestamp: date, summary: fact.content)) + } + } + + return items.sorted { $0.timestamp > $1.timestamp } + } + + public func formatTimelineMarkdown(items: [JourneyItem]) -> String { + guard !items.isEmpty else { + return "No learned memories or skills found in Iris journey timeline." + } + + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + + var markdown = "### 📜 Iris Learning Journey Timeline (\(items.count) Milestones)\n\n" + for item in items { + let dateStr = formatter.string(from: item.timestamp) + let badge = "[\(item.category.uppercased())]" + markdown += "• **\(dateStr)** `\(badge)` **\(item.title)**\n *\(item.summary)*\n\n" + } + return markdown + } + + private func getFileModDate(_ url: URL) -> Date { + (try? FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) ?? Date() + } + + private func parseFrontmatterDetails(content: String, fallbackDate: Date) -> (summary: String, date: Date) { + let lines = content.components(separatedBy: .newlines) + var inFrontmatter = false + var desc = "No summary provided." + var date = fallbackDate + let isoFormatter = ISO8601DateFormatter() + + for line in lines { + if line == "---" { + if inFrontmatter { break } + inFrontmatter = true + continue + } + if inFrontmatter { + if line.starts(with: "description:") { + desc = String(line.dropFirst("description:".count)).trimmingCharacters(in: .whitespaces) + } else if line.starts(with: "timestamp:") { + let rawDate = String(line.dropFirst("timestamp:".count)).trimmingCharacters(in: .whitespaces) + if let parsed = isoFormatter.date(from: rawDate) { + date = parsed + } + } + } + } + return (desc, date) + } +} diff --git a/Sources/iris/SkillBundleManager.swift b/Sources/iris/SkillBundleManager.swift new file mode 100644 index 0000000..e0d83f2 --- /dev/null +++ b/Sources/iris/SkillBundleManager.swift @@ -0,0 +1,91 @@ +import Foundation + +public struct SkillBundle: Codable, Sendable, Equatable { + public let name: String + public let description: String + public let skillNames: [String] + + public init(name: String, description: String, skillNames: [String]) { + self.name = name.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + self.description = description + self.skillNames = skillNames.map { $0.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) } + } +} + +public final class SkillBundleManager: Sendable { + public static let shared = SkillBundleManager() + + private let lock = NSLock() + private var _activeBundle: SkillBundle? = nil + + public var activeBundle: SkillBundle? { + get { + lock.lock() + defer { lock.unlock() } + return _activeBundle + } + set { + lock.lock() + _activeBundle = newValue + lock.unlock() + } + } + + private init() {} + + private func bundleFile(paths: IrisPaths) -> URL { + paths.memoryDir.appendingPathComponent("bundles.json") + } + + public func listBundles() -> [SkillBundle] { + listBundles(paths: .default) + } + + func listBundles(paths: IrisPaths) -> [SkillBundle] { + let url = bundleFile(paths: paths) + guard let data = try? Data(contentsOf: url), + let bundles = try? JSONDecoder().decode([SkillBundle].self, from: data) else { + return [] + } + return bundles.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + public func saveBundle(_ bundle: SkillBundle) throws { + try saveBundle(bundle, paths: .default) + } + + func saveBundle(_ bundle: SkillBundle, paths: IrisPaths) throws { + var current = listBundles(paths: paths) + current.removeAll { $0.name == bundle.name } + current.append(bundle) + + let url = bundleFile(paths: paths) + let fileManager = FileManager.default + try fileManager.createDirectory(at: paths.memoryDir, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(current) + try data.write(to: url, atomically: true) + } + + public func deleteBundle(name: String) throws { + try deleteBundle(name: name, paths: .default) + } + + func deleteBundle(name: String, paths: IrisPaths) throws { + let cleanName = name.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + var current = listBundles(paths: paths) + current.removeAll { $0.name == cleanName } + + let url = bundleFile(paths: paths) + let data = try JSONEncoder().encode(current) + try data.write(to: url, atomically: true) + } + + public func getBundle(name: String) -> SkillBundle? { + getBundle(name: name, paths: .default) + } + + func getBundle(name: String, paths: IrisPaths) -> SkillBundle? { + let cleanName = name.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + return listBundles(paths: paths).first { $0.name == cleanName } + } +} diff --git a/Sources/iris/SkillCurator.swift b/Sources/iris/SkillCurator.swift new file mode 100644 index 0000000..0573c3b --- /dev/null +++ b/Sources/iris/SkillCurator.swift @@ -0,0 +1,152 @@ +import Foundation + +public struct CurationReport: Sendable, Equatable { + public let timestamp: Date + public let totalSkillsScanned: Int + public let validSkillsCount: Int + public let prunedSkillsCount: Int + public let prunedSkillNames: [String] + public let recommendations: [String] +} + +public final class SkillCurator: Sendable { + public static let shared = SkillCurator() + + private init() {} + + public func curateSkills() async -> CurationReport { + await curateSkills(paths: .default) + } + + func curateSkills(paths: IrisPaths) async -> CurationReport { + let fileManager = FileManager.default + let skillsDir = paths.skillsDir + let trashDir = paths.memoryDir.appendingPathComponent("trash/skills") + var totalScanned = 0 + var validCount = 0 + var prunedNames: [String] = [] + var recommendations: [String] = [] + + guard let skillFolders = try? fileManager.contentsOfDirectory(atPath: skillsDir.path) else { + return CurationReport(timestamp: Date(), totalSkillsScanned: 0, validSkillsCount: 0, prunedSkillsCount: 0, prunedSkillNames: [], recommendations: ["No skills directory found."]) + } + + var skillDescriptions: [String: String] = [:] + let timestampPrefix = Int(Date().timeIntervalSince1970) + + for folder in skillFolders { + guard !folder.hasPrefix(".") else { continue } + totalScanned += 1 + let skillFolder = skillsDir.appendingPathComponent(folder) + let skillFile = skillFolder.appendingPathComponent("SKILL.md") + + guard fileManager.fileExists(atPath: skillFile.path), + let content = try? String(contentsOf: skillFile, encoding: .utf8) else { + // Quarantined (soft-delete) to trash directory instead of hard removal + quarantineFolder(skillFolder, folderName: folder, timestamp: timestampPrefix, trashDir: trashDir, fileManager: fileManager) + prunedNames.append(folder) + recommendations.append("Quarantined corrupt skill '\(folder)' to trash. Run `/skills new \(folder)` to scaffold a replacement.") + continue + } + + let trimmedContent = content.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedContent.count < 30 { + // Quarantined (soft-delete) to trash directory + quarantineFolder(skillFolder, folderName: folder, timestamp: timestampPrefix, trashDir: trashDir, fileManager: fileManager) + prunedNames.append(folder) + recommendations.append("Quarantined empty skill '\(folder)' to trash. Run `/skills new \(folder)` to scaffold a replacement.") + continue + } + + let info = parseSkillInfo(content: trimmedContent, folderName: folder) + validCount += 1 + + // Check for duplicates/overlap + if let existingFolder = skillDescriptions[info.description] { + recommendations.append("Overlap detected between '\(folder)' and '\(existingFolder)': consider consolidating into an umbrella skill via `create_skill`.") + } else { + skillDescriptions[info.description] = folder + } + } + + if prunedNames.count > 0 { + await AppState.shared.invalidateEnginePrompt() + } + + let report = CurationReport( + timestamp: Date(), + totalSkillsScanned: totalScanned, + validSkillsCount: validCount, + prunedSkillsCount: prunedNames.count, + prunedSkillNames: prunedNames, + recommendations: recommendations + ) + + try? saveReport(report, paths: paths) + return report + } + + public func formatReportMarkdown(_ report: CurationReport) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + + var md = "### 🧹 Iris Skill Curator Report (\(formatter.string(from: report.timestamp)))\n\n" + md += "- **Total Skills Scanned:** \(report.totalSkillsScanned)\n" + md += "- **Valid Active Skills:** \(report.validSkillsCount)\n" + md += "- **Pruned/Corrupt Skills:** \(report.prunedSkillsCount)" + if !report.prunedSkillNames.isEmpty { + md += " (\(report.prunedSkillNames.joined(separator: ", ")))\n" + } else { + md += "\n" + } + + if !report.recommendations.isEmpty { + md += "\n**Recommendations & Overlap Alerts:**\n" + for rec in report.recommendations { + md += "• \(rec)\n" + } + } else { + md += "\n✨ *Skill library is fully clean and healthy. No overlaps detected.*" + } + return md + } + + private func saveReport(_ report: CurationReport, paths: IrisPaths) throws { + let curatorDir = paths.memoryDir.appendingPathComponent("curator") + let reportFile = curatorDir.appendingPathComponent("REPORT.md") + let fileManager = FileManager.default + try fileManager.createDirectory(at: curatorDir, withIntermediateDirectories: true) + let md = formatReportMarkdown(report) + try md.write(to: reportFile, atomically: true, encoding: .utf8) + } + + private func quarantineFolder(_ source: URL, folderName: String, timestamp: Int, trashDir: URL, fileManager: FileManager) { + try? fileManager.createDirectory(at: trashDir, withIntermediateDirectories: true) + let target = trashDir.appendingPathComponent("\(timestamp)_\(folderName)") + try? fileManager.moveItem(at: source, to: target) + } + + private func parseSkillInfo(content: String, folderName: String) -> (name: String, description: String) { + let lines = content.components(separatedBy: .newlines) + var inFrontmatter = false + var desc = "No description provided." + var name = folderName + + for line in lines { + if line == "---" { + if inFrontmatter { break } + inFrontmatter = true + continue + } + if inFrontmatter { + if line.starts(with: "description:") { + desc = String(line.dropFirst("description:".count)).trimmingCharacters(in: .whitespaces) + } else if line.starts(with: "name:") { + name = String(line.dropFirst("name:".count)).trimmingCharacters(in: .whitespaces) + } + } + } + return (name, desc) + } +} diff --git a/Sources/iris/SkillManager.swift b/Sources/iris/SkillManager.swift index 234fa03..294844c 100644 --- a/Sources/iris/SkillManager.swift +++ b/Sources/iris/SkillManager.swift @@ -73,13 +73,26 @@ struct SkillManager { return skills.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } - func discoverSkills(paths: IrisPaths = .default) async -> String { - let skills = await listSkills(paths: paths) + func discoverSkills(paths: IrisPaths = .default, activeBundle: SkillBundle? = nil) async -> String { + let allSkills = await listSkills(paths: paths) + let skills: [SkillInfo] + if let bundle = activeBundle { + let bundleNames = Set(bundle.skillNames.map { $0.lowercased() }) + skills = allSkills.filter { + bundleNames.contains($0.name.lowercased()) || bundleNames.contains($0.folderName.lowercased()) + } + } else { + skills = allSkills + } + guard !skills.isEmpty else { + if let bundle = activeBundle { + return "# Available Skills (Active Bundle: \(bundle.name))\n\nNo matching skills found in active bundle." + } return "# Available Skills\n\nNo skills found." } - var skillsSummary = "# Available Skills\n\n" + var skillsSummary = "# Available Skills" + (activeBundle != nil ? " (Active Bundle: \(activeBundle!.name))\n\n" : "\n\n") for skill in skills { skillsSummary += "## Skill: \(skill.name)\n**Description:** \(skill.description)\n" skillsSummary += "**Path:** ~/.iris/memory/skills/\(skill.folderName)/SKILL.md\n\n" diff --git a/Sources/iris/SlashCommandItem.swift b/Sources/iris/SlashCommandItem.swift index 58672d6..baf9851 100644 --- a/Sources/iris/SlashCommandItem.swift +++ b/Sources/iris/SlashCommandItem.swift @@ -9,7 +9,9 @@ struct SlashCommandItem: Identifiable, Sendable, Equatable { static let allCommands: [SlashCommandItem] = [ SlashCommandItem(id: "goal", command: "/goal", usage: "/goal ", description: "Start an autonomous goal-driven loop"), SlashCommandItem(id: "stop", command: "/stop", usage: "/stop", description: "Cancel active goal mode or subagent tasks"), - SlashCommandItem(id: "skills", command: "/skills", usage: "/skills [reload|show ]", description: "List, reload, or view registered skills"), + SlashCommandItem(id: "skills", command: "/skills", usage: "/skills [reload|show |curate]", description: "List, reload, view, or curate registered skills"), + SlashCommandItem(id: "bundle", command: "/bundle", usage: "/bundle [save s1,s2|]", description: "List, save, or activate skill bundles"), + SlashCommandItem(id: "journey", command: "/journey", usage: "/journey", description: "View chronological learning journey timeline"), SlashCommandItem(id: "rules", command: "/rules", usage: "/rules [reload]", description: "View loaded system rules or force reload"), SlashCommandItem(id: "model", command: "/model", usage: "/model [fast|medium|heavy|]", description: "View or switch active model for conversation"), SlashCommandItem(id: "mcp", command: "/mcp", usage: "/mcp [reload]", description: "List connected MCP tools or reload transport"), diff --git a/Sources/iris/iris.swift b/Sources/iris/iris.swift index 6a9aaf5..8b86e8a 100644 --- a/Sources/iris/iris.swift +++ b/Sources/iris/iris.swift @@ -37,7 +37,8 @@ actor IrisEngine { if let existing = systemPrompt { return existing } return await measure(.contextAssembly) { let soul = await manager.loadSOUL() - let skills = await manager.discoverSkills() + let activeBundle = SkillBundleManager.shared.activeBundle + let skills = await manager.discoverSkills(activeBundle: activeBundle) let steering = SystemSteering.shipped() let customRules = await manager.loadCustomRules() let combined = "\(soul)\n\n\(skills)\n\n\(steering)\(customRules)" diff --git a/Tests/irisTests/SkillCurationAdvancementsTests.swift b/Tests/irisTests/SkillCurationAdvancementsTests.swift new file mode 100644 index 0000000..9b863ac --- /dev/null +++ b/Tests/irisTests/SkillCurationAdvancementsTests.swift @@ -0,0 +1,122 @@ +import Testing +import Foundation +@testable import iris + +@Suite("Skill Curation & Bundles Tests") +struct SkillCurationAdvancementsTests { + + @Test("SkillBundleManager creates, saves, lists, and retrieves skill bundles") + func testSkillBundleManager() throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("iris-bundle-test-\(UUID().uuidString)") + let paths = IrisPaths(root: tempRoot) + try paths.ensureDirectories() + + let bundle = SkillBundle(name: "writing-day", description: "Writing day tools", skillNames: ["humanizer", "ideation", "obsidian"]) + try SkillBundleManager.shared.saveBundle(bundle, paths: paths) + + let loaded = SkillBundleManager.shared.listBundles(paths: paths) + #expect(loaded.count == 1) + #expect(loaded.first?.name == "writing-day") + #expect(loaded.first?.skillNames == ["humanizer", "ideation", "obsidian"]) + + let fetched = SkillBundleManager.shared.getBundle(name: "writing-day", paths: paths) + #expect(fetched != nil) + #expect(fetched?.skillNames.contains("obsidian") == true) + } + + @Test("JourneyManager builds timeline from frontmatter timestamps") + func testJourneyManager() async throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("iris-journey-test-\(UUID().uuidString)") + let paths = IrisPaths(root: tempRoot) + try paths.ensureDirectories() + + // Create sample skill with frontmatter + let skillFolder = paths.skillsDir.appendingPathComponent("k8s-debug") + try FileManager.default.createDirectory(at: skillFolder, withIntermediateDirectories: true) + let skillContent = """ + --- + name: k8s-debug + description: Debug Kubernetes cluster issues + type: skill + timestamp: 2026-07-28T12:00:00Z + --- + + # K8s Debug Steps + 1. kubectl get pods + """ + try skillContent.write(to: skillFolder.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + + let items = await JourneyManager.shared.buildTimeline(paths: paths) + #expect(!items.isEmpty) + let skillItem = items.first { $0.title.contains("k8s-debug") } + #expect(skillItem != nil) + #expect(skillItem?.category == "Skill") + + let markdown = JourneyManager.shared.formatTimelineMarkdown(items: items) + #expect(markdown.contains("Iris Learning Journey Timeline")) + #expect(markdown.contains("k8s-debug")) + } + + @Test("SkillCurator scans skills, prunes empty ones, and outputs REPORT.md") + func testSkillCurator() async throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("iris-curator-test-\(UUID().uuidString)") + let paths = IrisPaths(root: tempRoot) + try paths.ensureDirectories() + + // Create 1 valid skill and 1 empty corrupt skill + let validFolder = paths.skillsDir.appendingPathComponent("valid-skill") + try FileManager.default.createDirectory(at: validFolder, withIntermediateDirectories: true) + try "--- \nname: valid-skill\ndescription: Valid skill\n---\n\nValid body content here.".write(to: validFolder.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + + let corruptFolder = paths.skillsDir.appendingPathComponent("corrupt-skill") + try FileManager.default.createDirectory(at: corruptFolder, withIntermediateDirectories: true) + try "".write(to: corruptFolder.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + + let report = await SkillCurator.shared.curateSkills(paths: paths) + #expect(report.totalSkillsScanned == 2) + #expect(report.validSkillsCount == 1) + #expect(report.prunedSkillsCount == 1) + #expect(report.prunedSkillNames.contains("corrupt-skill")) + + let reportFile = paths.memoryDir.appendingPathComponent("curator/REPORT.md") + #expect(FileManager.default.fileExists(atPath: reportFile.path)) + + let trashDir = paths.memoryDir.appendingPathComponent("trash/skills") + #expect(FileManager.default.fileExists(atPath: trashDir.path)) + + let reportContent = try String(contentsOf: reportFile, encoding: .utf8) + #expect(reportContent.contains("Iris Skill Curator Report")) + #expect(reportContent.contains("corrupt-skill")) + } + + @Test("SkillManager filters discoverSkills when activeBundle is set") + func testSkillManagerSelectiveBundleFiltering() async throws { + let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("iris-discover-bundle-test-\(UUID().uuidString)") + let paths = IrisPaths(root: tempRoot) + try paths.ensureDirectories() + + // Create 2 skills + let s1 = paths.skillsDir.appendingPathComponent("humanizer") + try FileManager.default.createDirectory(at: s1, withIntermediateDirectories: true) + try "---\nname: humanizer\ndescription: Humanize text\n---\n\nBody 1".write(to: s1.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + + let s2 = paths.skillsDir.appendingPathComponent("k8s-debug") + try FileManager.default.createDirectory(at: s2, withIntermediateDirectories: true) + try "---\nname: k8s-debug\ndescription: K8s debug\n---\n\nBody 2".write(to: s2.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + + // Without active bundle -> returns both + let allPrompt = await SkillManager.shared.discoverSkills(paths: paths, activeBundle: nil) + #expect(allPrompt.contains("humanizer")) + #expect(allPrompt.contains("k8s-debug")) + + // With active bundle 'writing-day' (only humanizer) -> returns only humanizer + let bundle = SkillBundle(name: "writing-day", description: "Writing day", skillNames: ["humanizer"]) + let bundlePrompt = await SkillManager.shared.discoverSkills(paths: paths, activeBundle: bundle) + #expect(bundlePrompt.contains("humanizer")) + #expect(!bundlePrompt.contains("k8s-debug")) + } +} diff --git a/docs/plans/2026-07-28-skill-bundles-journey-and-curation-plan.md b/docs/plans/2026-07-28-skill-bundles-journey-and-curation-plan.md new file mode 100644 index 0000000..6e80da9 --- /dev/null +++ b/docs/plans/2026-07-28-skill-bundles-journey-and-curation-plan.md @@ -0,0 +1,23 @@ +# Implementation Plan: Skill Bundles, Journey Timeline & Autonomous Curator + +* **Issue**: [#44](https://github.com/bnaylor/iris/issues/44) - Further tweaks to skill-curation +* **Spec**: `docs/specs/2026-07-28-skill-bundles-journey-and-curation-design.md` +* **Date**: 2026-07-28 + +--- + +## Task List + +- [ ] **Step 1: Implement Skill Bundle Manager (`SkillBundleManager.swift`)** + - Add `SkillBundleManager` to persist `~/.iris/memory/bundles.json`. + - Add `/bundle` command handlers in `AppState.swift` and `SlashCommandItem.swift`. +- [ ] **Step 2: Implement `/journey` Learning Timeline (`JourneyManager.swift`)** + - Build `JourneyManager` to scan OKF frontmatter across memories, skills, and FactStore. + - Add `/journey` command handler in `AppState.swift` and `SlashCommandItem.swift`. +- [ ] **Step 3: Implement Autonomous Skill Curator (`SkillCurator.swift`)** + - Build `SkillCurator` to grade skills against OKF rubric, prune corrupt skills, and write `~/.iris/memory/curator/REPORT.md`. + - Add `/skills curate` subcommand handler in `AppState.swift`. +- [ ] **Step 4: Unit Tests (`SkillCurationAdvancementsTests.swift`)** + - Author test suite covering skill bundles, journey timeline scanning, and curation reporting. +- [ ] **Step 5: Verification & Git Commit** + - Verify all unit tests pass and commit. diff --git a/docs/slash_commands.md b/docs/slash_commands.md index 1061638..c746428 100644 --- a/docs/slash_commands.md +++ b/docs/slash_commands.md @@ -9,8 +9,12 @@ Iris supports in-app slash commands (`/-commands`) typed into the chat composer | Command | Usage | Description | |---|---|---| | `/skills` | `/skills` | List all registered skills and memory tools. | +| `/skills new` | `/skills new ` | Scaffold a structured OKF `SKILL.md` template for a new skill. | | `/skills reload` | `/skills reload [name]` | Invalidate system prompt cache and reload skill definitions from disk. | | `/skills show` | `/skills show ` | Display the full `SKILL.md` body for a given skill. | +| `/skills curate` | `/skills curate` | Run skill health pass, soft-delete corrupt skills to trash, and write `~/.iris/memory/curator/REPORT.md`. | +| `/bundle` | `/bundle [save s1,s2\|\|clear]` | List, save, activate, or clear selective skill bundle context filters. | +| `/journey` | `/journey` | Render chronological timeline of all learned skills, profile evolutions, and facts. | | `/rules` | `/rules` | View loaded custom rules from `~/.iris/rules/`. | | `/rules reload` | `/rules reload` | Force system prompt cache invalidation to re-read custom rule files. | | `/model` | `/model [fast\|medium\|heavy\|]` | View active model tiers or switch active model for conversation. | diff --git a/docs/specs/2026-07-28-skill-bundles-journey-and-curation-design.md b/docs/specs/2026-07-28-skill-bundles-journey-and-curation-design.md new file mode 100644 index 0000000..5f39b77 --- /dev/null +++ b/docs/specs/2026-07-28-skill-bundles-journey-and-curation-design.md @@ -0,0 +1,66 @@ +# Design Spec: Skill Bundles, Journey Timeline & Autonomous Curator + +* **Issue**: [#44](https://github.com/bnaylor/iris/issues/44) - Further tweaks to skill-curation +* **Date**: 2026-07-28 +* **Status**: Approved + +--- + +## 1. Overview & Goals + +To continue building Iris into a self-improving, highly responsive agent harness, this spec introduces three major enhancements inspired by Hermes Agent: + +1. **Skill Bundles:** Define named groups of skills (e.g. `devops = ["k8s-debug", "gke-provision", "spanner-perf"]`) stored in `~/.iris/memory/bundles.json`. Activating a bundle via `/bundle ` loads all grouped skills at once into the session and invalidates the system prompt cache. +2. **`/journey` Learning Timeline:** A deterministic slash command that scans OKF timestamps across `USER.md`, `SOUL.md`, `skills/*`, and `FactStoreManager` to display a chronological, interactive timeline of everything Iris has learned. +3. **Autonomous Skill Curator (`SkillCurator.swift` & `/skills curate`):** A curation engine that evaluates skills against an OKF quality rubric, consolidates duplicate skills, prunes dead/empty skills, and outputs a per-run curation report to `~/.iris/memory/curator/REPORT.md`. + +--- + +## 2. Component Architecture + +### A. Skill Bundles (`SkillBundleManager.swift` & `/bundle`) +* **Storage:** `~/.iris/memory/bundles.json` mapping bundle names to arrays of skill names. +* **Command:** `/bundle` (list bundles), `/bundle save ` (create/update bundle), `/bundle ` (activate all skills in bundle). +* **Auto-Complete:** `SlashCommandItem` auto-suggests defined bundles. + +### B. `/journey` Timeline Generator (`JourneyManager.swift`) +* **Scanner:** Reads `timestamp:` YAML frontmatter across `~/.iris/memory/USER.md`, `~/.iris/memory/SOUL.md`, `~/.iris/memory/skills/*/SKILL.md`, and top records from `FactStoreManager`. +* **Output:** Sorts chronologically and outputs a clean Markdown timeline with category badges (`[Skill]`, `[Profile]`, `[Core]`, `[Fact]`) and timestamps. + +### C. Skill Curator (`SkillCurator.swift` & `/skills curate`) +* **Rubric:** + - **Validity:** Valid OKF YAML frontmatter (`name`, `description`, `type: skill`, `timestamp`). + - **Non-Emptiness:** Body contains >20 characters and actual steps/commands. + - **Uniqueness:** Checks for duplicate/overlapping skill names or descriptions. +* **Actions:** + - **Prune:** Removes empty or corrupt skill folders. + - **Report:** Writes a structured Markdown report to `~/.iris/memory/curator/REPORT.md` listing active skills, pruned skills, and recommendations. + +--- + +## 3. Detailed Data Models + +```swift +public struct SkillBundle: Codable, Sendable, Equatable { + public let name: String + public let description: String + public let skillNames: [String] +} + +public struct JourneyItem: Identifiable, Sendable { + public let id = UUID() + public let title: String + public let category: String // "skill", "profile", "soul", "fact" + public let timestamp: Date + public let summary: String + public let path: String? +} +``` + +--- + +## 4. Testing & Verification + +1. Unit tests for `SkillBundleManager` (create, load, activate, JSON persistence). +2. Unit tests for `JourneyManager` (scanning OKF frontmatter timestamps and sorting). +3. Unit tests for `SkillCurator` (pruning empty skills and generating `REPORT.md`).