@unblocklabs/unblock-memory 0.2.4 → 0.2.6

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.
package/README.md CHANGED
@@ -47,16 +47,16 @@ directories, or globs into named corpora:
47
47
  kind: "files",
48
48
  paths: ["MEMORY.md", "USER.md", "memory/**/*.md"],
49
49
  },
50
- {
51
- name: "projects",
52
- kind: "files",
53
- paths: ["/absolute/shared/**/*.md"],
54
- },
55
50
  {
56
51
  name: "sessions",
57
52
  kind: "sessions",
58
53
  chatTypes: ["channel", "group"],
59
54
  },
55
+ {
56
+ name: "knowledge",
57
+ kind: "files",
58
+ paths: ["knowledge/**/*.md"],
59
+ },
60
60
  ],
61
61
  // Optional: omit unless the local analysis worker is installed.
62
62
  analysis: {
@@ -80,7 +80,7 @@ context resident after first use. Set it to `false` to restore QMD's five-minute
80
80
  idle unload behavior.
81
81
 
82
82
  `memory_search` searches every configured corpus by default. Pass
83
- `corpora: ["projects"]` to search selected corpora or `corpora: ["all"]` to
83
+ `corpora: ["knowledge"]` to search selected corpora or `corpora: ["all"]` to
84
84
  request all of them explicitly. Search results include their corpus name and
85
85
  remain readable by passing the returned `qmd://` path to `memory_get`.
86
86
 
@@ -110,12 +110,15 @@ The optional `sessions` corpus reads the current agent's normal OpenClaw SQLite
110
110
  store and indexes its active user/assistant transcript branch. It defaults to
111
111
  channel and group conversations; add `direct` explicitly to include DMs. Run
112
112
  `memory_sync_sessions` to start a refresh, then use `memory_sync_status` to
113
- check its progress or result. Projections are private derived Markdown
114
- under the agent's `unblock-memory/sessions` state directory and can be rebuilt
115
- from OpenClaw at any time. Session results include provider, chat type,
116
- conversation identity, and start time as an ISO 8601 timestamp. They participate
117
- in the same search and clustering index as file memory. This phase does not sync
118
- sessions at startup or on a schedule; refreshes are manual through
113
+ check its progress or result. Projections are private derived Markdown under the
114
+ agent's `unblock-memory/sessions` state directory and can be rebuilt from
115
+ OpenClaw at any time. Their embedded text contains only `# Transcript` and
116
+ timestamped speaker messages; filtering metadata remains in the session
117
+ manifest. The projected file modification time matches the session start time
118
+ for meaningful chronological cluster reads. Session results include provider,
119
+ chat type, conversation identity, and start time as an ISO 8601 timestamp. They
120
+ participate in the same search and clustering index as file memory. This phase
121
+ does not sync sessions at startup or on a schedule; refreshes are manual through
119
122
  `memory_sync_sessions`.
120
123
 
121
124
  Indexes live at `~/.openclaw/agents/<agentId>/unblock-memory/index.sqlite` (or the
@@ -161,17 +164,26 @@ Unblock Memory exposes:
161
164
  - `memory_list_clusters` to cheaply list current clusters and report whether the
162
165
  retained analysis is stale
163
166
  - `memory_recluster` to explicitly rebuild clusters when the list is missing or stale
164
- - `memory_fetch_cluster` to return up to `topK` representative QMD chunks for a
165
- short `clusterId` returned by `memory_list_clusters`
167
+ - `memory_fetch_cluster` to return a sorted, paginated selection of QMD chunks
168
+ for a short `clusterId` returned by `memory_list_clusters`
166
169
 
167
170
  `memory_recluster` optionally accepts UMAP controls (`method`, components,
168
171
  neighbors, and minimum distance), HDBSCAN controls (minimum cluster size,
169
172
  minimum samples, selection method and epsilon, and single-cluster behavior),
170
173
  and a deterministic seed. Omitting them uses the worker's defaults.
171
174
 
172
- Cluster reads return at most 50 members. Member excerpts are capped at 2 KB
173
- each and 12 KB across a response; source aliases are capped at five per member
174
- and 50 across a response.
175
+ `memory_fetch_cluster` accepts `topK` (1–50), a zero-based `offset`, and
176
+ `sort`: `representative` (the default), `score_desc`, `score_asc`, `date_desc`,
177
+ or `date_asc`. Score is cluster membership probability for normal clusters and
178
+ outlier score for noise. Each member includes `sourceDate`, the latest
179
+ modification time among its active source aliases. For projected sessions that
180
+ date is the session start time. Responses include page totals and the next
181
+ offset when more members remain.
182
+
183
+ Member excerpts are capped at 2 KB each and 12 KB across a response; source
184
+ aliases are capped at five per member and 50 across a response. These budgets
185
+ are shared across the page so every returned member receives a useful excerpt
186
+ and at least one source path, including a full 50-member page.
175
187
 
176
188
  If indexing changes content or vectors, the previous derived analysis is kept
177
189
  and marked stale. Cluster reads include the analysis timestamp, stale timestamp,
@@ -181,5 +193,56 @@ A failed rebuild leaves the stale result intact, while a successful rebuild
181
193
  atomically replaces it. Analysis is never scheduled automatically. If the worker
182
194
  is absent or fails, `memory_search` and `memory_get` continue to work.
183
195
 
196
+ ## Curating knowledge
197
+
198
+ The plugin bundles the `memory-curator` skill for turning useful clusters into
199
+ durable knowledge. It becomes available when the plugin is enabled. If the
200
+ agent has an explicit skill allowlist, include `memory-curator`.
201
+
202
+ Keep maintained knowledge outside `memory/**` so each file belongs to only one
203
+ corpus. Use stable topic files updated in place:
204
+
205
+ ```text
206
+ knowledge/
207
+ ├── fleet.md
208
+ ├── people/
209
+ │ └── rico.md
210
+ └── projects/
211
+ └── unblock-memory.md
212
+ ```
213
+
214
+ Knowledge is the agent's maintained, current understanding of its unique world:
215
+ facts such as fleet membership, local decisions and preferences, assessments,
216
+ and explicit uncertainty that would be expensive to reconstruct from scattered
217
+ history. Each claim should carry its own epistemic qualification so it remains
218
+ honest when semantic chunking retrieves it alone. Remove stale conclusions
219
+ instead of preserving history, changelogs, or `Supersedes` passages in the same
220
+ file; raw memory and sessions retain the evidence history.
221
+
222
+ Public or vendor-owned facts, generic command syntax, and behavior likely to
223
+ change with third-party releases should normally be looked up from the current
224
+ authoritative source. A local policy or deliberate divergence may belong in
225
+ knowledge, but the local decision—not copied generic documentation—is the
226
+ durable content.
227
+
228
+ For a manual run, ask the agent:
229
+
230
+ ```text
231
+ Use $memory-curator to review my memory clusters and curate any durable updates.
232
+ ```
233
+
234
+ For recurring curation, use an OpenClaw automation with the same thin message:
235
+
236
+ ```text
237
+ Use $memory-curator to run the scheduled memory curation cycle.
238
+ ```
239
+
240
+ The skill treats a cluster as an incomplete attention signal. It frames the
241
+ question raised, uses representative, score, and chronological views as useful,
242
+ searches existing knowledge and adjacent corpora, and investigates live systems,
243
+ files, documentation, or the web when those are better evidence. It then updates
244
+ a stable knowledge topic or correctly writes nothing. Its own writes are indexed
245
+ for the next cycle; it does not recluster recursively in the same run.
246
+
184
247
  Existing `unblock-qmd` indexes are derived caches and may be left in place;
185
248
  Unblock Memory rebuilds its own index from configured corpora.
@@ -30,9 +30,11 @@ type MemoryAnalysisMember = {
30
30
  x: number;
31
31
  y: number;
32
32
  representativeRank: number | null;
33
+ sourceDate: string;
33
34
  text: string;
34
35
  sourcePaths: string[];
35
36
  };
37
+ export type MemoryClusterSort = "representative" | "score_desc" | "score_asc" | "date_desc" | "date_asc";
36
38
  export type MemoryAnalysisSummary = {
37
39
  status: "ok";
38
40
  runId: string;
@@ -72,6 +74,13 @@ export type MemoryClusterDetail = AnalysisReadMetadata & {
72
74
  runId?: string;
73
75
  cluster?: Omit<MemoryClusterSummary, "preview">;
74
76
  members?: MemoryAnalysisMember[];
77
+ page?: {
78
+ offset: number;
79
+ returned: number;
80
+ total: number;
81
+ hasMore: boolean;
82
+ nextOffset?: number;
83
+ };
75
84
  };
76
85
  export declare function ensureMemoryAnalysisSchema(db: AnalysisDatabase): void;
77
86
  export declare function markMemoryAnalysisStale(db: AnalysisDatabase): void;
@@ -85,5 +94,5 @@ export declare function runAnalysisWorker(params: {
85
94
  export declare function latestAnalysisRunId(db: AnalysisDatabase): string | undefined;
86
95
  export declare function readAnalysisSummary(db: AnalysisDatabase): MemoryAnalysisSummary | undefined;
87
96
  export declare function readClusters(db: AnalysisDatabase, requestedLimit?: number): MemoryClusterList;
88
- export declare function readCluster(db: AnalysisDatabase, clusterReferenceId: string, requestedLimit?: number): MemoryClusterDetail;
97
+ export declare function readCluster(db: AnalysisDatabase, clusterReferenceId: string, requestedLimit?: number, requestedOffset?: number, sort?: MemoryClusterSort): MemoryClusterDetail;
89
98
  export {};
@@ -208,8 +208,8 @@ function byteSlice(text, maxBytes) {
208
208
  return "";
209
209
  return bytes.subarray(0, maxBytes - 3).toString("utf8").replace(/\uFFFD$/u, "") + "…";
210
210
  }
211
- function members(db, runId, clusterId, limit, maxExcerptBytes = MAX_EXCERPT_BYTES, maxTotalBytes = MAX_TOTAL_EXCERPT_BYTES, maxTotalAliases = MAX_TOTAL_ALIASES) {
212
- const noiseOrder = clusterId === -1
211
+ function members(db, runId, clusterId, limit, offset = 0, sort = "representative", maxExcerptBytes = MAX_EXCERPT_BYTES, maxTotalBytes = MAX_TOTAL_EXCERPT_BYTES, maxTotalAliases = MAX_TOTAL_ALIASES) {
212
+ const representativeOrder = clusterId === -1
213
213
  ? "m.outlier_score DESC, m.hash, m.seq"
214
214
  : `CASE WHEN m.representative_rank IS NULL THEN 1 ELSE 0 END,
215
215
  m.representative_rank,
@@ -217,23 +217,40 @@ function members(db, runId, clusterId, limit, maxExcerptBytes = MAX_EXCERPT_BYTE
217
217
  m.outlier_score,
218
218
  m.hash,
219
219
  m.seq`;
220
+ const score = clusterId === -1 ? "m.outlier_score" : "m.probability";
221
+ const order = {
222
+ representative: representativeOrder,
223
+ score_desc: `${score} DESC, m.hash, m.seq`,
224
+ score_asc: `${score} ASC, m.hash, m.seq`,
225
+ date_desc: "m.source_date DESC, m.hash, m.seq",
226
+ date_asc: "m.source_date ASC, m.hash, m.seq",
227
+ }[sort];
220
228
  const rows = db.prepare(`
221
- SELECT
222
- m.hash, m.seq, m.probability, m.outlier_score, m.x, m.y,
223
- m.representative_rank, m.pos, m.chunk_len, m.doc
224
- FROM memory_analysis_available_memberships m
225
- WHERE m.run_id = ? AND m.cluster_id = ?
226
- ORDER BY ${noiseOrder}
227
- LIMIT ?
228
- `).all(runId, clusterId, limit);
229
+ WITH member_rows AS (
230
+ SELECT
231
+ m.hash, m.seq, m.probability, m.outlier_score, m.x, m.y,
232
+ m.representative_rank, m.pos, m.chunk_len, m.doc,
233
+ (
234
+ SELECT MAX(d.modified_at)
235
+ FROM documents d
236
+ WHERE d.hash = m.hash AND d.active = 1
237
+ ) AS source_date
238
+ FROM memory_analysis_available_memberships m
239
+ WHERE m.run_id = ? AND m.cluster_id = ?
240
+ )
241
+ SELECT * FROM member_rows m
242
+ ORDER BY ${order}
243
+ LIMIT ? OFFSET ?
244
+ `).all(runId, clusterId, limit, offset);
229
245
  let remaining = maxTotalBytes;
230
246
  let remainingAliases = maxTotalAliases;
231
- return rows.map((row) => {
232
- const text = remaining <= 0
233
- ? ""
234
- : byteSlice(row.doc.slice(row.pos, row.pos + row.chunk_len), Math.min(maxExcerptBytes, remaining));
247
+ return rows.map((row, index) => {
248
+ const remainingRows = rows.length - index;
249
+ const excerptBudget = Math.min(maxExcerptBytes, Math.floor(remaining / remainingRows));
250
+ const text = byteSlice(row.doc.slice(row.pos, row.pos + row.chunk_len), excerptBudget);
235
251
  remaining -= Buffer.byteLength(text);
236
- const aliases = sourcePaths(db, row.hash, Math.min(MAX_ALIASES_PER_MEMBER, remainingAliases));
252
+ const aliasBudget = Math.min(MAX_ALIASES_PER_MEMBER, Math.floor(remainingAliases / remainingRows));
253
+ const aliases = sourcePaths(db, row.hash, aliasBudget);
237
254
  remainingAliases -= aliases.length;
238
255
  return {
239
256
  hash: row.hash,
@@ -243,6 +260,7 @@ function members(db, runId, clusterId, limit, maxExcerptBytes = MAX_EXCERPT_BYTE
243
260
  x: row.x,
244
261
  y: row.y,
245
262
  representativeRank: row.representative_rank,
263
+ sourceDate: row.source_date,
246
264
  text,
247
265
  sourcePaths: aliases,
248
266
  };
@@ -276,7 +294,7 @@ function readMetadata(run) {
276
294
  }
277
295
  function toSummary(db, run, row, includePreview, previewBytes = 600, aliasLimit = MAX_TOTAL_ALIASES) {
278
296
  const preview = includePreview
279
- ? members(db, run.id, row.cluster_id, 1, previewBytes, previewBytes, aliasLimit)[0]
297
+ ? members(db, run.id, row.cluster_id, 1, 0, "representative", previewBytes, previewBytes, aliasLimit)[0]
280
298
  : undefined;
281
299
  return {
282
300
  clusterId: clusterReference(run.id, row.cluster_id),
@@ -362,7 +380,7 @@ function resolveClusterId(db, runId, reference) {
362
380
  `).all(runId, runId);
363
381
  return clusterIds.find((row) => clusterReference(runId, row.cluster_id) === reference)?.cluster_id;
364
382
  }
365
- export function readCluster(db, clusterReferenceId, requestedLimit = DEFAULT_MEMBER_LIMIT) {
383
+ export function readCluster(db, clusterReferenceId, requestedLimit = DEFAULT_MEMBER_LIMIT, requestedOffset = 0, sort = "representative") {
366
384
  const run = latestValidRun(db);
367
385
  if (!run) {
368
386
  return { status: "not_analyzed", ...readMetadata() };
@@ -388,6 +406,11 @@ export function readCluster(db, clusterReferenceId, requestedLimit = DEFAULT_MEM
388
406
  return { status: "not_found", runId: run.id, ...metadata };
389
407
  }
390
408
  const limit = Math.max(1, Math.min(MAX_MEMBER_LIMIT, Math.floor(requestedLimit)));
409
+ const offset = Math.max(0, Math.floor(requestedOffset));
410
+ const total = availableSize(db, run.id, clusterId);
411
+ const pageMembers = members(db, run.id, clusterId, limit, offset, sort);
412
+ const nextOffset = offset + pageMembers.length;
413
+ const hasMore = nextOffset < total;
391
414
  return {
392
415
  status: "ok",
393
416
  runId: run.id,
@@ -395,9 +418,16 @@ export function readCluster(db, clusterReferenceId, requestedLimit = DEFAULT_MEM
395
418
  cluster: {
396
419
  clusterId: clusterReferenceId,
397
420
  size: row.size,
398
- availableSize: availableSize(db, run.id, clusterId),
421
+ availableSize: total,
399
422
  meanProbability: row.mean_probability,
400
423
  },
401
- members: members(db, run.id, clusterId, limit),
424
+ members: pageMembers,
425
+ page: {
426
+ offset,
427
+ returned: pageMembers.length,
428
+ total,
429
+ hasMore,
430
+ ...(hasMore ? { nextOffset } : {}),
431
+ },
402
432
  };
403
433
  }
@@ -1,5 +1,5 @@
1
1
  import type { QMDStore } from "@unblocklabs/qmd";
2
- import { type AnalysisRunner, type MemoryAnalysisSummary, type MemoryClusterDetail, type MemoryClusterList, type MemoryReclusterOptions } from "./analysis.js";
2
+ import { type AnalysisRunner, type MemoryAnalysisSummary, type MemoryClusterDetail, type MemoryClusterList, type MemoryClusterSort, type MemoryReclusterOptions } from "./analysis.js";
3
3
  import type { CorpusMemorySearchResult, CorpusSearchOptions, MemoryEmbeddingProbeResult, MemoryProviderStatus, MemoryReadResult, MemorySearchManagerContract, MemorySyncParams } from "./contracts.js";
4
4
  import type { ChatType } from "./config.js";
5
5
  import { type SessionSyncResult } from "./session-sync.js";
@@ -44,6 +44,8 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
44
44
  fetchCluster(params: {
45
45
  clusterId: string;
46
46
  topK?: number;
47
+ offset?: number;
48
+ sort?: MemoryClusterSort;
47
49
  }): Promise<MemoryClusterDetail>;
48
50
  search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
49
51
  readFile(params: {
@@ -391,7 +391,7 @@ export class QmdMemoryManager {
391
391
  return this.#enqueue(async () => readClusters((await this.#getAnalysisStore()).internal.db, limit));
392
392
  }
393
393
  fetchCluster(params) {
394
- return this.#enqueue(async () => readCluster((await this.#getAnalysisStore()).internal.db, params.clusterId, params.topK));
394
+ return this.#enqueue(async () => readCluster((await this.#getAnalysisStore()).internal.db, params.clusterId, params.topK, params.offset, params.sort));
395
395
  }
396
396
  async #getAnalysisStore() {
397
397
  const store = await this.#getStore();
@@ -192,6 +192,14 @@ function createListClustersTool(runtime, ctx) {
192
192
  const fetchClusterParameters = Type.Object({
193
193
  clusterId: Type.String({ pattern: "^[0-9a-f]{10}$" }),
194
194
  topK: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
195
+ offset: Type.Optional(Type.Integer({ minimum: 0 })),
196
+ sort: Type.Optional(Type.Union([
197
+ Type.Literal("representative"),
198
+ Type.Literal("score_desc"),
199
+ Type.Literal("score_asc"),
200
+ Type.Literal("date_desc"),
201
+ Type.Literal("date_asc"),
202
+ ])),
195
203
  }, { additionalProperties: false });
196
204
  function createFetchClusterTool(runtime, ctx) {
197
205
  const active = getContext(ctx);
@@ -200,14 +208,14 @@ function createFetchClusterTool(runtime, ctx) {
200
208
  return {
201
209
  name: "memory_fetch_cluster",
202
210
  label: "Fetch Memory Cluster",
203
- description: "Fetch the top representative QMD chunks for a clusterId returned by memory_list_clusters.",
211
+ description: "Fetch a sorted page of QMD chunks for a clusterId returned by memory_list_clusters.",
204
212
  parameters: fetchClusterParameters,
205
213
  async execute(_toolCallId, params) {
206
- const { clusterId, topK } = Value.Parse(fetchClusterParameters, params);
214
+ const { clusterId, topK, offset, sort } = Value.Parse(fetchClusterParameters, params);
207
215
  const { manager, error } = await runtime.getMemorySearchManager(active);
208
216
  if (!manager)
209
217
  return jsonResult({ status: "unavailable", error: error ?? "memory unavailable" });
210
- return jsonResult(await manager.fetchCluster({ clusterId, topK }));
218
+ return jsonResult(await manager.fetchCluster({ clusterId, topK, offset, sort }));
211
219
  },
212
220
  };
213
221
  }
@@ -100,9 +100,6 @@ function formatTimestamp(value, timezone) {
100
100
  return `${part("year")}-${part("month")}-${part("day")} ` +
101
101
  `${part("hour")}:${part("minute")}:${part("second")} ${part("timeZoneName")}`.trim();
102
102
  }
103
- function inline(value) {
104
- return value.replace(/[\r\n]+/gu, " ").replaceAll("`", "\\`");
105
- }
106
103
  export function projectSession(input) {
107
104
  const messages = input.events.flatMap((event) => {
108
105
  const projected = projectMessage(event, input);
@@ -110,22 +107,8 @@ export function projectSession(input) {
110
107
  });
111
108
  if (messages.length === 0)
112
109
  return undefined;
113
- const provider = input.provider ?? "unknown";
114
- const header = [
115
- "# Session",
116
- "",
117
- `- Session ID: \`${inline(input.sessionId)}\``,
118
- `- Provider: ${inline(provider)}`,
119
- `- Chat type: ${input.chatType}`,
120
- ...(input.label ? [`- Conversation: ${inline(input.label)}`] : []),
121
- ...(input.conversationId ? [`- Conversation ID: \`${inline(input.conversationId)}\``] : []),
122
- `- Started: ${new Date(input.startedAt).toISOString()}`,
123
- "",
124
- "## Transcript",
125
- "",
126
- ];
127
110
  const transcript = messages.map((message) => `${formatTimestamp(message.timestamp, input.timezone)} — ${message.speaker}: ${message.text}`);
128
- return `${[...header, ...transcript].join("\n\n")}\n`;
111
+ return `# Transcript\n\n${transcript.join("\n\n")}\n`;
129
112
  }
130
113
  function hash(value) {
131
114
  return createHash("sha256").update(value).digest("hex").slice(0, 16);
@@ -1,11 +1,11 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync, lstatSync } from "node:fs";
3
- import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { chmod, mkdir, readFile, rename, unlink, utimes, writeFile } from "node:fs/promises";
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { DatabaseSync } from "node:sqlite";
6
6
  import { projectSession, sessionDocumentPath, } from "./session-projector.js";
7
7
  const MANIFEST_VERSION = 1;
8
- const PROJECTOR_VERSION = 1;
8
+ const PROJECTOR_VERSION = 2;
9
9
  const SUPPORTED_SCHEMA_VERSION = 17;
10
10
  const REQUIRED_COLUMNS = {
11
11
  schema_meta: ["meta_key", "role", "schema_version", "agent_id", "app_version"],
@@ -261,6 +261,7 @@ export async function syncSessionProjections(params) {
261
261
  }
262
262
  const target = projectionPath(params.outputDir, documentPath);
263
263
  await atomicWrite(target, content, 0o600);
264
+ await utimes(target, new Date(), new Date(metadata.startedAt));
264
265
  if (previous?.documentPath && previous.documentPath !== documentPath) {
265
266
  await remove(projectionPath(params.outputDir, previous.documentPath));
266
267
  }
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.2.4",
4
+ "version": "0.2.6",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": false },
8
+ "skills": ["./skills"],
8
9
  "contracts": { "tools": ["memory_search", "memory_get", "memory_sync_sessions", "memory_sync_status", "memory_recluster", "memory_list_clusters", "memory_fetch_cluster"] },
9
10
  "toolMetadata": {
10
11
  "memory_sync_sessions": { "sideEffecting": true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  "access": "public",
19
19
  "provenance": true
20
20
  },
21
- "files": ["dist", "README.md", "openclaw.plugin.json"],
21
+ "files": ["dist", "skills", "README.md", "openclaw.plugin.json"],
22
22
  "scripts": {
23
23
  "build": "tsc -p tsconfig.build.json",
24
24
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: memory-curator
3
+ description: Investigate Unblock Memory clusters and maintain supported, agent-specific knowledge that would otherwise be difficult to reconstruct.
4
+ ---
5
+
6
+ # Memory Curator
7
+
8
+ Use semantic clusters as attention signals for maintaining the agent's current
9
+ understanding of its world. A cluster shows similarity, not a complete timeline,
10
+ truth, or consensus. Do not write from a cluster alone, and prefer no write over
11
+ weak, duplicative, or easily looked-up knowledge.
12
+
13
+ ## Investigate
14
+
15
+ 1. Call `memory_list_clusters`. If analysis is missing or stale, call
16
+ `memory_recluster`, then list again.
17
+ 2. Fetch a useful cluster with `memory_fetch_cluster`. Start with
18
+ `sort: "representative"`; use `score_desc`, `date_asc`, or `date_desc` and
19
+ pagination when relevance, evolution, or recent state matters.
20
+ 3. State the question the cluster raises: what may be repeated, contradictory,
21
+ changing, or worth understanding?
22
+ 4. Search existing knowledge with `memory_search`, using
23
+ `corpora: ["knowledge"]`. If that corpus is not configured, report that and
24
+ do not create an unindexed file.
25
+ 5. Investigate the evidence needed to answer the question. Follow important
26
+ `qmd://` sources with `memory_get`, search adjacent memory or sessions, inspect
27
+ other clusters, and check live systems, local files, authoritative docs, or
28
+ the web when they are the right source. The selected cluster is not assumed
29
+ to contain the whole timeline or the current truth.
30
+ 6. Distinguish underlying evidence from earlier agent-created knowledge.
31
+ Derived repetition can locate or challenge a conclusion, but is not
32
+ independent corroboration.
33
+
34
+ ## Decide what belongs in knowledge
35
+
36
+ Write knowledge when the result is agent-specific, evolving, and expensive to
37
+ reconstruct, such as current fleet composition, relationships, preferences,
38
+ project decisions, local conventions, or an assessment synthesized across
39
+ time.
40
+
41
+ Prefer a lookup for public or vendor-owned facts, generic command syntax, and
42
+ behavior likely to change with a third-party release. Those sources may verify
43
+ a local conclusion, but do not copy ordinary documentation into knowledge. A
44
+ local policy or deliberate divergence can belong when its local meaning and
45
+ rationale are the durable part.
46
+
47
+ Choose one outcome: update knowledge or no write. No write is successful when
48
+ the evidence is insufficient, the information is already accurate, or lookup
49
+ is better than memory.
50
+
51
+ ## Maintain knowledge
52
+
53
+ Use a stable `knowledge/<topic>.md` file and update it in place. Facts, agent
54
+ assessments, and uncertainty may coexist. Qualify each claim where it appears so
55
+ it remains honest if semantic chunking retrieves it alone:
56
+
57
+ - identify what is verified and when or against which current source;
58
+ - label an interpretation as the agent's assessment rather than a settled fact;
59
+ - state uncertainty or an open question directly with the affected claim.
60
+
61
+ Keep the file focused on current understanding. Remove stale conclusions rather
62
+ than retaining `Supersedes`, history, changelog, migration, or old-process
63
+ sections. Raw memory and sessions preserve the evidence history. Include a
64
+ human-readable update time and useful evidence citations, but do not force a
65
+ rigid document template.
66
+
67
+ ## Finish the cycle
68
+
69
+ - Do not rewrite raw memory or session projections.
70
+ - Verify an updated file with `memory_search`, using
71
+ `corpora: ["knowledge"]`, and check all-corpora ranking when useful.
72
+ - Report the questions investigated, evidence consulted beyond each cluster,
73
+ files changed, current uncertainties, and intentional no-write decisions.
74
+ - Do not recluster again after this cycle's writes. Let them enter the next
75
+ scheduled cycle so the run cannot recursively react to its own output.