Skip to content

Commit fe84725

Browse files
committed
Align project bootstrap with workspace memory and default BM project
1 parent 855b32b commit fe84725

8 files changed

Lines changed: 141 additions & 16 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Add to your OpenClaw config:
8585
}
8686
```
8787

88-
This uses sensible defaults: auto-generated project name, watches `~/.openclaw/workspace/memory/`, captures conversations.
88+
This uses sensible defaults: auto-generated project name, maps Basic Memory to your workspace `memory/` directory, and captures conversations.
8989

9090
### Full configuration
9191
```json5
@@ -95,7 +95,7 @@ This uses sensible defaults: auto-generated project name, watches `~/.openclaw/w
9595
config: {
9696
project: "my-agent", // BM project name (default: "openclaw-{hostname}")
9797
bmPath: "bm", // Path to BM CLI binary
98-
projectPath: "~/.openclaw/workspace/memory/", // Directory BM watches and indexes
98+
projectPath: "~/.openclaw/workspace/memory/", // Optional override; supports absolute, ~/..., or workspace-relative paths
9999
memoryDir: "memory/", // Relative memory dir for task scanning
100100
memoryFile: "MEMORY.md", // Working memory file for grep search
101101
autoCapture: true, // Index conversations automatically
@@ -115,7 +115,7 @@ This uses sensible defaults: auto-generated project name, watches `~/.openclaw/w
115115
|--------|------|---------|-------------|
116116
| `project` | string | `"openclaw-{hostname}"` | Basic Memory project name |
117117
| `bmPath` | string | `"bm"` | Path to Basic Memory CLI binary |
118-
| `projectPath` | string | `"~/.openclaw/workspace/memory/"` | Directory for BM project data |
118+
| `projectPath` | string | `"memory/"` | Directory for BM project data (resolved from workspace unless absolute) |
119119
| `memoryDir` | string | `"memory/"` | Relative path for task scanning |
120120
| `memoryFile` | string | `"MEMORY.md"` | Working memory file (grep-searched) |
121121
| `autoCapture` | boolean | `true` | Auto-index agent conversations |
@@ -124,6 +124,8 @@ This uses sensible defaults: auto-generated project name, watches `~/.openclaw/w
124124

125125
Snake_case aliases (`memory_dir`, `memory_file`) are also supported.
126126

127+
On startup, the plugin ensures the configured BM project exists at `projectPath` by running `bm project add <project> <path> --default` when needed.
128+
127129
## How It Works
128130

129131
### File Watching

bm-client.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { beforeEach, describe, expect, it, jest } from "bun:test"
22
import {
33
BmClient,
4+
isProjectAlreadyExistsError,
45
isMissingEditNoteCommandError,
56
isNoteNotFoundError,
67
isUnsupportedStripFrontmatterError,
@@ -66,6 +67,15 @@ Warning: something happened
6667
isNoteNotFoundError(new Error("No such command 'edit-note'")),
6768
).toBe(false)
6869
})
70+
71+
it("detects project already exists errors", () => {
72+
expect(
73+
isProjectAlreadyExistsError(new Error("Project 'x' already exists")),
74+
).toBe(true)
75+
expect(isProjectAlreadyExistsError(new Error("permission denied"))).toBe(
76+
false,
77+
)
78+
})
6979
})
7080
})
7181

@@ -172,6 +182,54 @@ describe("BmClient behavior", () => {
172182
)
173183
})
174184

185+
it("ensureProject ignores already-exists errors", async () => {
186+
const execRaw = jest
187+
.fn()
188+
.mockRejectedValue(new Error("Project 'test-project' already exists"))
189+
;(client as any).execRaw = execRaw
190+
191+
await expect(client.ensureProject("/tmp/memory")).resolves.toBeUndefined()
192+
expect(execRaw).toHaveBeenCalledWith([
193+
"project",
194+
"add",
195+
"test-project",
196+
"/tmp/memory",
197+
"--default",
198+
])
199+
})
200+
201+
it("ensureProject throws when project creation fails for other reasons", async () => {
202+
const execRaw = jest
203+
.fn()
204+
.mockRejectedValue(new Error("permission denied"))
205+
;(client as any).execRaw = execRaw
206+
207+
await expect(client.ensureProject("/tmp/memory")).rejects.toThrow(
208+
'failed to ensure project "test-project" at "/tmp/memory"',
209+
)
210+
expect(execRaw).toHaveBeenCalledWith([
211+
"project",
212+
"add",
213+
"test-project",
214+
"/tmp/memory",
215+
"--default",
216+
])
217+
})
218+
219+
it("ensureProject passes --default when project is created", async () => {
220+
const execRaw = jest.fn().mockResolvedValue("")
221+
;(client as any).execRaw = execRaw
222+
223+
await expect(client.ensureProject("/tmp/memory")).resolves.toBeUndefined()
224+
expect(execRaw).toHaveBeenCalledWith([
225+
"project",
226+
"add",
227+
"test-project",
228+
"/tmp/memory",
229+
"--default",
230+
])
231+
})
232+
175233
it("indexConversation does not create fallback note on non-not-found edit errors", async () => {
176234
;(client as any).editNote = jest
177235
.fn()

bm-client.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ export function isMissingEditNoteCommandError(err: unknown): boolean {
122122
)
123123
}
124124

125+
export function isProjectAlreadyExistsError(err: unknown): boolean {
126+
const msg = getErrorMessage(err).toLowerCase()
127+
return msg.includes("project") && msg.includes("already exists")
128+
}
129+
125130
export function isNoteNotFoundError(err: unknown): boolean {
126131
const msg = getErrorMessage(err).toLowerCase()
127132
if (isMissingEditNoteCommandError(err)) return false
@@ -194,10 +199,23 @@ export class BmClient {
194199

195200
async ensureProject(projectPath: string): Promise<void> {
196201
try {
197-
await this.execRaw(["project", "add", this.project, projectPath])
198-
} catch {
199-
// Silently ignore — most likely the project already exists.
200-
// This is expected on every restart after first setup.
202+
await this.execRaw([
203+
"project",
204+
"add",
205+
this.project,
206+
projectPath,
207+
"--default",
208+
])
209+
} catch (err) {
210+
if (isProjectAlreadyExistsError(err)) {
211+
// Expected after first startup.
212+
return
213+
}
214+
throw new Error(
215+
`failed to ensure project "${this.project}" at "${projectPath}": ${
216+
err instanceof Error ? err.message : String(err)
217+
}`,
218+
)
201219
}
202220
}
203221

commands/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ export function registerCli(
158158
.description("Show plugin status")
159159
.action(() => {
160160
console.log(`Project: ${cfg.project}`)
161+
console.log(`Project path: ${cfg.projectPath}`)
161162
console.log(`BM CLI: ${cfg.bmPath}`)
162163
console.log(`Memory dir: ${cfg.memoryDir}`)
163164
console.log(`Memory file: ${cfg.memoryFile}`)

config.test.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "bun:test"
2-
import { parseConfig } from "./config.ts"
2+
import { homedir } from "node:os"
3+
import { parseConfig, resolveProjectPath } from "./config.ts"
34

45
describe("config", () => {
56
describe("parseConfig", () => {
@@ -12,7 +13,7 @@ describe("config", () => {
1213
expect(config.autoCapture).toBe(true)
1314
expect(config.debug).toBe(false)
1415
expect(config.project).toMatch(/^openclaw-/)
15-
expect(config.projectPath).toMatch(/\.openclaw\/workspace\/memory\//)
16+
expect(config.projectPath).toBe("memory/")
1617
expect(config.cloud).toBeUndefined()
1718
})
1819

@@ -67,6 +68,11 @@ describe("config", () => {
6768
expect(config.projectPath).toBe("/custom/project/path")
6869
})
6970

71+
it("should default projectPath to memoryDir", () => {
72+
const config = parseConfig({ memoryDir: "notes/" })
73+
expect(config.projectPath).toBe("notes/")
74+
})
75+
7076
it("should use provided autoCapture", () => {
7177
expect(parseConfig({ autoCapture: false }).autoCapture).toBe(false)
7278
expect(parseConfig({ autoCapture: true }).autoCapture).toBe(true)
@@ -124,4 +130,24 @@ describe("config", () => {
124130
expect(() => parseConfig({})).not.toThrow()
125131
})
126132
})
133+
134+
describe("resolveProjectPath", () => {
135+
it("resolves relative projectPath against workspace", () => {
136+
expect(resolveProjectPath("memory/", "/tmp/workspace")).toBe(
137+
"/tmp/workspace/memory",
138+
)
139+
})
140+
141+
it("expands tilde paths", () => {
142+
expect(resolveProjectPath("~/memory", "/tmp/workspace")).toBe(
143+
`${homedir()}/memory`,
144+
)
145+
})
146+
147+
it("keeps absolute paths unchanged", () => {
148+
expect(resolveProjectPath("/var/data/memory", "/tmp/workspace")).toBe(
149+
"/var/data/memory",
150+
)
151+
})
152+
})
127153
})

config.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { homedir, hostname } from "node:os"
2+
import { isAbsolute, resolve } from "node:path"
23

34
export type CloudConfig = {
45
url: string
@@ -46,6 +47,18 @@ function defaultProject(): string {
4647
.toLowerCase()}`
4748
}
4849

50+
function expandUserPath(path: string): string {
51+
if (path === "~") return homedir()
52+
if (path.startsWith("~/")) return `${homedir()}/${path.slice(2)}`
53+
return path
54+
}
55+
56+
export function resolveProjectPath(projectPath: string, workspaceDir: string): string {
57+
const expanded = expandUserPath(projectPath)
58+
if (isAbsolute(expanded)) return expanded
59+
return resolve(workspaceDir, expanded)
60+
}
61+
4962
export function parseConfig(raw: unknown): BasicMemoryConfig {
5063
const cfg =
5164
raw && typeof raw === "object" && !Array.isArray(raw)
@@ -87,7 +100,7 @@ export function parseConfig(raw: unknown): BasicMemoryConfig {
87100
projectPath:
88101
typeof cfg.projectPath === "string" && cfg.projectPath.length > 0
89102
? cfg.projectPath
90-
: `${homedir()}/.openclaw/workspace/memory/`,
103+
: memoryDir,
91104
bmPath:
92105
typeof cfg.bmPath === "string" && cfg.bmPath.length > 0
93106
? cfg.bmPath

index.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
44
import { BmClient } from "./bm-client.ts"
55
import { registerCli } from "./commands/cli.ts"
66
import { registerCommands } from "./commands/slash.ts"
7-
import { basicMemoryConfigSchema, parseConfig } from "./config.ts"
7+
import {
8+
basicMemoryConfigSchema,
9+
parseConfig,
10+
resolveProjectPath,
11+
} from "./config.ts"
812
import { buildCaptureHandler } from "./hooks/capture.ts"
913
import { initLogger, log } from "./logger.ts"
1014
import { registerContextTool } from "./tools/context.ts"
@@ -62,10 +66,13 @@ export default {
6266
start: async (ctx: { config?: unknown; workspaceDir?: string }) => {
6367
log.info("starting...")
6468

65-
await client.ensureProject(cfg.projectPath)
66-
log.debug(`project "${cfg.project}" at ${cfg.projectPath}`)
67-
6869
const workspace = ctx.workspaceDir ?? process.cwd()
70+
const projectPath = resolveProjectPath(cfg.projectPath, workspace)
71+
cfg.projectPath = projectPath
72+
73+
await client.ensureProject(projectPath)
74+
log.debug(`project "${cfg.project}" at ${projectPath}`)
75+
6976
setWorkspaceDir(workspace)
7077

7178
// Start `bm watch` as a long-running child process.

openclaw.plugin.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@
4040
},
4141
"projectPath": {
4242
"label": "Project Path",
43-
"placeholder": "~/.basic-memory/openclaw/",
44-
"help": "Filesystem path for the Basic Memory project data",
43+
"placeholder": "~/.openclaw/workspace/memory/",
44+
"help": "Filesystem path for Basic Memory project data (relative paths resolve from workspace); created automatically if missing",
4545
"advanced": true
4646
},
4747
"cloud": {

0 commit comments

Comments
 (0)