chore: rename project to clawdbot
This commit is contained in:
340
apps/macos/Sources/Clawdbot/AgentWorkspace.swift
Normal file
340
apps/macos/Sources/Clawdbot/AgentWorkspace.swift
Normal file
@@ -0,0 +1,340 @@
|
||||
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.default.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.default.contentsOfDirectory(atPath: workspaceURL.path)
|
||||
return contents.filter { !self.ignoredEntries.contains($0) }
|
||||
}
|
||||
|
||||
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
|
||||
let fm = FileManager.default
|
||||
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.default
|
||||
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.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
|
||||
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
||||
if !FileManager.default.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.default.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.default.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.default.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.default.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.default
|
||||
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.default.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.
|
||||
}
|
||||
Reference in New Issue
Block a user