@basicmemory/openclaw-basic-memory 0.23.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +24 -7
  2. package/bm-client.ts +107 -7
  3. package/dist/bm-client.d.ts +33 -1
  4. package/dist/bm-client.js +77 -6
  5. package/dist/index.js +9 -1
  6. package/dist/tools/edit-note.js +8 -1
  7. package/dist/tools/list-memory-projects.js +23 -5
  8. package/dist/tools/list-workspaces.js +3 -1
  9. package/dist/tools/memory-provider.js +2 -1
  10. package/dist/tools/read-note.js +18 -3
  11. package/dist/tools/search-notes.js +3 -1
  12. package/dist/tools/write-note.js +65 -5
  13. package/index.ts +9 -1
  14. package/openclaw.plugin.json +2 -0
  15. package/package.json +1 -1
  16. package/scripts/fetch-skills.ts +87 -36
  17. package/scripts/setup-bm.sh +4 -1
  18. package/skills/manifest.json +31 -16
  19. package/skills/memory-agents/SKILL.md +170 -0
  20. package/skills/memory-capture/SKILL.md +7 -5
  21. package/skills/memory-comark/SKILL.md +204 -0
  22. package/skills/memory-comark/references/components.md +740 -0
  23. package/skills/memory-continue/SKILL.md +11 -1
  24. package/skills/memory-curate/SKILL.md +32 -39
  25. package/skills/memory-defrag/SKILL.md +106 -57
  26. package/skills/memory-ingest/SKILL.md +14 -3
  27. package/skills/memory-lifecycle/SKILL.md +21 -21
  28. package/skills/memory-literary-analysis/SKILL.md +258 -28
  29. package/skills/memory-metadata-search/SKILL.md +29 -4
  30. package/skills/memory-notes/SKILL.md +69 -17
  31. package/skills/memory-okf/SKILL.md +135 -0
  32. package/skills/memory-onboarding/SKILL.md +2 -2
  33. package/skills/memory-onboarding/references/assistant-setup.md +8 -0
  34. package/skills/memory-onboarding/references/conventions.md +3 -3
  35. package/skills/memory-onboarding/references/domain-playbooks.md +1 -1
  36. package/skills/memory-onboarding/references/schema-guide.md +3 -3
  37. package/skills/memory-quest/SKILL.md +129 -0
  38. package/skills/memory-quest/agents/openai.yaml +9 -0
  39. package/skills/memory-reflect/SKILL.md +125 -44
  40. package/skills/memory-research/SKILL.md +9 -15
  41. package/skills/memory-schema/SKILL.md +29 -21
  42. package/skills/memory-tasks/SKILL.md +36 -10
  43. package/tools/edit-note.ts +15 -1
  44. package/tools/list-memory-projects.ts +24 -5
  45. package/tools/list-workspaces.ts +3 -1
  46. package/tools/memory-provider.ts +2 -1
  47. package/tools/read-note.ts +22 -3
  48. package/tools/search-notes.ts +3 -1
  49. package/tools/write-note.ts +94 -4
  50. package/skills/memory-ci-capture/SKILL.md +0 -56
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: memory-schema
3
- description: "Schema lifecycle management for Basic Memory: discover unschemaed notes, infer schemas, create and edit schema definitions, validate notes, and detect drift. Use when working with structured note types (Task, Person, Meeting, etc.) to maintain consistency across the knowledge graph."
3
+ description: "Manage Basic Memory schemas: find unschemaed notes, infer and edit schemas, validate notes, detect drift. Use when structured note types (Task, Person, Meeting) need consistency across the graph."
4
4
  ---
5
5
 
6
6
  # Memory Schema
@@ -112,12 +112,12 @@ settings:
112
112
 
113
113
  Look for clusters of notes that share structure but have no schema:
114
114
 
115
- 1. **Search by type**: `search_notes(query="type:Meeting")` — if many notes share a `type` but no `schema/Meeting.md` exists, it's a candidate.
115
+ 1. **Search by type**: `search_notes(note_types=["meeting"])` — if many notes share a `type` but no `schema/Meeting.md` exists, it's a candidate.
116
116
 
117
117
  2. **Infer a schema**: Use `schema_infer` to analyze existing notes and generate a suggested schema:
118
118
  ```python
119
- schema_infer(noteType="Meeting")
120
- schema_infer(noteType="Meeting", threshold=0.5) # fields in 50%+ of notes
119
+ schema_infer(note_type="Meeting")
120
+ schema_infer(note_type="Meeting", threshold=0.5) # fields in 50%+ of notes
121
121
  ```
122
122
  The threshold (0.0–1.0) controls how common a field must be to be included. Default is usually fine; lower it to catch rarer fields.
123
123
 
@@ -168,7 +168,7 @@ Check how well existing notes conform to their schema:
168
168
 
169
169
  ```python
170
170
  # Validate all notes of a type
171
- schema_validate(noteType="Meeting")
171
+ schema_validate(note_type="Meeting")
172
172
 
173
173
  # Validate a single note
174
174
  schema_validate(identifier="meetings/2026-02-10-standup")
@@ -176,11 +176,10 @@ schema_validate(identifier="meetings/2026-02-10-standup")
176
176
 
177
177
  **Important:** `schema_validate` checks for schema fields as **observation categories** in the note body — e.g., a `status` field expects `- [status] active` as an observation. Fields stored only in frontmatter metadata won't satisfy validation. To pass cleanly, include schema fields as both frontmatter values (for metadata search) and observations (for schema validation).
178
178
 
179
- Validation reports:
180
- - **Missing required fields** — the note lacks a field the schema requires (as an observation category)
181
- - **Unknown fields** — the note has fields the schema doesn't define
182
- - **Type mismatches** — a field value doesn't match the expected type
183
- - **Invalid enum values** — a value isn't in the allowed set
179
+ Validation reports **missing required fields** (as observation categories, or relations
180
+ for entity-reference fields) and **invalid enum values**. Undeclared observation categories
181
+ and relations are listed as informational "unmatched" items. Scalar values are not
182
+ type-checked.
184
183
 
185
184
  ### Handling Validation Results
186
185
 
@@ -192,32 +191,41 @@ Validation reports:
192
191
  Over time, notes evolve and schemas lag behind. Use `schema_diff` to find divergence:
193
192
 
194
193
  ```python
195
- schema_diff(noteType="Meeting")
194
+ schema_diff(note_type="Meeting")
196
195
  ```
197
196
 
198
197
  Diff reports:
199
198
  - **Fields in notes but not in schema** — candidates for adding to the schema (as optional)
200
199
  - **Schema fields rarely used** — consider making optional or removing
201
- - **Type inconsistencies** — fields used as different types across notes
200
+ - **Cardinality changes** — a field that moved between single-value and array
202
201
 
203
202
  ## Schema Evolution
204
203
 
205
204
  When note structure changes:
206
205
 
207
- 1. **Run diff** to see current state: `schema_diff(noteType="Meeting")`
208
- 2. **Update the schema note** via `edit_note`:
206
+ 1. **Run diff** to see current state: `schema_diff(note_type="Meeting")`
207
+ 2. **Update the schema note** via `edit_note` with the `metadata` parameter. Top-level
208
+ keys are replaced whole, so pass the complete updated `schema` map along with the new
209
+ `version`:
209
210
  ```python
210
211
  edit_note(
211
212
  identifier="schema/Meeting",
212
- operation="find_replace",
213
- find_text="version: 1",
214
- content="version: 2",
215
- expected_replacements=1
213
+ operation="append",
214
+ content="",
215
+ metadata={
216
+ "version": 2,
217
+ "schema": {
218
+ "topic": "string, what was discussed",
219
+ "date": "string, when it happened",
220
+ "attendees?(array)": "Person, who attended",
221
+ "decisions?(array)": "string, decisions made",
222
+ "location?": "string, where it happened"
223
+ }
224
+ }
216
225
  )
217
226
  ```
218
- 3. **Add/remove/modify fields** in the `schema:` block
219
- 4. **Re-validate** to confirm existing notes still pass: `schema_validate(noteType="Meeting")`
220
- 5. **Fix outliers** — update notes that don't conform to the new schema
227
+ 3. **Re-validate** to confirm existing notes still pass: `schema_validate(note_type="Meeting")`
228
+ 4. **Fix outliers** — update notes that don't conform to the new schema
221
229
 
222
230
  ### Evolution Guidelines
223
231
 
@@ -16,7 +16,7 @@ Manage work-in-progress using Basic Memory's schema system. Tasks are just notes
16
16
 
17
17
  ## Task Schema
18
18
 
19
- Tasks use the BM schema system (SPEC-SCHEMA). The schema note lives at `memory/schema/Task.md`:
19
+ Tasks use the BM schema system. The schema note lives at `schema/Task.md`:
20
20
 
21
21
  ```yaml
22
22
  ---
@@ -52,6 +52,7 @@ write_note(
52
52
  metadata={
53
53
  "status": "active",
54
54
  "priority": "high",
55
+ "assigned_to": "claude",
55
56
  "current_step": 1,
56
57
  "steps": ["First step", "Second step", "Third step"]
57
58
  },
@@ -85,7 +86,7 @@ What future-you needs to pick up this work. Include:
85
86
  - **Steps are concrete and checkable** — "Implement X in file Y", not "figure out stuff"
86
87
  - **Context is for post-amnesia resumption** — Write it as if explaining to a smart person who knows nothing about what you've been doing
87
88
  - **Relations link to other entities** — `parent_task [[Other Task]]`, `related_to [[Some Note]]`
88
- - **`note_types` is case-sensitive** — `write_note(note_type="Task")` stores the type as lowercase `task` in frontmatter. Use `note_types=["task"]` (lowercase) in search queries.
89
+ - **Note types are normalized to snake_case** — `write_note(note_type="Task")` stores `type: task`, and `note_types=["Task"]` or `note_types=["task"]` both match it.
89
90
 
90
91
  ## Resuming After Compaction
91
92
 
@@ -116,9 +117,21 @@ As work progresses, update the task note:
116
117
  Updated context reflecting current state...
117
118
  ```
118
119
 
119
- Update frontmatter too:
120
- ```yaml
121
- current_step: 3
120
+ Read the task as JSON first and pass its checksum as `expected_checksum` on each
121
+ edit. Two sessions often update the same task, for example in a pre-compaction
122
+ flush; a revision conflict means the other one wrote first, so re-read before
123
+ writing.
124
+
125
+ Update frontmatter too, with the `metadata` parameter (empty `content` leaves the body alone):
126
+ ```python
127
+ task = read_note(identifier="tasks/descriptive-task-name", output_format="json")
128
+ edit_note(
129
+ identifier="tasks/descriptive-task-name",
130
+ operation="append",
131
+ content="",
132
+ metadata={"current_step": 3},
133
+ expected_checksum=task["checksum"]
134
+ )
122
135
  ```
123
136
 
124
137
  ## Completing Tasks
@@ -140,7 +153,7 @@ When a compaction event is imminent:
140
153
  - `current_step` to reflect actual progress
141
154
  - `context` with everything needed to resume
142
155
  - Step checkboxes to show what's done
143
- 3. This is **critical** — context not written down is context lost
156
+ 3. Context that isn't written down is lost at compaction
144
157
 
145
158
  ## Querying Tasks
146
159
 
@@ -153,14 +166,27 @@ With BM's schema system, tasks are fully queryable:
153
166
  | `search_notes(note_types=["task"], status="blocked")` | Blocked tasks |
154
167
  | `search_notes(note_types=["task"], metadata_filters={"assigned_to": "claude"})` | My tasks |
155
168
  | `search_notes("blockers", note_types=["task"])` | Tasks with blockers |
156
- | `schema_validate(noteType="Task")` | Validate all tasks against schema |
157
- | `schema_diff(noteType="Task")` | Detect drift between schema and actual task notes |
169
+ | `search_notes(note_types=["task"], time_kind="due", valid_overlaps="[2026-10-14,2026-10-21)")` | Tasks with a step due this week |
170
+ | `schema_validate(note_type="Task")` | Validate all tasks against schema |
171
+ | `schema_diff(note_type="Task")` | Detect drift between schema and actual task notes |
172
+
173
+ ### Due Dates
174
+
175
+ Give a step or the task a due date with a qualifier (see **memory-notes**), so
176
+ "what's due" is one search:
177
+
178
+ ```markdown
179
+ - [deadline] @due:2026-10-20 Draft ready for review
180
+ ```
181
+
182
+ A day-precision due date counts from that day onward, so a due-this-week search
183
+ also returns overdue items.
158
184
 
159
185
  ## Guidelines
160
186
 
161
187
  - **One task per unit of work** — Don't cram multiple projects into one task
162
- - **Externalize early** — If you think "I should remember this", write it down NOW
188
+ - **Externalize early** — write things down when you notice them
163
189
  - **Context > steps** — Steps tell you what to do; context tells you why and how
164
190
  - **Close finished tasks** — Don't leave completed work as `active`
165
191
  - **Link related tasks** — Use `parent_task [[X]]` or relations to connect related work
166
- - **Schema validation is your friend** — Run `schema_validate(noteType="Task")` periodically to catch incomplete tasks
192
+ - **Schema validation is your friend** — Run `schema_validate(note_type="Task")` periodically to catch incomplete tasks
@@ -14,7 +14,9 @@ export function registerEditTool(
14
14
  description:
15
15
  "Incrementally edit an existing note in the Basic Memory knowledge graph. " +
16
16
  "Supports append, prepend, find/replace, and section replacement " +
17
- "without rewriting the entire note.",
17
+ "without rewriting the entire note. The result returns the note's " +
18
+ "permalink and file_path: cite those exact values when confirming " +
19
+ "the edit — never report a save that no tool call performed.",
18
20
  parameters: Type.Object({
19
21
  identifier: Type.String({
20
22
  description: "Note title, permalink, or memory:// URL to edit",
@@ -57,6 +59,16 @@ export function registerEditTool(
57
59
  description: "Target project name (defaults to current project)",
58
60
  }),
59
61
  ),
62
+ metadata: Type.Optional(
63
+ Type.Object(
64
+ {},
65
+ {
66
+ additionalProperties: true,
67
+ description:
68
+ "Frontmatter fields to merge independently of the edit operation. Nested objects are supported.",
69
+ },
70
+ ),
71
+ ),
60
72
  }),
61
73
  async execute(
62
74
  _toolCallId: string,
@@ -68,6 +80,7 @@ export function registerEditTool(
68
80
  section?: string
69
81
  expected_replacements?: number
70
82
  project?: string
83
+ metadata?: Record<string, unknown>
71
84
  },
72
85
  ) {
73
86
  log.debug(`edit_note: id="${params.identifier}" op=${params.operation}`)
@@ -81,6 +94,7 @@ export function registerEditTool(
81
94
  find_text: params.find_text,
82
95
  section: params.section,
83
96
  expected_replacements: params.expected_replacements,
97
+ metadata: params.metadata,
84
98
  },
85
99
  params.project,
86
100
  )
@@ -6,6 +6,9 @@ import { log } from "../logger.ts"
6
6
  function normalizeProject(project: ProjectListResult) {
7
7
  return {
8
8
  name: project.name,
9
+ external_id: project.external_id ?? null,
10
+ qualified_name: project.qualified_name ?? null,
11
+ source: project.source ?? null,
9
12
  path: project.path,
10
13
  display_name: project.display_name ?? null,
11
14
  is_private: project.is_private === true,
@@ -25,7 +28,13 @@ export function registerProjectListTool(
25
28
  {
26
29
  name: "list_memory_projects",
27
30
  label: "List Projects",
28
- description: "List all Basic Memory projects accessible to this agent",
31
+ description:
32
+ "List Basic Memory projects accessible to this agent, optionally filtered by " +
33
+ "workspace. Returns each project's name, external_id, source (local, cloud, " +
34
+ "or local+cloud), path, default flag, workspace, and qualified name. " +
35
+ "Call this before passing `project` to another tool when the user names a " +
36
+ "project that isn't the default. Pass a project's external_id as " +
37
+ "`project_id` to route to that exact project.",
29
38
  parameters: Type.Object({
30
39
  workspace: Type.Optional(
31
40
  Type.String({
@@ -58,10 +67,20 @@ export function registerProjectListTool(
58
67
  const text = normalized
59
68
  .map((project, idx) => {
60
69
  const defaultSuffix = project.is_default ? " (default)" : ""
61
- const displayLine = project.display_name
62
- ? `\n Display Name: ${project.display_name}`
63
- : ""
64
- return `${idx + 1}. **${project.name}**${defaultSuffix}\n Path: ${project.path}\n Private: ${project.is_private}${displayLine}`
70
+ const lines = [
71
+ `${idx + 1}. **${project.name}**${defaultSuffix}`,
72
+ project.external_id ? ` ID: ${project.external_id}` : null,
73
+ project.source ? ` Source: ${project.source}` : null,
74
+ ` Path: ${project.path}`,
75
+ ` Private: ${project.is_private}`,
76
+ project.display_name
77
+ ? ` Display Name: ${project.display_name}`
78
+ : null,
79
+ project.qualified_name
80
+ ? ` Qualified Name: ${project.qualified_name}`
81
+ : null,
82
+ ]
83
+ return lines.filter((line) => line !== null).join("\n")
65
84
  })
66
85
  .join("\n\n")
67
86
 
@@ -20,7 +20,9 @@ export function registerWorkspaceListTool(
20
20
  name: "list_workspaces",
21
21
  label: "List Workspaces",
22
22
  description:
23
- "List all Basic Memory workspaces (personal and organization) accessible to this user",
23
+ "List all Basic Memory workspaces (personal and organization) accessible to this user. " +
24
+ "Use it to find the workspace to pass to list_memory_projects when the same " +
25
+ "project name may exist in more than one workspace.",
24
26
  parameters: Type.Object({}),
25
27
  async execute(_toolCallId: string, _params: Record<string, never>) {
26
28
  log.debug("list_workspaces")
@@ -175,7 +175,8 @@ export function registerMemoryProvider(
175
175
  description:
176
176
  "Search across all memory sources: MEMORY.md (working memory), " +
177
177
  "Basic Memory knowledge graph (long-term archive), and active tasks. " +
178
- "Returns composited results from all sources.",
178
+ "Returns composited results from all sources. Use it for broad recall; use " +
179
+ "search_notes when you need metadata, tag, or status filters or a specific project.",
179
180
  parameters: Type.Object({
180
181
  query: Type.String({
181
182
  description: "Search query — natural language or keywords",
@@ -13,7 +13,9 @@ export function registerReadTool(
13
13
  label: "Read Note",
14
14
  description:
15
15
  "Read a specific note from the Basic Memory knowledge graph by title or permalink. " +
16
- "Returns the full note content including observations and relations.",
16
+ "Returns the full note content including observations and relations, " +
17
+ "followed by the note's checksum: pass it to write_note as " +
18
+ "expected_checksum (with overwrite=true) to replace only this revision.",
17
19
  parameters: Type.Object({
18
20
  identifier: Type.String({
19
21
  description: "Note title, permalink, or memory:// URL to read",
@@ -29,6 +31,13 @@ export function registerReadTool(
29
31
  description: "Target project name (defaults to current project)",
30
32
  }),
31
33
  ),
34
+ project_id: Type.Optional(
35
+ Type.String({
36
+ description:
37
+ "Target project external_id from list_memory_projects. Routes to " +
38
+ "that exact project and takes precedence over `project`.",
39
+ }),
40
+ ),
32
41
  }),
33
42
  async execute(
34
43
  _toolCallId: string,
@@ -36,6 +45,7 @@ export function registerReadTool(
36
45
  identifier: string
37
46
  include_frontmatter?: boolean
38
47
  project?: string
48
+ project_id?: string
39
49
  },
40
50
  ) {
41
51
  log.debug(`read_note: identifier="${params.identifier}"`)
@@ -43,15 +53,23 @@ export function registerReadTool(
43
53
  try {
44
54
  const note = await client.readNote(
45
55
  params.identifier,
46
- { includeFrontmatter: params.include_frontmatter === true },
56
+ {
57
+ includeFrontmatter: params.include_frontmatter === true,
58
+ projectId: params.project_id,
59
+ },
47
60
  params.project,
48
61
  )
62
+ // The checksum identifies the revision just read; the model sees only
63
+ // `content`, so it has to travel in the text to reach a guarded write.
64
+ const text = note.checksum
65
+ ? `${note.content}\n\nChecksum: ${note.checksum}`
66
+ : note.content
49
67
 
50
68
  return {
51
69
  content: [
52
70
  {
53
71
  type: "text" as const,
54
- text: note.content,
72
+ text,
55
73
  },
56
74
  ],
57
75
  details: {
@@ -59,6 +77,7 @@ export function registerReadTool(
59
77
  permalink: note.permalink,
60
78
  file_path: note.file_path,
61
79
  frontmatter: note.frontmatter ?? null,
80
+ checksum: note.checksum ?? null,
62
81
  },
63
82
  }
64
83
  } catch (err) {
@@ -14,7 +14,9 @@ export function registerSearchTool(
14
14
  description:
15
15
  "Search the Basic Memory knowledge graph for relevant notes, concepts, and connections. " +
16
16
  "Returns matching notes with titles, content previews, and relevance scores. " +
17
- "Optionally filter by frontmatter metadata fields, tags, or status.",
17
+ "Optionally filter by frontmatter metadata fields, tags, or status, or target " +
18
+ "another project. For a quick cross-source recall that also covers MEMORY.md " +
19
+ "and active tasks, use memory_search.",
18
20
  parameters: Type.Object({
19
21
  query: Type.String({ description: "Search query" }),
20
22
  limit: Type.Optional(
@@ -1,7 +1,10 @@
1
1
  import { Type } from "@sinclair/typebox"
2
2
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"
3
3
  import type { BmClient } from "../bm-client.ts"
4
- import { NoteAlreadyExistsError } from "../bm-client.ts"
4
+ import {
5
+ NoteAlreadyExistsError,
6
+ NoteRevisionConflictError,
7
+ } from "../bm-client.ts"
5
8
  import { log } from "../logger.ts"
6
9
 
7
10
  export function registerWriteTool(
@@ -14,8 +17,14 @@ export function registerWriteTool(
14
17
  label: "Write Note",
15
18
  description:
16
19
  "Create a note in the Basic Memory knowledge graph. " +
20
+ "Call this whenever the user asks to remember, record, save, or note " +
21
+ "something — saying 'saved' without this call saves nothing. The result " +
22
+ "returns the note's permalink and file_path: cite those exact values " +
23
+ "when confirming, never a recalled filename. " +
17
24
  "If the note already exists, returns an error by default. " +
18
- "Pass overwrite=true to replace, or use edit_note for incremental updates.",
25
+ "Pass overwrite=true to replace, or use edit_note for incremental updates. " +
26
+ "To replace only the revision you read, also pass expected_checksum " +
27
+ "(the checksum read_note reports); a newer revision is then left untouched.",
19
28
  parameters: Type.Object({
20
29
  title: Type.String({ description: "Note title" }),
21
30
  content: Type.String({
@@ -27,12 +36,47 @@ export function registerWriteTool(
27
36
  description: "Target project name (defaults to current project)",
28
37
  }),
29
38
  ),
39
+ project_id: Type.Optional(
40
+ Type.String({
41
+ description:
42
+ "Target project external_id from list_memory_projects. Routes to " +
43
+ "that exact project and takes precedence over `project`.",
44
+ }),
45
+ ),
30
46
  overwrite: Type.Optional(
31
47
  Type.Boolean({
32
48
  description:
33
49
  "Set to true to replace an existing note. Defaults to false.",
34
50
  }),
35
51
  ),
52
+ metadata: Type.Optional(
53
+ Type.Object(
54
+ {},
55
+ {
56
+ additionalProperties: true,
57
+ description:
58
+ "Extra frontmatter fields to merge into the note. Nested objects are supported.",
59
+ },
60
+ ),
61
+ ),
62
+ tags: Type.Optional(
63
+ Type.Union([Type.Array(Type.String()), Type.String()], {
64
+ description:
65
+ "Tags for the note, as an array or comma-separated string.",
66
+ }),
67
+ ),
68
+ note_type: Type.Optional(
69
+ Type.String({
70
+ description: 'Note type stored in frontmatter. Defaults to "note".',
71
+ }),
72
+ ),
73
+ expected_checksum: Type.Optional(
74
+ Type.String({
75
+ description:
76
+ "Requires overwrite=true. The checksum of the note as you read it; " +
77
+ "the note is replaced only while it is still that revision.",
78
+ }),
79
+ ),
36
80
  }),
37
81
  async execute(
38
82
  _toolCallId: string,
@@ -41,11 +85,31 @@ export function registerWriteTool(
41
85
  content: string
42
86
  folder: string
43
87
  project?: string
88
+ project_id?: string
44
89
  overwrite?: boolean
90
+ metadata?: Record<string, unknown>
91
+ tags?: string[] | string
92
+ note_type?: string
93
+ expected_checksum?: string
45
94
  },
46
95
  ) {
47
96
  log.debug(`write_note: title=${params.title} folder=${params.folder}`)
48
97
 
98
+ const options =
99
+ params.metadata !== undefined ||
100
+ params.tags !== undefined ||
101
+ params.note_type !== undefined ||
102
+ params.project_id !== undefined ||
103
+ params.expected_checksum !== undefined
104
+ ? {
105
+ metadata: params.metadata,
106
+ tags: params.tags,
107
+ noteType: params.note_type,
108
+ projectId: params.project_id,
109
+ expectedChecksum: params.expected_checksum,
110
+ }
111
+ : undefined
112
+
49
113
  try {
50
114
  const note = await client.writeNote(
51
115
  params.title,
@@ -53,9 +117,13 @@ export function registerWriteTool(
53
117
  params.folder,
54
118
  params.project,
55
119
  params.overwrite,
120
+ options,
56
121
  )
57
122
 
58
- const msg = `Note saved: ${note.title} (${note.permalink})`
123
+ const checksumSuffix = note.checksum
124
+ ? `\nChecksum: ${note.checksum}`
125
+ : ""
126
+ const msg = `Note saved: ${note.title} (${note.permalink})${checksumSuffix}`
59
127
  return {
60
128
  content: [
61
129
  {
@@ -67,6 +135,7 @@ export function registerWriteTool(
67
135
  title: note.title,
68
136
  permalink: note.permalink,
69
137
  file_path: note.file_path,
138
+ checksum: note.checksum ?? null,
70
139
  },
71
140
  }
72
141
  } catch (err) {
@@ -78,7 +147,7 @@ export function registerWriteTool(
78
147
  ` - edit_note("${err.permalink}", operation="append", content="...")`,
79
148
  ` - edit_note("${err.permalink}", operation="replace_section", section="...", content="...")`,
80
149
  ` - write_note("${params.title}", ..., overwrite=true) to fully replace`,
81
- ` - read_note("${err.permalink}") to inspect current content first`,
150
+ ` - read_note("${err.permalink}", include_frontmatter=true) to inspect it first; pass its checksum as expected_checksum with overwrite=true to replace only that revision`,
82
151
  ].join("\n")
83
152
 
84
153
  return {
@@ -91,6 +160,27 @@ export function registerWriteTool(
91
160
  }
92
161
  }
93
162
 
163
+ if (err instanceof NoteRevisionConflictError) {
164
+ const current = err.currentChecksum
165
+ ? `Its current checksum is ${err.currentChecksum}.`
166
+ : "No note exists at that path any more."
167
+ const text = [
168
+ `Note revision conflict: "${params.title}" changed since it was read, so nothing was replaced. ${current}`,
169
+ "",
170
+ "Read the note again, confirm it should still be replaced, then retry with the new checksum as expected_checksum.",
171
+ ].join("\n")
172
+
173
+ return {
174
+ content: [{ type: "text" as const, text }],
175
+ details: {
176
+ title: params.title,
177
+ permalink: err.permalink,
178
+ current_checksum: err.currentChecksum,
179
+ error: "note_revision_conflict",
180
+ },
181
+ }
182
+ }
183
+
94
184
  log.error("write_note failed", err)
95
185
  return {
96
186
  content: [
@@ -1,56 +0,0 @@
1
- ---
2
- name: memory-ci-capture
3
- description: Synthesize GitHub delivery context into a concise Basic Memory project update. Use in CI after `bm ci collect` prepares a ProjectUpdateContext; return only structured AgentSynthesis JSON for `bm ci publish`.
4
- ---
5
-
6
- # Memory CI Capture
7
-
8
- Turn a meaningful GitHub delivery moment into project memory. GitHub records the
9
- mechanics. Basic Memory remembers what changed and why.
10
-
11
- ## Inputs
12
-
13
- Read the `ProjectUpdateContext` JSON produced by `bm ci collect` at
14
- `.github/basic-memory/project-update-context.json`. Treat it as the immutable
15
- source of truth for repository, event type, PR number, workflow run, SHA, source
16
- URL, timestamps, and deployment environment.
17
-
18
- Do not invent tests, deploy checks, linked issues, product impact, or decisions.
19
- If evidence is absent, say so briefly in `verification` or leave the field empty.
20
-
21
- ## Output
22
-
23
- Return only JSON matching the `AgentSynthesis` shape:
24
-
25
- ```json
26
- {
27
- "summary": "What changed.",
28
- "why_it_matters": "Why this update matters for future humans and agents.",
29
- "user_facing_changes": [],
30
- "internal_changes": [],
31
- "verification": [],
32
- "follow_ups": [],
33
- "decision_candidates": [],
34
- "task_candidates": []
35
- }
36
- ```
37
-
38
- ## Synthesis Rules
39
-
40
- - Prefer a short explanation over a commit-by-commit changelog.
41
- - Preserve intent, changed behavior, source links, verification evidence present
42
- in the context, and concrete follow-ups.
43
- - Put explicit product or architecture decisions in `decision_candidates` only
44
- when the source context clearly contains them.
45
- - Put future work in `task_candidates` only when it is concrete enough to act on.
46
- - Keep the tone factual and useful. This is project memory, not marketing copy.
47
-
48
- ## Event Guidance
49
-
50
- For merged pull requests, focus on why the PR existed, what area changed, what
51
- issues it advanced or closed, and what verification evidence appears in the
52
- context.
53
-
54
- For production deploys, focus on what reached production, the deployed SHA,
55
- environment, workflow run, and verification evidence. Do not overclaim success
56
- beyond the workflow and source facts.