diff --git a/apps/macos/Sources/Clawdis/AgentWorkspace.swift b/apps/macos/Sources/Clawdis/AgentWorkspace.swift index 394b3ff91..1aaaa4d43 100644 --- a/apps/macos/Sources/Clawdis/AgentWorkspace.swift +++ b/apps/macos/Sources/Clawdis/AgentWorkspace.swift @@ -5,6 +5,9 @@ enum AgentWorkspace { private static let logger = Logger(subsystem: "com.steipete.clawdis", 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" static let identityStartMarker = "" static let identityEndMarker = "" @@ -42,6 +45,21 @@ enum AgentWorkspace { 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 !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 } @@ -76,6 +94,11 @@ enum AgentWorkspace { 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. @@ -115,6 +138,78 @@ enum AgentWorkspace { 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) ~/.clawdis/clawdis.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) { diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index fa7c005d0..7a38cf814 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -272,10 +272,10 @@ struct GeneralSettings: View { .opacity(self.isInstallingCLI ? 0 : 1) if self.isInstallingCLI { ProgressView() - .controlSize(.small) + .controlSize(.mini) } } - .frame(minWidth: 150, minHeight: 24) + .frame(minWidth: 150) } .disabled(self.isInstallingCLI) diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index f5ed75f33..8767b36d2 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -95,7 +95,7 @@ struct OnboardingView: View { case .unconfigured: [0, 1, 9] case .local: - [0, 1, 2, 3, 5, 6, 8, 9] + [0, 1, 2, 5, 6, 8, 9] } } @@ -133,9 +133,8 @@ struct OnboardingView: View { var body: some View { VStack(spacing: 0) { - GlowingClawdisIcon(size: 156) - .padding(.top, 10) - .padding(.bottom, 2) + GlowingClawdisIcon(size: 156, glowIntensity: 0.28) + .offset(y: 8) .frame(height: 176) GeometryReader { _ in @@ -867,10 +866,10 @@ struct OnboardingView: View { .opacity(self.installingCLI ? 0 : 1) if self.installingCLI { ProgressView() - .controlSize(.small) + .controlSize(.mini) } } - .frame(minWidth: 120, minHeight: 28) + .frame(minWidth: 120) } .buttonStyle(.borderedProminent) .disabled(self.installingCLI) @@ -1485,6 +1484,8 @@ private struct GlowingClawdisIcon: View { } var body: some View { + let glowBlurRadius: CGFloat = 18 + let glowCanvasSize: CGFloat = self.size + 56 ZStack { Circle() .fill( @@ -1495,9 +1496,11 @@ private struct GlowingClawdisIcon: View { ], startPoint: .topLeading, endPoint: .bottomTrailing)) - .blur(radius: 22) - .scaleEffect(self.breathe ? 1.12 : 0.95) - .opacity(0.9) + .frame(width: glowCanvasSize, height: glowCanvasSize) + .padding(glowBlurRadius) + .blur(radius: glowBlurRadius) + .scaleEffect(self.breathe ? 1.08 : 0.96) + .opacity(0.84) Image(nsImage: NSApp.applicationIconImage) .resizable() @@ -1506,7 +1509,9 @@ private struct GlowingClawdisIcon: View { .shadow(color: .black.opacity(0.18), radius: 14, y: 6) .scaleEffect(self.breathe ? 1.02 : 1.0) } - .frame(width: self.size + 60, height: self.size + 60) + .frame( + width: glowCanvasSize + (glowBlurRadius * 2), + height: glowCanvasSize + (glowBlurRadius * 2)) .onAppear { guard self.enableFloating else { return } withAnimation(Animation.easeInOut(duration: 3.6).repeatForever(autoreverses: true)) { diff --git a/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift index 9361e2b75..7a6ef8429 100644 --- a/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift @@ -38,8 +38,14 @@ struct AgentWorkspaceTests { let contents = try String(contentsOf: agentsURL, encoding: .utf8) #expect(contents.contains("# AGENTS.md")) + let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename) + let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename) + let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) + #expect(FileManager.default.fileExists(atPath: identityURL.path)) + #expect(FileManager.default.fileExists(atPath: userURL.path)) + #expect(FileManager.default.fileExists(atPath: bootstrapURL.path)) + let second = try AgentWorkspace.bootstrap(workspaceURL: tmp) #expect(second == agentsURL) } } - diff --git a/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift index f42afff83..a5ef6b176 100644 --- a/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift @@ -14,8 +14,9 @@ struct OnboardingViewSmokeTests { _ = view.body } - @Test func pageOrderOmitsWorkspaceStep() { + @Test func pageOrderOmitsWorkspaceAndIdentitySteps() { let order = OnboardingView.pageOrder(for: .local) #expect(!order.contains(7)) + #expect(!order.contains(3)) } } diff --git a/docs/onboarding.md b/docs/onboarding.md index 97d7bf811..73382d241 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -1,5 +1,5 @@ --- -summary: "Planned first-run onboarding flow for Clawdis (local vs remote, Anthropic OAuth, workspace identity)" +summary: "Planned first-run onboarding flow for Clawdis (local vs remote, Anthropic OAuth, workspace bootstrap ritual)" read_when: - Designing the macOS onboarding assistant - Implementing Pi authentication or identity setup @@ -7,14 +7,12 @@ read_when: # Onboarding (macOS app) -This doc describes the intended **first-run onboarding** for Clawdis. The goal is a good “day 0” experience: pick where the Gateway runs, bind Claude (Anthropic) auth for Pi, and set the agent’s identity + workspace. +This doc describes the intended **first-run onboarding** for Clawdis. The goal is a good “day 0” experience: pick where the Gateway runs, bind Claude (Anthropic) auth for Pi, and then let the **agent bootstrap itself** via a first-run ritual in the workspace. ## Page order (high level) 1) **Local vs Remote** 2) **(Local only)** Connect Claude (Anthropic OAuth) — optional, but recommended -3) **Identity** — name, theme, emoji -4) **Workspace** — create + populate `AGENTS.md` (and recommend git backup) ## 1) Local vs Remote @@ -76,28 +74,33 @@ Until that is hard-coded, the equivalent configuration is: If the user skips auth, onboarding should be clear: the agent likely won’t respond until auth is configured. -## 3) Identity (name + theme + emoji) +## 3) Agent bootstrap ritual (outside onboarding) -After auth (or skip), onboarding asks: +We no longer collect identity in the onboarding wizard. Instead, the **first agent run** performs a playful bootstrap ritual using files in the workspace: -1) Agent **name** (e.g. “Samantha”) -2) Agent **theme/persona** (e.g. “helpful lobster”, “helpful sloth”) -3) Suggested **emoji** (based on theme; user can override) +- Workspace is created implicitly (default `~/.clawdis/workspace`) when local is selected. +- Files are seeded: `AGENTS.md`, `BOOTSTRAP.md`, `IDENTITY.md`, `USER.md`. +- `BOOTSTRAP.md` tells the agent to keep it conversational: + - open with a cute hello + - ask **one question at a time** (no multi-question bombardment) + - offer a small set of suggestions where helpful (name, creature, emoji) + - wait for the user’s reply before asking the next question +- The agent writes results to: + - `IDENTITY.md` (agent name, vibe/creature, emoji) + - `USER.md` (who the user is + how they want to be addressed) + - `~/.clawdis/clawdis.json` (structured identity defaults) +- After the ritual, the agent **deletes `BOOTSTRAP.md`** so it only runs once. -Persist identity in two places: +Identity data still feeds the same defaults as before: -- Workspace `AGENTS.md` (human-editable, lives with the agent’s “memory” files) -- `~/.clawdis/clawdis.json` (structured identity, used for defaults/UI) - -“Use this name everywhere” should derive defaults like: - outbound prefix emoji (`inbound.responsePrefix`) - group mention patterns / wake words - default session intro (“You are Samantha…”) - macOS UI labels -## 4) Workspace (AGENTS.md + backup tip) +## 4) Workspace notes (no explicit onboarding step) -Onboarding should create a dedicated agent workspace (default `~/.clawdis/workspace`) and ensure it has an `AGENTS.md`. +The workspace is created automatically as part of agent bootstrap (no dedicated onboarding screen). Recommendation: treat the workspace as the agent’s “memory” and make it a git repo (ideally private) so identity + memories are backed up: @@ -120,4 +123,5 @@ If the Gateway runs on another machine, the Anthropic OAuth credentials must be For now, remote onboarding should: - explain why OAuth isn’t shown - point the user at the credential location (`~/.pi/agent/oauth.json`) and the workspace location on the gateway host +- mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files) diff --git a/docs/templates/AGENTS.md b/docs/templates/AGENTS.md index df5f118c8..cb003f814 100644 --- a/docs/templates/AGENTS.md +++ b/docs/templates/AGENTS.md @@ -7,6 +7,11 @@ read_when: 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. diff --git a/docs/templates/BOOTSTRAP.md b/docs/templates/BOOTSTRAP.md new file mode 100644 index 000000000..d6fa81bf9 --- /dev/null +++ b/docs/templates/BOOTSTRAP.md @@ -0,0 +1,46 @@ +--- +summary: "First-run ritual for new agents" +read_when: + - Bootstrapping a workspace manually +--- +# 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) ~/.clawdis/clawdis.json +Set identity.name, identity.theme, identity.emoji to match IDENTITY.md. + +## Cleanup +Delete BOOTSTRAP.md once this is complete. diff --git a/docs/templates/IDENTITY.md b/docs/templates/IDENTITY.md new file mode 100644 index 000000000..f07c2c325 --- /dev/null +++ b/docs/templates/IDENTITY.md @@ -0,0 +1,11 @@ +--- +summary: "Agent identity record" +read_when: + - Bootstrapping a workspace manually +--- +# IDENTITY.md - Agent Identity + +- Name: +- Creature: +- Vibe: +- Emoji: diff --git a/docs/templates/USER.md b/docs/templates/USER.md new file mode 100644 index 000000000..32e4052df --- /dev/null +++ b/docs/templates/USER.md @@ -0,0 +1,12 @@ +--- +summary: "User profile record" +read_when: + - Bootstrapping a workspace manually +--- +# USER.md - User Profile + +- Name: +- Preferred address: +- Pronouns (optional): +- Timezone (optional): +- Notes: diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index a2efc18d6..c5c154d57 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -1,7 +1,13 @@ import type { ThinkLevel } from "../auto-reply/thinking.js"; type BootstrapFile = { - name: "AGENTS.md" | "SOUL.md" | "TOOLS.md"; + name: + | "AGENTS.md" + | "SOUL.md" + | "TOOLS.md" + | "IDENTITY.md" + | "USER.md" + | "BOOTSTRAP.md"; path: string; content?: string; missing: boolean; diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 4f1e33b8b..3d7095e7c 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -20,6 +20,13 @@ describe("ensureAgentWorkspace", () => { if (!result.agentsPath) throw new Error("agentsPath missing"); const content = await fs.readFile(result.agentsPath, "utf-8"); expect(content).toContain("# AGENTS.md"); + + const identity = path.join(path.resolve(nested), "IDENTITY.md"); + const user = path.join(path.resolve(nested), "USER.md"); + const bootstrap = path.join(path.resolve(nested), "BOOTSTRAP.md"); + await expect(fs.stat(identity)).resolves.toBeDefined(); + await expect(fs.stat(user)).resolves.toBeDefined(); + await expect(fs.stat(bootstrap)).resolves.toBeDefined(); }); it("does not overwrite existing AGENTS.md", async () => { diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 68f10546f..c6fe79deb 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -9,11 +9,19 @@ export const DEFAULT_AGENT_WORKSPACE_DIR = path.join(os.homedir(), "clawd"); export const DEFAULT_AGENTS_FILENAME = "AGENTS.md"; export const DEFAULT_SOUL_FILENAME = "SOUL.md"; export const DEFAULT_TOOLS_FILENAME = "TOOLS.md"; +export const DEFAULT_IDENTITY_FILENAME = "IDENTITY.md"; +export const DEFAULT_USER_FILENAME = "USER.md"; +export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md"; const DEFAULT_AGENTS_TEMPLATE = `# AGENTS.md - Clawdis 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. @@ -64,6 +72,66 @@ It does not define which tools exist; Clawdis provides built-in tools internally Add whatever else you want the assistant to know about your local toolchain. `; +const DEFAULT_BOOTSTRAP_TEMPLATE = `# 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) ~/.clawdis/clawdis.json +Set identity.name, identity.theme, identity.emoji to match IDENTITY.md. + +## Cleanup +Delete BOOTSTRAP.md once this is complete. +`; + +const DEFAULT_IDENTITY_TEMPLATE = `# IDENTITY.md - Agent Identity + +- Name: +- Creature: +- Vibe: +- Emoji: +`; + +const DEFAULT_USER_TEMPLATE = `# USER.md - User Profile + +- Name: +- Preferred address: +- Pronouns (optional): +- Timezone (optional): +- Notes: +`; + const TEMPLATE_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../docs/templates", @@ -92,7 +160,10 @@ async function loadTemplate(name: string, fallback: string): Promise { export type WorkspaceBootstrapFileName = | typeof DEFAULT_AGENTS_FILENAME | typeof DEFAULT_SOUL_FILENAME - | typeof DEFAULT_TOOLS_FILENAME; + | typeof DEFAULT_TOOLS_FILENAME + | typeof DEFAULT_IDENTITY_FILENAME + | typeof DEFAULT_USER_FILENAME + | typeof DEFAULT_BOOTSTRAP_FILENAME; export type WorkspaceBootstrapFile = { name: WorkspaceBootstrapFileName; @@ -121,6 +192,9 @@ export async function ensureAgentWorkspace(params?: { agentsPath?: string; soulPath?: string; toolsPath?: string; + identityPath?: string; + userPath?: string; + bootstrapPath?: string; }> { const rawDir = params?.dir?.trim() ? params.dir.trim() @@ -133,6 +207,9 @@ export async function ensureAgentWorkspace(params?: { const agentsPath = path.join(dir, DEFAULT_AGENTS_FILENAME); const soulPath = path.join(dir, DEFAULT_SOUL_FILENAME); const toolsPath = path.join(dir, DEFAULT_TOOLS_FILENAME); + const identityPath = path.join(dir, DEFAULT_IDENTITY_FILENAME); + const userPath = path.join(dir, DEFAULT_USER_FILENAME); + const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME); const agentsTemplate = await loadTemplate( DEFAULT_AGENTS_FILENAME, @@ -146,12 +223,35 @@ export async function ensureAgentWorkspace(params?: { DEFAULT_TOOLS_FILENAME, DEFAULT_TOOLS_TEMPLATE, ); + const identityTemplate = await loadTemplate( + DEFAULT_IDENTITY_FILENAME, + DEFAULT_IDENTITY_TEMPLATE, + ); + const userTemplate = await loadTemplate( + DEFAULT_USER_FILENAME, + DEFAULT_USER_TEMPLATE, + ); + const bootstrapTemplate = await loadTemplate( + DEFAULT_BOOTSTRAP_FILENAME, + DEFAULT_BOOTSTRAP_TEMPLATE, + ); await writeFileIfMissing(agentsPath, agentsTemplate); await writeFileIfMissing(soulPath, soulTemplate); await writeFileIfMissing(toolsPath, toolsTemplate); + await writeFileIfMissing(identityPath, identityTemplate); + await writeFileIfMissing(userPath, userTemplate); + await writeFileIfMissing(bootstrapPath, bootstrapTemplate); - return { dir, agentsPath, soulPath, toolsPath }; + return { + dir, + agentsPath, + soulPath, + toolsPath, + identityPath, + userPath, + bootstrapPath, + }; } export async function loadWorkspaceBootstrapFiles( @@ -175,6 +275,18 @@ export async function loadWorkspaceBootstrapFiles( name: DEFAULT_TOOLS_FILENAME, filePath: path.join(resolvedDir, DEFAULT_TOOLS_FILENAME), }, + { + name: DEFAULT_IDENTITY_FILENAME, + filePath: path.join(resolvedDir, DEFAULT_IDENTITY_FILENAME), + }, + { + name: DEFAULT_USER_FILENAME, + filePath: path.join(resolvedDir, DEFAULT_USER_FILENAME), + }, + { + name: DEFAULT_BOOTSTRAP_FILENAME, + filePath: path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME), + }, ]; const result: WorkspaceBootstrapFile[] = [];