Spaces:
Paused
Paused
| import Foundation | |
| import OSLog | |
| enum AgentWorkspace { | |
| private static let logger = Logger(subsystem: "ai.openclaw", 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 OpenClawConfigFile.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 - OpenClaw 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) ~/.openclaw/openclaw.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. | |
| } | |