-
Notifications
You must be signed in to change notification settings - Fork 1
feat(curation): skill bundles, /journey timeline, and autonomous curator (#44) #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bfa4b3a
89e8c08
eaedd2f
b141a92
7424cd4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)'", | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The scaffold writes a placeholder description ( |
||
| 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 <name> 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 <name> 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the crux of the review: curation runs only when a human types
/skills curate. The stated goal is automatic curation, so this manual command is the wrong entry point. The curation pass belongs in the reflection cadence (which already runs periodically), reading its own report and acting — not a keystroke. As-is, 'autonomous curator' is a manual button.