341 lines
13 KiB
Swift
341 lines
13 KiB
Swift
import Foundation
|
|
import OSLog
|
|
|
|
enum AgentWorkspace {
|
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "workspace")
|
|
static let agentsFilename = "AGENTS.md"
|
|
static let soulFilename = "SOUL.md"
|
|
static let identityFilename = "IDENTITY.md"
|
|
static let userFilename = "USER.md"
|
|
static let bootstrapFilename = "BOOTSTRAP.md"
|
|
private static let templateDirname = "templates"
|
|
private static let ignoredEntries: Set<String> = [".DS_Store", ".git", ".gitignore"]
|
|
private static let templateEntries: Set<String> = [
|
|
AgentWorkspace.agentsFilename,
|
|
AgentWorkspace.soulFilename,
|
|
AgentWorkspace.identityFilename,
|
|
AgentWorkspace.userFilename,
|
|
AgentWorkspace.bootstrapFilename,
|
|
]
|
|
enum BootstrapSafety: Equatable {
|
|
case safe
|
|
case unsafe(reason: String)
|
|
}
|
|
|
|
static func displayPath(for url: URL) -> String {
|
|
let home = FileManager().homeDirectoryForCurrentUser.path
|
|
let path = url.path
|
|
if path == home { return "~" }
|
|
if path.hasPrefix(home + "/") {
|
|
return "~/" + String(path.dropFirst(home.count + 1))
|
|
}
|
|
return path
|
|
}
|
|
|
|
static func resolveWorkspaceURL(from userInput: String?) -> URL {
|
|
let trimmed = userInput?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if trimmed.isEmpty { return ClawdbotConfigFile.defaultWorkspaceURL() }
|
|
let expanded = (trimmed as NSString).expandingTildeInPath
|
|
return URL(fileURLWithPath: expanded, isDirectory: true)
|
|
}
|
|
|
|
static func agentsURL(workspaceURL: URL) -> URL {
|
|
workspaceURL.appendingPathComponent(self.agentsFilename)
|
|
}
|
|
|
|
static func workspaceEntries(workspaceURL: URL) throws -> [String] {
|
|
let contents = try FileManager().contentsOfDirectory(atPath: workspaceURL.path)
|
|
return contents.filter { !self.ignoredEntries.contains($0) }
|
|
}
|
|
|
|
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
|
|
let fm = FileManager()
|
|
var isDir: ObjCBool = false
|
|
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
|
return true
|
|
}
|
|
guard isDir.boolValue else { return false }
|
|
guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false }
|
|
return entries.isEmpty
|
|
}
|
|
|
|
static func isTemplateOnlyWorkspace(workspaceURL: URL) -> Bool {
|
|
guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false }
|
|
guard !entries.isEmpty else { return true }
|
|
return Set(entries).isSubset(of: self.templateEntries)
|
|
}
|
|
|
|
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
|
|
let fm = FileManager()
|
|
var isDir: ObjCBool = false
|
|
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
|
return .safe
|
|
}
|
|
if !isDir.boolValue {
|
|
return .unsafe(reason: "Workspace path points to a file.")
|
|
}
|
|
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
|
if fm.fileExists(atPath: agentsURL.path) {
|
|
return .safe
|
|
}
|
|
do {
|
|
let entries = try self.workspaceEntries(workspaceURL: workspaceURL)
|
|
return entries.isEmpty
|
|
? .safe
|
|
: .unsafe(reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.")
|
|
} catch {
|
|
return .unsafe(reason: "Couldn't inspect the workspace folder.")
|
|
}
|
|
}
|
|
|
|
static func bootstrap(workspaceURL: URL) throws -> URL {
|
|
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
|
|
try FileManager().createDirectory(at: workspaceURL, withIntermediateDirectories: true)
|
|
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
|
if !FileManager().fileExists(atPath: agentsURL.path) {
|
|
try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8)
|
|
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
|
|
}
|
|
let soulURL = workspaceURL.appendingPathComponent(self.soulFilename)
|
|
if !FileManager().fileExists(atPath: soulURL.path) {
|
|
try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8)
|
|
self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)")
|
|
}
|
|
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
|
|
if !FileManager().fileExists(atPath: identityURL.path) {
|
|
try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8)
|
|
self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)")
|
|
}
|
|
let userURL = workspaceURL.appendingPathComponent(self.userFilename)
|
|
if !FileManager().fileExists(atPath: userURL.path) {
|
|
try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8)
|
|
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
|
|
}
|
|
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
|
|
if shouldSeedBootstrap, !FileManager().fileExists(atPath: bootstrapURL.path) {
|
|
try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8)
|
|
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
|
|
}
|
|
return agentsURL
|
|
}
|
|
|
|
static func needsBootstrap(workspaceURL: URL) -> Bool {
|
|
let fm = FileManager()
|
|
var isDir: ObjCBool = false
|
|
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
|
return true
|
|
}
|
|
guard isDir.boolValue else { return true }
|
|
if self.hasIdentity(workspaceURL: workspaceURL) {
|
|
return false
|
|
}
|
|
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
|
|
guard fm.fileExists(atPath: bootstrapURL.path) else { return false }
|
|
return self.isTemplateOnlyWorkspace(workspaceURL: workspaceURL)
|
|
}
|
|
|
|
static func hasIdentity(workspaceURL: URL) -> Bool {
|
|
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
|
|
guard let contents = try? String(contentsOf: identityURL, encoding: .utf8) else { return false }
|
|
return self.identityLinesHaveValues(contents)
|
|
}
|
|
|
|
private static func identityLinesHaveValues(_ content: String) -> Bool {
|
|
for line in content.split(separator: "\n") {
|
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else { continue }
|
|
let value = trimmed[trimmed.index(after: colon)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !value.isEmpty {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
static func defaultTemplate() -> String {
|
|
let fallback = """
|
|
# AGENTS.md - Clawdbot Workspace
|
|
|
|
This folder is the assistant's working directory.
|
|
|
|
## First run (one-time)
|
|
- If BOOTSTRAP.md exists, follow its ritual and delete it once complete.
|
|
- Your agent identity lives in IDENTITY.md.
|
|
- Your profile lives in USER.md.
|
|
|
|
## Backup tip (recommended)
|
|
If you treat this workspace as the agent's "memory", make it a git repo (ideally private) so identity
|
|
and notes are backed up.
|
|
|
|
```bash
|
|
git init
|
|
git add AGENTS.md
|
|
git commit -m "Add agent workspace"
|
|
```
|
|
|
|
## Safety defaults
|
|
- Don't exfiltrate secrets or private data.
|
|
- Don't run destructive commands unless explicitly asked.
|
|
- Be concise in chat; write longer output to files in this workspace.
|
|
|
|
## Daily memory (recommended)
|
|
- Keep a short daily log at memory/YYYY-MM-DD.md (create memory/ if needed).
|
|
- On session start, read today + yesterday if present.
|
|
- Capture durable facts, preferences, and decisions; avoid secrets.
|
|
|
|
## Customize
|
|
- Add your preferred style, rules, and "memory" here.
|
|
"""
|
|
return self.loadTemplate(named: self.agentsFilename, fallback: fallback)
|
|
}
|
|
|
|
static func defaultSoulTemplate() -> String {
|
|
let fallback = """
|
|
# SOUL.md - Persona & Boundaries
|
|
|
|
Describe who the assistant is, tone, and boundaries.
|
|
|
|
- Keep replies concise and direct.
|
|
- Ask clarifying questions when needed.
|
|
- Never send streaming/partial replies to external messaging surfaces.
|
|
"""
|
|
return self.loadTemplate(named: self.soulFilename, fallback: fallback)
|
|
}
|
|
|
|
static func defaultIdentityTemplate() -> String {
|
|
let fallback = """
|
|
# IDENTITY.md - Agent Identity
|
|
|
|
- Name:
|
|
- Creature:
|
|
- Vibe:
|
|
- Emoji:
|
|
"""
|
|
return self.loadTemplate(named: self.identityFilename, fallback: fallback)
|
|
}
|
|
|
|
static func defaultUserTemplate() -> String {
|
|
let fallback = """
|
|
# USER.md - User Profile
|
|
|
|
- Name:
|
|
- Preferred address:
|
|
- Pronouns (optional):
|
|
- Timezone (optional):
|
|
- Notes:
|
|
"""
|
|
return self.loadTemplate(named: self.userFilename, fallback: fallback)
|
|
}
|
|
|
|
static func defaultBootstrapTemplate() -> String {
|
|
let fallback = """
|
|
# BOOTSTRAP.md - First Run Ritual (delete after)
|
|
|
|
Hello. I was just born.
|
|
|
|
## Your mission
|
|
Start a short, playful conversation and learn:
|
|
- Who am I?
|
|
- What am I?
|
|
- Who are you?
|
|
- How should I call you?
|
|
|
|
## How to ask (cute + helpful)
|
|
Say:
|
|
"Hello! I was just born. Who am I? What am I? Who are you? How should I call you?"
|
|
|
|
Then offer suggestions:
|
|
- 3-5 name ideas.
|
|
- 3-5 creature/vibe combos.
|
|
- 5 emoji ideas.
|
|
|
|
## Write these files
|
|
After the user chooses, update:
|
|
|
|
1) IDENTITY.md
|
|
- Name
|
|
- Creature
|
|
- Vibe
|
|
- Emoji
|
|
|
|
2) USER.md
|
|
- Name
|
|
- Preferred address
|
|
- Pronouns (optional)
|
|
- Timezone (optional)
|
|
- Notes
|
|
|
|
3) ~/.clawdbot/clawdbot.json
|
|
Set identity.name, identity.theme, identity.emoji to match IDENTITY.md.
|
|
|
|
## Cleanup
|
|
Delete BOOTSTRAP.md once this is complete.
|
|
"""
|
|
return self.loadTemplate(named: self.bootstrapFilename, fallback: fallback)
|
|
}
|
|
|
|
private static func loadTemplate(named: String, fallback: String) -> String {
|
|
for url in self.templateURLs(named: named) {
|
|
if let content = try? String(contentsOf: url, encoding: .utf8) {
|
|
let stripped = self.stripFrontMatter(content)
|
|
if !stripped.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
return stripped
|
|
}
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
private static func templateURLs(named: String) -> [URL] {
|
|
var urls: [URL] = []
|
|
if let resource = Bundle.main.url(
|
|
forResource: named.replacingOccurrences(of: ".md", with: ""),
|
|
withExtension: "md",
|
|
subdirectory: self.templateDirname)
|
|
{
|
|
urls.append(resource)
|
|
}
|
|
if let resource = Bundle.main.url(
|
|
forResource: named,
|
|
withExtension: nil,
|
|
subdirectory: self.templateDirname)
|
|
{
|
|
urls.append(resource)
|
|
}
|
|
if let dev = self.devTemplateURL(named: named) {
|
|
urls.append(dev)
|
|
}
|
|
let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath)
|
|
urls.append(cwd.appendingPathComponent("docs")
|
|
.appendingPathComponent(self.templateDirname)
|
|
.appendingPathComponent(named))
|
|
return urls
|
|
}
|
|
|
|
private static func devTemplateURL(named: String) -> URL? {
|
|
let sourceURL = URL(fileURLWithPath: #filePath)
|
|
let repoRoot = sourceURL
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
return repoRoot.appendingPathComponent("docs")
|
|
.appendingPathComponent(self.templateDirname)
|
|
.appendingPathComponent(named)
|
|
}
|
|
|
|
private static func stripFrontMatter(_ content: String) -> String {
|
|
guard content.hasPrefix("---") else { return content }
|
|
let start = content.index(content.startIndex, offsetBy: 3)
|
|
guard let range = content.range(of: "\n---", range: start..<content.endIndex) else {
|
|
return content
|
|
}
|
|
let remainder = content[range.upperBound...]
|
|
let trimmed = remainder.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed + "\n"
|
|
}
|
|
|
|
// Identity is written by the agent during the bootstrap ritual.
|
|
}
|