Skip to content
Merged
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
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>]` (list, reload, or view SKILL.md body), `/rules [reload]` (inspect/reload custom rules from `~/.iris/rules/`).
* **Memory & Facts:** `/facts [search <q>|probe <e>]` (query SQLite FactStore).
* **Skills & Bundles:** `/skills [new <name>|reload|show <name>|curate]` (scaffold, list, reload, view, or curate skills), `/bundle [save <name> s1,s2|<name>|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 <q>|probe <e>]` (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 <desc>` (autonomous execution loop), `/reflect` (memory reflection), `/vibecop init` (generate security rules).

Expand Down
88 changes: 87 additions & 1 deletion Sources/iris/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") {

Copy link
Copy Markdown
Owner

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.

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)'",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The scaffold writes a placeholder description ("Scaffolded skill '<name>'") and a near-empty body — which (a) pollutes discoverSkills (the model sees a skill described as 'Scaffolded skill X') and (b) is precisely what the curator's <30-char prune would later delete. The two new features work against each other.

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()
Expand Down Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions Sources/iris/JourneyManager.swift
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)
}
}
91 changes: 91 additions & 0 deletions Sources/iris/SkillBundleManager.swift
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 }
}
}
Loading